From 4b4a97ce1e796a1182582f5290bb2b5431ed51a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:43:54 +0700 Subject: [PATCH 01/51] Variables: a second text variable on one type object is refused Squashed from #2642 onto main. --- crates/doc/src/variables.rs | 11 +-- crates/engine/src/cmd/variablecmds.rs | 23 ++++- crates/engine/src/tests/variables.rs | 131 ++++++++++++++++++++++---- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 5 files changed, 145 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index efe50d9df..de37364fa 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -105,12 +105,11 @@ impl DataSet { /// The variable state of a document: definitions, datasets, bindings /// (object id → the variables bound to it) and which dataset is active. /// -/// An object may be driven by several variables — the reference app binds the selected object -/// to the selected variable, one click at a time, and never says a second binding replaces the -/// first — so a binding is a set. `dataset.select` applies the text values in the order the -/// variables are defined, which is what makes two text variables on one object deterministic: -/// the last one wins, and `applied` counts both. Of two visibility variables on one object, the -/// last one defined wins too. +/// An object may be driven by several variables, so a binding is a set. `variable.bind` and Make +/// Text Dynamic refuse a second text variable on one type object, but a document saved before +/// they did may hold two: `dataset.select` applies the text values in the order the variables +/// are defined, so the last one wins, and `applied` counts both. Of two visibility variables on +/// one object, the last one defined wins too. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Variables { #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 426479070..16762c1e5 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -181,6 +181,21 @@ fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId) -> String { } } +/// Refuse to bind the text variable `name` to type that already has a different text variable: +/// a type object holds one text variable. The error names the one it has (the first defined, in +/// a document that holds two). An object already bound to `name` is not refused, and variables +/// of other kinds are not counted. +fn check_one_text_variable(doc: &vectorcraft_doc::Document, ids: &[NodeId], name: &str, cmd: &str) -> Result<()> { + for id in ids { + let Some(bound) = doc.variables.bindings.get(id).filter(|b| !b.contains(name)) else { continue }; + let held = doc.variables.variables.iter().find(|v| v.kind == VariableKind::Text && bound.contains(&v.name)); + if let Some(v) = held { + return Err(bad(cmd, format!("node {} already has the text variable `{}`", id.0, v.name))); + } + } + Ok(()) +} + fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result { match kind { VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())), @@ -284,6 +299,9 @@ fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result Result { let doc = &s.doc()?.doc; let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?; check_bindable(doc, &ids, kind, C)?; + if kind == VariableKind::Text { + check_one_text_variable(doc, &ids, &name, C)?; + } // Binding again is not a second binding, and says so rather than counting as a change. let already: Vec = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect(); let n = edit_variables(s, "Bind Variable", |v| Ok(ids.iter().filter(|id| v.bind(**id, &name)).count()))?; @@ -738,7 +759,7 @@ pub fn specs() -> Vec { "Bind Variable", ["Window", "Variables"], None, - "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}", + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}", has_doc, bind ), diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 715bae959..5566f1158 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -97,7 +97,9 @@ fn applying_a_dataset_reports_the_area_type_it_overflows() { s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); s.execute("variable.bind", &json!({"variable": "Name", "ids": [a.0, p.0]})).unwrap(); - s.execute("variable.bind", &json!({"variable": "Title", "ids": [a.0]})).unwrap(); + // `variable.bind` refuses a second text variable on one type object; a document saved + // before it did can hold two. + Arc::make_mut(&mut s.doc_mut().unwrap().doc).variables.bind(a, "Title"); s.execute("dataset.new", &json!({"name": "Short", "values": {"Name": "Ann", "Title": "Ann"}})).unwrap(); s.execute("dataset.new", &json!({"name": "Long", "values": {"Name": long, "Title": long}})).unwrap(); @@ -263,6 +265,7 @@ fn a_row_with_no_value_for_a_bound_variable_warns_that_nothing_was_applied() { assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64(), &v["warning"]), (Some(0), Some(1), &json!(warning(b))), "{v}"); // Bound to a variable with a value, the row applies it and gives no warning. + s.execute("variable.unbind", &json!({"variable": "Other", "ids": [t.0]})).unwrap(); s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap(); let v = s.execute("dataset.select", &json!({"name": a})).unwrap(); assert_eq!(v["applied"].as_u64(), Some(1), "{v}"); @@ -361,50 +364,146 @@ fn one_object_can_be_driven_by_several_variables() { let t = text(&mut s, "x"); let r = rect(&mut s); s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); - s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Shown", "kind": "visibility"})).unwrap(); s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap(); - // A text object driven by two text variables keeps both. + // A text object driven by a text variable and a visibility variable keeps both. assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["bound"], 1); - let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + let v = s.execute("variable.bind", &json!({"variable": "Shown", "ids": [t.0]})).unwrap(); assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(1), json!([])), "Title is untouched"); // A rectangle driven by a visibility variable as well as nothing else yet. s.execute("variable.bind", &json!({"variable": "Badge", "ids": [r.0]})).unwrap(); - s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99", "Badge": false}})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Shown": true, "Badge": false}})).unwrap(); let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap(); assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(3), Some(0)), "every binding applied"); - // Both text variables are counted; the later one in definition order is what is left. - assert_eq!(plain(&s, t), "9.99"); + assert_eq!(plain(&s, t), "Hat"); + assert!(s.doc().unwrap().doc.node(t).unwrap().visible); assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); let vars = s.execute("variable.list", &json!({})).unwrap(); let of = |n: &str| vars["variables"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap()["bindings"].as_u64(); - assert_eq!((of("Title"), of("Price"), of("Badge")), (Some(1), Some(1), Some(1))); + assert_eq!((of("Title"), of("Shown"), of("Badge")), (Some(1), Some(1), Some(1))); // Binding again to one it already has is not a change, and says so. - let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + let v = s.execute("variable.bind", &json!({"variable": "Shown", "ids": [t.0]})).unwrap(); assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(0), json!([t.0]))); // Unbinding one variable leaves the object's others alone. - assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Price"})).unwrap()["unbound"], 1); + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Shown"})).unwrap()["unbound"], 1); s.execute("edit.undo", &json!({})).unwrap(); let vars = s.execute("variable.list", &json!({})).unwrap(); assert_eq!(vars["variables"][1]["bindings"], 1, "the other binding is still there"); assert!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Ghost"})).is_err()); // Deleting a variable drops only its own binding. - s.execute("variable.delete", &json!({"names": ["Price"]})).unwrap(); + s.execute("variable.delete", &json!({"names": ["Shown"]})).unwrap(); let vars = s.execute("variable.list", &json!({})).unwrap(); assert_eq!(vars["variables"][0]["bindings"], 1, "Title still drives the text"); } +/// A type object holds one text variable: binding another text variable to it is refused, the +/// error names the one it has, and nothing is bound, not even the other objects of the call. +/// Variables of other kinds still share the object. +#[test] +fn a_type_object_holds_one_text_variable() { + let mut s = session(); + let t = text(&mut s, "x"); + let u = text(&mut s, "y"); + for (name, kind) in [("Title", "text"), ("Price", "text"), ("Shown", "visibility")] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap(); + let bindings = |s: &Session, name: &str| s.doc().unwrap().doc.variables.objects_of(name); + + let e = s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0, t.0]})).unwrap_err().to_string(); + assert!(e.contains("`variable.bind`") && e.contains(&format!("node {} already has the text variable `Title`", t.0)), "{e}"); + assert_eq!((bindings(&s, "Title"), bindings(&s, "Price")), (vec![t], vec![]), "nothing was bound"); + // The same variable again is not a second one, and another type object takes Price. + assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["already"], json!([t.0])); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Shown", "ids": [t.0, u.0]})).unwrap(); + assert_eq!(bindings(&s, "Shown"), vec![t, u]); + // After Title is unbound from it, the object takes Price. + s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + assert_eq!(bindings(&s, "Price"), vec![t, u]); +} + +/// Make Text Dynamic refuses type that already has a text variable and defines no variable; +/// Make Visibility Dynamic still binds it. +#[test] +fn make_text_dynamic_refuses_type_that_has_a_text_variable() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("variable.makeTextDynamic", &json!({})).unwrap(); + + let e = s.execute("variable.makeTextDynamic", &json!({})).unwrap_err().to_string(); + assert!(e.contains("`variable.makeTextDynamic`") && e.contains(&format!("node {} already has the text variable `Alice`", t.0)), "{e}"); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().map(Vec::len), Some(1), "{vars}"); + let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap(); + assert_eq!(v["ids"], json!([t.0])); +} + +/// With unbound type selected before type that has a text variable, Make Text Dynamic is still +/// refused: it defines no variable and binds neither object. +#[test] +fn make_text_dynamic_refuses_a_selection_where_a_later_object_has_a_text_variable() { + let mut s = session(); + let u = text(&mut s, "Bob"); + let t = text(&mut s, "Alice"); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("variable.makeTextDynamic", &json!({})).unwrap(); + + s.execute("select.set", &json!({"ids": [u.0, t.0]})).unwrap(); + let e = s.execute("variable.makeTextDynamic", &json!({})).unwrap_err().to_string(); + assert!(e.contains(&format!("node {} already has the text variable `Alice`", t.0)), "{e}"); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.variables.variables.len(), 1, "no variable was defined"); + assert!(!doc.variables.bindings.contains_key(&u), "the unbound object stays unbound"); + assert_eq!(doc.variables.bindings[&t].iter().collect::>(), ["Alice"]); +} + +/// A document saved with two text variables on one type object opens with both bindings, and +/// applying a row still writes both, the later one defined last. Binding either again changes +/// nothing, and binding a third is refused with the first one named. +#[test] +fn a_document_with_two_text_variables_on_one_type_object_opens_unchanged() { + let mut s = session(); + let t = text(&mut s, "x"); + let u = text(&mut s, "y"); + for name in ["Title", "Price", "Note"] { + s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap(); + Arc::make_mut(&mut s.doc_mut().unwrap().doc).variables.bind(t, "Price"); + s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99"}})).unwrap(); + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "two.vectorcraft", "dataBase64": saved})).unwrap(); + let held: Vec = o.doc().unwrap().doc.variables.bindings[&t].iter().cloned().collect(); + assert_eq!(held, ["Price", "Title"]); + let v = o.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!(v["applied"], json!(2), "{v}"); + assert_eq!(plain(&o, t), "9.99"); + for name in ["Title", "Price"] { + let v = o.execute("variable.bind", &json!({"variable": name, "ids": [t.0, u.0]})).unwrap(); + assert_eq!((&v["bound"], &v["already"]), (&json!(1), &json!([t.0])), "{name}: {v}"); + o.execute("edit.undo", &json!({})).unwrap(); + } + let e = o.execute("variable.bind", &json!({"variable": "Note", "ids": [t.0]})).unwrap_err().to_string(); + assert!(e.contains(&format!("node {} already has the text variable `Title`", t.0)), "{e}"); +} + /// `unbound` counts what was actually dropped, like `variable.delete` does. #[test] fn unbind_reports_what_it_actually_removed() { let mut s = session(); let t = text(&mut s, "x"); s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); - s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Price", "kind": "visibility"})).unwrap(); s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap(); s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 1); @@ -552,8 +651,9 @@ fn making_a_selection_dynamic_names_and_binds_it() { let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap(); assert_eq!(v["kind"], "visibility"); - // The same object twice takes a number rather than colliding with the first variable. - s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + // Type with the same name takes a number rather than colliding with the first variable. + let twin = text(&mut s, "Alice"); + s.execute("select.set", &json!({"ids": [twin.0]})).unwrap(); let again = s.execute("variable.makeTextDynamic", &json!({})).unwrap(); assert_eq!(again["name"], "Alice 2"); let vars = s.execute("variable.list", &json!({})).unwrap(); @@ -1254,8 +1354,7 @@ fn a_row_writes_type_inside_a_group_it_shows_or_hides() { assert!(!s.doc().unwrap().doc.node(g).unwrap().visible); } -/// Of two visibility variables on one object, the one defined later decides whether it shows, -/// as with two text variables. +/// Of two visibility variables on one object, the one defined later decides whether it shows. #[test] fn the_later_visibility_variable_on_an_object_wins() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 9220b1c62..ae04c1647 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1812,7 +1812,8 @@ already walks Image Trace. Short agent entry points: first value at −180° to its second at 180°. A stroke without that input draws the brush's own value (a Scatter brush's value half way between the two). - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, - `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`, + `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` + (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph values CSV as `graph.setData`). `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; without `locked` it toggles, and the lock is saved with the document. While the variables are locked, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad1ecc921..ec7b76edf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -655,6 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every data set is deleted, and every variable is deleted with its bindings and values, except a text variable the library also declares, which stays bound so the library's data sets change its objects (#4136); then the library's other text variables are defined, unbound, and its data sets take the text of each value's `p` elements, one paragraph each. Variables of other kinds are left out with a warning that names their kinds. - Variables: Save Variable Library… in the Variables panel menu (`variable.saveLibrary`) writes the document's text variables and data sets as a variable library's XML file, which Load Variable Library reads back with the same text variables, data set names and text values. Variables of other kinds are left out with a warning that names their kinds. - JSON-lines control channel with real egui pointer and keyboard injection. + - Variables: a type object holds one text variable. `variable.bind` and Make Text Dynamic refuse a second one and name the text variable the object has; a visibility variable can still share the object, and a document saved with two text variables on one object opens with both. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. From 7c813e71710836158626ba1d3b87a9b90875845b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:44:34 +0700 Subject: [PATCH 02/51] Document: document.compare measures how far an artboard's render is from an image Squashed from #3995 onto main. --- crates/engine/src/cmd/edit.rs | 158 ++++++++++++++++ crates/engine/src/tests.rs | 1 + crates/engine/src/tests/compare.rs | 276 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fi.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ka.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sv.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + docs/mcp.md | 16 ++ 29 files changed, 581 insertions(+) create mode 100644 crates/engine/src/tests/compare.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dbe3d4509..21bd4f9bc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -41,6 +41,15 @@ pub fn specs() -> Vec { cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), + cmd!( + query "document.compare", + "Compare with Image", + [], + None, + "{path: an image file (PNG, JPEG, WebP, TIFF or BMP), artboard?: index=0, background?: \"#rrggbb\"=\"#ffffff\" (the render and the image are both flattened on it), threshold?: 0.08 (0–1), diffPath?: a PNG to write (each pixel's absolute R, G and B differences)} render the artboard at the image's size (the image's width in pixels over the artboard's width in points), drawn as document.export draws a PNG (linked images and placed documents from their files), and compare it with the image; an image whose height differs by more than a pixel from the artboard's height at that scale is refused (not in the browser) → {width, height, rmse: the root mean square of the R, G and B differences, 0–1 (6 decimals), over: the pixels with a channel that differs by more than threshold, total: the pixels, threshold, diffPath?, warnings?: as a PNG export's (type that doesn't fit its frame, an image that couldn't be decoded, type in a font or style that isn't available, previews drawn because a linked file can't be read)}", + has_doc, + doc_compare + ), cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), cmd!( "document.setUnits", @@ -385,6 +394,155 @@ fn select_found(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result { }) } +/// Render an artboard at an image's size, drawn as a PNG export draws it (linked images and placed +/// documents from their files, with the export's warnings), and measure how far the +/// two are apart: both flattened on `background`, the root mean square of the R, G and B +/// differences (0–1), and the pixels with a channel that differs by more than `threshold`. +#[cfg(not(target_arch = "wasm32"))] +fn doc_compare(s: &mut Session, p: &Value) -> Result { + use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, png}; + const C: &str = "document.compare"; + let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let diff_path = match p.get("diffPath") { + None | Some(Value::Null) => None, + Some(Value::String(v)) => Some(v.as_str()), + Some(_) => { + let key = "diffPath"; + return Err(bad(C, format!("`{key}` must be a string"))); + } + }; + let background = match p.get("background") { + None | Some(Value::Null) => [255; 3], + Some(v) => match super::fileio::background(v) { + Ok(Some(rgb)) => rgb, + _ => return Err(bad(C, format!("background {v}: a color such as \"#808080\""))), + }, + }; + let threshold = match p.get("threshold") { + None | Some(Value::Null) => 0.08, + Some(v) => v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad(C, "`threshold` must be a number from 0 to 1"))?, + }; + let doc = &s.doc()?.doc; + let count = doc.artboards.len(); + let i = opt_u64(p, C, "artboard")?.unwrap_or(0); + let region = usize::try_from(i) + .ok() + .and_then(|i| doc.artboards.get(i)) + .map(|a| a.rect) + .ok_or_else(|| bad(C, format!("no artboard {i} (0-based; the document has {count})")))?; + if let Some(d) = diff_path { + crate::file_access::check_write(d).map_err(EngineError::Other)?; + } + let image = compare_image(path)?; + let (iw, ih) = image.dimensions(); + // The image's width sets the scale; its height may differ from the artboard's at that scale by + // the pixel the rounding of a render takes. + let scale = f64::from(iw) / region.width(); + let rendered_height = region.height() * scale; + if iw == 0 || ih == 0 || !rendered_height.is_finite() || (rendered_height - f64::from(ih)).abs() > 1.0 { + let (rw, rh) = (iw, rendered_height.round()); + return Err(bad( + C, + format!("the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ"), + )); + } + vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; + vectorcraft_render::raster_size(vectorcraft_geom::Rect::new(0.0, 0.0, f64::from(iw), f64::from(ih)), 1.0).map_err(|e| bad(C, e))?; + let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + let opts = RasterExportOptions { background: Some(background), ..Default::default() }.render_options(RasterFormat::Png); + // As a PNG export: linked images and placed documents drawn from their files, not the + // previews saved for them. + let (full, linked) = super::place::document::full_documents(doc); + let failed = vectorcraft_render::decode_failures(); + let rendered = vectorcraft_render::Renderer::new().render(&full, iw, ih, view, &opts); + // A canceled render stops drawing: the picture is not whole. + vectorcraft_doc::cancel::check()?; + // As a PNG export says, in its order: type on the artboard that doesn't fit its frame, an image + // the renderer couldn't decode (missing from the render), type in a missing font or style (drawn + // in another one), and linked files drawn from their previews. + let mut warnings: Vec = super::fonts::export_overflow_warning(&full, &full.layers, Some(&[region])).into_iter().collect(); + if let Some(w) = super::rasterfx::undecoded_warning(failed) { + warnings.push(w.into()); + } + if let Some(w) = super::fonts::substitution_warning(&full) { + warnings.push(w); + } + warnings.extend(linked); + let rendered = rendered.to_straight(); + let flat = |px: &[u8; 4]| { + let a = u32::from(px[3]); + [0, 1, 2].map(|k| ((u32::from(px[k]) * a + u32::from(background[k]) * (255 - a) + 127) / 255) as u8) + }; + let row = iw as usize * 4; + let limit = threshold * 255.0; + let (mut squares, mut over) = (0.0f64, 0u64); + let mut diff = diff_path.map(|_| Vec::with_capacity(row * ih as usize)); + for (a, b) in rendered.chunks_exact(row).zip(image.as_raw().chunks_exact(row)) { + vectorcraft_doc::cancel::check()?; + for (pa, pb) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0) { + let (fa, fb) = (flat(pa), flat(pb)); + let d = [0, 1, 2].map(|k| fa[k].abs_diff(fb[k])); + squares += d.iter().map(|&x| f64::from(x) * f64::from(x)).sum::(); + if d.iter().any(|&x| f64::from(x) > limit) { + over += 1; + } + if let Some(out) = &mut diff { + out.extend_from_slice(&[d[0], d[1], d[2], 255]); + } + } + } + let total = u64::from(iw) * u64::from(ih); + let rmse = (squares / (total as f64 * 3.0)).sqrt() / 255.0; + let mut out = json!({ + "width": iw, + "height": ih, + "rmse": (rmse * 1e6).round() / 1e6, + "over": over, + "total": total, + "threshold": threshold, + }); + if let (Some(d), Some(pixels)) = (diff_path, diff) { + let bytes = png::encode(&pixels, iw, ih, &png::PngOptions { ppi: None, interlaced: false }).map_err(EngineError::Other)?; + super::fileio::write_file(d, &bytes)?; + out["diffPath"] = json!(d); + } + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } + Ok(out) +} + +/// The image `document.compare` reads from `path` (through the automation read root), as straight +/// RGBA: a PNG, JPEG, WebP, TIFF or BMP within the allocation an opened image may take. +#[cfg(not(target_arch = "wasm32"))] +fn compare_image(path: &str) -> Result { + use image::ImageDecoder as _; + const C: &str = "document.compare"; + let bytes = super::fileio::read_file(path)?; + if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(&bytes) { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| bad(C, format!("{path}: {e}")))?; + let readable = [image::ImageFormat::Png, image::ImageFormat::Jpeg, image::ImageFormat::WebP, image::ImageFormat::Tiff, image::ImageFormat::Bmp]; + if !reader.format().is_some_and(|f| readable.contains(&f)) { + return Err(bad(C, format!("{path}: not a PNG, JPEG, WebP, TIFF or BMP image"))); + } + let mut limits = image::Limits::default(); + limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC); + reader.limits(limits); + let decoder = reader.into_decoder().map_err(|e| bad(C, format!("{path}: {e}")))?; + let (w, h) = decoder.dimensions(); + if decoder.total_bytes() > vectorcraft_doc::MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > vectorcraft_doc::MAX_RASTER_ALLOC { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); + } + Ok(image::DynamicImage::from_decoder(decoder).map_err(|e| bad(C, format!("{path}: {e}")))?.to_rgba8()) +} + +#[cfg(target_arch = "wasm32")] +fn doc_compare(_: &mut Session, _: &Value) -> Result { + Err(bad("document.compare", "comparing with an image file isn't available in the browser")) +} + /// Document Setup's units (also `document.setup {units}`). fn set_units(s: &mut Session, p: &Value) -> Result { let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b9e247ed9..b708c25ca 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -31,6 +31,7 @@ mod colorize; pub(super) mod colormgmt; mod colorthemes; mod colourfonts; +mod compare; mod compound_shape; mod compoundpaint; mod constrain_new; diff --git a/crates/engine/src/tests/compare.rs b/crates/engine/src/tests/compare.rs new file mode 100644 index 000000000..03d788487 --- /dev/null +++ b/crates/engine/src/tests/compare.rs @@ -0,0 +1,276 @@ +//! `document.compare`: an artboard rendered at an image's size, as a PNG export draws it, and +//! measured against the image (RMSE, the pixels over a threshold, a difference image), with the +//! image and the render flattened on one background; images of other proportions, files that +//! can't be read and paths outside the automation roots are errors. + +use serde_json::{Value, json}; + +use crate::Session; + +/// A 40 × 20 pt document with a black 10 × 10 pt square at (10, 5), no stroke. +fn session() -> (Session, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap()["id"].clone(); + (s, id) +} + +fn path_in(dir: &std::path::Path, name: &str) -> String { + dir.join(name).to_string_lossy().into_owned() +} + +/// The artboard exported as a PNG at 2 pixels a point on white. +fn export(s: &mut Session, path: &str) { + s.execute("document.export", &json!({"path": path, "format": "png", "scale": 2, "background": "white"})).unwrap(); +} + +fn compare(s: &mut Session, p: Value) -> Value { + s.execute("document.compare", &p).unwrap_or_else(|e| panic!("document.compare {p}: {e}")) +} + +fn error(s: &mut Session, p: Value) -> String { + s.execute("document.compare", &p).map(|v| panic!("document.compare {p} answered {v}")).unwrap_err().to_string() +} + +#[test] +fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() { + let dir = vectorcraft_testkit::temp_dir("compare-moved"); + let png = path_in(&dir, "art.png"); + let (mut s, id) = session(); + export(&mut s, &png); + let before = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()); + let r = compare(&mut s, json!({"path": png})); + assert_eq!(r, json!({"width": 80, "height": 40, "rmse": 0.0, "over": 0, "total": 3200, "threshold": 0.08})); + assert_eq!((s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()), before, "comparing doesn't edit the document"); + // 4 pt is 8 pixels: 8 columns of the square's 20 rows turn white and 8 turn black, every + // channel by 255. + s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap(); + let r = compare(&mut s, json!({"path": png})); + let over = 2 * 8 * 20; + assert_eq!((r["over"].as_u64(), r["total"].as_u64()), (Some(over), Some(3200)), "{r}"); + let rmse = ((over as f64 / 3200.0).sqrt() * 1e6).round() / 1e6; + assert_eq!(r["rmse"].as_f64(), Some(rmse), "{r}"); + // Above every difference, nothing is over. + assert_eq!(compare(&mut s, json!({"path": png, "threshold": 1}))["over"], json!(0)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_image_and_the_render_are_flattened_on_the_background() { + let dir = vectorcraft_testkit::temp_dir("compare-background"); + let png = path_in(&dir, "clear.png"); + // Transparent everywhere, with red in the color channels that the alpha hides. + image::RgbaImage::from_pixel(80, 40, image::Rgba([255, 0, 0, 0])).save(&png).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap(); + // Nothing drawn: both are the background alone. + for background in ["#808080", "#ffffff"] { + let r = compare(&mut s, json!({"path": png, "background": background})); + assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(0.0), Some(0)), "{background}: {r}"); + } + // A black left half: 128 from gray in every channel of half the pixels, 255 from white. + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + let r = compare(&mut s, json!({"path": png, "background": "#808080"})); + let rmse = |d: f64| ((0.5f64).sqrt() * d / 255.0 * 1e6).round() / 1e6; + assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(128.0)), Some(1600)), "{r}"); + let r = compare(&mut s, json!({"path": png})); + assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(255.0)), Some(1600)), "{r}"); + // A threshold just above 128/255 leaves the gray differences out. + assert_eq!(compare(&mut s, json!({"path": png, "background": "#808080", "threshold": 0.51}))["over"], json!(0)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() { + let dir = vectorcraft_testkit::temp_dir("compare-size"); + let (mut s, _) = session(); + let square = path_in(&dir, "square.png"); + image::RgbaImage::from_pixel(30, 30, image::Rgba([255; 4])).save(&square).unwrap(); + let e = error(&mut s, json!({"path": square})); + assert!(e.contains("30 × 30 pixels") && e.contains("30 × 15 pixels"), "{e}"); + let taller = path_in(&dir, "taller.png"); + image::RgbaImage::from_pixel(80, 41, image::Rgba([255; 4])).save(&taller).unwrap(); + let r = compare(&mut s, json!({"path": taller})); + assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["total"].as_u64()), (Some(80), Some(41), Some(3280)), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_export_whose_height_was_rounded_compares_equal() { + let dir = vectorcraft_testkit::temp_dir("compare-rounded"); + let png = path_in(&dir, "art.png"); + // 20.25 pt at 2 pixels a point is 40.5 rows, which the export rounds to 41. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 20.25})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap(); + export(&mut s, &png); + let r = compare(&mut s, json!({"path": png})); + assert_eq!(r, json!({"width": 80, "height": 41, "rmse": 0.0, "over": 0, "total": 3280, "threshold": 0.08})); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() { + use crate::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write}; + let dir = Folder::new("compare-linked"); + let pic = dir.file("photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + place(&mut s, &pic); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + // The file changes after the save: the reopened document shows the red preview saved for it, + // and a PNG export reads the blue file. + write(&pic, &png(600, 300, BLUE)); + open(&mut s, &doc_path); + let out = dir.file("export.png"); + s.execute("document.export", &json!({"path": out, "format": "png", "background": "white"})).unwrap(); + let r = compare(&mut s, json!({"path": out})); + assert_eq!((r["rmse"].as_f64(), r["over"].as_u64(), r.get("warnings")), (Some(0.0), Some(0), None), "{r}"); + // Without the file the preview is drawn, and the reply says so. + std::fs::remove_file(&pic).unwrap(); + let r = compare(&mut s, json!({"path": out})); + assert_eq!(r["over"].as_u64(), r["total"].as_u64(), "{r}"); + let warnings = r["warnings"].as_array().unwrap(); + assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("output as their previews"), "{r}"); + // With type that doesn't fit its frame as well, the warnings come in the export's order. + let overset = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}})).unwrap(); + assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}"); + let exported = s.execute("document.export", &json!({"path": dir.file("now.png"), "format": "png", "background": "white"})).unwrap(); + let r = compare(&mut s, json!({"path": out})); + assert_eq!(r["warnings"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!(r["warnings"], exported["warnings"], "{r}"); +} + +#[test] +fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does() { + let dir = vectorcraft_testkit::temp_dir("compare-warnings"); + let png = path_in(&dir, "art.png"); + let (mut s, _) = session(); + export(&mut s, &png); + assert_eq!(compare(&mut s, json!({"path": png})).get("warnings"), None); + crate::tests_menucmds::place_undecodable_png(&mut s); + s.execute("text.create", &json!({"x": 2, "y": 18, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let overset = s.execute("text.create", &json!({"x": 22, "y": 2, "text": "Ab\nCd\nEf", "size": 6, "area": {"width": 15, "height": 8}})).unwrap(); + assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}"); + let exported = s.execute("document.export", &json!({"path": path_in(&dir, "now.png"), "format": "png", "scale": 2})).unwrap(); + let r = compare(&mut s, json!({"path": png})); + assert_eq!(r["warnings"], exported["warnings"], "{r}"); + let warnings = r["warnings"].as_array().unwrap(); + assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{r}"); + assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("Nonexistent Sans Probe")), "{r}"); + assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("doesn't fit its frame")), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn diff_path_writes_each_pixels_differences() { + let dir = vectorcraft_testkit::temp_dir("compare-diff"); + let png = path_in(&dir, "art.png"); + let diff = path_in(&dir, "diff.png"); + let (mut s, id) = session(); + export(&mut s, &png); + s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap(); + let r = compare(&mut s, json!({"path": png, "diffPath": diff})); + assert_eq!(r["diffPath"], json!(diff)); + let img = image::open(&diff).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (80, 40)); + // The square spanned pixels 20–39 and now spans 28–47, rows 10–29. + for (x, y, want) in [(24, 20, 255), (44, 20, 255), (30, 20, 0), (24, 5, 0), (60, 20, 0)] { + assert_eq!(img.get_pixel(x, y).0, [want, want, want, 255], "({x}, {y})"); + } + let changed = img.pixels().filter(|p| p.0[0] != 0).count(); + assert_eq!(changed as u64, r["over"].as_u64().unwrap()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn missing_unreadable_and_malformed_inputs_are_errors() { + let dir = vectorcraft_testkit::temp_dir("compare-errors"); + let (mut s, _) = session(); + let png = path_in(&dir, "art.png"); + export(&mut s, &png); + let missing = path_in(&dir, "missing.png"); + assert!(error(&mut s, json!({"path": missing})).contains(&missing)); + let text = path_in(&dir, "notes.png"); + std::fs::write(&text, "not an image\n").unwrap(); + assert!(error(&mut s, json!({"path": text})).contains("not a PNG, JPEG, WebP, TIFF or BMP image")); + let gif = path_in(&dir, "art.gif"); + image::RgbaImage::from_pixel(80, 40, image::Rgba([255; 4])).save(&gif).unwrap(); + assert!(error(&mut s, json!({"path": gif})).contains("not a PNG, JPEG, WebP, TIFF or BMP image")); + for (p, want) in [ + (json!({}), "missing `path`"), + (json!({"path": png, "artboard": 3}), "no artboard 3"), + (json!({"path": png, "threshold": 2}), "`threshold` must be a number from 0 to 1"), + (json!({"path": png, "threshold": "low"}), "`threshold` must be a number from 0 to 1"), + (json!({"path": png, "background": "transparent"}), "a color such as"), + (json!({"path": png, "diffPath": 5}), "`diffPath` must be a string"), + ] { + let e = error(&mut s, p.clone()); + assert!(e.contains(want), "{p}: {e}"); + } + let _ = std::fs::remove_dir_all(dir); +} + +/// A planar TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a strip +/// each, times 5 planes) is refused as too large to draw, without a panic. +#[test] +fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() { + let dir = vectorcraft_testkit::temp_dir("compare-strips"); + let (mut s, _) = session(); + let plane = |v: u16| vec![v; 6]; + let channels = [plane(200), plane(40), plane(10), plane(0), plane(90)]; + let t = vectorcraft_testkit::tiff::Tiff { + width: 3, + height: 2, + photometric: 5, + bits: 8, + extra: &[0], + planar: true, + ppi: Some(150), + channels: &channels, + }; + let mut file = vectorcraft_testkit::tiff::tiff(&t); + // ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the + // eighth, at offset 102). + file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes()); + file[102..106].copy_from_slice(&1u32.to_le_bytes()); + assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1))); + let tif = path_in(&dir, "strips.tif"); + std::fs::write(&tif, &file).unwrap(); + let e = error(&mut s, json!({"path": tif})); + assert_eq!(e, "invalid parameters for `document.compare`: an image of 3 × 2147483649 pixels is too large to draw"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_image_and_the_difference_image_stay_inside_the_automation_roots() { + let dir = vectorcraft_testkit::temp_dir("compare-roots"); + for d in ["in", "out", "elsewhere"] { + std::fs::create_dir_all(dir.join(d)).unwrap(); + } + let (mut s, _) = session(); + let inside = path_in(&dir.join("in"), "art.png"); + let outside = path_in(&dir.join("elsewhere"), "art.png"); + export(&mut s, &inside); + export(&mut s, &outside); + let roots = crate::file_access::AutomationRoots::new(&[&dir.join("in")], &[&dir.join("out")]).unwrap().unwrap(); + let mut run = |p: Value| crate::file_access::confine(Some(&roots), || s.execute("document.compare", &p)); + assert!(run(json!({"path": inside})).is_ok()); + let e = run(json!({"path": outside})).unwrap_err().to_string(); + assert!(e.contains("outside the read root"), "{e}"); + let stray = path_in(&dir.join("elsewhere"), "diff.png"); + let e = run(json!({"path": inside, "diffPath": stray})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(!std::path::Path::new(&stray).exists()); + let kept = path_in(&dir.join("out"), "diff.png"); + assert_eq!(run(json!({"path": inside, "diffPath": kept})).unwrap()["diffPath"], json!(kept)); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e9a219bd5..8bdbf65a9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1140,6 +1140,7 @@ font-technology Variable Променливи Find Font… Търсене на шрифт… Find Next Търсене на следващ Find Objects Търсене на обекти + Compare with Image Сравняване с изображение First Page Първа страница First-line Left Indent Ляв отстъп на първи ред Fisheye… Рибено око… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ea246b35c..68c1e81b0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1580,6 +1580,7 @@ digits Malayalam Malaiàlam Find Linked Files in Folder… Cerca fitxers enllaçats en una carpeta… Find Next Cerca el següent Find Objects Cerca objectes + Compare with Image Compara amb una imatge Fine Dots Punts fins Fine noise hides banding in the gradient fill El soroll fi amaga l’efecte de bandes del farciment de degradat First Primer @@ -5006,6 +5007,11 @@ digits Malayalam Malaiàlam @msg an image has no pixels and was left out una imatge no té píxels i s’ha omès @msg an image of {w} × {h} pixels is shown at reduced size una imatge de {w} × {h} píxels es mostra a mida reduïda @msg an image of {w} × {h} pixels is too large to draw una imatge de {w} × {h} píxels és massa gran per dibuixar-la +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: no és una imatge PNG, JPEG, WebP, TIFF ni BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ la imatge fa {iw} × {ih} píxels i el tauler a aquesta amplada fa {rw} × {rh} píxels: les proporcions són diferents +@msg `threshold` must be a number from 0 to 1 `threshold` ha de ser un nombre de 0 a 1 +@msg background {v}: a color such as "#808080" fons {v}: un color com "#808080" +@msg comparing with an image file isn't available in the browser la comparació amb un fitxer d’imatge no està disponible al navegador @msg an image VectorCraft can't read was left out s’ha omès una imatge que VectorCraft no pot llegir @msg graphs come in as plain art, without their data or settings els gràfics s’importen com a il·lustració normal, sense les seves dades ni la seva configuració @msg an image whose data is cut short was left out s’ha omès una imatge les dades de la qual estan tallades diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e51de244..e74e240d7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3316,6 +3316,7 @@ theme Appearance Motiv Document JSON JSON dokumentu Duplicate Duplikovat Find Objects Najít objekty + Compare with Image Porovnat s obrazem Inspect Document Prozkoumat dokument Inspect Object Prozkoumat objekt Begin Undo Group Zahájit skupinu kroků zpět @@ -5512,6 +5513,11 @@ mark-style Japanese Japonské @msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys} Load EPS into Clipboard Načíst EPS do schránky @msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: není obraz PNG, JPEG, WebP, TIFF ani BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ obraz má {iw} × {ih} obrazových bodů a pracovní plocha v této šířce má {rw} × {rh} obrazových bodů: jejich proporce se liší +@msg `threshold` must be a number from 0 to 1 `threshold` musí být číslo od 0 do 1 +@msg background {v}: a color such as "#808080" pozadí {v}: barva jako "#808080" +@msg comparing with an image file isn't available in the browser porovnání se souborem obrazu není v prohlížeči k dispozici @msg an image of {w} × {h} pixels is shown at reduced size obraz o velikosti {w} × {h} obrazových bodů se zobrazuje ve zmenšené velikosti @msg the image can't be read as a texture obraz nelze načíst jako texturu Fill and Stroke Controls Ovládací prvky výplně a tahu diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 84134c893..d50e862bb 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3058,6 +3058,7 @@ theme Appearance Udseende Document JSON Dokument-JSON Duplicate Duplikér Find Objects Find objekter + Compare with Image Sammenlign med billede Inspect Document Inspicér dokument Inspect Object Inspicér objekt Begin Undo Group Start fortrydelsesgruppe diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7d0e48fb8..2da424151 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1155,6 +1155,7 @@ font-technology Variable Variabel Find Font… Schrift suchen… Find Next Weitersuchen Find Objects Objekte suchen + Compare with Image Mit Bild vergleichen First Page Erste Seite First-line Left Indent Einzug links in erster Zeile Fisheye… Fischauge… @@ -5078,6 +5079,11 @@ lighting Light Licht @msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys} Load EPS into Clipboard EPS in die Zwischenablage laden @msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: kein PNG-, JPEG-, WebP-, TIFF- oder BMP-Bild +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ das Bild hat {iw} × {ih} Pixel und die Zeichenfläche in dieser Breite {rw} × {rh} Pixel: ihre Proportionen unterscheiden sich +@msg `threshold` must be a number from 0 to 1 `threshold` muss eine Zahl von 0 bis 1 sein +@msg background {v}: a color such as "#808080" background {v}: eine Farbe wie "#808080" +@msg comparing with an image file isn't available in the browser der Vergleich mit einer Bilddatei ist im Browser nicht verfügbar @msg an image of {w} × {h} pixels is shown at reduced size ein Bild mit {w} × {h} Pixeln wird verkleinert angezeigt @msg the image can't be read as a texture das Bild kann nicht als Struktur gelesen werden Basic Basis diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index be001e2fb..4d713dfed 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1113,6 +1113,7 @@ font-technology Variable Μεταβλητές Find Font… Εύρεση γραμματοσειράς… Find Next Εύρεση επόμενου Find Objects Εύρεση αντικειμένων + Compare with Image Σύγκριση με εικόνα First Page Πρώτη σελίδα First-line Left Indent Αριστερή εσοχή πρώτης γραμμής Fisheye… Εφέ ματιού ψαριού… @@ -5256,6 +5257,11 @@ effect Points Σημεία Load EPS into Clipboard Φόρτωση EPS στο πρόχειρο @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode Άγνωστο στοιχείο ελέγχου `{name}`: fillStroke, coloring, drawMode ή screenMode @msg an image of {w} × {h} pixels is too large to draw Η εικόνα {w} × {h} εικονοστοιχείων είναι υπερβολικά μεγάλη για σχεδίαση +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: δεν είναι εικόνα PNG, JPEG, WebP, TIFF ή BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ η εικόνα είναι {iw} × {ih} εικονοστοιχεία και ο πίνακας σχεδίασης σε αυτό το πλάτος είναι {rw} × {rh} εικονοστοιχεία: οι αναλογίες τους διαφέρουν +@msg `threshold` must be a number from 0 to 1 Το `threshold` πρέπει να είναι αριθμός από 0 έως 1 +@msg background {v}: a color such as "#808080" φόντο {v}: χρώμα όπως "#808080" +@msg comparing with an image file isn't available in the browser η σύγκριση με αρχείο εικόνας δεν είναι διαθέσιμη στο πρόγραμμα περιήγησης @msg an image of {w} × {h} pixels is shown at reduced size Η εικόνα {w} × {h} εικονοστοιχείων εμφανίζεται σε μειωμένο μέγεθος Open Recent File 1 Άνοιγμα πρόσφατου αρχείου 1 Open Recent File 2 Άνοιγμα πρόσφατου αρχείου 2 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 930ecf090..8c00c78d2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1184,6 +1184,7 @@ font-technology Variable Variables Find Font… Buscar fuente… Find Next Buscar siguiente Find Objects Buscar objetos + Compare with Image Comparar con imagen First Page Primera página First-line Left Indent Sangría izquierda en primera línea Fisheye… Ojo de pez… @@ -5452,6 +5453,11 @@ mark-style Japanese Japonesas @msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys} Load EPS into Clipboard Cargar EPS en el portapapeles @msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: no es una imagen PNG, JPEG, WebP, TIFF ni BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ la imagen mide {iw} × {ih} píxeles y la mesa de trabajo a ese ancho mide {rw} × {rh} píxeles: sus proporciones difieren +@msg `threshold` must be a number from 0 to 1 `threshold` debe ser un número de 0 a 1 +@msg background {v}: a color such as "#808080" fondo {v}: un color como "#808080" +@msg comparing with an image file isn't available in the browser la comparación con un archivo de imagen no está disponible en el navegador @msg an image of {w} × {h} pixels is shown at reduced size una imagen de {w} × {h} píxeles se muestra a tamaño reducido @msg the image can't be read as a texture la imagen no se puede leer como textura Fill and Stroke Controls Controles de relleno y trazo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 99594975d..3491ca30a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3254,6 +3254,7 @@ theme Appearance Ulkoasu Document JSON Asiakirjan JSON Duplicate Monista Find Objects Etsi objekteja + Compare with Image Vertaa kuvaan Inspect Document Tarkastele asiakirjaa Inspect Object Tarkastele objektia Begin Undo Group Aloita kumoamisryhmä @@ -5396,6 +5397,11 @@ mark-style Japanese Japanilainen @msg unknown parameter `{key}`; the command takes: {keys} tuntematon parametri `{key}`; komennon parametrit: {keys} Load EPS into Clipboard Lataa EPS leikepöydälle @msg an image of {w} × {h} pixels is too large to draw kuva, jonka koko on {w} × {h} pikseliä, on liian suuri piirrettäväksi +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: ei ole PNG-, JPEG-, WebP-, TIFF- tai BMP-kuva +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ kuva on {iw} × {ih} pikseliä ja piirtoalusta tällä leveydellä {rw} × {rh} pikseliä: niiden mittasuhteet eroavat +@msg `threshold` must be a number from 0 to 1 `threshold` on oltava luku väliltä 0–1 +@msg background {v}: a color such as "#808080" tausta {v}: väri, kuten "#808080" +@msg comparing with an image file isn't available in the browser vertailu kuvatiedostoon ei ole käytettävissä selaimessa @msg an image of {w} × {h} pixels is shown at reduced size kuva, jonka koko on {w} × {h} pikseliä, näytetään pienennettynä Fill and Stroke Controls Täytön ja viivan säätimet Coloring Controls Värityksen säätimet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1aebc348d..539c0f3ae 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1178,6 +1178,7 @@ font-technology Variable Variables Find Font… Rechercher une police… Find Next Rechercher le suivant Find Objects Rechercher des objets + Compare with Image Comparer à une image First Page Première page First-line Left Indent Retrait gauche de première ligne Fisheye… Fish-eye… @@ -5508,6 +5509,11 @@ mark-style Japanese Japonais theme Appearance Apparence Load EPS into Clipboard Charger un EPS dans le presse-papiers @msg an image of {w} × {h} pixels is too large to draw une image de {w} × {h} pixels est trop grande pour être dessinée +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path} : ce n’est pas une image PNG, JPEG, WebP, TIFF ou BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ l’image fait {iw} × {ih} pixels et le plan de travail à cette largeur fait {rw} × {rh} pixels : leurs proportions diffèrent +@msg `threshold` must be a number from 0 to 1 `threshold` doit être un nombre de 0 à 1 +@msg background {v}: a color such as "#808080" arrière-plan {v} : une couleur comme "#808080" +@msg comparing with an image file isn't available in the browser la comparaison avec un fichier image n’est pas disponible dans le navigateur @msg an image of {w} × {h} pixels is shown at reduced size une image de {w} × {h} pixels est affichée en taille réduite @msg the image can't be read as a texture l'image ne peut pas être lue comme texture Fill and Stroke Controls Commandes de fond et de contour diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a400c495f..247ffc64e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1484,6 +1484,7 @@ digits Malayalam Malajálam Find in Folder… Keresés mappában… Find Next Következő keresése Find Objects Objektumok keresése + Compare with Image Összehasonlítás képpel Fine Dots Finom pontok Simple Egyszerű @@ -5495,6 +5496,11 @@ imageTrace Outline Körvonal @msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys} Load EPS into Clipboard EPS betöltése a vágólapra @msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: nem PNG, JPEG, WebP, TIFF vagy BMP kép +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ a kép {iw} × {ih} pixeles, a rajztábla ilyen szélességben {rw} × {rh} pixeles: az arányaik eltérnek +@msg `threshold` must be a number from 0 to 1 a `threshold` értéke 0 és 1 közötti szám lehet +@msg background {v}: a color such as "#808080" background {v}: egy szín, például "#808080" +@msg comparing with an image file isn't available in the browser a képfájllal való összehasonlítás a böngészőben nem érhető el @msg an image of {w} × {h} pixels is shown at reduced size egy {w} × {h} pixeles kép csökkentett méretben jelenik meg @msg the image can't be read as a texture a kép nem olvasható be textúraként Fill and Stroke Controls Kitöltés és körvonal vezérlői diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0134649ea..4d0377083 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3058,6 +3058,7 @@ theme Appearance Tampilan Document JSON JSON dokumen Duplicate Duplikatkan Find Objects Cari objek + Compare with Image Bandingkan dengan gambar Inspect Document Periksa dokumen Inspect Object Periksa objek Begin Undo Group Mulai grup urungkan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b19e01f96..44da4c994 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1185,6 +1185,7 @@ font-technology Variable Variabili Find Font… Trova font… Find Next Trova successivo Find Objects Trova oggetti + Compare with Image Confronta con immagine First Page Prima pagina First-line Left Indent Rientro sinistro prima riga Fisheye… Occhio di pesce… @@ -5120,6 +5121,11 @@ lighting Light Luce @msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys} Load EPS into Clipboard Carica EPS negli appunti @msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: non è un'immagine PNG, JPEG, WebP, TIFF o BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ l'immagine è di {iw} × {ih} pixel e la tavola da disegno a quella larghezza è di {rw} × {rh} pixel: le proporzioni sono diverse +@msg `threshold` must be a number from 0 to 1 `threshold` deve essere un numero da 0 a 1 +@msg background {v}: a color such as "#808080" sfondo {v}: un colore come "#808080" +@msg comparing with an image file isn't available in the browser il confronto con un file immagine non è disponibile nel browser @msg an image of {w} × {h} pixels is shown at reduced size un'immagine di {w} × {h} pixel viene mostrata a dimensioni ridotte @msg the image can't be read as a texture l'immagine non può essere letta come texture Basic Base diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 77d50e0a8..07577badf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -154,6 +154,7 @@ digits Malayalam マラヤーラム数字 Find and Replace… 検索と置換… Find Next 次を検索 Find Objects オブジェクトを検索 + Compare with Image 画像と比較 Spelling スペルチェック Auto Spell Check 自動スペルチェック Check Spelling… スペルチェック… @@ -5162,6 +5163,11 @@ lighting Light ライト @msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys} Load EPS into Clipboard EPS をクリップボードに読み込む @msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG、JPEG、WebP、TIFF、BMP のいずれの画像でもありません +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ 画像は {iw} × {ih} ピクセルで、その幅でのアートボードは {rw} × {rh} ピクセルです: 縦横比が異なります +@msg `threshold` must be a number from 0 to 1 `threshold` には 0 から 1 までの数値を指定してください +@msg background {v}: a color such as "#808080" 背景に {v} は指定できません: 「#808080」のようなカラーを指定してください +@msg comparing with an image file isn't available in the browser 画像ファイルとの比較はブラウザーでは使用できません @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} ピクセルの画像は縮小して表示されます @msg the image can't be read as a texture この画像はテクスチャとして読み込めません Basic 基本 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c710ee3c5..6a5581ed4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2876,6 +2876,7 @@ theme Appearance იერსახე Document JSON დოკუმენტის JSON Duplicate დუბლირება Find Objects ობიექტების ძებნა + Compare with Image გამოსახულებასთან შედარება Inspect Document დოკუმენტის შემოწმება Inspect Object ობიექტის შემოწმება Begin Undo Group დაბრუნების ჯგუფის დაწყება @@ -5049,6 +5050,11 @@ imageTrace Outline კონტური @msg {path}: not a file {path}: ფაილი არ არის Load EPS into Clipboard EPS-ის ბუფერში ჩატვირთვა @msg an image of {w} × {h} pixels is too large to draw {w} × {h} პიქსელის გამოსახულება დასახატად ზედმეტად დიდია +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: არ არის PNG, JPEG, WebP, TIFF ან BMP გამოსახულება +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ გამოსახულება არის {iw} × {ih} პიქსელი, ხოლო სამუშაო დაფა ამ სიგანეზე არის {rw} × {rh} პიქსელი: მათი პროპორციები განსხვავდება +@msg `threshold` must be a number from 0 to 1 `threshold` უნდა იყოს რიცხვი 0-დან 1-მდე +@msg background {v}: a color such as "#808080" ფონი {v}: ფერი, მაგალითად "#808080" +@msg comparing with an image file isn't available in the browser გამოსახულების ფაილთან შედარება ბრაუზერში მიუწვდომელია @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} პიქსელის გამოსახულება შემცირებული ზომით ჩანს A later row takes this shortcut ამ მალსახმობს ქვემოთა სტრიქონი იყენებს Add to the Current Shortcuts მიმდინარე მალსახმობებზე დამატება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c0545a3da..cce6ce736 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -147,6 +147,7 @@ digits Malayalam 말라얄람어 Find and Replace… 찾기 및 바꾸기… Find Next 다음 찾기 Find Objects 개체 찾기 + Compare with Image 이미지와 비교 Spelling 맞춤법 Auto Spell Check 자동 맞춤법 검사 Check Spelling… 맞춤법 검사… @@ -5081,6 +5082,11 @@ lighting Light 광원 @msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys} Load EPS into Clipboard EPS를 클립보드로 불러오기 @msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다 +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG, JPEG, WebP, TIFF 또는 BMP 이미지가 아닙니다 +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ 이미지는 {iw} × {ih}픽셀이고 그 너비에서 아트보드는 {rw} × {rh}픽셀입니다: 비율이 다릅니다 +@msg `threshold` must be a number from 0 to 1 `threshold`은(는) 0에서 1 사이의 숫자여야 합니다 +@msg background {v}: a color such as "#808080" 배경 {v}: "#808080" 같은 색상이어야 합니다 +@msg comparing with an image file isn't available in the browser 이미지 파일과 비교하는 기능은 브라우저에서 사용할 수 없습니다 @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h}픽셀 이미지는 축소된 크기로 표시됩니다 @msg the image can't be read as a texture 이미지를 텍스처로 읽을 수 없습니다 Basic 기본 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 72d4ea21a..bd525526b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3072,6 +3072,7 @@ theme Appearance Vormgeving Document JSON Document-JSON Duplicate Dupliceren Find Objects Objecten zoeken + Compare with Image Vergelijken met afbeelding Inspect Document Document inspecteren Inspect Object Object inspecteren Begin Undo Group Groep voor ongedaan maken beginnen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ff2b9bd6b..adfb669e8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3195,6 +3195,7 @@ theme Appearance Wygląd Document JSON JSON dokumentu Duplicate Powiel Find Objects Znajdź obiekty + Compare with Image Porównaj z obrazem Inspect Document Zbadaj dokument Inspect Object Zbadaj obiekt Begin Undo Group Rozpocznij grupę cofania @@ -5350,6 +5351,11 @@ mark-style Japanese Japońskie @msg unknown parameter `{key}`; the command takes: {keys} nieznany parametr `{key}`; polecenie przyjmuje: {keys} Load EPS into Clipboard Wczytaj EPS do schowka @msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: to nie jest obraz PNG, JPEG, WebP, TIFF ani BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ obraz ma {iw} × {ih} pikseli, a obszar kompozycji przy tej szerokości ma {rw} × {rh} pikseli: ich proporcje się różnią +@msg `threshold` must be a number from 0 to 1 `threshold` musi być liczbą od 0 do 1 +@msg background {v}: a color such as "#808080" tło {v}: kolor, np. "#808080" +@msg comparing with an image file isn't available in the browser porównywanie z plikiem obrazu nie jest dostępne w przeglądarce @msg an image of {w} × {h} pixels is shown at reduced size obraz o wymiarach {w} × {h} pikseli jest wyświetlany w zmniejszonym rozmiarze Basic Podstawowy Fill and Stroke Controls Elementy sterujące wypełnieniem i obrysem diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3340597d8..183d41dc1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3343,6 +3343,7 @@ font-technology Variable Variáveis Favorites Favoritos Fill (X), double-click for the Color Picker Preenchimento (X), clique duas vezes para o seletor de cores Find Objects Localizar objetos + Compare with Image Comparar com imagem Flare Tool Options Opções da ferramenta Reflexo Halo Halo Rays Raios @@ -5447,6 +5448,11 @@ mark-style Japanese Japonesas @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma @msg an image of {w} × {h} pixels is too large to draw uma imagem de {w} × {h} pixels é grande demais para ser desenhada +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: não é uma imagem PNG, JPEG, WebP, TIFF ou BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ a imagem tem {iw} × {ih} pixels e a prancheta nessa largura tem {rw} × {rh} pixels: as proporções são diferentes +@msg `threshold` must be a number from 0 to 1 `threshold` deve ser um número de 0 a 1 +@msg background {v}: a color such as "#808080" plano de fundo {v}: uma cor como "#808080" +@msg comparing with an image file isn't available in the browser a comparação com um arquivo de imagem não está disponível no navegador @msg an image of {w} × {h} pixels is shown at reduced size uma imagem de {w} × {h} pixels é exibida em tamanho reduzido Fill and Stroke Controls Controles de Preenchimento e Traçado Coloring Controls Controles de Coloração diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3732558f7..544050c9a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1182,6 +1182,7 @@ font-technology Variable Вариативные Find Font… Найти шрифт… Find Next Найти далее Find Objects Найти объекты + Compare with Image Сравнить с изображением First Page Первая страница First-line Left Indent Отступ слева в первой строке Fisheye… Рыбий глаз… @@ -5115,6 +5116,11 @@ lighting Light Свет @msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys} Load EPS into Clipboard Загрузить EPS в буфер обмена @msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: это не изображение PNG, JPEG, WebP, TIFF или BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ размер изображения {iw} × {ih} пикселей, а монтажной области при этой ширине {rw} × {rh} пикселей: их пропорции различаются +@msg `threshold` must be a number from 0 to 1 `threshold` должно быть числом от 0 до 1 +@msg background {v}: a color such as "#808080" фон {v}: цвет, например "#808080" +@msg comparing with an image file isn't available in the browser сравнение с файлом изображения недоступно в браузере @msg an image of {w} × {h} pixels is shown at reduced size изображение размером {w} × {h} пикселей показано в уменьшенном размере @msg the image can't be read as a texture изображение не удаётся прочитать как текстуру Basic Базовый diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 58ceab701..f007400d9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1148,6 +1148,7 @@ font-technology Variable Variabla Find Font… Sök typsnitt… Find Next Sök nästa Find Objects Sök objekt + Compare with Image Jämför med bild First Page Första sidan First-line Left Indent Vänsterindrag för första raden Fisheye… Fisköga… @@ -5412,6 +5413,11 @@ mark-style Japanese Japanska theme Appearance Utseende Load EPS into Clipboard Läs in EPS till urklipp @msg an image of {w} × {h} pixels is too large to draw en bild på {w} × {h} pixlar är för stor för att ritas +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: inte en PNG-, JPEG-, WebP-, TIFF- eller BMP-bild +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ bilden är {iw} × {ih} pixlar och ritytan i den bredden är {rw} × {rh} pixlar: proportionerna skiljer sig åt +@msg `threshold` must be a number from 0 to 1 `threshold` måste vara ett tal från 0 till 1 +@msg background {v}: a color such as "#808080" background {v}: en färg som "#808080" +@msg comparing with an image file isn't available in the browser jämförelse med en bildfil är inte tillgänglig i webbläsaren @msg an image of {w} × {h} pixels is shown at reduced size en bild på {w} × {h} pixlar visas i förminskad storlek Fill and Stroke Controls Kontroller för fyllning och kontur Coloring Controls Kontroller för färgläggning diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9385b30cc..3c28f018c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1137,6 +1137,7 @@ font-technology Variable Değişken Find Font… Yazı Tipi Bul… Find Next Sonrakini Bul Find Objects Nesneleri Bul + Compare with Image Görüntüyle Karşılaştır First Page İlk Sayfa First-line Left Indent Birinci Satırda Sol Girinti Fisheye… Balıkgözü… @@ -4017,6 +4018,11 @@ axis Both Her İkisi @msg invalid image data for `{0}` `{0}` için geçersiz resim verileri @msg invalid parameters for `{cmd}`: {msg} `{cmd}` için geçersiz parametreler: {msg} @msg is not an image VectorCraft reads VectorCraft'ın okuduğu bir resim değil +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG, JPEG, WebP, TIFF veya BMP resmi değil +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ resim {iw} × {ih} piksel, çalışma yüzeyi bu genişlikte {rw} × {rh} piksel: oranları farklı +@msg `threshold` must be a number from 0 to 1 `threshold` 0 ile 1 arasında bir sayı olmalıdır +@msg background {v}: a color such as "#808080" background {v}: "#808080" gibi bir renk +@msg comparing with an image file isn't available in the browser bir resim dosyasıyla karşılaştırma tarayıcıda kullanılamaz @msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know VectorCraft'ın PostScript okuyucusunun bilmediği `{at}`yi kullanıyor @msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} {procs}'de VectorCraft'ın PostScript okuyucusunun bilmediği `{at}`yi kullanıyor @msg its Windows metafile preview was opened instead, which may simplify the art bunun yerine Windows meta dosyası önizlemesi açıldı, bu da işi basitleştirebilir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1b96c235e..7de17b0fc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1179,6 +1179,7 @@ font-technology Variable Варіативні Find Font… Знайти шрифт… Find Next Знайти далі Find Objects Знайти об’єкти + Compare with Image Порівняти із зображенням First Page Перша сторінка First-line Left Indent Відступ ліворуч у першому рядку Fisheye… Риб’яче око… @@ -5100,6 +5101,11 @@ lighting Light Світло @msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys} Load EPS into Clipboard Завантажити EPS у буфер обміну @msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: це не зображення PNG, JPEG, WebP, TIFF чи BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ розмір зображення {iw} × {ih} пікселів, а монтажної області за цієї ширини {rw} × {rh} пікселів: їхні пропорції відрізняються +@msg `threshold` must be a number from 0 to 1 `threshold` має бути числом від 0 до 1 +@msg background {v}: a color such as "#808080" фон {v}: колір, наприклад "#808080" +@msg comparing with an image file isn't available in the browser порівняння з файлом зображення недоступне в браузері @msg an image of {w} × {h} pixels is shown at reduced size зображення розміром {w} × {h} пікселів показано в зменшеному розмірі @msg the image can't be read as a texture зображення не вдається прочитати як текстуру Basic Базовий diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ce98bc978..678eddbce 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1389,6 +1389,7 @@ digits Malayalam Malayalam Find Linked Files in Folder… Tìm tệp liên kết trong thư mục… Find Next Tìm tiếp Find Objects Tìm đối tượng + Compare with Image So sánh với ảnh Fine Dots Chấm mịn Simple Đơn giản @@ -5104,6 +5105,11 @@ theme Appearance Giao diện @msg {tool} isn't in a toolbar group of several tools {tool} không nằm trong nhóm thanh công cụ có nhiều công cụ Load EPS into Clipboard Tải EPS vào bảng tạm @msg an image of {w} × {h} pixels is too large to draw ảnh {w} × {h} điểm ảnh quá lớn nên không thể vẽ +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: không phải ảnh PNG, JPEG, WebP, TIFF hay BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ ảnh có kích thước {iw} × {ih} điểm ảnh, còn bảng vẽ ở chiều rộng đó có kích thước {rw} × {rh} điểm ảnh: tỉ lệ của chúng khác nhau +@msg `threshold` must be a number from 0 to 1 `threshold` phải là một số từ 0 đến 1 +@msg background {v}: a color such as "#808080" background {v}: màu như "#808080" +@msg comparing with an image file isn't available in the browser không thể so sánh với tệp ảnh trong trình duyệt @msg an image of {w} × {h} pixels is shown at reduced size ảnh {w} × {h} điểm ảnh được hiển thị ở kích thước thu nhỏ Basic Cơ bản Fill and Stroke Controls Điều khiển tô và nét diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2cf40630..9a3346839 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1125,6 +1125,7 @@ Find Font… 查找字体… Find Next 查找下一个 Find Objects 查找对象 + Compare with Image 与图像比较 First Page 第一页 First-line Left Indent 首行左缩进 Fisheye… 鱼眼… @@ -5386,6 +5387,11 @@ lighting Light 光源 @msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys} Load EPS into Clipboard 将 EPS 载入剪贴板 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制 +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 图像 +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ 图像为 {iw} × {ih} 像素,而画板在该宽度下为 {rw} × {rh} 像素:两者的比例不同 +@msg `threshold` must be a number from 0 to 1 `threshold` 必须是 0 到 1 之间的数字 +@msg background {v}: a color such as "#808080" background {v}:颜色,例如 "#808080" +@msg comparing with an image file isn't available in the browser 浏览器中无法与图像文件进行比较 @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} 像素的图像以缩小的尺寸显示 Fill and Stroke Controls 填色和描边控件 Coloring Controls 颜色控件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9440e225f..4b7107054 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1150,6 +1150,7 @@ font-technology Variable 可變 Find Font… 尋找字體… Find Next 尋找下一個 Find Objects 尋找物件 + Compare with Image 與影像比較 First Page 第一頁 First-line Left Indent 首行左縮排 Fisheye… 魚眼… @@ -5200,6 +5201,11 @@ lighting Light 光源 @msg unknown parameter `{key}`; the command takes: {keys} 未知的參數 `{key}`;此指令接受:{keys} Load EPS into Clipboard 將 EPS 載入剪貼簿 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製 +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 影像 +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ 影像為 {iw} × {ih} 像素,而工作區域在該寬度下為 {rw} × {rh} 像素:兩者的比例不同 +@msg `threshold` must be a number from 0 to 1 `threshold` 必須是 0 到 1 之間的數字 +@msg background {v}: a color such as "#808080" background {v}:顏色,例如 "#808080" +@msg comparing with an image file isn't available in the browser 瀏覽器中無法與影像檔案進行比較 @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} 像素的影像以縮小的尺寸顯示 Basic 基本 Fill and Stroke Controls 填色和筆畫控制項 diff --git a/docs/mcp.md b/docs/mcp.md index ae04c1647..6b4a6bbeb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -269,6 +269,22 @@ chain (layer first) and `limit` caps `matches` (default 100; `0` only counts); w `selected`, the number of objects selected. `document.json` stays whole: it is the fidelity path, not the way to look around. +`run_command document.compare {path, artboard?, background?, threshold?, diffPath?}` measures how far an +artboard is from an image file (PNG, JPEG, WebP, TIFF or BMP). It renders the +artboard (default 0) at the image's size, as `export` draws a PNG: the scale is the image's width in pixels over +the artboard's width in points, and an image whose height differs from the artboard's height at that scale by +more than one pixel is an error that names both sizes. The render and the image are both flattened on +`background` (`"#rrggbb"`, default `"#ffffff"`). The reply is `{width, height, rmse, over, total, threshold}`: +`rmse` is the root mean square of the R, G and B differences on a 0 to 1 scale, rounded to 6 decimals, and +`over` counts the pixels with a channel that differs by more than `threshold` (0 to 1, default 0.08) of the +total `total`. `diffPath` writes a PNG of each pixel's absolute R, G and B differences (black where the two +match) and adds `diffPath` to the reply. As in a PNG export, linked images and placed documents are drawn +from their files rather than from the previews saved for them. The reply's `warnings` are a PNG export's, in +the same order: type on the artboard that doesn't fit its frame, an embedded image that couldn't be decoded (it +is missing from the render), type in a font or style that isn't available, and a linked file that can't be read +(its preview is drawn). `path` is read through the automation read root, and `diffPath` is written through the +write root. The command does not change the document, and it is not available in the web app. + ### Completions `completion/complete` answers for a prompt argument (`ref/prompt`) or a template variable From 88881288225438bd14efd5df8fb7eb39211c7a16 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:44:09 +0700 Subject: [PATCH 03/51] Image Trace: Flat Logo can trace only the named colors, without a white of its own Squashed from #3946 onto main. --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/tests/build.rs | 37 ++++++++++++ crates/trace/src/logo/pipeline.rs | 63 +++++++++++++++----- crates/trace/src/logo/tests.rs | 96 ++++++++++++++++++++++++++++++ crates/trace/src/params.rs | 12 +++- docs/image-trace-adjustments.md | 9 +++ docs/roadmap.md | 1 + docs/target-app-parity.md | 1 + 8 files changed, 204 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 442b68c58..f54752632 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index b4be82c2e..9e397975f 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3825,6 +3825,43 @@ fn image_trace_flat_logo_refuses_a_gradient_and_a_bad_palette() { assert_eq!(r["colors"], 1); } +#[test] +fn image_trace_flat_logo_can_trace_only_the_named_colors() { + // A light disc in a black ring, a blue square on it, and a white spot. + let art = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let d = (fx - 60.0).hypot(fy - 60.0); + if (fx - 32.0).hypot(fy - 40.0) <= 6.0 { + [255, 255, 255, 255] + } else if (45..75).contains(&x) && (45..75).contains(&y) { + [40, 120, 240, 255] + } else if d <= 46.0 { + [240, 240, 220, 255] + } else if d <= 50.0 { + [0, 0, 0, 255] + } else { + [0, 0, 0, 0] + } + }); + let fills = |logo_white: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "logo", "logoColors": ["#000000", "#F0F0DC", "#2878F0"], "logoWhite": logo_white}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let mut hex: Vec = g.children().unwrap().iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect(); + hex.sort(); + hex.dedup(); + (r["colors"].clone(), hex) + }; + let (colors, hex) = fills(true); + assert_eq!(colors, 4); + assert!(hex.contains(&"#ffffff".to_string()), "{hex:?}"); + let (colors, hex) = fills(false); + assert_eq!(colors, 3); + assert_eq!(hex, ["#000000", "#2878f0", "#f0f0dc"]); +} + #[test] fn image_trace_object_remembers_its_settings() { let mut s = session(); diff --git a/crates/trace/src/logo/pipeline.rs b/crates/trace/src/logo/pipeline.rs index 02955b146..b8382c5a7 100644 --- a/crates/trace/src/logo/pipeline.rs +++ b/crates/trace/src/logo/pipeline.rs @@ -353,22 +353,38 @@ fn plane_at(planes: &[Vec], class: usize, i: usize) -> f32 { planes.get(class).and_then(|p| p.get(i)).copied().unwrap_or(0.0) } -/// The unmixing classes for a palette: each colour, then white, then transparent. -fn classes_for(palette: &[[u8; 3]]) -> Vec<[f64; 4]> { +/// The unmixing classes for a palette: each colour, then white (when `own_white`), then transparent. +fn classes_for(palette: &[[u8; 3]], own_white: bool) -> Vec<[f64; 4]> { let mut classes: Vec<[f64; 4]> = palette.iter().map(|c| [f64::from(c[0]) / 255.0, f64::from(c[1]) / 255.0, f64::from(c[2]) / 255.0, 1.0]).collect(); - classes.push([1.0, 1.0, 1.0, 1.0]); + if own_white { + classes.push([1.0, 1.0, 1.0, 1.0]); + } classes.push([0.0, 0.0, 0.0, 0.0]); classes } +/// Whether white is traced as a class of its own: always, unless the caller named the colors and +/// turned [`TraceParams::logo_white`] off. With Ignore White it is, so white areas are left out. +fn own_white(params: &TraceParams) -> bool { + params.logo_white || params.logo_colors.is_empty() || params.ignore_white +} + +/// Without a white class of its own: the palette color that takes white's coverage, the one +/// nearest white (the first of equally near ones). `None` when a named color is white already +/// (closer than [`SAME_COLOUR`]), so white needs no class to unmix with. +fn white_goes_to(palette: &[[u8; 3]]) -> Option { + let (i, d) = palette.iter().enumerate().map(|(i, &c)| (i, distance(c, [255; 3]))).min_by(|a, b| a.1.total_cmp(&b.1))?; + (d >= SAME_COLOUR).then_some(i) +} + fn distance(a: [u8; 3], b: [u8; 3]) -> f64 { (0..3).map(|k| (f64::from(a[k]) - f64::from(b[k])).powi(2)).sum::().sqrt() } type Colours = Vec<[u8; 3]>; -/// The colours to trace: those the caller named (white is always its own class so it is skipped; +/// The colours to trace: those the caller named (white is skipped when it is its own class; /// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found, /// the tones of one colour folded into it ([`fold_tones`]); and with them the colours before folding. fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<(Colours, Colours), TraceError> { @@ -380,7 +396,7 @@ fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Resul for s in ¶ms.logo_colors { // Every entry is checked, but only the first few distinct colours are kept. let c = parse_hex(s).ok_or_else(|| TraceError::BadPalette(s.clone()))?; - let white = c.iter().all(|&v| v >= WHITE_MIN); + let white = own_white(params) && c.iter().all(|&v| v >= WHITE_MIN); if palette.len() < MAX_COLOURS && !white && !palette.iter().any(|&q| distance(q, c) < SAME_COLOUR) { palette.push(c); } @@ -405,7 +421,11 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) if fraction < MIN_FLAT { return Err(TraceError::NotFlat { fraction }); } - let mut classes = classes_for(&palette); + let own_white = own_white(params); + // Without a white of its own, white is still unmixed as a class, so blended edges split where + // they are, and its coverage is then added to the color it goes to. + let merge_white = if own_white { None } else { white_goes_to(&palette) }; + let mut classes = classes_for(&palette, own_white || merge_white.is_some()); // A very busy image is averaged down further until the unmixing work is bounded. for _ in 0..6 { if count_ambiguous(&pix, &classes) <= EDGE_BUDGET || w * h <= 16_384 { @@ -423,15 +443,28 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len())); // (A colour nothing explained may be one more tone of a colour that is there.) palette = fold_tones(&pix, palette); - classes = classes_for(&palette); + classes = classes_for(&palette, own_white); unmix(&pix, w, h, &classes).0 } } else { std::mem::take(&mut planes) }; - let (white, transparent) = (palette.len(), palette.len() + 1); + if let Some(to) = merge_white + && palette.len() < planes.len() + { + let white_plane = planes.remove(palette.len()); + if let Some(target) = planes.get_mut(to) { + for (t, v) in target.iter_mut().zip(&white_plane) { + *t += *v; + } + } + } + // Without a white class, `white` is an index no class has. + let (white, transparent) = if own_white { (palette.len(), palette.len() + 1) } else { (usize::MAX, palette.len()) }; let aliased = soften_if_aliased(&mut planes, w, h); - fill_knockouts(&mut planes, w, h, white, transparent); + if own_white { + fill_knockouts(&mut planes, w, h, white, transparent); + } let n = w * h; let dominant: Vec = (0..n) .map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(&planes, a, i).total_cmp(&plane_at(&planes, b, i))).unwrap_or(transparent) as u8) @@ -474,7 +507,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) let to_source = ToSource { factor: factor as f64, work: (w, h), source: (w0, h0) }; let mut shapes: Vec = Vec::new(); let mut anchors_made = 0usize; - for class in 0..=white { + for class in 0..transparent { if class == white && params.ignore_white { continue; } @@ -488,8 +521,9 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) if a2.abs() / 2.0 < min_area { continue; } - // Holes are cut only under Ignore White; otherwise white and the other colors are traced as their own shapes. - if a2 >= 0.0 && !params.ignore_white { + // Holes are cut only under Ignore White or with no white class; otherwise white and the other colors are + // traced as their own shapes. + if a2 >= 0.0 && !(params.ignore_white || !own_white) { continue; } // More real outlines than we will fit is refused, not silently truncated. @@ -499,8 +533,9 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) let pts = to_pixels(&lp, u); if a2 < 0.0 { outers.push((a2.abs() / 2.0, pts)) } else { holes.push(pts) } } - // Each hole belongs to the smallest outline that contains it, and to no other. - let owner: Vec> = if params.ignore_white { + // Each hole belongs to the smallest outline that contains it, and to no other. (Holes are + // cut where white does not paint over them: with Ignore White, or with no white class.) + let owner: Vec> = if params.ignore_white || !own_white { holes .iter() .map(|hole| { diff --git a/crates/trace/src/logo/tests.rs b/crates/trace/src/logo/tests.rs index 2335fe77e..8d3f6a79f 100644 --- a/crates/trace/src/logo/tests.rs +++ b/crates/trace/src/logo/tests.rs @@ -62,6 +62,9 @@ fn render(w: u32, h: u32, blur: f64, shade: impl Fn(f64, f64) -> Option<[u8; 3]> const RED: [u8; 3] = [210, 40, 50]; const BLUE: [u8; 3] = [40, 80, 200]; +const LIGHT: [u8; 3] = [240, 240, 220]; +const SKY: [u8; 3] = [40, 120, 240]; +const WHITE: [u8; 3] = [255, 255, 255]; fn logo_params() -> TraceParams { TraceParams { @@ -205,6 +208,99 @@ fn the_named_palette_is_used_exactly_and_a_bad_one_is_an_error() { assert!(matches!(trace_within(&img, &bad, 1000), Err(TraceError::BadPalette(s)) if s == "red")); } +/// The color of the topmost path at `p` (paths are listed bottom first). +fn top_color(res: &TraceResult, p: Point) -> Option<[u8; 3]> { + res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color) +} + +#[test] +fn logo_white_off_traces_only_the_named_colors() { + // A light disc in a black ring, a blue square on it, and two white spots. + let img = render(120, 120, 0.7, |x, y| { + let d = (x - 60.0).hypot(y - 60.0); + if (x - 32.0).hypot(y - 40.0) <= 6.0 || (x - 78.0).hypot(y - 86.0) <= 6.0 { + Some(WHITE) + } else if (45.0..=75.0).contains(&x) && (45.0..=75.0).contains(&y) { + Some(SKY) + } else if d <= 46.0 { + Some(LIGHT) + } else if d <= 50.0 { + Some([0, 0, 0]) + } else { + None + } + }); + let named = TraceParams { logo_colors: vec!["#000000".into(), "#F0F0DC".into(), "#2878F0".into()], ..logo_params() }; + // By default white is a fourth color. + let res = trace(&img, &named); + assert_eq!(res.palette.len(), 4, "{:?}", res.palette); + assert_eq!(top_color(&res, Point::new(32.0, 40.0)), Some(WHITE)); + // Without it, the three named colors only: the white spots take the light color. + let res = trace(&img, &TraceParams { logo_white: false, ..named.clone() }); + assert_eq!(res.palette.len(), 3, "{:?}", res.palette); + assert!(!res.palette.contains(&WHITE)); + for p in [Point::new(32.0, 40.0), Point::new(78.0, 86.0), Point::new(20.0, 60.0)] { + assert_eq!(top_color(&res, p), Some(LIGHT), "at {p:?}"); + } + assert_eq!(top_color(&res, Point::new(60.0, 60.0)), Some(SKY)); + assert_eq!(top_color(&res, Point::new(60.0, 12.0)), Some([0, 0, 0])); + assert!(!covered(&res, Point::new(5.0, 5.0))); + // Ignore White still leaves the white spots out. + let res = trace(&img, &TraceParams { logo_white: false, ignore_white: true, ..named.clone() }); + for p in [Point::new(32.0, 40.0), Point::new(78.0, 86.0)] { + assert!(!covered(&res, p), "at {p:?}"); + } + assert_eq!(top_color(&res, Point::new(20.0, 60.0)), Some(LIGHT)); +} + +#[test] +fn logo_white_off_keeps_a_named_white_and_knockouts_as_holes() { + let disc = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(WHITE)); + let only_white = TraceParams { logo_colors: vec!["#ffffff".into()], logo_white: false, ..logo_params() }; + let res = trace(&disc, &only_white); + assert_eq!(res.paths.len(), 1); + assert_eq!(res.paths[0].color, WHITE); + // An enclosed transparent area stays a hole: there is no white to paint it with. + let ring = render(64, 64, 0.7, |x, y| (12.0..=25.0).contains(&(x - 32.0).hypot(y - 32.0)).then_some(BLUE)); + let res = trace(&ring, &TraceParams { logo_colors: vec!["#2850c8".into()], logo_white: false, ..logo_params() }); + assert_eq!(res.paths.len(), 1); + assert_eq!(res.paths[0].path.subpaths.len(), 2, "outer outline and a hole"); + assert!(!covered(&res, Point::new(32.0, 32.0)), "the hole must be empty"); + assert!(covered(&res, Point::new(32.0, 32.0 + 18.0)), "the ring itself is filled"); +} + +#[test] +fn logo_white_off_gives_each_white_the_nearest_named_color() { + // Red and blue are equally far from white: the white disc takes the first one named. + let disc = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(WHITE)); + let params = TraceParams { logo_colors: vec!["#ff0000".into(), "#0000ff".into()], logo_white: false, ..logo_params() }; + let res = trace(&disc, ¶ms); + assert_eq!(top_color(&res, Point::new(32.0, 32.0)), Some([255, 0, 0])); + assert!(!covered(&res, Point::new(5.0, 5.0))); + // Each white takes the named color nearest it, so two named whites both stay. + const OFF: [u8; 3] = [245, 245, 245]; + let two = render(96, 64, 0.7, |x, y| { + if (x - 24.0).hypot(y - 32.0) <= 16.0 { + Some(OFF) + } else if (x - 72.0).hypot(y - 32.0) <= 16.0 { + Some(WHITE) + } else { + None + } + }); + let params = TraceParams { logo_colors: vec!["#f5f5f5".into(), "#ffffff".into()], logo_white: false, ..logo_params() }; + let res = trace(&two, ¶ms); + assert_eq!(top_color(&res, Point::new(24.0, 32.0)), Some(OFF)); + assert_eq!(top_color(&res, Point::new(72.0, 32.0)), Some(WHITE)); + // A white square on blue keeps its edges where they are when it turns red. + let square = render(100, 100, 0.7, |x, y| Some(if (30.0..70.0).contains(&x) && (30.0..70.0).contains(&y) { WHITE } else { [0, 0, 255] })); + let res = trace(&square, &TraceParams { logo_colors: vec!["#ff0000".into(), "#0000ff".into()], logo_white: false, ..logo_params() }); + for (inside, outside) in [(30.3, 29.7), (69.7, 70.3)] { + assert_eq!(top_color(&res, Point::new(inside, 50.0)), Some([255, 0, 0]), "at x {inside}"); + assert_eq!(top_color(&res, Point::new(outside, 50.0)), Some([0, 0, 255]), "at x {outside}"); + } +} + #[test] fn a_large_image_is_traced_at_reduced_size_and_scaled_back() { let (cx, cy, r) = (1200.0, 600.0, 400.0); diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index dd5bc9929..65e5bb5c0 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -110,9 +110,16 @@ pub struct TraceParams { /// Stroke: the widest feature (px) traced as a stroke. pub stroke_width: f64, /// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is - /// always traced on its own and cannot be named, colours closer than about 12 (RGB distance) - /// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.) + /// traced on its own and cannot be named unless [`logo_white`](Self::logo_white) is off, colours + /// closer than about 12 (RGB distance) count as one, and at most ten are used. (Not `palette`: + /// that is Color mode's colour source.) pub logo_colors: Vec, + /// Flat Logo mode with named `logo_colors`: `true` traces white (every channel 245 or more) as a + /// color of its own beside the named ones. `false` traces the named colors only: white areas + /// take the nearest of them, a named white or near-white is used as named, and enclosed + /// transparent areas stay holes. With the colors found automatically, white is always its own, + /// and with [`ignore_white`](Self::ignore_white) white areas are left out whatever this says. + pub logo_white: bool, } impl Default for TraceParams { @@ -141,6 +148,7 @@ impl Default for TraceParams { strokes: false, stroke_width: 10.0, logo_colors: Vec::new(), + logo_white: true, } } } diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 92e5f42ac..d0ac9fda7 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -32,6 +32,15 @@ white beside a cream), at most six; scattered specks of a colour add none. Adobe its own palettes are chosen; this is VectorCraft's own method (see [image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)). +**Flat Logo colors.** Flat Logo traces white as a color of its own beside the colors named in +`logoColors`, and takes a named color with every channel 245 or more as that white. Each pixel is read +as a blend of the traced colors and goes to the color with the largest share, so a near-white area can +go to a named light color instead of white. With `"logoWhite": false` in the params of `imageTrace.make` or +`imageTrace.makeAndExpand`, it traces the named colors only: white areas take the nearest named color, +a named white or near-white is used as named, and enclosed transparent areas stay holes instead of +being painted white. When Flat Logo finds the colors itself, `logoWhite` has no effect, and with +`ignoreWhite` white areas are left out whatever `logoWhite` says. + **Document Library color groups.** With Palette › Document Library and Library › Document Swatches, the panel's **Colors** menu traces with every document swatch (**All**) or with the colors of one color group from the Swatches panel, as the public diff --git a/docs/roadmap.md b/docs/roadmap.md index e1a7d3360..5e6ee6dda 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -136,6 +136,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | +| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; Flat Logo with named colors can leave out white with `logoWhite: false`; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ec7b76edf..d1d0c19e6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1090,6 +1090,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. +- **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. From 743b8031d3c3b2ead233272a775cf35ca875845a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:02:38 +0700 Subject: [PATCH 04/51] Document: the compare test expects main's wording for one missing linked image --- crates/engine/src/tests/compare.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tests/compare.rs b/crates/engine/src/tests/compare.rs index 03d788487..3bd4446d6 100644 --- a/crates/engine/src/tests/compare.rs +++ b/crates/engine/src/tests/compare.rs @@ -138,7 +138,7 @@ fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() { let r = compare(&mut s, json!({"path": out})); assert_eq!(r["over"].as_u64(), r["total"].as_u64(), "{r}"); let warnings = r["warnings"].as_array().unwrap(); - assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("output as their previews"), "{r}"); + assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("1 linked image output as its preview"), "{r}"); // With type that doesn't fit its frame as well, the warnings come in the export's order. let overset = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}})).unwrap(); From f118cf6aa654721c8ff3a11edbfe34e7ed605932 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:44:44 +0700 Subject: [PATCH 05/51] Image Trace: Expand can group the traced paths by color Squashed from #4058 onto main. --- crates/engine/src/cmd/buildcmds.rs | 4 +- crates/engine/src/cmd/buildcmds/trace.rs | 347 ++++++++++++++++++++++- crates/engine/src/tests/build.rs | 310 ++++++++++++++++++++ crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/mcp.md | 11 + 25 files changed, 732 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index f54752632..1b37ca96f 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "Make and Expand", ["Object", "Image Trace"], None, - "{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}", + "{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}", has_selection_or_ids, |s, p| trace_make(s, p, true) ), @@ -171,7 +171,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Image Trace"], None, - "{id?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}", + "{id?, ids?: [id…] (instead of id), byColor?: false (each color's paths in a group of their own, named by the swatch the color links to or as #rrggbb, gradient fills in one group named Gradients; the groups stack by their lowest path and keep their paths' order; a color whose paths overlap another color's from above and below gets a group for each part, so the art draws the same; refused for a trace made with autoGrouping and for a trace with a live Pathfinder effect)} keep only the traced paths (drop the source image) → {ids}", has_selection_or_ids, trace_expand ), diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 00bcc1c83..1924ed405 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -4,10 +4,16 @@ use vectorcraft_color::Swatch; use vectorcraft_doc::ImageObject; use super::{ - Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, + Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, Rect, Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr, }; +#[cfg(test)] +thread_local! { + static BOX_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static CANDIDATE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // ---------- Image Trace ---------- fn is_trace_group(n: &Node) -> bool { @@ -16,6 +22,8 @@ fn is_trace_group(n: &Node) -> bool { && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) } +const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor"; + /// The `view` param ([`TraceView`] id), if given. fn view_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; @@ -368,6 +376,156 @@ fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() } +/// The `byColor` param of an Expand (false when not given). +fn by_color_param(cmd: &str, p: &Value) -> Result { + match p.get("byColor").filter(|v| !v.is_null()) { + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "byColor must be true or false")), + None => Ok(false), + } +} + +/// Expand by color: an expanded trace's `items` (bottom-most first) in a group for each paint, a +/// stroked line counted by its stroke. A solid color's group is named by the swatch it links to (a +/// tint with its percentage) or as `#rrggbb`, and gradient fills share one group, "Gradients". The +/// groups stack in the order of the lowest item in each, and each keeps its items' order. When the +/// paths meet, an item joins its paint's last group only if no item of a later group meets it +/// (by the boxes of everything they paint, effects included, within one point); otherwise it +/// starts another group of that paint, which keeps the stacking order of those items. Items +/// farther apart can still share antialiased pixels at small view or raster-export scales, so +/// their edge pixels may change. A group among `items` (Auto Grouping) is refused. +fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result>> { + use std::collections::{BTreeMap, HashMap}; + use std::ops::Bound::{Excluded, Unbounded}; + use vectorcraft_color::tint_percent; + // Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a + // gradient, or another paint (its label). The name is apart from it: a swatch may be named as + // a color's `#rrggbb` or as "Gradients". + type PaintKey = (u8, String, u32); + struct Run { + name: String, + members: Vec>, + } + // Boxes closer than a point count as overlapping: their antialiased edges can share pixels. + let meet = |a: &Rect, b: &Rect| { + #[cfg(test)] + BOX_TESTS.with(|n| n.set(n.get() + 1)); + let b = b.inflate(1.0, 1.0); + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 + }; + // Choose a cell size from the painted extent and path count. A box over more than eight cells + // across but at most eight down (a stripe) is indexed by its rows, one the other way round by + // its columns, and one over more than eight both ways stays in a separate list, keeping the + // index bounded even when a path covers most of the document. + let items: Vec<_> = items + .into_iter() + .map(|item| { + let bounds = vectorcraft_render::painted_bounds(d, &item); + (item, bounds) + }) + .collect(); + let extent = items.iter().filter_map(|(_, b)| *b).reduce(|a, b| a.union(b)); + let cell_size = extent.map_or(1.0, |r| ((r.x1 - r.x0).abs() * (r.y1 - r.y0).abs() / items.len().max(1) as f64).sqrt().max(1.0)); + // The first and last column and row of the cells `r` covers; `None` when it isn't finite. + let span = |r: Rect| -> Option<(i64, i64, i64, i64)> { + let coord = |v: f64| (v / cell_size).floor() as i64; + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then(|| (coord(r.x0), coord(r.y0), coord(r.x1), coord(r.y1))) + }; + let count = |first: i64, last: i64| i128::from(last) - i128::from(first) + 1; + // A box over more cells than this is checked against every box. + const MAX_CELLS: i128 = 1024; + let mut groups: Vec = vec![]; + let mut latest: HashMap = HashMap::new(); + // An item can join an earlier group, so insertion order is not group-index order. Index + // boxes by group to skip older groups without scanning their entries. + let mut spatial: HashMap<(i64, i64), BTreeMap>> = HashMap::new(); + let mut rows: HashMap>> = HashMap::new(); + let mut columns: HashMap>> = HashMap::new(); + let mut large: BTreeMap> = BTreeMap::new(); + let mut all_boxes: BTreeMap> = BTreeMap::new(); + for (item, bounds) in items { + if matches!(item.kind, NodeKind::Group { .. }) { + return Err(bad(cmd, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } + let paint = match item.appearance.fill_paint() { + Paint::None => item.appearance.stroke_paint(), + fill => fill, + }; + let (key, name) = match &paint { + Paint::Solid { swatch: Some(n), tint, .. } => { + let percent = tint_percent(*tint); + let name = if *tint < 1.0 { format!("{n} {percent}%") } else { n.clone() }; + ((1, n.clone(), tint.to_bits()), name) + } + Paint::Solid { color, .. } => ((0, color.to_hex(), 0), color.to_hex()), + Paint::Gradient(_) => ((2, String::new(), 0), "Gradients".to_string()), + other => ((3, other.label(), 0), other.label()), + }; + // Joining an earlier group moves the item under every later group's items. + let hidden_by_later = |at: usize| { + bounds.is_some_and(|b| { + let overlaps_later = |boxes: &BTreeMap>| { + boxes.range((Excluded(at), Unbounded)).rev().any(|(_, group_boxes)| { + group_boxes.iter().rev().any(|box_bounds| { + #[cfg(test)] + CANDIDATE_VISITS.with(|n| n.set(n.get() + 1)); + meet(box_bounds, &b) + }) + }) + }; + match span(b) { + Some((x0, y0, x1, y1)) if count(x0, x1) * count(y0, y1) <= MAX_CELLS => { + (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).filter_map(|c| spatial.get(&c)).any(overlaps_later) + || (y0..=y1).filter_map(|y| rows.get(&y)).any(overlaps_later) + || (x0..=x1).filter_map(|x| columns.get(&x)).any(overlaps_later) + || overlaps_later(&large) + } + _ => overlaps_later(&all_boxes), + } + }) + }; + let at = latest.get(&key).copied().filter(|&at| !hidden_by_later(at)); + let mut started = Run { name, members: vec![] }; + let joins = at.and_then(|at| groups.get_mut(at)); + let new = joins.is_none(); + let g = joins.unwrap_or(&mut started); + g.members.push(item); + if new { + groups.push(started); + latest.insert(key, groups.len() - 1); + } + if let Some(b) = bounds { + let index = if new { groups.len() - 1 } else { at.unwrap_or_default() }; + all_boxes.entry(index).or_default().push(b); + match span(b.inflate(1.0, 1.0)) { + Some((x0, y0, x1, y1)) if count(x0, x1) <= 8 && count(y0, y1) <= 8 => { + for place in (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))) { + spatial.entry(place).or_default().entry(index).or_default().push(b); + } + } + Some((_, y0, _, y1)) if count(y0, y1) <= 8 => { + for y in y0..=y1 { + rows.entry(y).or_default().entry(index).or_default().push(b); + } + } + Some((x0, _, x1, _)) if count(x0, x1) <= 8 => { + for x in x0..=x1 { + columns.entry(x).or_default().entry(index).or_default().push(b); + } + } + _ => large.entry(index).or_default().push(b), + } + } + } + Ok(groups + .into_iter() + .map(|run| { + let mut g = Node::group(d.alloc_id(), run.members); + g.name = Some(run.name); + Arc::new(g) + }) + .collect()) +} + /// What a trace depends on: the object traced (an image, or an Image Trace object traced again), /// its image, and the settings it is traced with. The tracer makes the same paths of the same /// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own. @@ -398,6 +556,10 @@ struct Prepared { fn prepare(s: &Session, c: &str, p: &Value) -> Result { let mut params = trace_params(s, c, p)?; let view = view_param(c, p)?; + let by_color = c == "imageTrace.makeAndExpand" && by_color_param(c, p)?; + if by_color && params.auto_grouping { + return Err(bad(c, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; // The preset's name if the parameters are exactly that preset's or its enhanced version's (a // saved preset's under its saved name), else "Custom". @@ -428,6 +590,9 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { }) .ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))? }; + if by_color && s.doc()?.doc.node(target).is_some_and(vectorcraft_render::effects::has_pathfinder) { + return Err(bad(c, LIVE_PATHFINDER_BY_COLOR)); + } let blob = s.doc()?.doc.images.get(&image.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", image.key)))?; let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; @@ -533,6 +698,7 @@ impl TraceRun { pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?; + let group_by_color = expand && by_color_param(c, p)?; // A saved preset's settings join the journal entry, so a replay traces as this run did after // the preset is changed, renamed or deleted. let named = str_param(p, "preset").unwrap_or("Default"); @@ -608,6 +774,9 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Result { } pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { + const C: &str = "imageTrace.expand"; + let by_color = by_color_param(C, p)?; let targets = trace_targets(s, p)?; + if by_color { + // A trace made with Auto Grouping is refused even when none of its paths were grouped. + let d = &s.doc()?.doc; + let grouped = |n: &Node| n.extras().trace.as_deref().and_then(|t| t.pointer("/params/autoGrouping")).and_then(Value::as_bool) == Some(true); + if targets.iter().any(|g| d.node(*g).is_some_and(grouped)) { + return Err(bad(C, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } + if targets.iter().any(|g| d.node(*g).is_some_and(vectorcraft_render::effects::has_pathfinder)) { + return Err(bad(C, LIVE_PATHFINDER_BY_COLOR)); + } + } s.edit("Expand Image Trace", |d, sel| { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; @@ -715,6 +897,15 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { if let Some(ch) = n.children_mut() { ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); } + if by_color { + let items = d.node(*g).and_then(Node::children).cloned().unwrap_or_default(); + // The paths may have been moved or reshaped since the trace was made, so any trace's + // paths are taken to overlap. + let groups = color_groups(C, d, items)?; + if let Some(ch) = d.node_mut(*g).and_then(Node::children_mut) { + *ch = groups; + } + } } sel.set(targets.iter().copied()); Ok(()) @@ -722,6 +913,160 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&targets) })) } +#[cfg(test)] +mod color_group_tests { + use super::*; + use vectorcraft_geom::{PathData, SubPath}; + + fn area(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64, color: Color) -> Arc { + let path = PathData::single(SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)); + Arc::new(Node::path(d.alloc_id(), path, fill_only(Paint::solid(color)))) + } + + #[test] + fn abutting_color_groups_preserve_order_at_shared_edges() { + let mut d = Document::new(100.0, 100.0); + let a = Color::rgb(1.0, 0.0, 0.0); + let b = Color::rgb(0.0, 0.0, 1.0); + let items = vec![area(&mut d, 0.0, 0.0, 20.0, 20.0, a), area(&mut d, 20.0, 0.0, 40.0, 20.0, b), area(&mut d, 40.0, 0.0, 60.0, 20.0, a)]; + let original: Vec<_> = items.iter().map(|n| n.id).collect(); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let actual: Vec<_> = groups.iter().flat_map(|g| g.children().unwrap().iter().map(|n| n.id)).collect(); + assert_eq!(actual, original, "shared-edge paths changed stacking order"); + } + + #[test] + fn dense_color_groups_use_bounded_box_tests() { + let mut d = Document::new(200.0, 200.0); + let a = Color::rgb(1.0, 0.0, 0.0); + let b = Color::rgb(0.0, 0.0, 1.0); + let items = (0..4000) + .map(|i| { + if i % 2 == 0 { + let x = 100.0 + (i % 40) as f64 * 0.01; + area(&mut d, x, 100.0, x + 0.1, 100.1, a) + } else { + let x = if i % 4 == 1 { 0.0 } else { 198.0 }; + area(&mut d, x, x, x + 2.0, x + 2.0, b) + } + }) + .collect(); + BOX_TESTS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let tests = BOX_TESTS.with(std::cell::Cell::get); + assert_eq!(groups.iter().map(|g| g.children().map_or(0, Vec::len)).sum::(), 4000); + assert!(tests < 4000 * 32, "{tests} box tests for 4000 paths"); + } + + #[test] + fn overlapping_alternating_colors_visit_bounded_candidates() { + const COUNT: usize = 3000; + let mut d = Document::new(100.0, 100.0); + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + let items = (0..COUNT).map(|i| area(&mut d, 10.0, 10.0, 20.0, 20.0, colors[i % 2])).collect(); + CANDIDATE_VISITS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let visits = CANDIDATE_VISITS.with(std::cell::Cell::get); + assert_eq!(groups.len(), COUNT); + assert!(visits < COUNT * 16, "{visits} candidate visits for {COUNT} paths"); + } + + #[test] + fn separate_stripes_of_alternating_colors_visit_bounded_candidates() { + const COUNT: usize = 2000; + let mut d = Document::new(1000.0, 4000.0); + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + for tall in [false, true] { + let items = (0..COUNT) + .map(|i| { + let at = i as f64 * 2.0; + if tall { area(&mut d, at, 0.0, at + 0.5, 1000.0, colors[i % 2]) } else { area(&mut d, 0.0, at, 1000.0, at + 0.5, colors[i % 2]) } + }) + .collect(); + CANDIDATE_VISITS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let visits = CANDIDATE_VISITS.with(std::cell::Cell::get); + assert_eq!(groups.len(), 2, "tall {tall}"); + assert!(visits < COUNT * 32, "tall {tall}: {visits} candidate visits for {COUNT} stripes"); + } + } + + #[test] + fn spatial_color_groups_match_simple_scan() { + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]; + for layout in 0..6 { + let mut d = Document::new(200.0, 200.0); + let items: Vec<_> = (0..90) + .map(|i| { + let x = match layout { + 0 => i as f64 * 2.0, + 1 => (i % 10) as f64 * 5.0, + 2 => (i % 7) as f64 * 3.0, + 3 => (i % 13) as f64 * 9.0, + 4 => (i % 4) as f64 * 10.0, + _ => i as f64 * 1.5, + }; + let y = match layout { + 0 => 0.0, + 1 => (i / 10) as f64 * 5.0, + 2 => (i / 7) as f64 * 3.0, + 3 => (i / 13) as f64 * 9.0, + 4 => i as f64 * 1.5, + _ => (i % 4) as f64 * 10.0, + }; + // Layouts 4 and 5: wide and tall stripes, some meeting their neighbors. + let (w, h) = match layout { + 4 => (150.0, 1.0), + 5 => (1.0, 150.0), + _ => (3.0, 3.0), + }; + area(&mut d, x, y, x + w, y + h, colors[i % 3]) + }) + .collect(); + let mut expected: Vec<(String, Vec<(NodeId, Rect)>)> = vec![]; + for item in &items { + let key = item.appearance.fill_paint().color().unwrap().to_hex(); + let bounds = vectorcraft_render::painted_bounds(&d, item).unwrap(); + let previous = expected.iter().rposition(|(k, _)| *k == key); + let can_join = previous.filter(|&at| { + !expected.iter().skip(at + 1).any(|(_, boxes)| { + boxes.iter().any(|(_, m)| { + let b = bounds.inflate(1.0, 1.0); + m.x0 < b.x1 && b.x0 < m.x1 && m.y0 < b.y1 && b.y0 < m.y1 + }) + }) + }); + match can_join.and_then(|at| expected.get_mut(at)) { + Some((_, members)) => members.push((item.id, bounds)), + None => expected.push((key, vec![(item.id, bounds)])), + } + } + let actual = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let actual: Vec> = actual.iter().map(|g| g.children().unwrap().iter().map(|n| n.id).collect()).collect(); + let expected: Vec> = expected.into_iter().map(|(_, members)| members.into_iter().map(|(id, _)| id).collect()).collect(); + assert_eq!(actual, expected, "layout {layout}"); + } + } + + #[test] + fn an_abutting_gradient_stays_above_the_solid_area_it_borders() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops.clone())))); + let mut d = Document::new(100.0, 100.0); + let mut area = |x0: f64, x1: f64, paint: Paint| { + let path = + PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 60.0), Point::new(x0, 60.0)], true)); + Arc::new(Node::path(d.alloc_id(), path, fill_only(paint))) + }; + // A gradient, a solid area beside it and a second gradient beside that, bottom to top. + let items = vec![area(0.0, 50.0, ramp()), area(50.0, 80.0, Paint::solid(Color::rgb(0.2, 0.6, 0.2))), area(80.0, 100.0, ramp())]; + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let names: Vec<_> = groups.iter().map(|g| g.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Gradients", "#339933", "Gradients"]); + } +} + #[cfg(test)] mod trace_limit_tests { use super::{MAX_TRACE_ANCHORS, trace_warnings}; diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 9e397975f..a94696d16 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3221,6 +3221,316 @@ fn image_trace_release_and_expand() { assert!(s.execute("imageTrace.release", &json!({})).is_err()); } +/// Expand by color: each color's paths in a group of their own, named by the color, the groups +/// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a +/// sublayer for each color. +#[test] +#[allow(clippy::cognitive_complexity)] // Exercises several color and stacking cases in one trace fixture. +fn image_trace_expand_by_color_groups_each_colors_paths() { + // A light field with a black ring and a red square on it. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 75.0); + if (34.0..=40.0).contains(&r) { + [0, 0, 0, 255] + } else if (130..170).contains(&x) && (55..95).contains(&y) { + [200, 40, 50, 255] + } else { + [240, 240, 220, 255] + } + }); + let names = |n: &Node| n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect::>(); + let hex = |n: &Node| n.appearance.fill_paint().color().unwrap().to_hex(); + let shot = |s: &Session| { + let img = render_doc(s, false); + (0..200).flat_map(|x| (0..150).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::>() + }; + for method in ["abutting", "overlapping"] { + let params = json!({"mode": "color", "palette": "limited", "colors": 3, "method": method}); + let expand = |by_color: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params, "byColor": by_color})).unwrap(); + let g = NodeId(r["id"].as_u64().unwrap()); + (s, g) + }; + let (flat, fg) = expand(false); + let (mut s, g) = expand(true); + let paths = node(&flat, fg).children().unwrap().clone(); + // Without byColor (the default) the paths sit in the trace's group, with no group of their own. + assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}"); + let (default, dg) = { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) + }; + assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given"); + // The colors in the order they first come in, bottom to top. + let mut order: Vec = vec![]; + for p in &paths { + if !order.contains(&hex(p)) { + order.push(hex(p)); + } + } + let mut sorted = order.clone(); + sorted.sort(); + assert_eq!(sorted, ["#000000", "#c82832", "#f0f0dc"], "{method}"); + let n = node(&s, g); + assert_eq!(names(&n), order, "{method}"); + // Each group holds its color's paths, and in turn they are the paths of the trace without + // byColor, in their order. + let groups = n.children().unwrap(); + for c in groups { + assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}"); + assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name); + } + let members: Vec> = groups.iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect(); + assert!(members == paths, "{method}"); + assert!(shot(&s) == shot(&flat), "{method}: the art changed"); + // Release to Layers: a sublayer for each color, in the same order. + let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap(); + let subs = ids(&r); + assert_eq!(subs.len(), 3, "{method}"); + for (sub, name) in subs.iter().zip(&order) { + let l = node(&s, *sub); + assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}"); + assert_eq!(names(&l), std::slice::from_ref(name), "{method}"); + } + } + + // imageTrace.expand takes it too, as one undo step. + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": params})).unwrap()["id"].as_u64().unwrap()); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + + // A trace of one color gives one group holding every path. + let mut one = session(); + let bar = + vectorcraft_trace::Raster::from_fn(80, 40, |x, y| if (10..70).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] }); + add_image(&mut one, &bar, Affine::IDENTITY); + let r = one.execute("imageTrace.makeAndExpand", &json!({"params": {"ignoreWhite": true}, "byColor": true})).unwrap(); + let og = node(&one, NodeId(r["id"].as_u64().unwrap())); + assert_eq!(names(&og), ["#000000"]); + let only = &og.children().unwrap()[0]; + assert!(!only.children().unwrap().is_empty() && only.children().unwrap().iter().all(|m| hex(m) == "#000000")); + + // Auto Grouping's groups are not split: refused, and the trace stays as it was. + let mut grouped = params.clone(); + grouped["autoGrouping"] = json!(true); + let e = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": grouped, "byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": grouped})).unwrap()["id"].as_u64().unwrap()); + let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + let e = s.execute("imageTrace.expand", &json!({"byColor": "yes"})).unwrap_err().to_string(); + assert!(e.contains("true or false"), "{e}"); + // Refused too when Auto Grouping grouped nothing, as with side-by-side stripes. + let mut s = session(); + let stripes = vectorcraft_trace::Raster::from_fn(60, 60, |x, _| match x / 20 { + 0 => [200, 40, 40, 255], + 1 => [40, 160, 60, 255], + _ => [40, 70, 200, 255], + }); + add_image(&mut s, &stripes, Affine::IDENTITY); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap()); + assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. }))); + let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + + // A color linked to a global swatch goes by the swatch's name, a tint with its percentage. + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap(); + let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) { + (true, _) => [200, 40, 40, 255], + (false, true) => [40, 160, 60, 255], + (false, false) => [40, 70, 200, 255], + }); + add_image(&mut s, &r, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec<(u64, String)> = + node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect(); + let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect(); + colors.sort(); + colors.dedup(); + assert_eq!(colors, ["#2846c8", "#28a03c", "#c82828"]); + // The red paths link to Ink and the blue ones to Sky at 50%; green stays a plain color. + let pick = |want: &str| paths.iter().filter(|(_, h)| h == want).map(|(id, _)| *id).collect::>(); + s.execute("paint.setFill", &json!({"ids": pick("#c82828"), "swatch": "Ink"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": pick("#2846c8"), "swatch": "Sky", "tint": 50})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]); + // A swatch named as another color's #rrggbb still gets a group of its own. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]); + + // Gradient fills share one group, and a stroked line goes by its stroke. + let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| { + let t = (f64::from(x) + 0.5) / 100.0; + [(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255] + }); + let line = + vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] }); + for (art, params, want) in [ + (ramp, json!({"preset": "6 Colors", "params": {"gradients": true}, "byColor": true}), "Gradients"), + (line, json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}, "byColor": true}), "#000000"), + ] { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let res = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + assert_eq!(names(&node(&s, NodeId(res["id"].as_u64().unwrap()))), [want]); + } + + // Flat Logo stacks its shapes biggest first without cutting holes under white: a black ring's + // outline, a white disc on it and a black dot on that. Black keeps a group below white and one + // above it, and the art draws as before, through Make and Expand and through Expand. + let target = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| { + let r = (f64::from(x) + 0.5 - 60.0).hypot(f64::from(y) + 0.5 - 60.0); + if r < 15.0 || (30.0..50.0).contains(&r) { + [0, 0, 0, 255] + } else if r < 30.0 { + [255, 255, 255, 255] + } else { + [0, 0, 0, 0] + } + }); + let logo = json!({"mode": "logo", "logoColors": ["#000000"]}); + let traced = |by_color: bool, expand_after: bool| { + let mut s = session(); + add_image(&mut s, &target, Affine::IDENTITY); + let g = if expand_after { + let g = s.execute("imageTrace.make", &json!({"params": logo})).unwrap()["id"].as_u64().unwrap(); + s.execute("imageTrace.expand", &json!({"byColor": by_color})).unwrap(); + g + } else { + s.execute("imageTrace.makeAndExpand", &json!({"params": logo, "byColor": by_color})).unwrap()["id"].as_u64().unwrap() + }; + (s, NodeId(g)) + }; + for expand_after in [false, true] { + let (flat, fg) = traced(false, expand_after); + let (s, g) = traced(true, expand_after); + let paths = node(&flat, fg).children().unwrap().clone(); + assert_eq!(paths.iter().map(|p| hex(p)).collect::>(), ["#000000", "#ffffff", "#000000"]); + assert_eq!(names(&node(&s, g)), ["#000000", "#ffffff", "#000000"], "expand after: {expand_after}"); + let members: Vec> = node(&s, g).children().unwrap().iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect(); + assert!(members == paths, "expand after: {expand_after}"); + assert!(shot(&s) == shot(&flat), "expand after: {expand_after}: the art changed"); + } + + // An Abutting trace's top path, given the bottom path's color and moved onto the middle one (by + // a move or by a Transform effect), moved clear of it with a drop shadow cast onto it, or moved + // to share a pixel at its edge, before Expand, still draws on top of it. + let stripes = vectorcraft_trace::Raster::from_fn(90, 30, |x, _| match x / 30 { + 0 => [200, 40, 40, 255], + 1 => [40, 160, 60, 255], + _ => [40, 70, 200, 255], + }); + let edited = |by_color: bool, how: &str| { + let mut s = session(); + add_image(&mut s, &stripes, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect(); + assert_eq!(paths.len(), 3); + let (first, middle, last) = (&paths[0], &paths[1], &paths[2]); + s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap(); + let (to, from) = (middle.visual_bounds().unwrap(), last.visual_bounds().unwrap()); + let (dx, dy) = (to.x0 - from.x0, to.y0 - from.y0); + match how { + "beside" => { + // The middle path and the moved top path end and start inside one pixel. + s.execute("object.move", &json!({"ids": [middle.id.0], "dx": -0.5, "dy": 0})).unwrap(); + s.execute("object.move", &json!({"ids": [last.id.0], "dx": -0.3, "dy": 0})).unwrap() + } + "move" => s.execute("object.move", &json!({"ids": [last.id.0], "dx": dx, "dy": dy})).unwrap(), + "transform" => { + s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "distort.transform", "params": {"moveH": dx, "moveV": dy}})).unwrap() + } + _ => { + s.execute("object.move", &json!({"ids": [last.id.0], "dx": 40, "dy": 0})).unwrap(); + s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "stylize.dropShadow", "params": {"x": -70, "y": 0, "blur": 0}})) + .unwrap() + } + }; + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": by_color})).unwrap(); + (s, g) + }; + for how in ["move", "transform", "shadow", "beside"] { + let (flat, _) = edited(false, how); + let (s, g) = edited(true, how); + assert_eq!(names(&node(&s, g)).len(), 3, "{how}"); + assert!(shot(&s) == shot(&flat), "{how}: the edited art changed"); + } + + // Two tints of one swatch that round to the same percentage are still two colors. + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap(); + add_image(&mut s, &stripes, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect(); + assert_eq!(paths.len(), 3); + s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let groups = node(&s, g).children().unwrap().clone(); + assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]); + assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::>(), [1, 2]); +} + +#[test] +fn image_trace_expand_by_color_preserves_live_pathfinder_art() { + let mut s = session(); + let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, y| { + if (10..40).contains(&y) && ((10..35).contains(&x) || (65..90).contains(&x)) { [0, 0, 0, 255] } else { [255, 255, 255, 255] } + }); + add_image(&mut s, &art, Affine::IDENTITY); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec = node(&s, g).children().unwrap().iter().filter(|c| c.path_data().is_some()).map(|c| c.id).collect(); + assert_eq!(paths.len(), 2); + s.execute("select.set", &json!({"ids": [paths[1].0]})).unwrap(); + s.execute("object.move", &json!({"dx": -45, "dy": 0})).unwrap(); + s.execute("effect.apply", &json!({"ids": [g.0], "effect": "pathfinder.subtract"})).unwrap(); + let before = render_doc(&s, false); + let before_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| before.pixel(x, y)).collect(); + let original = node(&s, g); + let steps = s.doc().unwrap().history.undo.len(); + let result = s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})); + if let Err(e) = &result { + assert!(e.to_string().contains("Pathfinder"), "{e}"); + assert_eq!(node(&s, g), original); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + } + let after = render_doc(&s, false); + let after_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| after.pixel(x, y)).collect(); + let changed = after_pixels.iter().zip(&before_pixels).filter(|(a, b)| a != b).count(); + assert_eq!(changed, 0, "byColor changed {changed} live Pathfinder pixels: {result:?}"); + assert!(result.is_err(), "byColor must refuse a live Pathfinder operand group"); + let error = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": {"ignoreWhite": true}, "byColor": true})).unwrap_err(); + assert!(error.to_string().contains("Pathfinder"), "{error}"); + assert_eq!(node(&s, g), original); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + #[test] fn image_trace_make_and_expand_and_color_presets() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 68c1e81b0..0f39585bb 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6127,6 +6127,9 @@ anchor Points: Punts: CMYK Images Imatges CMYK Grayscale Images Imatges en escala de grisos @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` ha de ser spotColors, colorEffects, rgbImages, cmykImages o grayImages +@msg byColor must be true or false byColor ha de ser true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor no pot separar els grups de l'agrupació automàtica: traça sense l'agrupació automàtica +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor no pot tornar a agrupar un traçat amb un efecte Pathfinder actiu: expandeix-lo sense byColor # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Color dels punts de la il·lustració diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e74e240d7..13700edfa 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5711,6 +5711,9 @@ mark-style Japanese Japonské @msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů). +@msg byColor must be true or false byColor musí být true nebo false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor nerozdělí skupiny z Automatického seskupení: vektorizujte bez Automatického seskupení +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor nemůže přeskupit trasování s živým efektem Pathfinder: rozbalte bez byColor Snapping Přichytávání Snap to Tangent Přichytávat k tečně Snap to Perpendicular Přichytávat ke kolmici diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2da424151..9938513a8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5290,6 +5290,9 @@ effect Soft Weich @msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel). +@msg byColor must be true or false byColor muss true oder false sein +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kann Gruppen aus Automatisch gruppieren nicht aufteilen: ohne Automatisch gruppieren nachzeichnen +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kann eine Nachzeichnung mit einem aktiven Pathfinder-Effekt nicht neu gruppieren: ohne byColor umwandeln Snapping Einrasten Snap to Tangent An Tangente ausrichten Snap to Perpendicular An Senkrechter ausrichten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4d713dfed..c2330c4bf 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5662,6 +5662,9 @@ effect Points Σημεία @msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel). +@msg byColor must be true or false το byColor πρέπει να είναι true ή false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping το byColor δεν μπορεί να χωρίσει τις ομάδες της Αυτόματης ομαδοποίησης: ανιχνεύστε χωρίς Αυτόματη ομαδοποίηση +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor το byColor δεν μπορεί να αναομαδοποιήσει μια ανίχνευση με ενεργό εφέ Pathfinder: επεκτείνετε χωρίς byColor Snapping Προσκόλληση Snap to Tangent Προσκόλληση σε εφαπτομένη Snap to Perpendicular Προσκόλληση σε κάθετο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8c00c78d2..e4807fae6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5652,6 +5652,9 @@ mark-style Japanese Japonesas @msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo). +@msg byColor must be true or false byColor debe ser true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor no puede dividir los grupos de Agrupación automática: calque sin Agrupación automática +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor no puede reagrupar un calco con un efecto Pathfinder activo: expanda sin byColor Snapping Ajuste Snap to Tangent Ajustar a tangente Snap to Perpendicular Ajustar a perpendicular diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3491ca30a..3970072a3 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5674,6 +5674,9 @@ mark-style Japanese Japanilainen @msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä). +@msg byColor must be true or false byColor-arvon on oltava true tai false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ei voi jakaa Automaattisen ryhmittelyn ryhmiä: jäljitä ilman Automaattista ryhmittelyä +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ei voi ryhmitellä uudelleen jäljitystä, jossa on aktiivinen Pathfinder-tehoste: laajenna ilman byColor-asetusta Snapping Tartunta Snap to Tangent Kohdista tangenttiin Snap to Perpendicular Kohdista kohtisuoraan diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 539c0f3ae..1dd0177b6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5706,6 +5706,9 @@ theme Appearance Apparence @msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus). +@msg byColor must be true or false byColor doit être true ou false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ne peut pas scinder les groupes du Groupement automatique : vectorisez sans Groupement automatique +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ne peut pas regrouper un tracé avec un effet Pathfinder actif : développez sans byColor Snapping Magnétisme Snap to Tangent Magnétisme de la tangente Snap to Perpendicular Magnétisme de la perpendiculaire diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 247ffc64e..fe27d894f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5694,6 +5694,9 @@ imageTrace Outline Körvonal @msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel). +@msg byColor must be true or false a byColor értéke true vagy false lehet +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping a byColor nem bontja fel az Automatikus csoportosítás csoportjait: vektorizáljon Automatikus csoportosítás nélkül +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor a byColor nem csoportosíthatja át az élő Pathfinder-effektussal rendelkező vektorizálást: bővítse ki byColor nélkül Snapping Illesztés Snap to Tangent Illesztés érintőre Snap to Perpendicular Illesztés merőlegesre diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 44da4c994..cf88a606a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5324,6 +5324,9 @@ lighting Light Luce @msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel). +@msg byColor must be true or false byColor deve essere true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor non può dividere i gruppi di Raggruppamento automatico: vettorializza senza Raggruppamento automatico +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor non può raggruppare di nuovo una traccia con un effetto Pathfinder attivo: espandi senza byColor Snapping Aggancio Snap to Tangent Aggancia alla tangente Snap to Perpendicular Aggancia alla perpendicolare diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 07577badf..d4ee9ab66 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5367,6 +5367,9 @@ lighting Light ライト @msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。 +@msg byColor must be true or false byColor は true または false にしてください +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor は自動グループ化のグループを分割できません: 自動グループ化なしでトレースしてください +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor はライブ Pathfinder 効果のあるトレースを再グループ化できません: byColor なしで拡張してください Snapping スナップ Snap to Tangent 接線にスナップ Snap to Perpendicular 垂線にスナップ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 6a5581ed4..f46a2eb14 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5557,6 +5557,9 @@ imageTrace Outline კონტური @msg `colorGroup` needs `mode: "color"` `colorGroup`-ს სჭირდება `mode: "color"` @msg no color group `{name}` in the document (see swatch.list) დოკუმენტში ფერების ჯგუფი `{name}` არ არის (იხ. swatch.list) @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. ბრტყელი ლოგოს რეჟიმში გამოსახულება ვექტორიზდა {_1} × {_2} პიქსელზე, საშუალოს გამოთვლით შემცირდა {_3} × {_4}-დან. +@msg byColor must be true or false byColor უნდა იყოს true ან false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ვერ დაყოფს ავტომატური დაჯგუფების ჯგუფებს: ვექტორიზება გააკეთეთ ავტომატური დაჯგუფების გარეშე +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ვერ გადააჯგუფებს ტრასირებას აქტიური Pathfinder ეფექტით: გააფართოვეთ byColor-ის გარეშე @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ბიბლიოთეკის პანელი არ არის (იხ. window.swatchLibrary) @msg `{key}` must be a #rrggbb color `{key}` უნდა იყოს #rrggbb ფერი @msg a key object sits on that point ამ წერტილზე საკვანძო ობიექტი მდებარეობს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cce6ce736..9b6c84093 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5285,6 +5285,9 @@ lighting Light 광원 @msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀). +@msg byColor must be true or false byColor는 true 또는 false여야 합니다 +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor는 자동 그룹화 그룹을 나눌 수 없습니다. 자동 그룹화 없이 추적하세요 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor는 라이브 Pathfinder 효과가 있는 추적을 다시 그룹화할 수 없습니다. byColor 없이 확장하세요 Snapping 스냅 Snap to Tangent 접선에 스냅 Snap to Perpendicular 수직선에 스냅 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index adfb669e8..f00ae7858 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5564,6 +5564,9 @@ mark-style Japanese Japońskie @msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli). +@msg byColor must be true or false byColor musi mieć wartość true lub false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor nie może podzielić grup Automatycznego grupowania: obrysuj bez Automatycznego grupowania +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor nie może ponownie zgrupować obrysu z aktywnym efektem Pathfinder: rozwiń bez byColor Snapping Przyciąganie Snap to Tangent Przyciągaj do stycznych Snap to Perpendicular Przyciągaj do prostopadłych diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 183d41dc1..5d277fab5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5626,6 +5626,9 @@ mark-style Japanese Japonesas @msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels). +@msg byColor must be true or false byColor deve ser true ou false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor não pode dividir os grupos de Agrupamento Automático: trace sem Agrupamento Automático +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor não pode reagrupar um traçado com um efeito Pathfinder ativo: expanda sem byColor Snapping Ajuste Snap to Tangent Ajustar à Tangente Snap to Perpendicular Ajustar à Perpendicular diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 544050c9a..42d30eadd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5319,6 +5319,9 @@ lighting Light Свет @msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Изображение трассировано в размере {_1} × {_2} пикселей, уменьшенное с {_3} × {_4} (не более {_5} мегапикселей). +@msg byColor must be true or false byColor должен иметь значение true или false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor не может разделить группы автогруппировки: трассируйте без автогруппировки +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor не может перегруппировать трассировку с действующим эффектом Pathfinder: разверните без byColor Snapping Привязка Snap to Tangent Привязать к касательной Snap to Perpendicular Привязать к перпендикуляру diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f007400d9..896b59c2e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5733,6 +5733,9 @@ swatch Yellow Green Gulgrön @msg “{name}” is not a file ”{name}” är inte en fil @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar). +@msg byColor must be true or false byColor måste vara true eller false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kan inte dela upp grupper från Automatisk gruppering: vektorisera utan Automatisk gruppering +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kan inte gruppera om en kalkering med en aktiv Pathfinder-effekt: expandera utan byColor Snapping Fäst Snap to Tangent Fäst mot tangent Snap to Perpendicular Fäst mot vinkelrät diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3c28f018c..5cf7665d4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5712,6 +5712,9 @@ theme Appearance Görünüm @msg library panel `{given}` isn't missing its library `{given}` kitaplık panelinin kitaplığı eksik değil @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Görüntü {_1} × {_2} piksel, bir kenarı {LARGE_TRACE_SIDE} değerinden büyük: izlenir, ancak daha yavaş. Daha düşük doğruluklu bir ön ayar veya görüntünün daha küçük bir kopyası daha hızlı izlenir. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Görüntü {_3} × {_4} boyutundan küçültülerek {_1} × {_2} pikselde izlendi (en fazla {_5} megapiksel). +@msg byColor must be true or false byColor true veya false olmalıdır +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor, Otomatik Gruplama gruplarını bölemez: Otomatik Gruplama olmadan izleyin +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor, canlı Pathfinder efekti olan bir izlemeyi yeniden gruplayamaz: byColor olmadan genişletin Gap Length: Boşluk Uzunluğu: @msg `colorGroup` needs `library: "document"` `colorGroup` için `library: "document"` gerekir @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` için `palette: "documentLibrary"` gerekir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7de17b0fc..e7055639b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5304,6 +5304,9 @@ lighting Light Світло @msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Зображення трасовано в розмірі {_1} × {_2} пікселів, зменшене з {_3} × {_4} (щонайбільше {_5} мегапікселів). +@msg byColor must be true or false byColor має мати значення true або false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor не може розділити групи автогрупування: трасуйте без автогрупування +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor не може перегрупувати трасування з активним ефектом Pathfinder: розгорніть без byColor Snapping Прив’язка Snap to Tangent Прив’язати до дотичної Snap to Perpendicular Прив’язати до перпендикуляра diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 678eddbce..4b9de70d4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5305,6 +5305,9 @@ theme Appearance Giao diện @msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel). +@msg byColor must be true or false byColor phải là true hoặc false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor không thể tách các nhóm của Tự động nhóm: hãy dò mà không bật Tự động nhóm +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor không thể nhóm lại bản dò có hiệu ứng Pathfinder đang hoạt động: mở rộng mà không dùng byColor Snapping Hút Snap to Tangent Hút vào tiếp tuyến Snap to Perpendicular Hút vào đường vuông góc diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9a3346839..225ca1112 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5542,6 +5542,9 @@ lighting Light 光源 @msg too many library panels were opened in this session 本次会话中打开的库面板过多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 图像已按 {_1} × {_2} 像素描摹,由 {_3} × {_4} 缩小而来(最多 {_5} 百万像素)。 +@msg byColor must be true or false byColor 必须是 true 或 false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor 无法拆分自动编组生成的编组:请在不使用自动编组的情况下描摹 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor 无法重新编组带有实时 Pathfinder 效果的描摹:请不使用 byColor 进行扩展 Snapping 对齐 Snap to Tangent 对齐切线 Snap to Perpendicular 对齐垂线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4b7107054..e18a0fc58 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2553,6 +2553,9 @@ file-filter Templates 範本 @msg `colorGroup` needs `mode: "color"` `colorGroup` 需要 `mode: "color"` @msg no color group `{name}` in the document (see swatch.list) 文件中沒有顏色群組 `{name}` (請參閱 swatch.list) @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. 「扁平標誌」模式已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 平均縮小而來。 +@msg byColor must be true or false byColor 必須是 true 或 false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor 無法拆分自動群組產生的群組:請不使用自動群組來描圖 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor 無法重新編組帶有即時 Pathfinder 效果的描圖:請不使用 byColor 進行擴充 # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 「編輯原稿」只能開啟影像與文件檔案;「{name}」不屬於這兩類 @msg only http, https and mailto URLs open in the browser, not “{url}” 只有 http、https 和 mailto URL 會在瀏覽器中開啟,「{url}」不會 diff --git a/docs/mcp.md b/docs/mcp.md index 6b4a6bbeb..426fb7d88 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2015,6 +2015,17 @@ already walks Image Trace. Short agent entry points: - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. + With `byColor: true`, `imageTrace.expand` and `imageTrace.makeAndExpand` put each color's paths in a group of their + own, named by the global swatch the color links to (a tint with its percentage, as `Sky 50%`) or as `#rrggbb`; + gradient fills share a group named `Gradients`. The groups stack in the order of the lowest path in each, and each + keeps its paths' order. For either Expand command, a path that would move under a path of another + color whose painted bounds, effects and shadows included, come within one document point of it starts another group + of its color instead of joining an earlier one. Paths whose painted bounds come within one document point of an + intervening color stay in separate groups to preserve their stacking order; at small view or raster-export scales, + regrouped paths farther apart can still share antialiased pixels, so edge pixels may differ. In a Flat Logo trace of a + black ring around a white disc around a black dot, black gets two groups. `layer.releaseToLayers` on the expanded group then + gives each group a sublayer. A trace made with `autoGrouping` is refused. Expanding by color also refuses a trace + with a live Pathfinder effect. `imageTrace.presets.save {name?, newName?, preset?, enhanced?, params?}` saves tracing settings as a preset kept with the preferences: `params` over `preset`'s (default: the saved preset `name`, else Default), checked as `imageTrace.make` checks them; without `name` it is a new "Tracing Preset N", and `newName` renames a saved preset (the reply's From 7fd287238fcd0de0fa177c675b780179fd1e06ec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 17:28:30 -0700 Subject: [PATCH 06/51] Actions: Batch refuses Save and Close over data sets instead of saving each one over the open document Data sets share one open document, so Save and Close saved every data set over the same file and only the last one survived. action.batch now returns an error for source datasets with destination saveAndClose before it selects a data set or runs a step, and the Batch dialog offers None and Folder for a Data Sets source, moving a Save and Close choice to Folder. --- crates/engine/src/cmd/fileio/action_batch.rs | 76 ++++++++++++++++++++ crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/ui-egui/src/dialogs/action_batch.rs | 42 +++++++++-- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + 31 files changed, 145 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index e11b6adcd..c1b5ae016 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -73,6 +73,10 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { if !matches!(destination, "none" | "saveAndClose" | "folder") { return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`")); } + // Data sets share one open document, so Save and Close would write every row over one file. + if source == "datasets" && destination == "saveAndClose" { + return Err(bad(C, "Save and Close cannot batch data sets: choose Folder to save each data set to its own file")); + } let dest_folder = str_param(p, "destinationFolder").map(absolute_path); if destination == "folder" { let Some(ref folder) = dest_folder else { @@ -1292,6 +1296,78 @@ mod tests { assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}"); } + /// A saved document with a text object bound to `Name` and two data sets, `A` and `B`. + fn saved_with_two_data_sets(path: &std::path::Path) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap(); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap(); + s.execute("dataset.capture", &json!({"name": "A"})).unwrap(); + s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap(); + s.execute("dataset.capture", &json!({"name": "B"})).unwrap(); + s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s + } + + fn save_and_close_over_data_sets(s: &mut Session) -> Result { + s.execute( + "action.batch", + &json!({"source": "datasets", "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}), + ) + } + + #[test] + fn batch_refuses_save_and_close_over_data_sets_before_writing() { + let path = tmp("save-close-sets").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let bytes = std::fs::read(&path).unwrap(); + let (active, revision) = { + let st = s.doc().unwrap(); + (st.doc.variables.active_dataset.clone(), st.revision) + }; + let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); + assert!(msg.contains("choose Folder"), "{msg}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template is not written"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); + assert_eq!(st.revision, revision, "no step runs"); + assert_eq!(st.path.as_deref(), Some(path.to_string_lossy().as_ref()), "the template stays open"); + } + + #[test] + fn batch_refuses_save_and_close_over_data_sets_of_an_unsaved_document() { + let mut s = with_data_sets(&["a", "b"]); + let (active, revision) = { + let st = s.doc().unwrap(); + (st.doc.variables.active_dataset.clone(), st.revision) + }; + let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); + assert!(msg.contains("choose Folder"), "{msg}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); + assert_eq!(st.revision, revision, "no step runs"); + assert!(st.path.is_none()); + } + + #[test] + fn batch_save_and_close_saves_and_closes_each_source_file() { + let src = tmp("save-close-src"); + let a = write_svg(&src, "a.svg", "#f00"); + let before = std::fs::read(&a).unwrap(); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({"folder": src.to_string_lossy(), "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_ne!(std::fs::read(&a).unwrap(), before, "the source file is saved over"); + assert_eq!(file_names(&src), ["a.svg"], "{r}"); + assert!(s.doc().is_err(), "the source file is closed"); + } + #[test] fn batch_refuses_nesting_and_empty_commands() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 011d025e3..0c010de10 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; refused for source datasets: use folder to save each data set to its own file) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", always, action_batch::action_batch ), diff --git a/crates/ui-egui/src/dialogs/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index 0d6382e9a..36369132a 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -77,7 +77,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } ui.add_space(8.0); - form::choice(ui, d, "destination", "Destination:", (LABEL, 200.0), &[("none", "None"), ("saveAndClose", "Save and Close"), ("folder", "Folder")]); + let choices = destinations(&source); + let destination = d.str("destination"); + if !choices.iter().any(|(value, _)| *value == destination) { + d.fields.insert("destination".into(), json!("folder")); + } + form::choice(ui, d, "destination", "Destination:", (LABEL, 200.0), choices); if d.str("destination") == "folder" { widgets::label_row(ui, tl!("Destination Folder:"), LABEL, |ui| { form::text(ui, d, "destinationFolder", 200.0); @@ -95,6 +100,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The Destination choices for `source`. Data sets share one open document, so Save and Close is +/// offered for a Folder source only. +fn destinations(source: &str) -> &'static [(&'static str, &'static str)] { + if source == "datasets" { + &[("none", "None"), ("folder", "Folder")] + } else { + &[("none", "None"), ("saveAndClose", "Save and Close"), ("folder", "Folder")] + } +} + fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -161,6 +176,13 @@ mod tests { use vectorcraft_engine::Session; fn shown(source: &str) -> Vec { + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["source"] = json!(source); + render(&mut d) + } + + /// Draws `d` once and returns the texts it shows. + fn render(d: &mut Dialog) -> Vec { fn collect(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), @@ -169,12 +191,10 @@ mod tests { } } let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); - d.fields["source"] = json!(source); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut frame = ctx.run_ui(Default::default(), |ui| { - body(&mut app, ui, &mut d); + body(&mut app, ui, d); }); frame.textures_delta.clear(); let mut labels = vec![]; @@ -220,4 +240,18 @@ mod tests { d.fields["source"] = json!("folder"); assert!(params(&d, "Test", vec![]).get("datasetFileNaming").is_none()); } + + #[test] + fn data_sets_do_not_offer_save_and_close() { + assert!(!destinations("datasets").iter().any(|(value, _)| *value == "saveAndClose")); + assert!(destinations("folder").iter().any(|(value, _)| *value == "saveAndClose")); + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["destination"] = json!("saveAndClose"); + assert!(render(&mut d).iter().any(|label| label == "Save and Close")); + d.fields["source"] = json!("datasets"); + let labels = render(&mut d); + assert!(!labels.iter().any(|label| label == "Save and Close"), "{labels:?}"); + assert!(labels.iter().any(|label| label == "Destination Folder:"), "{labels:?}"); + assert_eq!(params(&d, "Test", vec![])["destination"], "folder"); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8bdbf65a9..85c714b78 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6099,6 +6099,7 @@ breakText Glyphs Глифи File + Data Set Name Файл + име на набора от данни Data Set Name Име на набора от данни @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` трябва да е `fileNumber`, `fileDataSetName` или `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Запазване и затваряне“ не може да обработва набори от данни: изберете „Папка“, за да запишете всеки набор от данни в отделен файл @msg no free data set file name in destination folder Няма свободно име за файл на набор от данни в целевата папка # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ca52bf517..edd42d7c4 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6111,6 +6111,7 @@ shape Open Otvoren File + Data Set Name Datoteka + naziv skupa podataka Data Set Name Naziv skupa podataka @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` mora biti `fileNumber`, `fileDataSetName` ili `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Sačuvaj i zatvori“ ne može obraditi skupove podataka: odaberite „Folder“ da biste svaki skup podataka sačuvali u zasebnu datoteku @msg no free data set file name in destination folder Nema slobodnog naziva datoteke skupa podataka u odredišnoj mapi # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0f39585bb..f2b00581a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6255,6 +6255,7 @@ breakText Glyphs Glifs File + Data Set Name Fitxer + nom del conjunt de dades Data Set Name Nom del conjunt de dades @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` ha de ser `fileNumber`, `fileDataSetName` o `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Desa i tanca» no pot processar conjunts de dades: trieu «Carpeta» per desar cada conjunt de dades en un fitxer propi @msg no free data set file name in destination folder No hi ha cap nom de fitxer de conjunt de dades lliure a la carpeta de destinació # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 13700edfa..66aaefa8e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6274,6 +6274,7 @@ breakText Glyphs Glyfy File + Data Set Name Soubor + název sady dat Data Set Name Název sady dat @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musí být `fileNumber`, `fileDataSetName` nebo `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Uložit a zavřít“ nelze použít pro sady dat: zvolte „Složka“, aby se každá sada dat uložila do vlastního souboru @msg no free data set file name in destination folder V cílové složce není volný název souboru sady dat # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index d50e862bb..3e9389579 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6114,6 +6114,7 @@ breakText Glyphs Glyffer File + Data Set Name Fil + datasætnavn Data Set Name Datasætnavn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` skal være `fileNumber`, `fileDataSetName` eller `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Gem og luk" kan ikke køre på datasæt: vælg "Mappe" for at gemme hvert datasæt i sin egen fil @msg no free data set file name in destination folder Intet ledigt filnavn til datasættet i destinationsmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9938513a8..75345b1f7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5859,6 +5859,7 @@ breakText Glyphs Glyphen File + Data Set Name Datei + Datensatzname Data Set Name Datensatzname @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` muss `fileNumber`, `fileDataSetName` oder `dataSetName` sein +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Speichern und schließen“ kann nicht auf Datensätze angewendet werden: Wählen Sie „Ordner“, um jeden Datensatz in einer eigenen Datei zu speichern @msg no free data set file name in destination folder Kein freier Dateiname für den Datensatz im Zielordner # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c2330c4bf..cf071420c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6225,6 +6225,7 @@ breakText Glyphs Γλύφοι File + Data Set Name Αρχείο + όνομα συνόλου δεδομένων Data Set Name Όνομα συνόλου δεδομένων @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` Το `datasetFileNaming` πρέπει να είναι `fileNumber`, `fileDataSetName` ή `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file Η «Αποθήκευση και κλείσιμο» δεν μπορεί να εφαρμοστεί σε σύνολα δεδομένων: επιλέξτε «Φάκελος» για να αποθηκευτεί κάθε σύνολο δεδομένων σε δικό του αρχείο @msg no free data set file name in destination folder Δεν υπάρχει διαθέσιμο όνομα αρχείου συνόλου δεδομένων στον φάκελο προορισμού # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e4807fae6..29d345748 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6216,6 +6216,7 @@ breakText Glyphs Glifos File + Data Set Name Archivo + nombre del conjunto de datos Data Set Name Nombre del conjunto de datos @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` debe ser `fileNumber`, `fileDataSetName` o `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Guardar y cerrar» no puede procesar conjuntos de datos: elija «Carpeta» para guardar cada conjunto de datos en su propio archivo @msg no free data set file name in destination folder No hay un nombre de archivo libre para el conjunto de datos en la carpeta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3970072a3..7e0695592 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6304,6 +6304,7 @@ breakText Glyphs Merkit File + Data Set Name Tiedosto + tietojoukon nimi Data Set Name Tietojoukon nimi @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`-arvon on oltava `fileNumber`, `fileDataSetName` tai `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file ”Tallenna ja sulje” ei voi käsitellä tietojoukkoja: valitse ”Kansio”, niin jokainen tietojoukko tallennetaan omaan tiedostoonsa @msg no free data set file name in destination folder Kohdekansiossa ei ole vapaata tietojoukon tiedostonimeä # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1dd0177b6..69a5d71e7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6269,6 +6269,7 @@ breakText Glyphs Glyphes File + Data Set Name Fichier + nom du jeu de données Data Set Name Nom du jeu de données @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` doit être `fileNumber`, `fileDataSetName` ou `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file « Enregistrer et fermer » ne peut pas traiter les jeux de données : choisissez « Dossier » pour enregistrer chaque jeu de données dans son propre fichier @msg no free data set file name in destination folder Aucun nom de fichier libre pour le jeu de données dans le dossier de destination # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fe27d894f..f33c5d7d6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6258,6 +6258,7 @@ breakText Glyphs Glifák File + Data Set Name Fájl + adatkészlet neve Data Set Name Adatkészlet neve @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` A `datasetFileNaming` értéke `fileNumber`, `fileDataSetName` vagy `dataSetName` lehet +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file A „Mentés és bezárás” nem dolgozhat fel adatkészleteket: válassza a „Mappa” lehetőséget, hogy minden adatkészlet külön fájlba kerüljön @msg no free data set file name in destination folder Nincs szabad adatkészlet-fájlnév a célmappában # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 4d0377083..e7b1655d6 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6114,6 +6114,7 @@ breakText Glyphs Glif File + Data Set Name File + nama set data Data Set Name Nama set data @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` harus `fileNumber`, `fileDataSetName`, atau `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Simpan dan tutup" tidak dapat memproses set data: pilih "Folder" untuk menyimpan setiap set data ke file tersendiri @msg no free data set file name in destination folder Tidak ada nama file set data yang tersedia di folder tujuan # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cf88a606a..231ed0230 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5893,6 +5893,7 @@ breakText Glyphs Glifi File + Data Set Name File + nome set di dati Data Set Name Nome set di dati @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve essere `fileNumber`, `fileDataSetName` o `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Salva e chiudi» non può elaborare i set di dati: scegli «Cartella» per salvare ogni set di dati in un file a sé @msg no free data set file name in destination folder Nessun nome file libero per il set di dati nella cartella di destinazione # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d4ee9ab66..ac6bb51bc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5958,6 +5958,7 @@ breakText Glyphs 字形 File + Data Set Name ファイル名 + データセット名 Data Set Name データセット名 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` は `fileNumber`、`fileDataSetName`、`dataSetName` のいずれかである必要があります +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file 「保存して閉じる」ではデータセットをバッチ処理できません:各データセットを個別のファイルに保存するには「フォルダー」を選択してください @msg no free data set file name in destination folder 保存先フォルダーに使用可能なデータセットのファイル名がありません # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index f46a2eb14..a0b1d42a6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6271,6 +6271,7 @@ breakText Glyphs გლიფები File + Data Set Name ფაილი + მონაცემთა ნაკრების სახელი Data Set Name მონაცემთა ნაკრების სახელი @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` უნდა იყოს `fileNumber`, `fileDataSetName` ან `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „შენახვა და დახურვა“ ვერ ამუშავებს მონაცემთა ნაკრებებს: აირჩიეთ „საქაღალდე“, რომ თითოეული მონაცემთა ნაკრები ცალკე ფაილში შეინახოთ @msg no free data set file name in destination folder დანიშნულების საქაღალდეში მონაცემთა ნაკრებისთვის თავისუფალი ფაილის სახელი არ არის # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9b6c84093..c083822b2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5854,6 +5854,7 @@ breakText Glyphs 글리프 File + Data Set Name 파일 + 데이터 세트 이름 Data Set Name 데이터 세트 이름 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`은 `fileNumber`, `fileDataSetName` 또는 `dataSetName`이어야 합니다 +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file '저장 후 닫기'로는 데이터 세트를 일괄 처리할 수 없습니다: 각 데이터 세트를 별도의 파일로 저장하려면 '폴더'를 선택하세요 @msg no free data set file name in destination folder 대상 폴더에 사용할 수 있는 데이터 세트 파일 이름이 없습니다 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bd525526b..5c9bd74f1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6160,6 +6160,7 @@ breakText Glyphs Glyphs File + Data Set Name Bestand + naam gegevensset Data Set Name Naam gegevensset @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` moet `fileNumber`, `fileDataSetName` of `dataSetName` zijn +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file 'Opslaan en sluiten' kan geen gegevenssets verwerken: kies 'Map' om elke gegevensset in een eigen bestand op te slaan @msg no free data set file name in destination folder Geen vrije bestandsnaam voor de gegevensset in de doelmap # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f00ae7858..cb934d784 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6128,6 +6128,7 @@ breakText Glyphs Glify File + Data Set Name Plik + nazwa zestawu danych Data Set Name Nazwa zestawu danych @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musi mieć wartość `fileNumber`, `fileDataSetName` lub `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Zapisz i zamknij” nie może przetwarzać zestawów danych: wybierz „Folder”, aby zapisać każdy zestaw danych w osobnym pliku @msg no free data set file name in destination folder Brak wolnej nazwy pliku zestawu danych w folderze docelowym # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5d277fab5..8ac21f8e9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6190,6 +6190,7 @@ breakText Glyphs Glifos File + Data Set Name Arquivo + nome do conjunto de dados Data Set Name Nome do conjunto de dados @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve ser `fileNumber`, `fileDataSetName` ou `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Salvar e fechar" não pode processar conjuntos de dados: escolha "Pasta" para salvar cada conjunto de dados em um arquivo próprio @msg no free data set file name in destination folder Não há nome de arquivo livre para o conjunto de dados na pasta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 42d30eadd..d436e1b7e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6251,6 +6251,7 @@ breakText Glyphs Глифы File + Data Set Name Файл + имя набора данных Data Set Name Имя набора данных @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` должно быть `fileNumber`, `fileDataSetName` или `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Сохранить и закрыть» не работает с наборами данных: выберите «Папка», чтобы сохранить каждый набор данных в отдельный файл @msg no free data set file name in destination folder В папке назначения нет свободного имени файла для набора данных # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 896b59c2e..4438027d8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6326,6 +6326,7 @@ breakText Glyphs Glyfer File + Data Set Name Fil + datauppsättningens namn Data Set Name Datauppsättningens namn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` måste vara `fileNumber`, `fileDataSetName` eller `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file ”Spara och stäng” kan inte köras på datauppsättningar: välj ”Mapp” för att spara varje datauppsättning i en egen fil @msg no free data set file name in destination folder Inget ledigt filnamn för datauppsättningen i målmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5cf7665d4..5157d5bf3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6231,6 +6231,7 @@ breakText Glyphs Glifler File + Data Set Name Dosya + veri kümesi adı Data Set Name Veri kümesi adı @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` değeri `fileNumber`, `fileDataSetName` veya `dataSetName` olmalıdır +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Kaydet ve Kapat" veri kümelerini toplu işleyemez: her veri kümesini ayrı bir dosyaya kaydetmek için "Klasör" seçeneğini seçin @msg no free data set file name in destination folder Hedef klasörde veri kümesi için boş bir dosya adı yok # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e7055639b..db7e9ccd2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5873,6 +5873,7 @@ breakText Glyphs Гліфи File + Data Set Name Файл + назва набору даних Data Set Name Назва набору даних @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` має бути `fileNumber`, `fileDataSetName` або `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Зберегти й закрити» не працює з наборами даних: виберіть «Папка», щоб зберегти кожен набір даних в окремий файл @msg no free data set file name in destination folder У папці призначення немає вільного імені файлу набору даних # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4b9de70d4..f59304809 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5874,6 +5874,7 @@ breakText Glyphs Glyph File + Data Set Name Tệp + tên tập dữ liệu Data Set Name Tên tập dữ liệu @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` phải là `fileNumber`, `fileDataSetName` hoặc `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Lưu và đóng" không thể xử lý các tập dữ liệu: chọn "Thư mục" để lưu mỗi tập dữ liệu vào một tệp riêng @msg no free data set file name in destination folder Không có tên tệp tập dữ liệu còn trống trong thư mục đích # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 225ca1112..379f4dc23 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6115,6 +6115,7 @@ breakText Glyphs 字形 File + Data Set Name 文件 + 数据集名称 Data Set Name 数据集名称 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必须是 `fileNumber`、`fileDataSetName` 或 `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file “存储并关闭”无法批处理数据集:请选择“文件夹”,将每个数据集存储为单独的文件 @msg no free data set file name in destination folder 目标文件夹中没有可用的数据集文件名 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e18a0fc58..46c332bd4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5911,6 +5911,7 @@ breakText Glyphs 字符 File + Data Set Name 檔案 + 資料集名稱 Data Set Name 資料集名稱 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必須是 `fileNumber`、`fileDataSetName` 或 `dataSetName` +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file 「儲存並關閉」無法批次處理資料集:請選擇「資料夾」,將每個資料集儲存為個別檔案 @msg no free data set file name in destination folder 目的地資料夾中沒有可用的資料集檔案名稱 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/docs/mcp.md b/docs/mcp.md index 426fb7d88..41d518456 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3514,7 +3514,9 @@ Actions panel plays an action as a `command.batch`, omitting steps whose playbac cleared. An action set saved as `.vcactions` keeps each excluded step with `excluded: true`; loading an earlier version 1 set without that flag includes every step. The Actions panel menu's Batch… runs `action.batch`: the same steps on every readable file in a folder (or every data set of the open document), with -destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. For a Data Sets source, +destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. A Data Sets source does not +take `saveAndClose`: `action.batch` refuses it before it selects a data set or runs a step, and the dialog offers +None and Folder only. For a Data Sets source, `datasetFileNaming?: fileNumber | fileDataSetName | dataSetName` chooses File + Number (`source001`), File + Data Set Name (`source_Alpha`), or Data Set Name (`Alpha`). An omitted parameter keeps File + Data Set Name for existing callers; the dialog starts with File + Number. A folder source ignores this parameter. The folder diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d1d0c19e6..470d36886 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -643,6 +643,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). + - Batch… over data sets offers None and Folder as the destination, and `action.batch` refuses Save and Close for a Data Sets source before it runs a step, so the batch does not save every data set over the open document's file. This is VectorCraft's own rule: [Play actions on a batch of files](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/play-actions-on-a-batch-of-files.html) describes Save and Close as saving "the file in their current location" and does not say what it does over data sets. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. From 8a52d50154d7ea18e3687ab2b4d5342290ca6d87 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 17:39:03 -0700 Subject: [PATCH 07/51] i18n: French rows keep the no-break space before punctuation --- crates/ui-egui/src/i18n/fr.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 69a5d71e7..e814e8378 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6269,7 +6269,7 @@ breakText Glyphs Glyphes File + Data Set Name Fichier + nom du jeu de données Data Set Name Nom du jeu de données @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` doit être `fileNumber`, `fileDataSetName` ou `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file « Enregistrer et fermer » ne peut pas traiter les jeux de données : choisissez « Dossier » pour enregistrer chaque jeu de données dans son propre fichier +@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file « Enregistrer et fermer » ne peut pas traiter les jeux de données : choisissez « Dossier » pour enregistrer chaque jeu de données dans son propre fichier @msg no free data set file name in destination folder Aucun nom de fichier libre pour le jeu de données dans le dossier de destination # Path Precision Tools add-on: Path Effects (Effect › Path Effects). From 2ad0268388427bfc2b8a85520ed7b7e790e44172 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 01:06:03 -0700 Subject: [PATCH 08/51] Actions: Batch over data sets keeps Save and Close and warns when each data set saves over one file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch… with a Data Sets source offers None, Save and Close and Folder again. Save and Close saves the open document over its own file after each data set. When every data set saves over the same file, the dialog shows a warning and `action.batch` returns `warning`: with Save and Close, or with a recorded step that writes a file and that the batch does not point into the destination folder. A Save step without a path counts when it writes the document's own file; Save As, Save a Copy, Save as Template and Export steps count when they give a path; Export for Screens counts when its folder, given or from its preset, names a folder. Save and Close over the data sets of a document that was never saved is refused with "the document has never been saved" before a data set is selected. --- crates/engine/src/cmd/fileio.rs | 1 + crates/engine/src/cmd/fileio/action_batch.rs | 142 ++++++++++++++++--- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/screens.rs | 2 +- crates/ui-egui/src/dialogs/action_batch.rs | 68 ++++++--- crates/ui-egui/src/i18n/bg.tsv | 2 +- crates/ui-egui/src/i18n/bs.tsv | 2 +- crates/ui-egui/src/i18n/ca.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/da.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/id.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ka.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/nl.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 33 files changed, 206 insertions(+), 72 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 0c888d13c..ae2ecde66 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -54,6 +54,7 @@ pub mod zip; use serde_json::{Value, json}; +pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index c1b5ae016..26d6c476e 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -73,10 +73,6 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { if !matches!(destination, "none" | "saveAndClose" | "folder") { return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`")); } - // Data sets share one open document, so Save and Close would write every row over one file. - if source == "datasets" && destination == "saveAndClose" { - return Err(bad(C, "Save and Close cannot batch data sets: choose Folder to save each data set to its own file")); - } let dest_folder = str_param(p, "destinationFolder").map(absolute_path); if destination == "folder" { let Some(ref folder) = dest_folder else { @@ -99,11 +95,65 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { }; match source { "folder" => batch_folder(s, C, p, override_open, &run), - "datasets" => batch_datasets(s, C, &run), + "datasets" => { + let one_file = batch_overwrites_one_file(s, p); + let mut r = batch_datasets(s, C, &run)?; + if one_file { + r["warning"] = json!(ONE_FILE_WARNING); + } + Ok(r) + } _ => Err(bad(C, "`source` must be `folder` or `datasets`")), } } +/// The `warning` of a Data Sets batch for which [`batch_overwrites_one_file`] holds. +#[cfg(not(target_arch = "wasm32"))] +const ONE_FILE_WARNING: &str = "each data set saves over the same file, which keeps only the last data set"; + +/// Whether an `action.batch` with params `p` plays on data sets of the open document in `s` and +/// writes each data set to the same file. Save and Close saves the open document over its own file +/// after every data set. A step that the batch does not point into `destinationFolder` writes the +/// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export +/// or Export Selection step that gives a `path`; a Save step without a `path` when the document has +/// a file of its own (saved, and not converted from an older version or restored by Data Recovery) +/// and the step gives no `format` or the document's own; an Export for Screens +/// step whose `folder`, given or from its `exportPreset`, names a folder, whatever the overrides. +/// Other steps return their data instead of writing a file. +pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { + if p.get("source").and_then(Value::as_str) != Some("datasets") { + return false; + } + if p.get("destination").and_then(Value::as_str) == Some("saveAndClose") { + return true; + } + let folder = p.get("destinationFolder").and_then(Value::as_str).is_some(); + let redirected = |key: &str| folder && p.get(key).and_then(Value::as_bool).unwrap_or(true); + let (save, export) = (redirected("overrideSave"), redirected("overrideExport")); + // The format of the open document's own file, which a Save step without a `path` writes. + let own_format = s.active().filter(|st| st.path.is_some() && !st.converted && !st.recovered).map(|st| st.format); + let names = |v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty()); + p.get("commands").and_then(Value::as_array).is_some_and(|cmds| { + cmds.iter().any(|c| { + let params = c.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())); + let path = names(params.get("path")); + let own_file = || match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { + None => own_format.is_some(), + Some(f) => own_format.is_some_and(|own| super::format(f).is_some_and(|f| f.id == own)), + }; + match c.get("command").and_then(Value::as_str).unwrap_or("") { + "document.save" | "file.save" => !save && (path || own_file()), + "file.saveAs" => !save && path, + "file.saveCopy" | "file.saveAsTemplate" => path, + "document.export" | "document.exportSelection" => !export && path, + // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. + "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), + _ => false, + } + }) + }) +} + #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::{absolute_path, may_read}; @@ -149,6 +199,9 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { if names.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets"))); } + if run.destination == "saveAndClose" && st.path.is_none() { + return Err(bad(cmd, "the document has never been saved")); + } let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string(); let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?; let steps = filter_steps(run.steps, true, false); @@ -1318,21 +1371,76 @@ mod tests { } #[test] - fn batch_refuses_save_and_close_over_data_sets_before_writing() { + fn batch_save_and_close_over_data_sets_saves_each_over_the_template_and_warns() { let path = tmp("save-close-sets").join("template.vectorcraft"); let mut s = saved_with_two_data_sets(&path); - let bytes = std::fs::read(&path).unwrap(); - let (active, revision) = { - let st = s.doc().unwrap(); - (st.doc.variables.active_dataset.clone(), st.revision) - }; - let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); - assert!(msg.contains("choose Folder"), "{msg}"); - assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template is not written"); + std::fs::write(&path, b"stale").unwrap(); + let r = save_and_close_over_data_sets(&mut s).unwrap(); + assert_eq!((r["count"].as_u64(), r["failed"].as_u64()), (Some(2), Some(0)), "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + assert_ne!(std::fs::read(&path).unwrap(), b"stale", "the template's file is saved over"); + assert_eq!(file_names(path.parent().unwrap()), ["template.vectorcraft"], "{r}"); let st = s.doc().unwrap(); - assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); - assert_eq!(st.revision, revision, "no step runs"); + assert_eq!(st.doc.variables.active_dataset.as_deref(), Some("B"), "the last data set is applied"); assert_eq!(st.path.as_deref(), Some(path.to_string_lossy().as_ref()), "the template stays open"); + assert!(!st.is_dirty(), "the last data set is saved"); + } + + #[test] + fn batch_warns_when_a_recorded_save_writes_one_file_for_every_data_set() { + let path = tmp("recorded-save-sets").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let save = json!([{"command": "document.save", "params": {}}]); + let r = s.execute("action.batch", &json!({"source": "datasets", "commands": save})).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + let dest = tmp("recorded-save-dest"); + let p = json!({"source": "datasets", "destination": "folder", "destinationFolder": dest.to_string_lossy(), "commands": save}); + let r = s.execute("action.batch", &p).unwrap(); + assert!(r.get("warning").is_none(), "Override Save points each save into the folder: {r}"); + let unlock = json!({"source": "datasets", "commands": [{"command": "object.unlockAll", "params": {}}]}); + let r = s.execute("action.batch", &unlock).unwrap(); + assert!(r.get("warning").is_none(), "{r}"); + } + + #[test] + fn one_file_rule_covers_save_and_close_and_steps_left_in_place() { + let saved = saved_with_two_data_sets(&tmp("one-file-rule").join("template.vectorcraft")); + let unsaved = with_data_sets(&["a"]); + let with = |extra: Value| { + let mut p = json!({"source": "datasets", "commands": [{"command": "document.export", "params": {"path": "/x/a.png"}}]}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + batch_overwrites_one_file(&saved, &p) + }; + assert!(with(json!({})), "an export with its own path"); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out"}))); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false}))); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "overrideSave": false}))); + assert!(!with(json!({"source": "folder"})), "a folder source opens a file per pass"); + assert!(with(json!({"destination": "saveAndClose", "commands": [{"command": "object.unlockAll"}]}))); + let saved_as = json!({"commands": [{"command": "file.saveAs", "params": {"path": "/x/a.svg"}}]}); + assert!(with(saved_as.clone())); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": saved_as["commands"]}))); + assert!(!with(json!({"commands": [{"command": "file.saveAs", "params": {}}]})), "no path: the data comes back"); + assert!(!with(json!({"commands": [{"command": "document.export", "params": {"format": "png"}}]})), "no path: the data comes back"); + let copy = json!([{"command": "file.saveCopy", "params": {"path": "/x/a.vectorcraft"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": copy})), "Override Save leaves copies alone"); + let save = |format: Option<&str>| { + let params = format.map_or(json!({}), |f| json!({"format": f})); + json!({"source": "datasets", "commands": [{"command": "document.save", "params": params}]}) + }; + assert!(batch_overwrites_one_file(&saved, &save(None)), "the document's own file"); + assert!(batch_overwrites_one_file(&saved, &save(Some("vectorcraft"))), "the document's own format"); + assert!(!batch_overwrites_one_file(&unsaved, &save(None)), "a document never saved has no file to write"); + assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back"); + assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back"); + let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder"); + assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]}))); + let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]); + assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back"); } #[test] @@ -1343,7 +1451,7 @@ mod tests { (st.doc.variables.active_dataset.clone(), st.revision) }; let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); - assert!(msg.contains("choose Folder"), "{msg}"); + assert!(msg.ends_with("the document has never been saved"), "{msg}"); let st = s.doc().unwrap(); assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); assert_eq!(st.revision, revision, "no step runs"); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 0c010de10..9b62a5342 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; refused for source datasets: use folder to save each data set to its own file) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close, or a Save, Save a Copy, Save as Template or Export step that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 201a4b479..11e878bc1 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -670,7 +670,7 @@ fn find_preset(s: &Session, name: &str) -> Option { } /// `p` over the settings of its `exportPreset`, when it names one (its own keys win). -fn with_preset(s: &Session, p: &Value) -> Result { +pub(super) fn with_preset(s: &Session, p: &Value) -> Result { let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) }; let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?; let mut merged = preset.settings; diff --git a/crates/ui-egui/src/dialogs/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index 36369132a..4e8a3bfae 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -77,12 +77,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } ui.add_space(8.0); - let choices = destinations(&source); - let destination = d.str("destination"); - if !choices.iter().any(|(value, _)| *value == destination) { - d.fields.insert("destination".into(), json!("folder")); - } - form::choice(ui, d, "destination", "Destination:", (LABEL, 200.0), choices); + form::choice(ui, d, "destination", "Destination:", (LABEL, 200.0), &[("none", "None"), ("saveAndClose", "Save and Close"), ("folder", "Folder")]); if d.str("destination") == "folder" { widgets::label_row(ui, tl!("Destination Folder:"), LABEL, |ui| { form::text(ui, d, "destinationFolder", 200.0); @@ -97,17 +92,21 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "overrideSave", "Override Action \"Save\" Commands"); form::check(ui, d, "overrideExport", "Override Action \"Export\" Commands"); form::check(ui, d, "stopOnError", "Stop on Errors"); + if overwrites_one_file(app, d) { + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("Each data set saves over the same file, which keeps only the last data set.")).size(11.5).color(t.warning)); + } false } -/// The Destination choices for `source`. Data sets share one open document, so Save and Close is -/// offered for a Folder source only. -fn destinations(source: &str) -> &'static [(&'static str, &'static str)] { - if source == "datasets" { - &[("none", "None"), ("folder", "Folder")] - } else { - &[("none", "None"), ("saveAndClose", "Save and Close"), ("folder", "Folder")] - } +/// Whether the batch the dialog would run saves every data set over the same file +/// ([`vectorcraft_engine::cmd::fileio::batch_overwrites_one_file`]). +fn overwrites_one_file(app: &VectorcraftApp, d: &Dialog) -> bool { + let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; + let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; + let steps = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).map_or(&[][..], |a| &a.steps[..]); + let commands = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect(); + vectorcraft_engine::cmd::fileio::batch_overwrites_one_file(&app.session, ¶ms(d, "", commands)) } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { @@ -183,6 +182,11 @@ mod tests { /// Draws `d` once and returns the texts it shows. fn render(d: &mut Dialog) -> Vec { + render_in(&mut VectorcraftApp::new(Session::new(), Default::default()), d) + } + + /// Draws `d` once over `app` and returns the texts it shows. + fn render_in(app: &mut VectorcraftApp, d: &mut Dialog) -> Vec { fn collect(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), @@ -190,11 +194,10 @@ mod tests { _ => {} } } - let mut app = VectorcraftApp::new(Session::new(), Default::default()); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut frame = ctx.run_ui(Default::default(), |ui| { - body(&mut app, ui, d); + body(app, ui, d); }); frame.textures_delta.clear(); let mut labels = vec![]; @@ -241,17 +244,36 @@ mod tests { assert!(params(&d, "Test", vec![]).get("datasetFileNaming").is_none()); } + const ONE_FILE: &str = "Each data set saves over the same file, which keeps only the last data set."; + #[test] - fn data_sets_do_not_offer_save_and_close() { - assert!(!destinations("datasets").iter().any(|(value, _)| *value == "saveAndClose")); - assert!(destinations("folder").iter().any(|(value, _)| *value == "saveAndClose")); + fn data_sets_offer_save_and_close_with_a_warning() { let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); d.fields["destination"] = json!("saveAndClose"); - assert!(render(&mut d).iter().any(|label| label == "Save and Close")); + let labels = render(&mut d); + assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}"); + assert!(!labels.iter().any(|label| label == ONE_FILE), "a folder source opens a file per pass: {labels:?}"); d.fields["source"] = json!("datasets"); let labels = render(&mut d); - assert!(!labels.iter().any(|label| label == "Save and Close"), "{labels:?}"); - assert!(labels.iter().any(|label| label == "Destination Folder:"), "{labels:?}"); - assert_eq!(params(&d, "Test", vec![])["destination"], "folder"); + assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}"); + assert!(labels.iter().any(|label| label == ONE_FILE), "{labels:?}"); + assert_eq!(params(&d, "Test", vec![])["destination"], "saveAndClose"); + d.fields["destination"] = json!("folder"); + assert!(!render(&mut d).iter().any(|label| label == ONE_FILE)); + } + + #[test] + fn data_sets_warn_about_a_recorded_save_left_in_place() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.action_sets[0].actions[0].steps.push(("document.save".into(), json!({"path": "/x/a.vectorcraft"}))); + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["source"] = json!("datasets"); + d.fields["destinationFolder"] = json!("/out"); + assert!(!render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE), "Override Save points the save into the folder"); + d.fields["overrideSave"] = json!(false); + assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE)); + d.fields["overrideSave"] = json!(true); + d.fields["destination"] = json!("none"); + assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE)); } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 85c714b78..b94e7895c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6099,7 +6099,7 @@ breakText Glyphs Глифи File + Data Set Name Файл + име на набора от данни Data Set Name Име на набора от данни @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` трябва да е `fileNumber`, `fileDataSetName` или `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Запазване и затваряне“ не може да обработва набори от данни: изберете „Папка“, за да запишете всеки набор от данни в отделен файл + Each data set saves over the same file, which keeps only the last data set. Всеки набор от данни се записва върху същия файл, в който остава само последният набор от данни. @msg no free data set file name in destination folder Няма свободно име за файл на набор от данни в целевата папка # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index edd42d7c4..176aafe80 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6111,7 +6111,7 @@ shape Open Otvoren File + Data Set Name Datoteka + naziv skupa podataka Data Set Name Naziv skupa podataka @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` mora biti `fileNumber`, `fileDataSetName` ili `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Sačuvaj i zatvori“ ne može obraditi skupove podataka: odaberite „Folder“ da biste svaki skup podataka sačuvali u zasebnu datoteku + Each data set saves over the same file, which keeps only the last data set. Svaki skup podataka se sačuva preko iste datoteke, u kojoj ostaje samo posljednji skup podataka. @msg no free data set file name in destination folder Nema slobodnog naziva datoteke skupa podataka u odredišnoj mapi # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f2b00581a..2efc35f97 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6255,7 +6255,7 @@ breakText Glyphs Glifs File + Data Set Name Fitxer + nom del conjunt de dades Data Set Name Nom del conjunt de dades @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` ha de ser `fileNumber`, `fileDataSetName` o `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Desa i tanca» no pot processar conjunts de dades: trieu «Carpeta» per desar cada conjunt de dades en un fitxer propi + Each data set saves over the same file, which keeps only the last data set. Cada conjunt de dades es desa sobre el mateix fitxer, que només conserva l'últim conjunt de dades. @msg no free data set file name in destination folder No hi ha cap nom de fitxer de conjunt de dades lliure a la carpeta de destinació # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 66aaefa8e..5e72b3e66 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6274,7 +6274,7 @@ breakText Glyphs Glyfy File + Data Set Name Soubor + název sady dat Data Set Name Název sady dat @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musí být `fileNumber`, `fileDataSetName` nebo `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Uložit a zavřít“ nelze použít pro sady dat: zvolte „Složka“, aby se každá sada dat uložila do vlastního souboru + Each data set saves over the same file, which keeps only the last data set. Každá sada dat se uloží přes stejný soubor, ve kterém zůstane jen poslední sada dat. @msg no free data set file name in destination folder V cílové složce není volný název souboru sady dat # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3e9389579..516fada6e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6114,7 +6114,7 @@ breakText Glyphs Glyffer File + Data Set Name Fil + datasætnavn Data Set Name Datasætnavn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` skal være `fileNumber`, `fileDataSetName` eller `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Gem og luk" kan ikke køre på datasæt: vælg "Mappe" for at gemme hvert datasæt i sin egen fil + Each data set saves over the same file, which keeps only the last data set. Hvert datasæt gemmes oven i den samme fil, som kun beholder det sidste datasæt. @msg no free data set file name in destination folder Intet ledigt filnavn til datasættet i destinationsmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 75345b1f7..73217a465 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5859,7 +5859,7 @@ breakText Glyphs Glyphen File + Data Set Name Datei + Datensatzname Data Set Name Datensatzname @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` muss `fileNumber`, `fileDataSetName` oder `dataSetName` sein -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Speichern und schließen“ kann nicht auf Datensätze angewendet werden: Wählen Sie „Ordner“, um jeden Datensatz in einer eigenen Datei zu speichern + Each data set saves over the same file, which keeps only the last data set. Jeder Datensatz wird über dieselbe Datei gespeichert, die nur den letzten Datensatz behält. @msg no free data set file name in destination folder Kein freier Dateiname für den Datensatz im Zielordner # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index cf071420c..1a703f159 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6225,7 +6225,7 @@ breakText Glyphs Γλύφοι File + Data Set Name Αρχείο + όνομα συνόλου δεδομένων Data Set Name Όνομα συνόλου δεδομένων @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` Το `datasetFileNaming` πρέπει να είναι `fileNumber`, `fileDataSetName` ή `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file Η «Αποθήκευση και κλείσιμο» δεν μπορεί να εφαρμοστεί σε σύνολα δεδομένων: επιλέξτε «Φάκελος» για να αποθηκευτεί κάθε σύνολο δεδομένων σε δικό του αρχείο + Each data set saves over the same file, which keeps only the last data set. Κάθε σύνολο δεδομένων αποθηκεύεται πάνω στο ίδιο αρχείο, το οποίο κρατά μόνο το τελευταίο σύνολο δεδομένων. @msg no free data set file name in destination folder Δεν υπάρχει διαθέσιμο όνομα αρχείου συνόλου δεδομένων στον φάκελο προορισμού # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 29d345748..0e4df7790 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6216,7 +6216,7 @@ breakText Glyphs Glifos File + Data Set Name Archivo + nombre del conjunto de datos Data Set Name Nombre del conjunto de datos @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` debe ser `fileNumber`, `fileDataSetName` o `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Guardar y cerrar» no puede procesar conjuntos de datos: elija «Carpeta» para guardar cada conjunto de datos en su propio archivo + Each data set saves over the same file, which keeps only the last data set. Cada conjunto de datos se guarda sobre el mismo archivo, que solo conserva el último conjunto de datos. @msg no free data set file name in destination folder No hay un nombre de archivo libre para el conjunto de datos en la carpeta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7e0695592..8d1fe5f74 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6304,7 +6304,7 @@ breakText Glyphs Merkit File + Data Set Name Tiedosto + tietojoukon nimi Data Set Name Tietojoukon nimi @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`-arvon on oltava `fileNumber`, `fileDataSetName` tai `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file ”Tallenna ja sulje” ei voi käsitellä tietojoukkoja: valitse ”Kansio”, niin jokainen tietojoukko tallennetaan omaan tiedostoonsa + Each data set saves over the same file, which keeps only the last data set. Jokainen tietojoukko tallennetaan saman tiedoston päälle, johon jää vain viimeinen tietojoukko. @msg no free data set file name in destination folder Kohdekansiossa ei ole vapaata tietojoukon tiedostonimeä # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e814e8378..6f1bc1715 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6269,7 +6269,7 @@ breakText Glyphs Glyphes File + Data Set Name Fichier + nom du jeu de données Data Set Name Nom du jeu de données @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` doit être `fileNumber`, `fileDataSetName` ou `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file « Enregistrer et fermer » ne peut pas traiter les jeux de données : choisissez « Dossier » pour enregistrer chaque jeu de données dans son propre fichier + Each data set saves over the same file, which keeps only the last data set. Chaque jeu de données est enregistré sur le même fichier, qui ne conserve que le dernier jeu de données. @msg no free data set file name in destination folder Aucun nom de fichier libre pour le jeu de données dans le dossier de destination # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f33c5d7d6..b52b62022 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6258,7 +6258,7 @@ breakText Glyphs Glifák File + Data Set Name Fájl + adatkészlet neve Data Set Name Adatkészlet neve @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` A `datasetFileNaming` értéke `fileNumber`, `fileDataSetName` vagy `dataSetName` lehet -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file A „Mentés és bezárás” nem dolgozhat fel adatkészleteket: válassza a „Mappa” lehetőséget, hogy minden adatkészlet külön fájlba kerüljön + Each data set saves over the same file, which keeps only the last data set. Minden adatkészlet ugyanabba a fájlba mentődik, amely csak az utolsó adatkészletet őrzi meg. @msg no free data set file name in destination folder Nincs szabad adatkészlet-fájlnév a célmappában # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e7b1655d6..176e1da88 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6114,7 +6114,7 @@ breakText Glyphs Glif File + Data Set Name File + nama set data Data Set Name Nama set data @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` harus `fileNumber`, `fileDataSetName`, atau `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Simpan dan tutup" tidak dapat memproses set data: pilih "Folder" untuk menyimpan setiap set data ke file tersendiri + Each data set saves over the same file, which keeps only the last data set. Setiap set data disimpan menimpa file yang sama, sehingga hanya set data terakhir yang tersisa. @msg no free data set file name in destination folder Tidak ada nama file set data yang tersedia di folder tujuan # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 231ed0230..c9d316985 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5893,7 +5893,7 @@ breakText Glyphs Glifi File + Data Set Name File + nome set di dati Data Set Name Nome set di dati @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve essere `fileNumber`, `fileDataSetName` o `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Salva e chiudi» non può elaborare i set di dati: scegli «Cartella» per salvare ogni set di dati in un file a sé + Each data set saves over the same file, which keeps only the last data set. Ogni set di dati viene salvato sullo stesso file, che conserva solo l'ultimo set di dati. @msg no free data set file name in destination folder Nessun nome file libero per il set di dati nella cartella di destinazione # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ac6bb51bc..6dfa754c7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5958,7 +5958,7 @@ breakText Glyphs 字形 File + Data Set Name ファイル名 + データセット名 Data Set Name データセット名 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` は `fileNumber`、`fileDataSetName`、`dataSetName` のいずれかである必要があります -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file 「保存して閉じる」ではデータセットをバッチ処理できません:各データセットを個別のファイルに保存するには「フォルダー」を選択してください + Each data set saves over the same file, which keeps only the last data set. 各データセットが同じファイルに上書き保存されるため、最後のデータセットだけが残ります。 @msg no free data set file name in destination folder 保存先フォルダーに使用可能なデータセットのファイル名がありません # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a0b1d42a6..fb53b753d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6271,7 +6271,7 @@ breakText Glyphs გლიფები File + Data Set Name ფაილი + მონაცემთა ნაკრების სახელი Data Set Name მონაცემთა ნაკრების სახელი @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` უნდა იყოს `fileNumber`, `fileDataSetName` ან `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „შენახვა და დახურვა“ ვერ ამუშავებს მონაცემთა ნაკრებებს: აირჩიეთ „საქაღალდე“, რომ თითოეული მონაცემთა ნაკრები ცალკე ფაილში შეინახოთ + Each data set saves over the same file, which keeps only the last data set. თითოეული მონაცემთა ნაკრები ერთსა და იმავე ფაილზე ინახება, რომელშიც მხოლოდ ბოლო მონაცემთა ნაკრები რჩება. @msg no free data set file name in destination folder დანიშნულების საქაღალდეში მონაცემთა ნაკრებისთვის თავისუფალი ფაილის სახელი არ არის # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c083822b2..81e98bb47 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5854,7 +5854,7 @@ breakText Glyphs 글리프 File + Data Set Name 파일 + 데이터 세트 이름 Data Set Name 데이터 세트 이름 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`은 `fileNumber`, `fileDataSetName` 또는 `dataSetName`이어야 합니다 -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file '저장 후 닫기'로는 데이터 세트를 일괄 처리할 수 없습니다: 각 데이터 세트를 별도의 파일로 저장하려면 '폴더'를 선택하세요 + Each data set saves over the same file, which keeps only the last data set. 각 데이터 세트가 같은 파일에 덮어써지므로 마지막 데이터 세트만 남습니다. @msg no free data set file name in destination folder 대상 폴더에 사용할 수 있는 데이터 세트 파일 이름이 없습니다 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 5c9bd74f1..345d7918e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6160,7 +6160,7 @@ breakText Glyphs Glyphs File + Data Set Name Bestand + naam gegevensset Data Set Name Naam gegevensset @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` moet `fileNumber`, `fileDataSetName` of `dataSetName` zijn -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file 'Opslaan en sluiten' kan geen gegevenssets verwerken: kies 'Map' om elke gegevensset in een eigen bestand op te slaan + Each data set saves over the same file, which keeps only the last data set. Elke gegevensset wordt over hetzelfde bestand opgeslagen, dat alleen de laatste gegevensset bewaart. @msg no free data set file name in destination folder Geen vrije bestandsnaam voor de gegevensset in de doelmap # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cb934d784..1290a0f79 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6128,7 +6128,7 @@ breakText Glyphs Glify File + Data Set Name Plik + nazwa zestawu danych Data Set Name Nazwa zestawu danych @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musi mieć wartość `fileNumber`, `fileDataSetName` lub `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file „Zapisz i zamknij” nie może przetwarzać zestawów danych: wybierz „Folder”, aby zapisać każdy zestaw danych w osobnym pliku + Each data set saves over the same file, which keeps only the last data set. Każdy zestaw danych jest zapisywany w tym samym pliku, który zachowuje tylko ostatni zestaw danych. @msg no free data set file name in destination folder Brak wolnej nazwy pliku zestawu danych w folderze docelowym # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8ac21f8e9..ab767fcc2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6190,7 +6190,7 @@ breakText Glyphs Glifos File + Data Set Name Arquivo + nome do conjunto de dados Data Set Name Nome do conjunto de dados @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve ser `fileNumber`, `fileDataSetName` ou `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Salvar e fechar" não pode processar conjuntos de dados: escolha "Pasta" para salvar cada conjunto de dados em um arquivo próprio + Each data set saves over the same file, which keeps only the last data set. Cada conjunto de dados é salvo sobre o mesmo arquivo, que mantém apenas o último conjunto de dados. @msg no free data set file name in destination folder Não há nome de arquivo livre para o conjunto de dados na pasta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d436e1b7e..d126c5dab 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6251,7 +6251,7 @@ breakText Glyphs Глифы File + Data Set Name Файл + имя набора данных Data Set Name Имя набора данных @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` должно быть `fileNumber`, `fileDataSetName` или `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Сохранить и закрыть» не работает с наборами данных: выберите «Папка», чтобы сохранить каждый набор данных в отдельный файл + Each data set saves over the same file, which keeps only the last data set. Каждый набор данных сохраняется поверх одного и того же файла, в котором остаётся только последний набор данных. @msg no free data set file name in destination folder В папке назначения нет свободного имени файла для набора данных # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4438027d8..4d9e8da51 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6326,7 +6326,7 @@ breakText Glyphs Glyfer File + Data Set Name Fil + datauppsättningens namn Data Set Name Datauppsättningens namn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` måste vara `fileNumber`, `fileDataSetName` eller `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file ”Spara och stäng” kan inte köras på datauppsättningar: välj ”Mapp” för att spara varje datauppsättning i en egen fil + Each data set saves over the same file, which keeps only the last data set. Varje datauppsättning sparas över samma fil, som bara behåller den sista datauppsättningen. @msg no free data set file name in destination folder Inget ledigt filnamn för datauppsättningen i målmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5157d5bf3..ec0c64cd1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6231,7 +6231,7 @@ breakText Glyphs Glifler File + Data Set Name Dosya + veri kümesi adı Data Set Name Veri kümesi adı @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` değeri `fileNumber`, `fileDataSetName` veya `dataSetName` olmalıdır -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Kaydet ve Kapat" veri kümelerini toplu işleyemez: her veri kümesini ayrı bir dosyaya kaydetmek için "Klasör" seçeneğini seçin + Each data set saves over the same file, which keeps only the last data set. Her veri kümesi aynı dosyanın üzerine kaydedilir ve dosyada yalnızca son veri kümesi kalır. @msg no free data set file name in destination folder Hedef klasörde veri kümesi için boş bir dosya adı yok # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index db7e9ccd2..014ee77f0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5873,7 +5873,7 @@ breakText Glyphs Гліфи File + Data Set Name Файл + назва набору даних Data Set Name Назва набору даних @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` має бути `fileNumber`, `fileDataSetName` або `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file «Зберегти й закрити» не працює з наборами даних: виберіть «Папка», щоб зберегти кожен набір даних в окремий файл + Each data set saves over the same file, which keeps only the last data set. Кожен набір даних зберігається поверх того самого файлу, у якому лишається тільки останній набір даних. @msg no free data set file name in destination folder У папці призначення немає вільного імені файлу набору даних # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f59304809..f79734084 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5874,7 +5874,7 @@ breakText Glyphs Glyph File + Data Set Name Tệp + tên tập dữ liệu Data Set Name Tên tập dữ liệu @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` phải là `fileNumber`, `fileDataSetName` hoặc `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file "Lưu và đóng" không thể xử lý các tập dữ liệu: chọn "Thư mục" để lưu mỗi tập dữ liệu vào một tệp riêng + Each data set saves over the same file, which keeps only the last data set. Mỗi tập dữ liệu được lưu đè lên cùng một tệp, nên tệp chỉ giữ lại tập dữ liệu cuối cùng. @msg no free data set file name in destination folder Không có tên tệp tập dữ liệu còn trống trong thư mục đích # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 379f4dc23..2c6da8373 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6115,7 +6115,7 @@ breakText Glyphs 字形 File + Data Set Name 文件 + 数据集名称 Data Set Name 数据集名称 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必须是 `fileNumber`、`fileDataSetName` 或 `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file “存储并关闭”无法批处理数据集:请选择“文件夹”,将每个数据集存储为单独的文件 + Each data set saves over the same file, which keeps only the last data set. 每个数据集都会存储到同一个文件并覆盖它,该文件只保留最后一个数据集。 @msg no free data set file name in destination folder 目标文件夹中没有可用的数据集文件名 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 46c332bd4..096e8602a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5911,7 +5911,7 @@ breakText Glyphs 字符 File + Data Set Name 檔案 + 資料集名稱 Data Set Name 資料集名稱 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必須是 `fileNumber`、`fileDataSetName` 或 `dataSetName` -@msg Save and Close cannot batch data sets: choose Folder to save each data set to its own file 「儲存並關閉」無法批次處理資料集:請選擇「資料夾」,將每個資料集儲存為個別檔案 + Each data set saves over the same file, which keeps only the last data set. 每個資料集都會儲存到同一個檔案並覆寫它,該檔案只保留最後一個資料集。 @msg no free data set file name in destination folder 目的地資料夾中沒有可用的資料集檔案名稱 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/docs/mcp.md b/docs/mcp.md index 41d518456..78c3082cd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3514,9 +3514,12 @@ Actions panel plays an action as a `command.batch`, omitting steps whose playbac cleared. An action set saved as `.vcactions` keeps each excluded step with `excluded: true`; loading an earlier version 1 set without that flag includes every step. The Actions panel menu's Batch… runs `action.batch`: the same steps on every readable file in a folder (or every data set of the open document), with -destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. A Data Sets source does not -take `saveAndClose`: `action.batch` refuses it before it selects a data set or runs a step, and the dialog offers -None and Folder only. For a Data Sets source, +destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. With a Data Sets source, +`saveAndClose` saves the open document over its own file after each data set, so the file keeps only the last one, +and a document that was never saved is refused before a data set is selected. The result carries `warning` when +every data set saves over the same file: with `saveAndClose`, or with a Save, Save a Copy, Save as Template or +Export step that writes a file and that the batch does not point into `destinationFolder`. The dialog shows the same +warning. For a Data Sets source, `datasetFileNaming?: fileNumber | fileDataSetName | dataSetName` chooses File + Number (`source001`), File + Data Set Name (`source_Alpha`), or Data Set Name (`Alpha`). An omitted parameter keeps File + Data Set Name for existing callers; the dialog starts with File + Number. A folder source ignores this parameter. The folder diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 470d36886..ac4da65b5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -643,7 +643,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). - - Batch… over data sets offers None and Folder as the destination, and `action.batch` refuses Save and Close for a Data Sets source before it runs a step, so the batch does not save every data set over the open document's file. This is VectorCraft's own rule: [Play actions on a batch of files](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/play-actions-on-a-batch-of-files.html) describes Save and Close as saving "the file in their current location" and does not say what it does over data sets. + - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set. A recorded Save, Save a Copy, Save as Template or Export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy or template step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. `action.batch` refuses Save and Close over the data sets of a document that was never saved. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. From 64cfa8b5531df2ea031baac66328b2cb860359f2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:49:25 -0800 Subject: [PATCH 09/51] Actions: Batch over data sets warns about PDF, EPS, DXF and web exports and refuses Save and Close on converted documents A recorded PDF, EPS, DXF, Save for Web or Save for Office Documents step that gives a `path` writes the same file for every data set, because the batch does not point these steps into the destination folder. The one-file rule now counts them, so the dialog and `action.batch` warn. Save and Close over the data sets of a document converted from an older version or restored by Data Recovery is refused with "save the document with Save As before Save and Close" before a data set is selected. Saving such a document does not write its file, so the batch reported success without saving anything. The message has a row in each of the 26 catalogs. --- crates/engine/src/cmd/fileio/action_batch.rs | 53 +++++++++++++++++--- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 7 +-- docs/target-app-parity.md | 2 +- 30 files changed, 78 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 26d6c476e..5c29ec88f 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -114,11 +114,12 @@ const ONE_FILE_WARNING: &str = "each data set saves over the same file, which ke /// Whether an `action.batch` with params `p` plays on data sets of the open document in `s` and /// writes each data set to the same file. Save and Close saves the open document over its own file /// after every data set. A step that the batch does not point into `destinationFolder` writes the -/// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export -/// or Export Selection step that gives a `path`; a Save step without a `path` when the document has -/// a file of its own (saved, and not converted from an older version or restored by Data Recovery) -/// and the step gives no `format` or the document's own; an Export for Screens -/// step whose `folder`, given or from its `exportPreset`, names a folder, whatever the overrides. +/// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export, +/// Export Selection, PDF, EPS, DXF, Save for Web or Save for Office Documents step that gives a +/// `path`; a Save step without a `path` when the document has a file of its own (saved, and not +/// converted from an older version or restored by Data Recovery) and the step gives no `format` or +/// the document's own; an Export for Screens step whose `folder`, given or from its +/// `exportPreset`, names a folder, whatever the overrides. /// Other steps return their data instead of writing a file. pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { if p.get("source").and_then(Value::as_str) != Some("datasets") { @@ -146,6 +147,8 @@ pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { "file.saveAs" => !save && path, "file.saveCopy" | "file.saveAsTemplate" => path, "document.export" | "document.exportSelection" => !export && path, + // The batch does not point these exports into `destinationFolder`. + "document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path, // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), _ => false, @@ -199,8 +202,15 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { if names.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets"))); } - if run.destination == "saveAndClose" && st.path.is_none() { - return Err(bad(cmd, "the document has never been saved")); + if run.destination == "saveAndClose" { + if st.path.is_none() { + return Err(bad(cmd, "the document has never been saved")); + } + // `save_plan` writes no file for a document converted from an older version or restored by + // Data Recovery. + if st.converted || st.recovered { + return Err(bad(cmd, "save the document with Save As before Save and Close")); + } } let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string(); let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?; @@ -1441,6 +1451,35 @@ mod tests { assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]}))); let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]); assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back"); + for id in ["document.exportPdf", "document.exportEps", "document.exportDxf", "document.exportForWeb", "document.exportForOffice"] { + let step = json!([{"command": id, "params": {"path": "/x/a.out"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": step})), "{id} keeps its path"); + assert!(!with(json!({"commands": [{"command": id, "params": {}}]})), "{id} without a path: the data comes back"); + } + } + + #[test] + fn batch_refuses_save_and_close_over_data_sets_of_a_converted_or_recovered_document() { + for converted in [true, false] { + let path = tmp(if converted { "converted-sets" } else { "recovered-sets" }).join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let bytes = std::fs::read(&path).unwrap(); + let (active, revision) = { + let st = s.doc_mut().unwrap(); + if converted { + st.converted = true; + } else { + st.recovered = true; + } + (st.doc.variables.active_dataset.clone(), st.revision) + }; + let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); + assert!(msg.ends_with("save the document with Save As before Save and Close"), "{msg}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the file is not written"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); + assert_eq!(st.revision, revision, "no step runs"); + } } #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 9b62a5342..d982b6127 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close, or a Save, Save a Copy, Save as Template or Export step that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index b94e7895c..d8ada61f0 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6100,6 +6100,7 @@ breakText Glyphs Глифи Data Set Name Име на набора от данни @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` трябва да е `fileNumber`, `fileDataSetName` или `dataSetName` Each data set saves over the same file, which keeps only the last data set. Всеки набор от данни се записва върху същия файл, в който остава само последният набор от данни. +@msg save the document with Save As before Save and Close запазете документа със „Запази като“ преди „Запази и затвори“ @msg no free data set file name in destination folder Няма свободно име за файл на набор от данни в целевата папка # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 176aafe80..bcd447280 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6112,6 +6112,7 @@ shape Open Otvoren Data Set Name Naziv skupa podataka @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` mora biti `fileNumber`, `fileDataSetName` ili `dataSetName` Each data set saves over the same file, which keeps only the last data set. Svaki skup podataka se sačuva preko iste datoteke, u kojoj ostaje samo posljednji skup podataka. +@msg save the document with Save As before Save and Close sačuvajte dokument pomoću „Sačuvaj kao“ prije „Sačuvaj i zatvori“ @msg no free data set file name in destination folder Nema slobodnog naziva datoteke skupa podataka u odredišnoj mapi # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2efc35f97..bb3c3a35a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6256,6 +6256,7 @@ breakText Glyphs Glifs Data Set Name Nom del conjunt de dades @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` ha de ser `fileNumber`, `fileDataSetName` o `dataSetName` Each data set saves over the same file, which keeps only the last data set. Cada conjunt de dades es desa sobre el mateix fitxer, que només conserva l'últim conjunt de dades. +@msg save the document with Save As before Save and Close deseu el document amb «Desa com a» abans de «Desa i tanca» @msg no free data set file name in destination folder No hi ha cap nom de fitxer de conjunt de dades lliure a la carpeta de destinació # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5e72b3e66..9991617c3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6275,6 +6275,7 @@ breakText Glyphs Glyfy Data Set Name Název sady dat @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musí být `fileNumber`, `fileDataSetName` nebo `dataSetName` Each data set saves over the same file, which keeps only the last data set. Každá sada dat se uloží přes stejný soubor, ve kterém zůstane jen poslední sada dat. +@msg save the document with Save As before Save and Close před „Uložit a zavřít“ uložte dokument pomocí „Uložit jako“ @msg no free data set file name in destination folder V cílové složce není volný název souboru sady dat # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 516fada6e..874d196f0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6115,6 +6115,7 @@ breakText Glyphs Glyffer Data Set Name Datasætnavn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` skal være `fileNumber`, `fileDataSetName` eller `dataSetName` Each data set saves over the same file, which keeps only the last data set. Hvert datasæt gemmes oven i den samme fil, som kun beholder det sidste datasæt. +@msg save the document with Save As before Save and Close gem dokumentet med Gem som før Gem og luk @msg no free data set file name in destination folder Intet ledigt filnavn til datasættet i destinationsmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 73217a465..114657cc7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5860,6 +5860,7 @@ breakText Glyphs Glyphen Data Set Name Datensatzname @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` muss `fileNumber`, `fileDataSetName` oder `dataSetName` sein Each data set saves over the same file, which keeps only the last data set. Jeder Datensatz wird über dieselbe Datei gespeichert, die nur den letzten Datensatz behält. +@msg save the document with Save As before Save and Close speichern Sie das Dokument mit „Speichern unter“, bevor Sie „Speichern und schließen“ wählen @msg no free data set file name in destination folder Kein freier Dateiname für den Datensatz im Zielordner # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1a703f159..00dcc7044 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6226,6 +6226,7 @@ breakText Glyphs Γλύφοι Data Set Name Όνομα συνόλου δεδομένων @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` Το `datasetFileNaming` πρέπει να είναι `fileNumber`, `fileDataSetName` ή `dataSetName` Each data set saves over the same file, which keeps only the last data set. Κάθε σύνολο δεδομένων αποθηκεύεται πάνω στο ίδιο αρχείο, το οποίο κρατά μόνο το τελευταίο σύνολο δεδομένων. +@msg save the document with Save As before Save and Close αποθηκεύστε το έγγραφο με «Αποθήκευση ως» πριν από «Αποθήκευση και κλείσιμο» @msg no free data set file name in destination folder Δεν υπάρχει διαθέσιμο όνομα αρχείου συνόλου δεδομένων στον φάκελο προορισμού # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0e4df7790..1ae8b7fae 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6217,6 +6217,7 @@ breakText Glyphs Glifos Data Set Name Nombre del conjunto de datos @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` debe ser `fileNumber`, `fileDataSetName` o `dataSetName` Each data set saves over the same file, which keeps only the last data set. Cada conjunto de datos se guarda sobre el mismo archivo, que solo conserva el último conjunto de datos. +@msg save the document with Save As before Save and Close guarde el documento con «Guardar como» antes de «Guardar y cerrar» @msg no free data set file name in destination folder No hay un nombre de archivo libre para el conjunto de datos en la carpeta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8d1fe5f74..bf26c1cc3 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6305,6 +6305,7 @@ breakText Glyphs Merkit Data Set Name Tietojoukon nimi @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`-arvon on oltava `fileNumber`, `fileDataSetName` tai `dataSetName` Each data set saves over the same file, which keeps only the last data set. Jokainen tietojoukko tallennetaan saman tiedoston päälle, johon jää vain viimeinen tietojoukko. +@msg save the document with Save As before Save and Close tallenna asiakirja komennolla Tallenna nimellä ennen kuin valitset Tallenna ja sulje @msg no free data set file name in destination folder Kohdekansiossa ei ole vapaata tietojoukon tiedostonimeä # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6f1bc1715..455af6aa5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6270,6 +6270,7 @@ breakText Glyphs Glyphes Data Set Name Nom du jeu de données @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` doit être `fileNumber`, `fileDataSetName` ou `dataSetName` Each data set saves over the same file, which keeps only the last data set. Chaque jeu de données est enregistré sur le même fichier, qui ne conserve que le dernier jeu de données. +@msg save the document with Save As before Save and Close enregistrez le document avec Enregistrer sous avant Enregistrer et fermer @msg no free data set file name in destination folder Aucun nom de fichier libre pour le jeu de données dans le dossier de destination # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b52b62022..9574c304a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6259,6 +6259,7 @@ breakText Glyphs Glifák Data Set Name Adatkészlet neve @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` A `datasetFileNaming` értéke `fileNumber`, `fileDataSetName` vagy `dataSetName` lehet Each data set saves over the same file, which keeps only the last data set. Minden adatkészlet ugyanabba a fájlba mentődik, amely csak az utolsó adatkészletet őrzi meg. +@msg save the document with Save As before Save and Close mentse a dokumentumot a „Mentés másként” paranccsal a „Mentés és bezárás” előtt @msg no free data set file name in destination folder Nincs szabad adatkészlet-fájlnév a célmappában # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 176e1da88..2b4ab48e8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6115,6 +6115,7 @@ breakText Glyphs Glif Data Set Name Nama set data @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` harus `fileNumber`, `fileDataSetName`, atau `dataSetName` Each data set saves over the same file, which keeps only the last data set. Setiap set data disimpan menimpa file yang sama, sehingga hanya set data terakhir yang tersisa. +@msg save the document with Save As before Save and Close simpan dokumen dengan Simpan sebagai sebelum Simpan dan tutup @msg no free data set file name in destination folder Tidak ada nama file set data yang tersedia di folder tujuan # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c9d316985..1f6d0a139 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5894,6 +5894,7 @@ breakText Glyphs Glifi Data Set Name Nome set di dati @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve essere `fileNumber`, `fileDataSetName` o `dataSetName` Each data set saves over the same file, which keeps only the last data set. Ogni set di dati viene salvato sullo stesso file, che conserva solo l'ultimo set di dati. +@msg save the document with Save As before Save and Close salva il documento con «Salva con nome» prima di «Salva e chiudi» @msg no free data set file name in destination folder Nessun nome file libero per il set di dati nella cartella di destinazione # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6dfa754c7..17b2aa06f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5959,6 +5959,7 @@ breakText Glyphs 字形 Data Set Name データセット名 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` は `fileNumber`、`fileDataSetName`、`dataSetName` のいずれかである必要があります Each data set saves over the same file, which keeps only the last data set. 各データセットが同じファイルに上書き保存されるため、最後のデータセットだけが残ります。 +@msg save the document with Save As before Save and Close 「保存して閉じる」の前に「別名で保存」でドキュメントを保存してください @msg no free data set file name in destination folder 保存先フォルダーに使用可能なデータセットのファイル名がありません # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index fb53b753d..f9545785b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6272,6 +6272,7 @@ breakText Glyphs გლიფები Data Set Name მონაცემთა ნაკრების სახელი @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` უნდა იყოს `fileNumber`, `fileDataSetName` ან `dataSetName` Each data set saves over the same file, which keeps only the last data set. თითოეული მონაცემთა ნაკრები ერთსა და იმავე ფაილზე ინახება, რომელშიც მხოლოდ ბოლო მონაცემთა ნაკრები რჩება. +@msg save the document with Save As before Save and Close „შენახვა და დახურვამდე“ შეინახეთ დოკუმენტი „შენახვა როგორც“ ბრძანებით @msg no free data set file name in destination folder დანიშნულების საქაღალდეში მონაცემთა ნაკრებისთვის თავისუფალი ფაილის სახელი არ არის # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 81e98bb47..ae73d31ac 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5855,6 +5855,7 @@ breakText Glyphs 글리프 Data Set Name 데이터 세트 이름 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`은 `fileNumber`, `fileDataSetName` 또는 `dataSetName`이어야 합니다 Each data set saves over the same file, which keeps only the last data set. 각 데이터 세트가 같은 파일에 덮어써지므로 마지막 데이터 세트만 남습니다. +@msg save the document with Save As before Save and Close '저장 후 닫기' 전에 '다른 이름으로 저장'으로 문서를 저장하십시오 @msg no free data set file name in destination folder 대상 폴더에 사용할 수 있는 데이터 세트 파일 이름이 없습니다 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 345d7918e..c52207339 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6161,6 +6161,7 @@ breakText Glyphs Glyphs Data Set Name Naam gegevensset @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` moet `fileNumber`, `fileDataSetName` of `dataSetName` zijn Each data set saves over the same file, which keeps only the last data set. Elke gegevensset wordt over hetzelfde bestand opgeslagen, dat alleen de laatste gegevensset bewaart. +@msg save the document with Save As before Save and Close sla het document op met Opslaan als voordat u Opslaan en sluiten kiest @msg no free data set file name in destination folder Geen vrije bestandsnaam voor de gegevensset in de doelmap # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1290a0f79..f3538c540 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6129,6 +6129,7 @@ breakText Glyphs Glify Data Set Name Nazwa zestawu danych @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musi mieć wartość `fileNumber`, `fileDataSetName` lub `dataSetName` Each data set saves over the same file, which keeps only the last data set. Każdy zestaw danych jest zapisywany w tym samym pliku, który zachowuje tylko ostatni zestaw danych. +@msg save the document with Save As before Save and Close zapisz dokument poleceniem „Zapisz jako” przed „Zapisz i zamknij” @msg no free data set file name in destination folder Brak wolnej nazwy pliku zestawu danych w folderze docelowym # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ab767fcc2..d1028010b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6191,6 +6191,7 @@ breakText Glyphs Glifos Data Set Name Nome do conjunto de dados @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve ser `fileNumber`, `fileDataSetName` ou `dataSetName` Each data set saves over the same file, which keeps only the last data set. Cada conjunto de dados é salvo sobre o mesmo arquivo, que mantém apenas o último conjunto de dados. +@msg save the document with Save As before Save and Close salve o documento com Salvar Como antes de Salvar e fechar @msg no free data set file name in destination folder Não há nome de arquivo livre para o conjunto de dados na pasta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d126c5dab..f0a56b613 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6252,6 +6252,7 @@ breakText Glyphs Глифы Data Set Name Имя набора данных @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` должно быть `fileNumber`, `fileDataSetName` или `dataSetName` Each data set saves over the same file, which keeps only the last data set. Каждый набор данных сохраняется поверх одного и того же файла, в котором остаётся только последний набор данных. +@msg save the document with Save As before Save and Close сохраните документ командой «Сохранить как» перед «Сохранить и закрыть» @msg no free data set file name in destination folder В папке назначения нет свободного имени файла для набора данных # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4d9e8da51..9e2a2ce01 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6327,6 +6327,7 @@ breakText Glyphs Glyfer Data Set Name Datauppsättningens namn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` måste vara `fileNumber`, `fileDataSetName` eller `dataSetName` Each data set saves over the same file, which keeps only the last data set. Varje datauppsättning sparas över samma fil, som bara behåller den sista datauppsättningen. +@msg save the document with Save As before Save and Close spara dokumentet med Spara som före Spara och stäng @msg no free data set file name in destination folder Inget ledigt filnamn för datauppsättningen i målmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ec0c64cd1..7cd9822fd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6232,6 +6232,7 @@ breakText Glyphs Glifler Data Set Name Veri kümesi adı @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` değeri `fileNumber`, `fileDataSetName` veya `dataSetName` olmalıdır Each data set saves over the same file, which keeps only the last data set. Her veri kümesi aynı dosyanın üzerine kaydedilir ve dosyada yalnızca son veri kümesi kalır. +@msg save the document with Save As before Save and Close Kaydet ve Kapat'tan önce belgeyi Farklı Kaydet ile kaydedin @msg no free data set file name in destination folder Hedef klasörde veri kümesi için boş bir dosya adı yok # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 014ee77f0..2f50e0991 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5874,6 +5874,7 @@ breakText Glyphs Гліфи Data Set Name Назва набору даних @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` має бути `fileNumber`, `fileDataSetName` або `dataSetName` Each data set saves over the same file, which keeps only the last data set. Кожен набір даних зберігається поверх того самого файлу, у якому лишається тільки останній набір даних. +@msg save the document with Save As before Save and Close збережіть документ командою «Зберегти як» перед «Зберегти й закрити» @msg no free data set file name in destination folder У папці призначення немає вільного імені файлу набору даних # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f79734084..d2dc9a478 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5875,6 +5875,7 @@ breakText Glyphs Glyph Data Set Name Tên tập dữ liệu @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` phải là `fileNumber`, `fileDataSetName` hoặc `dataSetName` Each data set saves over the same file, which keeps only the last data set. Mỗi tập dữ liệu được lưu đè lên cùng một tệp, nên tệp chỉ giữ lại tập dữ liệu cuối cùng. +@msg save the document with Save As before Save and Close hãy lưu tài liệu bằng Lưu thành trước khi dùng Lưu và đóng @msg no free data set file name in destination folder Không có tên tệp tập dữ liệu còn trống trong thư mục đích # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2c6da8373..100128f3c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6116,6 +6116,7 @@ breakText Glyphs 字形 Data Set Name 数据集名称 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必须是 `fileNumber`、`fileDataSetName` 或 `dataSetName` Each data set saves over the same file, which keeps only the last data set. 每个数据集都会存储到同一个文件并覆盖它,该文件只保留最后一个数据集。 +@msg save the document with Save As before Save and Close 请先用“另存为”保存文档,再使用“保存并关闭” @msg no free data set file name in destination folder 目标文件夹中没有可用的数据集文件名 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 096e8602a..d0f9c079e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5912,6 +5912,7 @@ breakText Glyphs 字符 Data Set Name 資料集名稱 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必須是 `fileNumber`、`fileDataSetName` 或 `dataSetName` Each data set saves over the same file, which keeps only the last data set. 每個資料集都會儲存到同一個檔案並覆寫它,該檔案只保留最後一個資料集。 +@msg save the document with Save As before Save and Close 請先用「另存新檔」儲存文件,再使用「儲存並關閉」 @msg no free data set file name in destination folder 目的地資料夾中沒有可用的資料集檔案名稱 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). diff --git a/docs/mcp.md b/docs/mcp.md index 78c3082cd..deb110a53 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3516,9 +3516,10 @@ an earlier version 1 set without that flag includes every step. The Actions pane `action.batch`: the same steps on every readable file in a folder (or every data set of the open document), with destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. With a Data Sets source, `saveAndClose` saves the open document over its own file after each data set, so the file keeps only the last one, -and a document that was never saved is refused before a data set is selected. The result carries `warning` when -every data set saves over the same file: with `saveAndClose`, or with a Save, Save a Copy, Save as Template or -Export step that writes a file and that the batch does not point into `destinationFolder`. The dialog shows the same +and a document that was never saved, was converted from an older version or was restored by Data Recovery is +refused before a data set is selected. The result carries `warning` when every data set saves over the same file: +with `saveAndClose`, or with a Save, Save a Copy, Save as Template or export step (PDF, EPS, DXF, Save for Web and +Save for Office Documents included) that writes a file and that the batch does not point into `destinationFolder`. The dialog shows the same warning. For a Data Sets source, `datasetFileNaming?: fileNumber | fileDataSetName | dataSetName` chooses File + Number (`source001`), File + Data Set Name (`source_Alpha`), or Data Set Name (`Alpha`). An omitted parameter keeps File + Data Set Name diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ac4da65b5..b932d6cf0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -643,7 +643,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). - - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set. A recorded Save, Save a Copy, Save as Template or Export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy or template step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. `action.batch` refuses Save and Close over the data sets of a document that was never saved. + - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. From 427029def66385d7951f69953147b7e696261124 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 07:07:20 -0800 Subject: [PATCH 10/51] Actions: Batch Save and Close over data sets follows a redirected Save step With a destination folder and Override Save on, a recorded Save or Save As step writes each data set to its own file in that folder and makes that file the document's own, so Save and Close then saves each data set to its own file. The batch no longer warns that every data set saves over one file in that case, and no longer refuses a document that was never saved, converted or restored, since the recorded step gives it a file first. --- crates/engine/src/cmd/fileio/action_batch.rs | 89 ++++++++++++++------ crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 6 +- docs/target-app-parity.md | 2 +- 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 5c29ec88f..942f58e77 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -113,7 +113,8 @@ const ONE_FILE_WARNING: &str = "each data set saves over the same file, which ke /// Whether an `action.batch` with params `p` plays on data sets of the open document in `s` and /// writes each data set to the same file. Save and Close saves the open document over its own file -/// after every data set. A step that the batch does not point into `destinationFolder` writes the +/// after every data set, unless a Save step that Override Save points into `destinationFolder` +/// first makes each data set's file the document's own. A step that the batch does not point into `destinationFolder` writes the /// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export, /// Export Selection, PDF, EPS, DXF, Save for Web or Save for Office Documents step that gives a /// `path`; a Save step without a `path` when the document has a file of its own (saved, and not @@ -125,38 +126,44 @@ pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { if p.get("source").and_then(Value::as_str) != Some("datasets") { return false; } - if p.get("destination").and_then(Value::as_str) == Some("saveAndClose") { - return true; - } let folder = p.get("destinationFolder").and_then(Value::as_str).is_some(); let redirected = |key: &str| folder && p.get(key).and_then(Value::as_bool).unwrap_or(true); let (save, export) = (redirected("overrideSave"), redirected("overrideExport")); + let steps = p.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + if p.get("destination").and_then(Value::as_str) == Some("saveAndClose") && !saves_move_to_each_file(steps, save) { + return true; + } // The format of the open document's own file, which a Save step without a `path` writes. let own_format = s.active().filter(|st| st.path.is_some() && !st.converted && !st.recovered).map(|st| st.format); let names = |v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty()); - p.get("commands").and_then(Value::as_array).is_some_and(|cmds| { - cmds.iter().any(|c| { - let params = c.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())); - let path = names(params.get("path")); - let own_file = || match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { - None => own_format.is_some(), - Some(f) => own_format.is_some_and(|own| super::format(f).is_some_and(|f| f.id == own)), - }; - match c.get("command").and_then(Value::as_str).unwrap_or("") { - "document.save" | "file.save" => !save && (path || own_file()), - "file.saveAs" => !save && path, - "file.saveCopy" | "file.saveAsTemplate" => path, - "document.export" | "document.exportSelection" => !export && path, - // The batch does not point these exports into `destinationFolder`. - "document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path, - // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. - "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), - _ => false, - } - }) + steps.iter().any(|c| { + let params = c.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())); + let path = names(params.get("path")); + let own_file = || match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { + None => own_format.is_some(), + Some(f) => own_format.is_some_and(|own| super::format(f).is_some_and(|f| f.id == own)), + }; + match c.get("command").and_then(Value::as_str).unwrap_or("") { + "document.save" | "file.save" => !save && (path || own_file()), + "file.saveAs" => !save && path, + "file.saveCopy" | "file.saveAsTemplate" => path, + "document.export" | "document.exportSelection" => !export && path, + // The batch does not point these exports into `destinationFolder`. + "document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path, + // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. + "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), + _ => false, + } }) } +/// Whether `steps` hold a Save, Save As or `document.save` step that Override Save points at each +/// data set's own file in the destination folder (`redirected`). Such a step makes that file the +/// document's own, so Save and Close then saves each data set to its own file. +fn saves_move_to_each_file(steps: &[Value], redirected: bool) -> bool { + redirected && steps.iter().any(|c| matches!(c.get("command").and_then(Value::as_str), Some("document.save" | "file.save" | "file.saveAs"))) +} + #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::{absolute_path, may_read}; @@ -202,7 +209,7 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { if names.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets"))); } - if run.destination == "saveAndClose" { + if run.destination == "saveAndClose" && !saves_move_to_each_file(run.steps, run.override_save && run.dest_folder.is_some()) { if st.path.is_none() { return Err(bad(cmd, "the document has never been saved")); } @@ -1413,6 +1420,38 @@ mod tests { assert!(r.get("warning").is_none(), "{r}"); } + #[test] + fn batch_save_and_close_after_a_redirected_save_writes_each_data_set_to_its_own_file() { + let save_close = |dest: &std::path::Path| { + json!({ + "source": "datasets", + "destination": "saveAndClose", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.save", "params": {}}] + }) + }; + let path = tmp("redirected-save-close").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let bytes = std::fs::read(&path).unwrap(); + let dest = tmp("redirected-save-close-dest"); + let p = save_close(&dest); + let mut kept = p.clone(); + kept["overrideSave"] = json!(false); + assert!(batch_overwrites_one_file(&s, &kept), "without Override Save the save stays on the template"); + let r = s.execute("action.batch", &p).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert_eq!(file_names(&dest), ["template_A.vectorcraft", "template_B.vectorcraft"], "{r}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template's file stays as it was"); + // A document never saved gets its files from the redirected save too. + let mut unsaved = with_data_sets(&["a", "b"]); + let dest = tmp("redirected-save-close-unsaved"); + let r = unsaved.execute("action.batch", &save_close(&dest)).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a.vectorcraft", "Untitled_b.vectorcraft"], "{r}"); + } + #[test] fn one_file_rule_covers_save_and_close_and_steps_left_in_place() { let saved = saved_with_two_data_sets(&tmp("one-file-rule").join("template.vectorcraft")); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index d982b6127..65872508f 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own first) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/docs/mcp.md b/docs/mcp.md index deb110a53..312230a86 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3517,8 +3517,10 @@ an earlier version 1 set without that flag includes every step. The Actions pane destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. With a Data Sets source, `saveAndClose` saves the open document over its own file after each data set, so the file keeps only the last one, and a document that was never saved, was converted from an older version or was restored by Data Recovery is -refused before a data set is selected. The result carries `warning` when every data set saves over the same file: -with `saveAndClose`, or with a Save, Save a Copy, Save as Template or export step (PDF, EPS, DXF, Save for Web and +refused before a data set is selected. When `destinationFolder` is set and Override Save points a recorded Save or +Save As step into it, that step makes each data set's file the document's own, and `saveAndClose` saves each data +set to its own file. The result carries `warning` when every data set saves over the same file: +with `saveAndClose` without such a step, or with a Save, Save a Copy, Save as Template or export step (PDF, EPS, DXF, Save for Web and Save for Office Documents included) that writes a file and that the batch does not point into `destinationFolder`. The dialog shows the same warning. For a Data Sets source, `datasetFileNaming?: fileNumber | fileDataSetName | dataSetName` chooses File + Number (`source001`), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b932d6cf0..cddb2ecb9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -643,7 +643,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). - - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. + - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set, unless a recorded Save step that Override Save points into the destination folder first makes each data set's file the document's own. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. Without such a Save step, `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. From 0d1a8b6ffa838cb46ce6d252f1882a604de63a82 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 07:21:38 -0800 Subject: [PATCH 11/51] Actions: Batch Save and Close over data sets counts only saves that make the file the document's own A recorded save in the template format writes a copy and leaves the document's file as it was, so it no longer clears the warning or the refusals. A recorded Save or Save As with its own path gives a never-saved, converted or restored document its file before Save and Close, so the batch runs and warns that every data set saves over that one file. --- crates/engine/src/cmd/fileio/action_batch.rs | 43 +++++++++++++++++++- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 942f58e77..ded4dcd5d 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -161,7 +161,25 @@ pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { /// data set's own file in the destination folder (`redirected`). Such a step makes that file the /// document's own, so Save and Close then saves each data set to its own file. fn saves_move_to_each_file(steps: &[Value], redirected: bool) -> bool { - redirected && steps.iter().any(|c| matches!(c.get("command").and_then(Value::as_str), Some("document.save" | "file.save" | "file.saveAs"))) + redirected && steps.iter().any(|c| retargeting_save(c, true)) +} + +/// Whether step `c` is a Save, Save As or `document.save` that writes a file (its own `path`, or one +/// in the destination folder when `redirected`) and makes that file the document's own: a save in +/// the native template format writes a copy and leaves the document's file as it was. +fn retargeting_save(c: &Value, redirected: bool) -> bool { + if !matches!(c.get("command").and_then(Value::as_str), Some("document.save" | "file.save" | "file.saveAs")) { + return false; + } + let params = c.get("params"); + let path = params.and_then(|p| p.get("path")).and_then(Value::as_str).filter(|p| !p.is_empty()); + let format_ok = match params.and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()) { + Some(f) => super::format(f).is_some_and(|f| f.id != "template"), + // Override Save gives the step a native file name; else its own path names the format. + None if redirected => true, + None => path.and_then(super::format_for_name).is_none_or(|f| f.id != "template"), + }; + format_ok && (redirected || path.is_some()) } #[cfg(not(target_arch = "wasm32"))] @@ -209,7 +227,9 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { if names.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets"))); } - if run.destination == "saveAndClose" && !saves_move_to_each_file(run.steps, run.override_save && run.dest_folder.is_some()) { + let redirected = run.override_save && run.dest_folder.is_some(); + // A recorded save with a file to write gives the document a file of its own before Save and Close. + if run.destination == "saveAndClose" && !run.steps.iter().any(|c| retargeting_save(c, redirected)) { if st.path.is_none() { return Err(bad(cmd, "the document has never been saved")); } @@ -1450,6 +1470,25 @@ mod tests { assert_eq!(r["failed"], 0, "{r}"); assert!(r.get("warning").is_none(), "{r}"); assert_eq!(file_names(&dest), ["Untitled_a.vectorcraft", "Untitled_b.vectorcraft"], "{r}"); + // A template save writes a copy: Save and Close still saves over the document's own file. + let mut template = save_close(&dest); + template["commands"] = json!([{"command": "document.save", "params": {"format": "template"}}]); + assert!(batch_overwrites_one_file(&s, &template), "{template}"); + let mut never = with_data_sets(&["a", "b"]); + let msg = never.execute("action.batch", &template).unwrap_err().to_string(); + assert!(msg.ends_with("the document has never been saved"), "{msg}"); + // A recorded Save As with its own path gives a never-saved document its file, the same one + // for every data set. + let one = tmp("save-as-then-close").join("one.vectorcraft"); + let p = json!({ + "source": "datasets", + "destination": "saveAndClose", + "commands": [{"command": "file.saveAs", "params": {"path": one.to_string_lossy()}}] + }); + let r = never.execute("action.batch", &p).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + assert!(one.exists(), "{r}"); } #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 65872508f..b71ede096 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own first) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/docs/mcp.md b/docs/mcp.md index 312230a86..facbe02e4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3517,7 +3517,8 @@ an earlier version 1 set without that flag includes every step. The Actions pane destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. With a Data Sets source, `saveAndClose` saves the open document over its own file after each data set, so the file keeps only the last one, and a document that was never saved, was converted from an older version or was restored by Data Recovery is -refused before a data set is selected. When `destinationFolder` is set and Override Save points a recorded Save or +refused before a data set is selected, unless a recorded Save or Save As step (not in the template format) writes a +file and makes it the document's own first. When `destinationFolder` is set and Override Save points a recorded Save or Save As step into it, that step makes each data set's file the document's own, and `saveAndClose` saves each data set to its own file. The result carries `warning` when every data set saves over the same file: with `saveAndClose` without such a step, or with a Save, Save a Copy, Save as Template or export step (PDF, EPS, DXF, Save for Web and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cddb2ecb9..794ab3fb8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -643,7 +643,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). - - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set, unless a recorded Save step that Override Save points into the destination folder first makes each data set's file the document's own. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. Without such a Save step, `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. + - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set, unless a recorded Save step that Override Save points into the destination folder first makes each data set's file the document's own. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. Unless a recorded Save or Save As step writes a file and makes it the document's own first, `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. From a33d6a6ec8eaa03a0d49628437b34d6c66b04cbc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:45:26 +0700 Subject: [PATCH 12/51] Canvas: a General option makes Cmd/Ctrl-wheel scroll the canvas sideways Squashed from #5023 onto main. --- crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/prefs.rs | 3 + crates/engine/src/tests/prefs.rs | 14 ++ crates/ui-egui/src/canvas/input.rs | 10 +- crates/ui-egui/src/canvas/tests.rs | 290 ++++++++++++++++++++++- crates/ui-egui/src/canvas/view.rs | 93 +++++++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/prefs_dialog.rs | 5 + crates/ui-egui/src/prefs_dialog/tests.rs | 35 +++ docs/control-protocol.md | 2 +- docs/mcp.md | 6 + docs/target-app-parity.md | 1 + 37 files changed, 472 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a702f1580..ad52c808a 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -150,6 +150,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("scaleCorners", "General", "Options", "Scale Corners", bool), p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool), p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool), + p!("ctrlWheelScrollsSideways", "General", "Options", "Scroll Sideways with Cmd/Ctrl + Wheel", bool), p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool), p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool), p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 82946ac29..920ef3d1c 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -54,6 +54,8 @@ pub struct Prefs { /// Scale Strokes & Effects. pub scale_strokes: bool, pub zoom_with_mouse_wheel: bool, + /// While Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls sideways instead of zooming. + pub ctrl_wheel_scrolls_sideways: bool, /// A horizontal drag on a numeric field or its label steps its value (#400). pub scrub_numeric_fields: bool, /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. @@ -367,6 +369,7 @@ impl Default for Prefs { scale_corners: false, scale_strokes: false, zoom_with_mouse_wheel: false, + ctrl_wheel_scrolls_sideways: true, scrub_numeric_fields: true, trackpad_rotate_view: true, hold_tool_keys: true, diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 437701136..7a17fe413 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -2511,6 +2511,20 @@ fn a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide() { } } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel starts on: in a new session, in a preferences +/// file saved before the option existed and after a reset. +#[test] +fn scroll_sideways_with_cmd_wheel_is_on_by_default() { + assert!(Prefs::default().ctrl_wheel_scrolls_sideways); + let old: Prefs = serde_json::from_value(json!({"zoomWithMouseWheel": false})).unwrap(); + assert!(old.ctrl_wheel_scrolls_sideways); + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap(); + assert!(!s.prefs.ctrl_wheel_scrolls_sideways); + s.execute("prefs.reset", &json!({})).unwrap(); + assert!(s.prefs.ctrl_wheel_scrolls_sideways); +} + /// The Transform panel menu's choice starts as `default` (the panels follow General › Transform /// Pattern Tiles): in a new session, in a preferences file saved before the choice existed and /// after a reset; `prefs.set` takes it and the three menu choices, and nothing else. diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 49baf494b..0094dc07d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -206,8 +206,14 @@ fn wheel_view( let turn_id = egui::Id::unique("canvas-wheel-turn"); let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); let mut turn = turn_before; - let (pointer, m, space, (factor, scroll)) = - ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, animate, line, rect.height(), &mut turn))); + let (pointer, m, space, (factor, scroll)) = ui.input(|i| { + ( + i.pointer.clone(), + i.modifiers, + i.key_down(egui::Key::Space), + wheel(i, &app.session.prefs, wheel_zooms, animate, line, rect.height(), &mut turn), + ) + }); if turn != turn_before { ui.data_mut(|d| d.insert_temp(turn_id, turn)); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d9fa7f09c..c37d6b868 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4306,7 +4306,8 @@ fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() { } /// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off, -/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel +/// the wheel scrolls and Cmd-wheel scrolls sideways (Scroll Sideways with Cmd/Ctrl + Wheel is on +/// by default); on, the wheel zooms about the pointer, Shift-wheel /// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a /// trackpad pinch zoom about the pointer either way. #[test] @@ -4348,7 +4349,9 @@ fn zoom_with_mouse_wheel_preference() { let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; let (zoom, scroll, _) = turn(&mut app, ""); assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})"); - assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + // General › Scroll Sideways with Cmd/Ctrl + Wheel is on by default. + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer"); assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer"); app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); @@ -4361,6 +4364,289 @@ fn zoom_with_mouse_wheel_preference() { assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default), through the control channel's +/// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the +/// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch +/// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain +/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl +/// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that +/// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with +/// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms. +#[test] +fn cmd_wheel_scrolls_sideways_preference() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // A canvas frame through the host's input hook (it feeds the control channel's input). + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + // One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how + // far the point under `at` moved). + let turn = |app: &mut VectorcraftApp, mods: &str| { + let before = *app.view().unwrap(); + if mods == "pinch" { + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + } else { + let mut p = json!({"x": at.x, "y": at.y, "dy": 1}); + for m in mods.split('+').filter(|m| !m.is_empty()) { + p[m] = json!(true); + } + crate::tests_synthetic::control(app, &ctx, "ui.wheel", p); + } + // egui spreads a notch over a few frames. + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) + }; + let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; + assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default"); + app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap(); + assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Shift-wheel scrolls sideways ({zoom}, {scroll:?})"); + app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": true})).unwrap(); + // A notch scrolls one wheel line. + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!( + (zoom - 1.0).abs() < 1e-9 && (scroll.x + line).abs() < 0.5 && scroll.y == 0.0, + "on: Cmd-wheel scrolls sideways by a notch ({zoom}, {scroll:?})" + ); + assert!(zooms_in(turn(&mut app, "pinch")), "on: a pinch right after it zooms in about the pointer"); + // Two fingers spread on a touch screen, with no wheel turn since the Cmd-wheel one. + let before = app.view().unwrap().zoom; + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let fingers = |phase, d: f32| vec![touch(1, phase, at - vec2(40.0 + d, 0.0)), touch(2, phase, at + vec2(40.0 + d, 0.0))]; + frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, 0.0)); + for k in 1..=3 { + frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, 10.0 * k as f32)); + } + frame(&mut app, &ctx, fingers(egui::TouchPhase::End, 30.0)); + let zoom = app.view().unwrap().zoom / before; + assert!(zoom > 1.01, "on: fingers spread on a touch screen zoom in ({zoom})"); + assert!(zooms_in(turn(&mut app, "alt+cmd")), "on: Alt + Cmd-wheel zooms in about the pointer"); + assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel"); + let (zoom, scroll, _) = turn(&mut app, ""); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: the wheel scrolls up ({zoom}, {scroll:?})"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Shift-wheel scrolls sideways ({zoom}, {scroll:?})"); + // A Cmd-wheel notch with a plain one right after it, before egui has spread the first: they + // scroll two notches in all, sideways and up. + let before = *app.view().unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + scroll.x < -1.0 && scroll.y < -1.0 && (scroll.x + scroll.y + 2.0 * line).abs() < 0.5, + "on: a Cmd-wheel notch and a plain one right after it scroll two notches in all ({scroll:?})" + ); + // A pinch while egui still spreads a Cmd-wheel notch zooms by the pinch alone, and the + // canvas scrolls sideways by one notch; the pinch magnifies the part scrolled before it. + let before = *app.view().unwrap(); + let p = Xf::new(rect, &before).to_doc(at); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let (zoom, moved) = (after.zoom / before.zoom, Xf::new(rect, &after).to_screen(p) - at); + let notch = line as f32; + assert!( + (zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3, + "on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})" + ); + // A trackpad gesture starts with no modifier, and Cmd is pressed and released during it: + // egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling + // sideways until then. + let before = *app.view().unwrap(); + let swipe = |phase, modifiers| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 6.0), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + app.synthetic.extend([ + egui::Event::PointerMoved(at), + swipe(egui::TouchPhase::Start, egui::Modifiers::NONE), + swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND), + swipe(egui::TouchPhase::Move, egui::Modifiers::NONE), + swipe(egui::TouchPhase::End, egui::Modifiers::NONE), + ]); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0, + "on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})", + after.zoom / before.zoom + ); + // An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui + // ends it before the next event, so a move with Cmd alone scrolls sideways. + // Frames at the given times, with the events in the frame's own input (the host's input + // hook hands synthetic events out over several frames). + let run_at = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + // The Alt part rests the fingers without moving, so only the Cmd move below acts. + let rest = |phase| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::ZERO, + phase, + modifiers: egui::Modifiers::ALT, + source: egui::MouseWheelSource::Unknown, + }; + let t = ctx.input(|i| i.time) + 1.0; + run_at(&mut app, t, vec![egui::Event::PointerMoved(at), rest(egui::TouchPhase::Start), rest(egui::TouchPhase::Move)]); + let before = *app.view().unwrap(); + run_at(&mut app, t + 2.0, vec![swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND)]); + for k in 1..=40 { + run_at(&mut app, t + 2.0 + f64::from(k) / 60.0, vec![]); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 6.0).abs() < 0.01 && scroll.y == 0.0, + "on: after egui's timeout, a Cmd move scrolls sideways ({}, {scroll:?})", + after.zoom / before.zoom + ); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!( + (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, + "with Zoom with Mouse Wheel: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})" + ); + assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer"); +} + +/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms +/// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and +/// Cmd-wheel alone scrolls sideways. +#[test] +fn cmd_wheel_sideways_alt_cmd_zooms_without_animated_zoom() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (key, value) in [("ctrlWheelScrollsSideways", true), ("zoomWithMouseWheel", false), ("animatedZoom", false)] { + app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let before = *app.view().unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "alt": true, "cmd": true})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let expected = ctx.options(|o| f64::from(o.input_options.line_scroll_speed * o.input_options.scroll_zoom_speed).exp()); + let zoom = after.zoom / before.zoom; + assert!((zoom - expected).abs() < 1e-3, "Alt + Cmd-wheel zooms in by one notch ({zoom}, expected {expected})"); + assert!(Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)) < 1e-6, "Alt + Cmd-wheel zooms about the pointer"); + + let before = after; + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "Cmd-wheel does not zoom"); + assert!((scroll.x + line).abs() < 0.5 && scroll.y == 0.0, "Cmd-wheel scrolls sideways ({scroll:?})"); +} + +/// With Zoom with Mouse Wheel off, a trackpad gesture that starts with Alt keeps zooming about the +/// pointer until it ends, also after Alt is let go, as egui keeps a modifier held during a gesture, +/// with Animated Zoom on and off. A gesture that gains Alt partway zooms from then on. +#[test] +fn alt_wheel_gesture_keeps_zooming_after_alt_is_let_go() { + for animate in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (key, value) in [("zoomWithMouseWheel", false), ("animatedZoom", animate)] { + app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let swipe = |phase, modifiers| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 6.0), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + // One gesture with the given modifiers on its Start and its two Moves → (zoom ratio, how far + // the point under `at` moved). + let gesture = |app: &mut VectorcraftApp, mods: [egui::Modifiers; 3]| { + let before = *app.view().unwrap(); + app.synthetic.extend([ + egui::Event::PointerMoved(at), + swipe(egui::TouchPhase::Start, mods[0]), + swipe(egui::TouchPhase::Move, mods[1]), + swipe(egui::TouchPhase::Move, mods[2]), + swipe(egui::TouchPhase::End, egui::Modifiers::NONE), + ]); + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + (after.zoom / before.zoom, Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at))) + }; + let alt = egui::Modifiers::ALT; + let (held, _) = gesture(&mut app, [alt, alt, alt]); + assert!(held > 1.01, "animate {animate}: an Alt gesture zooms in ({held})"); + let (zoom, moved) = gesture(&mut app, [alt, alt, egui::Modifiers::NONE]); + assert!( + (zoom - held).abs() < 1e-9 && moved < 1e-6, + "animate {animate}: a move without Alt keeps the gesture zooming about the pointer ({zoom}, expected {held}; moved {moved})" + ); + let (zoom, moved) = gesture(&mut app, [egui::Modifiers::NONE, alt, egui::Modifiers::NONE]); + assert!( + (zoom - held).abs() < 1e-9 && moved < 1e-6, + "animate {animate}: Alt pressed partway zooms the rest of the gesture about the pointer ({zoom}, expected {held}; moved {moved})" + ); + } +} + /// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a /// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom. #[test] diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index c0e2a4bc0..39bc80545 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -52,8 +52,19 @@ const WHEEL_GLIDE: f64 = 0.05; /// What the wheel did across frames ([`wheel`]). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct WheelTurn { - /// The last wheel turn was an Alt-wheel one. + /// The last wheel turn was an Alt-wheel one: `held` had Alt without Cmd/Ctrl at its last Start + /// or Move. alt: bool, + /// The modifiers egui's wheel state holds, followed while Zoom with Mouse Wheel is off: each + /// wheel event's own, or, through a trackpad gesture, all those held since it began; none + /// while the wheel is idle. + held: egui::Modifiers, + /// A trackpad gesture is under way: egui has seen its Start phase and has not yet seen its End + /// or Cancel phase. + gesture: bool, + /// When the last wheel event came (`InputState::time`), and whether a trackpad sent it. + last: f64, + trackpad: bool, /// The zoom (natural log) wheel notches still have to glide in. pub(super) glide: f64, } @@ -342,13 +353,22 @@ struct ThumbDrag { /// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: /// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option -/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel -/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points +/// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls +/// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the +/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points /// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated /// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does /// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at /// once either way. -pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) { +pub(super) fn wheel( + i: &egui::InputState, + prefs: &vectorcraft_engine::Prefs, + wheel_zooms: bool, + animate: bool, + line: f32, + page: f32, + turn: &mut WheelTurn, +) -> (f64, egui::Vec2) { // Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead). let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta); // A wheel event's motion in points. @@ -360,17 +380,72 @@ pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line if !wheel_zooms { // egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few // frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go. - if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) { - turn.alt = m.alt && !m.command; + // Each wheel event changes `turn.held` and `turn.gesture` as `WheelState::on_wheel_event` + // changes egui's wheel modifiers and status. Both are cleared while egui's wheel is idle. + // `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held + // Alt keeps zooming until it ends, also after Alt is let go. + for e in &i.events { + if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e { + // A wheel event that comes after egui's scroll timeout ends the turn first, as + // `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else + // 0.15 s), also when no frame ran in between. + let timeout = if turn.gesture && turn.trackpad { 1.0 } else { 0.150 }; + if i.time - turn.last > timeout { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + turn.last = i.time; + turn.trackpad = *source == egui::MouseWheelSource::Trackpad; + match phase { + egui::TouchPhase::Start => { + turn.gesture = true; + turn.held = *modifiers; + turn.alt = turn.held.alt && !turn.held.command; + } + egui::TouchPhase::Move => { + if turn.gesture { + turn.held |= *modifiers; + } else { + turn.held = *modifiers; + } + turn.alt = turn.held.alt && !turn.held.command; + } + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + } + } + } + if !i.is_scrolling() { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); + // Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without + // Alt, egui's zoom, less the pinches', is `exp(points × scroll_zoom_speed)`, at egui's + // default speed, which the app keeps. The canvas scrolls sideways by those points instead. + // A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the + // fingers' alone, and the canvas leaves it as it is. + if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() { + let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product(); + let wheel_zoom = zoom / pinch; + if wheel_zoom.is_finite() && wheel_zoom > 0.0 { + scroll.x += (wheel_zoom.ln() / f64::from(egui::InputOptions::default().scroll_zoom_speed)) as f32; + zoom = pinch; + } } - let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); if turn.alt && !animate { // Without Animated Zoom, Alt-wheel zooms by the wheel events themselves, on the frame - // they arrive; the rest of egui's spread scroll is dropped. + // they arrive; the rest of egui's spread scroll is dropped. Only Move events count, as + // `on_wheel_event` moves egui's scroll by Move events alone. let d: f32 = i .events .iter() - .filter_map(|e| if let egui::Event::MouseWheel { unit, delta, .. } = e { Some(points(unit, *delta)) } else { None }) + .filter_map(|e| match e { + egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, .. } => Some(points(unit, *delta)), + _ => None, + }) .map(|d| d.x + d.y) .sum(); return (zoom * (f64::from(d) * WHEEL_ZOOM).exp(), pan); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d8ada61f0..92e94aa1e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3710,6 +3710,7 @@ digits Malayalam Малаялам Zoom to Selection Приближаване до селекцията Zoom Tool Инструмент „Мащаб“ Zoom with Mouse Wheel Мащабиране с колелцето на мишката + Scroll Sideways with Cmd/Ctrl + Wheel Хоризонтално превъртане с Cmd/Ctrl + колелцето Scrub Numeric Fields by Dragging Плъзгане на числени полета чрез влачене @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} файл в {folder} {note}. Показване на папката на пакета?|{n} файла в {folder} {note}. Показване на папката на пакета? @plural {n} linked file not found|{n} linked files not found {n} свързан файл не е намерен|{n} свързани файла не са намерени diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index bcd447280..2c365fa50 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3699,6 +3699,7 @@ theme Appearance Izgled Yards Jardi Zoom to Selection Zumiraj na odabir Zoom with Mouse Wheel Zumiraj točkićem miša + Scroll Sideways with Cmd/Ctrl + Wheel Pomjeraj vodoravno uz Cmd/Ctrl + točkić miša Scrub Numeric Fields by Dragging Mijenjaj numerička polja prevlačenjem Linux Display Server Linux server prikaza Print Preview Pregled štampanja diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index bb3c3a35a..f0c3542ad 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3728,6 +3728,7 @@ digits Malayalam Malaiàlam Scribble… Garabateig… Script / Brush Cal·ligràfica / Pinzell Scripts Scripts + Scroll Sideways with Cmd/Ctrl + Wheel Desplaça horitzontalment amb Cmd/Ctrl + roda del ratolí Scrub Numeric Fields by Dragging Canvia els camps numèrics arrossegant Search Cerca Search All Cerca a tot diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9991617c3..233257897 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3744,6 +3744,7 @@ theme Appearance Motiv Yards Yardy Zoom to Selection Zvětšit na výběr Zoom with Mouse Wheel Zvětšovat kolečkem myši + Scroll Sideways with Cmd/Ctrl + Wheel Posouvat vodorovně kolečkem s Cmd/Ctrl Scrub Numeric Fields by Dragging Měnit číselné hodnoty tažením Linux Display Server Zobrazovací server Linuxu Print Preview Náhled tisku diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 874d196f0..94516cb64 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3472,6 +3472,7 @@ theme Appearance Udseende Yards Yards Zoom to Selection Zoom til markering Zoom with Mouse Wheel Zoom med musehjul + Scroll Sideways with Cmd/Ctrl + Wheel Rul vandret med Cmd/Ctrl + musehjul Scrub Numeric Fields by Dragging Juster numeriske felter ved at trække Linux Display Server Linux-visningsserver Print Preview Udskriftsvisning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 114657cc7..6dc11392e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3703,6 +3703,7 @@ effect Points Punkte Zoom to Selection Auf Auswahl zoomen Zoom Tool Zoom-Werkzeug Zoom with Mouse Wheel Mit Mausrad zoomen + Scroll Sideways with Cmd/Ctrl + Wheel Mit Cmd/Ctrl + Mausrad horizontal scrollen Scrub Numeric Fields by Dragging Zahlenfelder durch Ziehen ändern @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} Datei in {folder} {note}. Paketordner anzeigen?|{n} Dateien in {folder} {note}. Paketordner anzeigen? @plural {n} linked file not found|{n} linked files not found {n} verknüpfte Datei nicht gefunden|{n} verknüpfte Dateien nicht gefunden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 00dcc7044..267920a0f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4036,6 +4036,7 @@ digits Malayalam Μαλαγιάλαμ Zig Zag… Ζιγκ-ζαγκ… Zoom to Selection Ζουμ στην επιλογή Zoom with Mouse Wheel Ζουμ με τον τροχό του ποντικιού + Scroll Sideways with Cmd/Ctrl + Wheel Οριζόντια κύλιση με Cmd/Ctrl + τροχό του ποντικιού Scrub Numeric Fields by Dragging Ρύθμιση αριθμητικών πεδίων με σύρσιμο @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} αρχείο στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας;|{n} αρχεία στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας; @plural {n} linked file not found|{n} linked files not found Δεν βρέθηκε {n} συνδεδεμένο αρχείο|Δεν βρέθηκαν {n} συνδεδεμένα αρχεία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1ae8b7fae..105146217 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3793,6 +3793,7 @@ digits Malayalam Malayálam Zoom to Selection Zoom a la selección Zoom Tool Herramienta Zoom Zoom with Mouse Wheel Zoom con la rueda del mouse + Scroll Sideways with Cmd/Ctrl + Wheel Desplazar horizontalmente con Cmd/Ctrl + rueda del mouse Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} archivo en {folder} {note}. ¿Mostrar la carpeta del paquete?|{n} archivos en {folder} {note}. ¿Mostrar la carpeta del paquete? @plural {n} linked file not found|{n} linked files not found No se encontró {n} archivo vinculado|No se encontraron {n} archivos vinculados diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bf26c1cc3..821935c27 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3668,6 +3668,7 @@ theme Appearance Ulkoasu Yards Jaardit Zoom to Selection Zoomaa valintaan Zoom with Mouse Wheel Zoomaa hiiren rullalla + Scroll Sideways with Cmd/Ctrl + Wheel Vieritä vaakasuunnassa Cmd/Ctrl + hiiren rullalla Scrub Numeric Fields by Dragging Muuta numerokenttiä vetämällä Linux Display Server Linuxin näyttöpalvelin Print Preview Tulostuksen esikatselu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 455af6aa5..f7215a99b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3785,6 +3785,7 @@ digits Malayalam Malayalam Zoom to Selection Zoom sur la sélection Zoom Tool Outil Zoom Zoom with Mouse Wheel Zoom avec la molette de la souris + Scroll Sideways with Cmd/Ctrl + Wheel Défilement horizontal avec Cmd/Ctrl + molette Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fichier dans {folder} {note}. Afficher le dossier de l’assemblage ?|{n} fichiers dans {folder} {note}. Afficher le dossier de l’assemblage ? @plural {n} linked file not found|{n} linked files not found {n} fichier lié introuvable|{n} fichiers liés introuvables diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9574c304a..9690969c5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4526,6 +4526,7 @@ mark-style Japanese Japán Zoom to Selection Nagyítás a kijelölésre Zoom Tool Nagyító Zoom with Mouse Wheel Nagyítás egérgörgővel + Scroll Sideways with Cmd/Ctrl + Wheel Vízszintes görgetés Cmd/Ctrl + görgővel @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Fájlok: {n}, mappa: {folder} {note}. Megnyitod a csomag mappáját? @plural {n} folder or file couldn't be read|{n} folders or files couldn't be read Nem olvasható mappák vagy fájlok: {n} @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Kihagyott mappák: {n} (például más alkalmazások, a rendszer és a rejtett mappák) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 2b4ab48e8..12c1b4c85 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3472,6 +3472,7 @@ theme Appearance Tampilan Yards Yard Zoom to Selection Zum ke seleksi Zoom with Mouse Wheel Zum dengan roda mouse + Scroll Sideways with Cmd/Ctrl + Wheel Gulir horizontal dengan Cmd/Ctrl + roda mouse Scrub Numeric Fields by Dragging Atur nilai kolom numerik dengan menyeret Linux Display Server Server tampilan Linux Print Preview Pratinjau cetak diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1f6d0a139..004d41e2c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3830,6 +3830,7 @@ digits Malayalam Malayalam Zoom to Selection Zoom sulla selezione Zoom Tool Strumento Zoom Zoom with Mouse Wheel Zoom con la rotellina del mouse + Scroll Sideways with Cmd/Ctrl + Wheel Scorrimento orizzontale con Cmd/Ctrl + rotellina Scrub Numeric Fields by Dragging Regola i campi numerici trascinando @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto?|{n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto? @plural {n} linked file not found|{n} linked files not found File collegati non trovati: {n}|File collegati non trovati: {n} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 17b2aa06f..716de0b24 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3408,6 +3408,7 @@ mark-style Japanese 日本式 Zoom Tool ズームツール Zoom to Selection 選択範囲にズーム Zoom with Mouse Wheel マウスホイールでズーム + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + ホイールで横スクロール Scrub Numeric Fields by Dragging ドラッグで数値フィールドを調整 [Default] is protected: New… starts an editable copy. [Default] は変更できません。「新規…」で編集できる複製を作ります。 [Unnamed] [名称未設定] diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index f9545785b..e5d0a28c0 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3288,6 +3288,7 @@ theme Appearance იერსახე Yards იარდები Zoom to Selection მონიშნულის მასშტაბირება Zoom with Mouse Wheel მაუსის ბორბლით მასშტაბირება + Scroll Sideways with Cmd/Ctrl + Wheel ჰორიზონტალური გადაადგილება Cmd/Ctrl + ბორბლით Scrub Numeric Fields by Dragging რიცხვითი ველების შეცვლა გადათრევით Linux Display Server Linux-ის ეკრანის სერვერი Print Preview ნახვა ამობეჭდვამდე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ae73d31ac..a8f2c0db4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3397,6 +3397,7 @@ mark-style Japanese 일본식 Zoom Tool 확대/축소 도구 Zoom to Selection 선택 항목에 맞춰 확대/축소 Zoom with Mouse Wheel 마우스 휠로 확대/축소 + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + 휠로 가로 스크롤 Scrub Numeric Fields by Dragging 드래그로 숫자 필드 조절 [Default] is protected: New… starts an editable copy. [Default]는 보호되어 있습니다. 새로 만들기…를 누르면 편집 가능한 복사본이 만들어집니다. [Unnamed] [이름 없음] diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c52207339..e803314e0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3486,6 +3486,7 @@ theme Appearance Vormgeving Yards Yards Zoom to Selection Inzoomen op selectie Zoom with Mouse Wheel Zoomen met muiswiel + Scroll Sideways with Cmd/Ctrl + Wheel Horizontaal scrollen met Cmd/Ctrl + muiswiel Scrub Numeric Fields by Dragging Numerieke velden aanpassen door te slepen Linux Display Server Linux-weergaveserver Print Preview Afdrukvoorbeeld diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f3538c540..591790721 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3607,6 +3607,7 @@ theme Appearance Wygląd Yards Jardy Zoom to Selection Powiększaj do zaznaczenia Zoom with Mouse Wheel Powiększaj kółkiem myszy + Scroll Sideways with Cmd/Ctrl + Wheel Przewijaj w poziomie kółkiem z Cmd/Ctrl Scrub Numeric Fields by Dragging Zmieniaj pola liczbowe przeciąganiem Linux Display Server Serwer wyświetlania Linux Print Preview Podgląd wydruku diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d1028010b..cae6a2fd7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3115,6 +3115,7 @@ digits Malayalam Malaiala Zoom Tool Ferramenta Zoom Zoom to Selection Ampliar para a Seleção Zoom with Mouse Wheel Zoom com a Roda do Mouse + Scroll Sideways with Cmd/Ctrl + Wheel Rolar na Horizontal com Cmd/Ctrl + Roda Scrub Numeric Fields by Dragging Ajustar Campos Numéricos Arrastando [Default] is protected: New… starts an editable copy. [Padrão] é protegido: Novo… inicia uma cópia editável. [Unnamed] [Sem Nome] diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f0a56b613..3f1b8e702 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3825,6 +3825,7 @@ digits Malayalam Малаялам Zoom to Selection Масштаб по выделению Zoom Tool Инструмент «Масштаб» Zoom with Mouse Wheel Масштабировать колесом мыши + Scroll Sideways with Cmd/Ctrl + Wheel Прокручивать по горизонтали колесом с Cmd/Ctrl Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета? @plural {n} linked file not found|{n} linked files not found Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 9e2a2ce01..3362155f0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3715,6 +3715,7 @@ digits Malayalam Malayalam Zoom to Selection Zooma till markering Zoom Tool Zoomverktyg Zoom with Mouse Wheel Zooma med mushjulet + Scroll Sideways with Cmd/Ctrl + Wheel Rulla horisontellt med Cmd/Ctrl + mushjulet Scrub Numeric Fields by Dragging Ändra numeriska fält genom att dra @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fil i {folder} {note}. Visa paketmappen?|{n} filer i {folder} {note}. Visa paketmappen? @plural {n} linked file not found|{n} linked files not found {n} länkad fil hittades inte|{n} länkade filer hittades inte diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7cd9822fd..9308d7ea6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3602,6 +3602,7 @@ digits Malayalam Malayalam Zoom to Selection Seçimi Yakınlaştır Zoom Tool Yakınlaştırma Aracı Zoom with Mouse Wheel Fare Tekerleği ile Yakınlaştırma + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + Tekerlek ile Yatay Kaydırma Scrub Numeric Fields by Dragging Sayısal Alanları Sürükleyerek Fırçalayın @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} içinde {n} dosya {note}. Paket klasörü gösterilsin mi? @plural {n} linked file not found|{n} linked files not found {n} bağlantılı dosya bulunamadı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2f50e0991..55edd38d5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3823,6 +3823,7 @@ effect Points Точки Zoom to Selection Масштаб виділення Zoom Tool Інструмент «Масштаб» Zoom with Mouse Wheel Масштабувати колесом миші + Scroll Sideways with Cmd/Ctrl + Wheel Прокручувати по горизонталі колесом із Cmd/Ctrl Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета? @plural {n} linked file not found|{n} linked files not found не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d2dc9a478..e8ea0991f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4189,6 +4189,7 @@ mark-style Japanese Kiểu Nhật Zoom to Selection Thu phóng đến vùng chọn Zoom Tool Công cụ thu phóng Zoom with Mouse Wheel Thu phóng bằng con lăn chuột + Scroll Sideways with Cmd/Ctrl + Wheel Cuộn ngang bằng Cmd/Ctrl + con lăn @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} tệp trong {folder} {note}. Hiện thư mục gói? @plural {n} folder or file couldn't be read|{n} folders or files couldn't be read {n} thư mục hoặc tệp không thể đọc được @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Đã bỏ qua {n} thư mục, chẳng hạn thư mục của ứng dụng khác, thư mục hệ thống và thư mục ẩn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 100128f3c..4cf9b672f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3680,6 +3680,7 @@ digits Malayalam 马拉雅拉姆数字 Zoom to Selection 缩放到所选对象 Zoom Tool 缩放工具 Zoom with Mouse Wheel 使用鼠标滚轮缩放 + Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滚轮水平滚动 Scrub Numeric Fields by Dragging 拖动以调整数值字段 @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 个文件{note}。要显示打包文件夹吗? @plural {n} linked file not found|{n} linked files not found 找不到 {n} 个链接文件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d0f9c079e..4aee5622d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4716,6 +4716,7 @@ digits Malayalam 馬來亞拉姆數字 Zoom to Selection 縮放至選取範圍 Zoom Tool 縮放工具 Zoom with Mouse Wheel 使用滑鼠滾輪縮放 + Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滾輪水平捲動 Scrub Numeric Fields by Dragging 拖曳以調整數值欄位 @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 個檔案{note}。要顯示封裝資料夾嗎? @plural {n} linked file not found|{n} linked files not found 找不到 {n} 個連結檔案 diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index c3532da83..8c3fa35d6 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -456,6 +456,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null); let mark = found_mark(ui, d, sp.key); match sp.kind { + // General › Scroll Sideways with Cmd/Ctrl + Wheel acts only while Zoom with Mouse Wheel + // is off: dimmed while it is on. + PrefKind::Bool if sp.key == "ctrlWheelScrollsSideways" => { + ui.add_enabled_ui(!d.bool("zoomWithMouseWheel"), |ui| bool_row(ui, d, sp.key, sp.label)); + } // Units › Numbers Without Units Are Points only tells points from picas: dimmed // unless a unit is Picas. PrefKind::Bool if sp.key == "numbersWithoutUnitsArePoints" => { diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index a43ec6677..9e8ce2804 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -503,6 +503,41 @@ fn numbers_without_units_are_points_reaches_the_fields_and_dims_without_picas() assert!(picas_in_use(d), "enabled with a unit in picas"); } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel: its checkbox is dimmed while Zoom with +/// Mouse Wheel is checked in the dialog, and a click on it then leaves the setting as it was. +#[test] +fn scroll_sideways_with_cmd_wheel_dims_with_zoom_with_mouse_wheel() { + fn label(s: &egui::Shape, text: &str) -> Option<(egui::Color32, egui::Rect)> { + match s { + egui::Shape::Text(t) if t.galley.text() == text => t.galley.job.sections.first().map(|s| (s.format.color, t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().find_map(|s| label(s, text)), + _ => None, + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut a = app(); + let mut frame = |d: &mut Dialog, events: Vec| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| category_fields(ui, &mut a, d, "General")); + out.textures_delta.clear(); + out.shapes.iter().find_map(|c| label(&c.shape, "Scroll Sideways with Cmd/Ctrl + Wheel")) + }; + let t = Tokens::get(&ctx); + for (zoom, color, what) in [(false, t.text, "enabled while Zoom with Mouse Wheel is off"), (true, t.text_disabled, "dimmed while it is on")] { + let mut d = Dialog::new("preferences", json!({"zoomWithMouseWheel": zoom, "ctrlWheelScrollsSideways": true})); + let (shown, at) = frame(&mut d, vec![]).unwrap_or_else(|| panic!("no label ({what})")); + assert_eq!(shown, color, "{what}"); + let press = + |pressed| egui::Event::PointerButton { pos: at.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut d, vec![egui::Event::PointerMoved(at.center()), press(true)]); + frame(&mut d, vec![press(false)]); + // A click turns it off while it is enabled, and leaves it on while it is dimmed. + assert_eq!(d.fields["ctrlWheelScrollsSideways"], json!(zoom), "after a click, {what}"); + } +} + /// 0.5.0 saved `powerSaving` for everyone (its default): it reads as Automatic, while the /// choices this version offers are kept (#502). #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4e8408cd5..9357927f0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -100,7 +100,7 @@ the two can still win that race. | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | -| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it | +| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` or `{fields: [{field, value}…]}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | diff --git a/docs/mcp.md b/docs/mcp.md index facbe02e4..8ad9232d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3693,6 +3693,12 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; `disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a segment or an anchor of a selected path. +`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming while +`zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac +trackpad, on a touch screen or in the web build still zoom about the pointer. With `zoomWithMouseWheel` off, once a +trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. The control channel's +`ui.wheel` with `cmd: true` turns the wheel with Cmd/Ctrl held. + `gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets the grid of every open document. `file.new` gives a new document that grid, and `document.open`, `file.newFromTemplate` and `file.revert` give it to diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 794ab3fb8..42a9d7011 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -736,6 +736,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). + - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). From 6cd4171fbdc95dc8279ad6f64689ecdf1af68a60 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:45:41 +0700 Subject: [PATCH 13/51] Paragraph: Reset Panel also resets the Mojikumi Set and tab stops Squashed from #5025 onto main. --- crates/ui-egui/src/panels/paragraph.rs | 22 ++- crates/ui-egui/src/panels/paragraph/tests.rs | 141 +++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 8cb29ef30..4c3241608 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -828,11 +828,19 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification -/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the -/// Every-line Composer, Roman Hanging Punctuation off and no kashidas. +/// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi +/// Set, Regular burasagari, the Hard kinsoku set, the Every-line Composer, Roman Hanging +/// Punctuation off, no kashidas and no tab stops, as one undo step named Reset Panel (inside an +/// undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); + // The Type tool's typing session stays an undo step of its own. + super::character::end_typing(app); + let open = app.session.active().is_some_and(|st| st.undo_group.is_some()); + if !open { + app.session.begin_named_undo_group(Some("Reset Panel".into())); + } para_cmd(app, "text.setStyle", json!({"justify": "auto"})); format( app, @@ -842,10 +850,16 @@ fn reset_panel(app: &mut VectorcraftApp) { "hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized, "kashida": "none", "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", - "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine", - "romanHangingPunctuation": false + "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", + "composer": "everyLine", "romanHangingPunctuation": false }), ); + para_cmd(app, "text.tabs.clear", json!({})); + // Closes the active document's undo group only, so an undo group open in another document + // stays open. + if !open && let Some(st) = app.session.active_mut() { + st.undo_group = None; + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index a1df5a927..5facaee19 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -1,4 +1,5 @@ use super::*; +use vectorcraft_doc::Mojikumi; use vectorcraft_doc::NodeId; use vectorcraft_engine::Session; @@ -691,3 +692,143 @@ fn the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right() assert!(seen.iter().all(|s| *s), "{case}: a wide bar shows every group: {seen:?}"); } } + +/// Reset Panel gives the paragraphs it acts on new type's Mojikumi Set (Line-end Punctuation +/// Half Width) and no tab stops, as one undo step named Reset Panel: while the Type tool edits, +/// the paragraph at its caret (the typing before it stays a step of its own), and then the +/// selected type's paragraphs. In an undo group that is already open, the reset is part of +/// that group's step. +#[test] +fn reset_panel_resets_the_mojikumi_set_and_tab_stops_in_one_step() { + let (mut app, id) = editing_text("First\nSecond"); + app.run("text.setFormat", json!({"id": id, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "stops": [{"position": 36}, {"position": 90, "align": "right"}]})).unwrap(); + let text = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + text.as_ref().clone() + }; + let paras = |app: &VectorcraftApp| { + let text = text(app); + [text.para_at(0), text.para_at(1)].map(|p| (p.mojikumi, p.tabs.len())) + }; + let custom = (Mojikumi::None, 2); + let reset = (Mojikumi::LineEndHalf, 0); + assert_eq!(paras(&app), [custom, custom]); + let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let last_step = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|e| e.label.clone()); + // Type over the second paragraph's text: the typing session is still open. + select_range(&mut app, 6, 12); + let before = steps(&app); + let ctx = egui::Context::default(); + let raw = egui::RawInput { events: vec![egui::Event::Text("X".into())], ..Default::default() }; + ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())).textures_delta.clear(); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + reset_panel(&mut app); + assert_eq!(paras(&app), [custom, reset], "only the paragraph at the caret"); + assert_eq!((steps(&app), last_step(&app)), (before + 2, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paras(&app), [custom, custom]); + assert_eq!(text(&app).plain_text(), "First\nX", "the typing stays"); + app.run("edit.redo", json!({})).unwrap(); + app.session.set_tool_option("stopEditing", &json!(true)); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app), [reset, reset]); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + // In an undo group that is already open (MCP `command_batch` with `atomic`), the reset is + // part of that group's step. + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app), [reset, reset]); + app.run("text.setFormat", json!({"id": id, "mojikumi": "none"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Batch".to_string()))); +} + +/// Reset Panel gives paragraphs that start with different Mojikumi Sets and tab stops the same +/// Mojikumi Set and no tab stops, as one undo step, and one Undo gives each paragraph its own +/// values back: while the Type tool edits, the paragraphs the selection touches, and then the +/// paragraphs of every selected type object. +#[test] +fn reset_panel_resets_paragraphs_with_different_values_in_one_step() { + let (mut app, id) = editing_text("First\nSecond\nThird"); + app.run("text.setFormat", json!({"id": id, "start": 0, "end": 1, "mojikumi": "none"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 6, "end": 7, "mojikumi": "lineEndHalf"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 13, "end": 14, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "start": 0, "end": 1, "stops": [{"position": 36}, {"position": 90, "align": "right"}]})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "start": 6, "end": 7, "stops": [{"position": 120, "align": "decimal", "leader": "."}]})).unwrap(); + let paras = |app: &VectorcraftApp, id: &Value| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + text.paragraph_styles().into_iter().map(|p| (p.mojikumi, p.tabs.iter().map(|t| t.position).collect::>())).collect::>() + }; + let first = (Mojikumi::None, vec![36.0, 90.0]); + let second = (Mojikumi::LineEndHalf, vec![120.0]); + let third = (Mojikumi::None, vec![]); + let reset = (Mojikumi::LineEndHalf, vec![]); + assert_eq!(paras(&app, &id), [first.clone(), second.clone(), third.clone()]); + let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let last_step = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|e| e.label.clone()); + // A selection from the first paragraph into the second. + select_range(&mut app, 2, 8); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app, &id), [reset.clone(), reset.clone(), third.clone()], "only the paragraphs the selection touches"); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + assert_eq!(paras(&app, &id), [first.clone(), second.clone(), third.clone()]); + // Two selected type objects. + app.session.set_tool_option("stopEditing", &json!(true)); + let other = app.run("text.create", json!({"x": 40, "y": 120, "text": "Other"})).unwrap()["id"].clone(); + app.run("text.setFormat", json!({"id": other, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": other, "stops": [{"position": 48, "align": "center"}]})).unwrap(); + let other_values = (Mojikumi::None, vec![48.0]); + app.run("select.set", json!({"ids": [id, other]})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app, &id), [reset.clone(), reset.clone(), reset.clone()]); + assert_eq!(paras(&app, &other), [reset]); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + assert_eq!(paras(&app, &id), [first, second, third]); + assert_eq!(paras(&app, &other), [other_values]); +} + +/// Reset Panel closes only the undo group it opens: an undo group open in another document +/// stays open, and that document's later edits stay part of its step. +#[test] +fn reset_panel_leaves_an_undo_group_open_in_another_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); + // The second document is open before the group begins, so switching to it opens no group in it. + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap(); + app.session.set_active(0); + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "mojikumi": "none"})).unwrap(); + let steps = |st: &vectorcraft_engine::DocState| (st.history.undo.len(), st.history.undo.last().map(|e| e.label.clone())); + let a_steps = steps(&app.session.documents()[0]); + assert_eq!(a_steps.1, Some("Batch".to_string())); + app.session.set_active(1); + assert!(app.session.active().unwrap().undo_group.is_none()); + let b_steps = steps(app.session.active().unwrap()).0; + reset_panel(&mut app); + assert_eq!(steps(app.session.active().unwrap()), (b_steps + 1, Some("Reset Panel".to_string()))); + assert!(app.session.active().unwrap().undo_group.is_none()); + assert!(app.session.documents()[0].undo_group.is_some(), "the other document's undo group stays open"); + app.session.set_active(0); + app.run("text.setFormat", json!({"id": a, "mojikumi": "lineEndHalf"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 42a9d7011..4d22add2f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1037,6 +1037,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, the Every-line Composer, Roman Hanging Punctuation off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. From be391e541afb4e474b8b15953060f586050cceb0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:45:50 +0700 Subject: [PATCH 14/51] Type: Type Orientation is a setting of the whole thread Squashed from #5027 onto main. --- crates/engine/src/cmd/fileio/load.rs | 6 +- crates/engine/src/cmd/threads.rs | 64 ++++++++- crates/engine/src/cmd/threads/tests.rs | 171 +++++++++++++++++++++++++ crates/engine/src/cmd/typecmd.rs | 18 ++- docs/mcp.md | 6 + docs/target-app-parity.md | 1 + docs/type-parity.md | 1 + 7 files changed, 258 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index a1ec671aa..ae63ba918 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -475,11 +475,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } // Threaded type read from a file flows through its frames (the threaded type of a restored // document keeps its text as saved), and every frame of a thread takes its first frame's - // Optical Margin Alignment setting. + // 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. 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() { + crate::cmd::threads::keep_frame_sizes(&mut doc); crate::cmd::threads::share_optical_margin(&mut doc); + crate::cmd::threads::share_orientation(&mut doc); // A frame whose first paragraph continues from the frame before draws it without the // paragraph's first-line indent and list marker. crate::cmd::threads::mark_continued(&mut doc); diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 61a21e506..61fc7f33a 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -168,7 +168,17 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } } let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None }; - let frames: Vec = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect(); + let vertical = if live.len() >= 2 { thread_setting(before, doc, &live, |t| t.vertical) } else { None }; + // The story is measured in each frame with the settings the frame takes. + let frames: Vec = live + .iter() + .filter_map(|id| text_of(doc, *id).cloned()) + .map(|mut t| { + t.optical_margin = optical.unwrap_or(t.optical_margin); + t.vertical = vertical.unwrap_or(t.vertical); + t + }) + .collect(); let refs: Vec<&TextObject> = frames.iter().collect(); let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, ¶s); for (id, part) in live.iter().zip(parts) { @@ -177,11 +187,13 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { next.runs = part.runs; next.set_paragraph_styles(part.paras); let optical = optical.unwrap_or(t.optical_margin); + let vertical = vertical.unwrap_or(t.vertical); let continues = part.continues; if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin + || vertical != t.vertical || part.list_count != t.list_count || continues != t.first_para_continues { @@ -189,6 +201,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { t.para = next.para; t.paras = next.paras; t.optical_margin = optical; + t.vertical = vertical; t.list_count = part.list_count; t.first_para_continues = continues; refresh_bounds(t); @@ -235,11 +248,37 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { /// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes: /// that of the first frame whose setting changed between `before` and `doc`, else the first frame's. fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option { - let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin); - let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id))); + thread_setting(before, doc, live, |t| t.optical_margin) +} + +/// The value of the frame setting `get` that every frame of a thread with the frames `live` takes: +/// that of the first frame whose value changed between `before` and `doc`, or the first frame's value +/// when none changed. +fn thread_setting(before: &Document, doc: &Document, live: &[NodeId], get: impl Fn(&TextObject) -> T) -> Option { + let now = |id: &NodeId| text_of(doc, *id).map(&get); + let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(get(t)) != now(id))); changed.or(live.first()).and_then(now) } +/// Turn Auto Size off for the frames of each thread of two or more frames, as [`reflow`] does, +/// without re-flowing the story: for a document restored from a file as it was saved, so a later +/// change to a frame's settings keeps the frame's size. +pub(crate) fn keep_frame_sizes(doc: &mut Document) { + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + if live.len() < 2 { + continue; + } + for id in live { + if text_of(doc, id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight) + && let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) + { + t.area.fit = vectorcraft_doc::AreaFit::None; + } + } + } +} + /// Give every frame of each thread the first frame's Optical Margin Alignment setting, without /// re-flowing the story: for a document restored from a file as it was saved. pub(crate) fn share_optical_margin(doc: &mut Document) { @@ -257,6 +296,25 @@ pub(crate) fn share_optical_margin(doc: &mut Document) { } } +/// Give every frame of each thread the first frame's writing direction, and re-flow each thread in +/// which that changes a frame: for a document restored from a file as it was saved. +pub(crate) fn share_orientation(doc: &mut Document) { + let before = doc.clone(); + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + let Some(vertical) = thread_setting(doc, doc, &live, |t| t.vertical) else { continue }; + // Only the frames whose direction changes are copied, so only their threads re-flow. + let turn: Vec = live.into_iter().filter(|id| text_of(doc, *id).is_some_and(|t| t.vertical != vertical)).collect(); + for id in turn { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.vertical = vertical; + refresh_bounds(t); + } + } + } + reflow(&before, doc); +} + /// Mark the frames of each thread whose first paragraph continues from the frame before /// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored /// from a file as it was saved. diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 71d175b3a..1ead2e823 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -1090,6 +1090,177 @@ fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() { } } +/// The writing direction of each of the frames `ids` (true: vertical). +fn vertical(s: &Session, ids: [u64; N]) -> [bool; N] { + ids.map(|id| { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + t.vertical + }) +} + +/// Whether each frame of the thread `ids` holds the part of the story that its layout, in its +/// writing direction, has room for: the parts that [`vectorcraft_text::thread::distribute`] +/// gives the frames as they are now. +fn flows_as_laid_out(s: &Session, ids: &[u64]) -> bool { + let d = &s.doc().unwrap().doc; + let frames: Vec<&TextObject> = ids.iter().map(|id| text_of(d, NodeId(*id)).unwrap()).collect(); + let story = vectorcraft_text::thread::story(&frames); + let paras = vectorcraft_text::thread::story_paras(&frames, &[]); + let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &frames, &story, ¶s); + let texts = |runs: &[TextRun]| runs.iter().map(|r| r.text.as_str()).collect::(); + parts.iter().zip(&frames).all(|(part, f)| texts(&part.runs) == f.plain_text()) +} + +/// Type Orientation is a setting of the whole thread: Type › Type Orientation in any frame sets +/// it in every frame of the thread, in one undo step, and the story flows through the frames in +/// that direction. Typing keeps it, and frames taken out of the thread keep it. +#[test] +fn type_orientation_is_set_for_every_frame_of_a_thread() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("type.orientation.vertical", &json!({})).unwrap(); + assert_eq!(r, json!({"vertical": true, "ids": [a, b]})); + assert_eq!(vertical(&s, [a, b]), [true, true], "the second frame is vertical too"); + assert!(flows_as_laid_out(&s, &[a, b])); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + // Set back from the second frame, both are horizontal, and one undo makes both vertical. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + assert_eq!(s.execute("type.orientation.horizontal", &json!({})).unwrap()["ids"], json!([b, a])); + assert_eq!(vertical(&s, [a, b]), [false, false]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + // Typing in the first frame re-flows the story and keeps the direction. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + assert!(flows_as_laid_out(&s, &[a, b])); + // Any edit that sets one frame sets the thread. + s.edit("Set", |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = false; + refresh_bounds(t); + Ok(()) + }) + .unwrap(); + assert_eq!(vertical(&s, [a, b]), [false, false]); + assert!(flows_as_laid_out(&s, &[a, b])); + s.execute("edit.undo", &json!({})).unwrap(); + // Released or unthreaded, each frame keeps it. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(vertical(&s, [a, b]), [true, true]); +} + +/// Threading type gives the joined thread the writing direction of its first frame, and the +/// story flows through every frame in that direction. +#[test] +fn a_thread_takes_its_first_frames_type_orientation() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let area = json!({"width": 150, "height": 60}); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": area, "vertical": true})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 60, "height": 200})).unwrap()); + let c = new_id(s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b, c]), [true; 3]); + assert!(!text(&s, b).is_empty() && !text(&s, c).is_empty()); + assert!(flows_as_laid_out(&s, &[a, b, c]), "the middle frame holds what fits it as vertical type"); + // From `c`'s out port into horizontal area type `d`: `d` joins the thread and becomes vertical. + let d = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap()); + s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap(); + assert_eq!(threads(&s), [[a, b, c, d]]); + assert_eq!(vertical(&s, [a, b, c, d]), [true; 4]); + assert!(flows_as_laid_out(&s, &[a, b, c, d])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(vertical(&s, [d]), [false], "undo gives `d` its own direction back"); +} + +/// A file saved with the frames of one thread in different writing directions opens with the +/// first frame's direction in every frame, and the story flows through them in it: a native +/// file, and an EPS file restored from the document it carries. +#[test] +fn a_thread_saved_with_differing_orientations_opens_with_the_first_frames() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] { + for (id, on) in [(a, first), (b, !first)] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = on; + refresh_bounds(t); + } + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!(vertical(&s, [a, b]), [first, first], "{name}, first frame vertical: {first}"); + assert!(flows_as_laid_out(&s, &[a, b]), "{name}, first frame vertical: {first}"); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4), "{name}"); + } +} + +/// A thread restored from an EPS file whose frames share one writing direction keeps its text +/// in its frames as saved. +#[test] +fn a_restored_thread_in_one_direction_keeps_its_text_as_saved() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let story = TEXT.repeat(4); + for (id, part) in [(a, &story[..10]), (b, &story[10..])] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.runs = vec![TextRun::new(part.to_string(), t.runs[0].style.clone())]; + refresh_bounds(t); + } + let r = s.execute("document.exportEps", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], true, "{r}"); + assert_eq!((text(&s, a), text(&s, b)), (story[..10].to_string(), story[10..].to_string())); +} + +/// A threaded frame restored from a file with Auto Size on keeps its size when it turns from +/// vertical to horizontal: as the file opens, when the first frame is horizontal, and with Type +/// Orientation, when the whole thread is vertical. +#[test] +fn a_restored_threaded_frame_with_auto_size_keeps_its_size_when_it_turns_horizontal() { + for first_vertical in [false, true] { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + for (id, on) in [(a, first_vertical), (b, true)] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = on; + if id == b { + t.area.fit = vectorcraft_doc::AreaFit::AutoHeight; + } + refresh_bounds(t); + } + let saved = frame_box(&s, b); + let r = s.execute("document.exportEps", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], true, "{r}"); + if first_vertical { + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("type.orientation.horizontal", &json!({})).unwrap(); + } + assert_eq!(vertical(&s, [a, b]), [false, false], "first frame vertical: {first_vertical}"); + assert_eq!(frame_box(&s, b), saved, "first frame vertical: {first_vertical}"); + assert!(flows_as_laid_out(&s, &[a, b]), "first frame vertical: {first_vertical}"); + } +} + /// One count runs through a thread: the second frame's numbered paragraphs go on from the /// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a /// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 4ad577651..b349ef894 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -16,15 +16,21 @@ use super::{ pub fn specs() -> Vec { vec![ - cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| { - orientation(s, p, true) - }), + cmd!( + "type.orientation.vertical", + "Vertical", + ["Type", "Orientation"], + None, + "{ids?|id?} set vertical writing for the selected type and every frame of its threads → {vertical, ids}", + has_selection, + |s, p| orientation(s, p, true) + ), cmd!( "type.orientation.horizontal", "Horizontal", ["Type", "Orientation"], None, - "{ids?|id?} set horizontal writing", + "{ids?|id?} set horizontal writing for the selected type and every frame of its threads → {vertical, ids}", has_selection, |s, p| orientation(s, p, false) ), @@ -139,8 +145,10 @@ pub fn specs() -> Vec { ] } +/// Type › Type Orientation: set vertical or horizontal writing on every selected text object and +/// every frame of their threads, in one undo step. fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { - let ids = text_targets(s, p, "Text Orientation")?; + let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Text Orientation")?); s.edit("Text Orientation", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { diff --git a/docs/mcp.md b/docs/mcp.md index 8ad9232d8..f7b5d261e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -481,6 +481,12 @@ for horizontal writing. This is the same option as `text.create` / `text.createI on the OpenType `vert` feature, so a font's vertical glyph alternates are used; it does not change the engine's CJK composition. +`type.orientation.vertical` and `type.orientation.horizontal` (Type › Type Orientation) switch existing type objects +(the selection, or `ids`) and reply `{vertical, ids}`. The writing direction is a setting of the whole thread: the +commands set every frame of a selected frame's thread, and `ids` lists them all. Threading frames gives the thread its +first frame's direction, frames taken out of a thread keep theirs, and a file whose frames of one thread differ opens +with the first frame's direction in all of them. + For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4d22add2f..7ec7277d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1035,6 +1035,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. + The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, the Every-line Composer, Roman Hanging Punctuation off, no kashidas and no tab stops, as one undo step. diff --git a/docs/type-parity.md b/docs/type-parity.md index 010c74aa5..69ea7c373 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -21,6 +21,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction | | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | From 3f4d1a251236bcd048d30197f245ab3bcc75bd22 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:46:20 +0700 Subject: [PATCH 15/51] Render: the decoded-image cache can grow to a quarter of installed memory on every desktop OS Squashed from #5034 onto main. --- crates/render/src/images.rs | 267 ++++++++++++++++++++++++++++++++++-- docs/target-app-parity.md | 1 + 2 files changed, 255 insertions(+), 13 deletions(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index dbdbf0066..93adbf245 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -21,8 +21,9 @@ use vello_cpu::color::PremulRgba8; use crate::ink::Ink; -/// Largest default budget of the shared image store, in bytes; less on machines with under 8 GiB -/// of memory (an eighth of it). [`set_image_cache_budget`] changes it. +/// Default budget of the shared image store, in bytes, where the installed memory can't be read. +/// Elsewhere the default is a quarter of the installed memory, and on the web it is 256 MiB. +/// [`set_image_cache_budget`] changes it. pub const IMAGE_CACHE_MAX: usize = 1 << 30; /// The default budget on the web (a tab's memory is tight). @@ -33,14 +34,21 @@ fn default_budget() -> usize { #[cfg(not(target_arch = "wasm32"))] fn default_budget() -> usize { - physical_memory().map_or(IMAGE_CACHE_MAX, budget_for) + budget_for_reading(physical_memory()) } -/// The default budget on a machine with `memory` bytes installed: an eighth of it, at most -/// [`IMAGE_CACHE_MAX`]. +/// The default budget for a reading of the installed memory: [`IMAGE_CACHE_MAX`] when `memory` is +/// `None` or 0, otherwise [`budget_for`] it. +#[cfg(not(target_arch = "wasm32"))] +fn budget_for_reading(memory: Option) -> usize { + memory.filter(|&m| m > 0).map_or(IMAGE_CACHE_MAX, budget_for) +} + +/// The default budget on a machine with `memory` bytes installed: a quarter of it, and at most a +/// quarter of the address space (on 32-bit builds). #[cfg(not(target_arch = "wasm32"))] fn budget_for(memory: u64) -> usize { - usize::try_from(memory / 8).unwrap_or(usize::MAX).min(IMAGE_CACHE_MAX) + usize::try_from(memory / 4).unwrap_or(usize::MAX).min(usize::MAX / 4) } /// Installed memory in bytes, `None` where it isn't known (the web, other systems). Other crates @@ -52,13 +60,116 @@ pub fn installed_memory() -> Option { physical_memory() } -/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`). +/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`), or the memory limit of the process's +/// cgroups where that is lower. #[cfg(target_os = "linux")] fn physical_memory() -> Option { let info = std::fs::read_to_string("/proc/meminfo").ok()?; let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; - kib.checked_mul(1024) + let total = kib.checked_mul(1024)?; + let cgroups = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default(); + let mounts = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default(); + Some(cgroup_limit(&cgroups, &mounts, |path| std::fs::read_to_string(path).ok()).map_or(total, |limit| limit.min(total))) +} + +/// The lowest memory limit of the cgroups listed in `/proc/self/cgroup` (`cgroups`) and of their +/// ancestors within the mounted cgroup hierarchy: `memory.max` under cgroup v2 and +/// `memory.limit_in_bytes` under cgroup v1. Each cgroup is looked up under the mount in +/// `/proc/self/mountinfo` (`mounts`) whose root holds it, or under `/sys/fs/cgroup` (v2) or +/// `/sys/fs/cgroup/memory` (v1) when `mounts` lists no mount of its hierarchy; a cgroup that no +/// listed mount holds sets no limit. A mount whose root lies above the cgroup namespace's root +/// (`/..`) gives the limit of its mount point alone. `read` reads a file; a file that can't be read, or that holds +/// `max`, sets no limit. +#[cfg(any(target_os = "linux", test))] +fn cgroup_limit(cgroups: &str, mounts: &str, read: impl Fn(&str) -> Option) -> Option { + let mut lowest: Option = None; + for line in cgroups.lines() { + let mut fields = line.splitn(3, ':'); + let (Some(_), Some(controllers), Some(path)) = (fields.next(), fields.next(), fields.next()) else { + continue; + }; + let v2 = controllers.is_empty(); + if !v2 && !controllers.split(',').any(|c| c == "memory") { + continue; + } + let file = if v2 { "memory.max" } else { "memory.limit_in_bytes" }; + let mounted = cgroup_mounts(mounts, v2); + // Every mount that holds the cgroup shows the same hierarchy; each may show more ancestors. + let holding: Vec<(String, String)> = if mounted.is_empty() { + vec![(path.to_owned(), (if v2 { "/sys/fs/cgroup" } else { "/sys/fs/cgroup/memory" }).to_owned())] + } else { + mounted + .into_iter() + .filter_map(|(root, point)| { + let root = root.trim_end_matches('/'); + // A root of `..` steps is an ancestor of the cgroup namespace's root, so of every + // cgroup listed: only the mount point's own limit can be read. + if !root.is_empty() && root.split('/').skip(1).all(|step| step == "..") { + return Some((String::new(), point)); + } + let rest = path.strip_prefix(root).filter(|rest| rest.is_empty() || rest.starts_with('/'))?; + Some((rest.to_owned(), point)) + }) + .collect() + }; + for (relative, mount_point) in &holding { + let root = mount_point.trim_end_matches('/'); + let mut dir = relative.trim_end_matches('/'); + loop { + if let Some(limit) = read(&format!("{root}{dir}/{file}")).and_then(|s| s.trim().parse::().ok()) { + lowest = Some(lowest.map_or(limit, |l| l.min(limit))); + } + match dir.rsplit_once('/') { + Some((parent, _)) => dir = parent, + None => break, + } + } + } + } + lowest +} + +/// The root and the mount point of each cgroup v2 mount (`v2`) or cgroup v1 mount with the +/// `memory` controller in `/proc/self/mountinfo` (`mounts`), with their octal escapes decoded. +#[cfg(any(target_os = "linux", test))] +fn cgroup_mounts(mounts: &str, v2: bool) -> Vec<(String, String)> { + mounts + .lines() + .filter_map(|line| { + let (before, after) = line.split_once(" - ")?; + let mut fields = before.split_whitespace().skip(3); + let (root, point) = (fields.next()?, fields.next()?); + let mut after = after.split_whitespace(); + let (fs, _, options) = (after.next()?, after.next()?, after.next().unwrap_or("")); + let found = if v2 { fs == "cgroup2" } else { fs == "cgroup" && options.split(',').any(|o| o == "memory") }; + found.then(|| (mountinfo_unescape(root), mountinfo_unescape(point))) + }) + .collect() +} + +/// A path field of `/proc/self/mountinfo` with its octal escapes (`\040` for a space) decoded. +#[cfg(any(target_os = "linux", test))] +fn mountinfo_unescape(field: &str) -> String { + let mut bytes = Vec::with_capacity(field.len()); + let mut rest = field.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let code = match tail { + [a @ b'0'..=b'3', c @ b'0'..=b'7', d @ b'0'..=b'7', ..] if b == b'\\' => Some(((a - b'0') << 6) | ((c - b'0') << 3) | (d - b'0')), + _ => None, + }; + match code { + Some(code) => { + bytes.push(code); + rest = tail.get(3..).unwrap_or_default(); + } + None => { + bytes.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&bytes).into_owned() } /// Installed memory in bytes (`GlobalMemoryStatusEx`). @@ -524,18 +635,148 @@ mod tests { drop(held); } + #[test] + #[cfg(target_pointer_width = "64")] + fn default_budget_is_a_quarter_of_installed_memory() { + assert_eq!(budget_for(16 << 30), 4 << 30); + assert_eq!(budget_for(64 << 30), 16 << 30, "no fixed ceiling"); + assert_eq!(budget_for(2 << 30), 512 << 20); + } + + #[test] + #[cfg(target_pointer_width = "64")] + fn budget_follows_the_memory_reading() { + assert_eq!(budget_for_reading(Some(2 << 30)), 512 << 20); + assert_eq!(budget_for_reading(Some(8 << 30)), 2 << 30); + assert_eq!(budget_for_reading(Some(64 << 30)), 16 << 30); + assert_eq!(budget_for_reading(None), IMAGE_CACHE_MAX, "memory that can't be read"); + assert_eq!(budget_for_reading(Some(0)), IMAGE_CACHE_MAX, "memory that reads as 0"); + } + #[test] #[cfg(not(target_arch = "wasm32"))] - fn default_budget_is_an_eighth_of_installed_memory_at_most_1_gib() { - assert_eq!(budget_for(4 << 30), 512 << 20); - assert_eq!(budget_for(8 << 30), IMAGE_CACHE_MAX); - assert_eq!(budget_for(64 << 30), IMAGE_CACHE_MAX); + fn images_past_the_budget_drop_the_least_recently_used() { + // 16 MiB installed (16 GiB scaled down): a 4 MiB budget, room for four 512 × 512 images. + let mut s = ImageStore::new(budget_for_reading(Some(16 << 20))); + for k in ["a", "b", "c", "d"] { + drop(s.insert(key(k), Arc::new(Pixmap::new(512, 512)))); + } + assert_eq!(s.bytes(), 4 << 20, "four images fill the budget exactly"); + assert!(["a", "b", "c", "d"].iter().all(|k| s.peek(&key(k)).is_some())); + drop(s.insert(key("e"), Arc::new(Pixmap::new(512, 512)))); + assert_eq!(s.bytes(), 4 << 20); + assert!(s.peek(&key("a")).is_none(), "the oldest image is dropped"); + assert!(["b", "c", "d", "e"].iter().all(|k| s.peek(&key(k)).is_some())); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn an_image_larger_than_the_budget_is_kept_only_while_drawn() { + // 16 MiB installed: a 4 MiB budget. A 1,100 × 1,000 image takes 4.2 MiB. + let budget = budget_for_reading(Some(16 << 20)); + let mut s = ImageStore::new(budget); + assert!(!s.keep(key("big"), Arc::new(Pixmap::new(1100, 1000))), "not kept for a later frame"); + assert_eq!(s.bytes(), 0); + drop(s.insert(key("small"), Arc::new(Pixmap::new(512, 512)))); + let drawn = s.insert(key("big"), Arc::new(Pixmap::new(1100, 1000))); + assert!(s.peek(&key("big")).is_some(), "kept while drawn"); + assert!(s.peek(&key("small")).is_none(), "the other image is dropped to make room"); + drop(drawn); + s.set_budget(budget); + assert!(s.peek(&key("big")).is_none(), "dropped once nothing draws it"); + assert_eq!(s.bytes(), 0); } #[test] #[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))] fn installed_memory_is_read() { - assert!(physical_memory().is_some_and(|m| m >= 256 << 20), "{:?}", physical_memory()); + // A cgroup's limit can be far below the installed memory. + assert!(physical_memory().is_some_and(|m| m > 0), "{:?}", physical_memory()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn two_images_of_a_tenth_of_memory_each_stay_cached() { + // 16 MiB installed (16 GiB scaled down): two images of 1,600 KiB (640 × 640 pixels) take + // about a fifth of it. + let mut s = ImageStore::new(budget_for(16 << 20)); + drop(s.insert(key("a"), Arc::new(Pixmap::new(640, 640)))); + drop(s.insert(key("b"), Arc::new(Pixmap::new(640, 640)))); + assert!(s.peek(&key("a")).is_some() && s.peek(&key("b")).is_some(), "neither image is decoded again"); + assert_eq!(s.bytes(), 2 * 640 * 640 * 4); + } + + #[test] + fn cgroup_limit_is_the_lowest_limit_of_the_cgroups_and_their_ancestors() { + let files = |list: &'static [(&'static str, &'static str)]| { + move |path: &str| list.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()) + }; + let v2 = files(&[("/sys/fs/cgroup/user.slice/memory.max", "2147483648\n"), ("/sys/fs/cgroup/user.slice/app.scope/memory.max", "max\n")]); + assert_eq!(cgroup_limit("0::/user.slice/app.scope\n", "", v2), Some(2 << 30), "the parent's limit applies"); + let v1 = files(&[("/sys/fs/cgroup/memory/memory.limit_in_bytes", "1073741824\n")]); + let v1_mounts = "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu,cpuacct\n\ + 31 25 0:27 /docker/1 /sys/fs/cgroup/memory rw,nosuid master:9 - cgroup cgroup rw,memory\n"; + let cgroups = "4:cpu,cpuacct:/docker/1\n5:memory:/docker/1\n"; + assert_eq!(cgroup_limit(cgroups, v1_mounts, v1), Some(1 << 30), "a container's own mount"); + assert_eq!(cgroup_limit("0::/\n", "", files(&[("/sys/fs/cgroup/memory.max", "max\n")])), None, "no limit"); + assert_eq!(cgroup_limit("", "", files(&[])), None); + } + + #[test] + fn cgroup_limit_reads_a_subtree_mount_relative_to_its_root() { + let mounts = "25 1 0:22 / /proc rw - proc proc rw\n\ + 40 25 0:30 /docker/id /sys/fs/cgroup rw,nosuid - cgroup2 cgroup2 rw,nsdelegate\n"; + let files = [("/sys/fs/cgroup/worker/memory.max", "536870912\n"), ("/sys/fs/cgroup/memory.max", "4294967296\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/docker/id/worker\n", mounts, read), Some(512 << 20), "the worker's own limit"); + assert_eq!(cgroup_limit("0::/elsewhere\n", mounts, read), None, "no mount holds the cgroup"); + } + + #[test] + fn cgroup_limit_reads_the_mount_that_holds_the_cgroup() { + let mounts = "40 25 0:30 /other /mnt/other rw - cgroup2 cgroup2 rw\n\ + 41 25 0:30 /docker/id /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/mnt/other/memory.max", "1048576\n"), ("/sys/fs/cgroup/memory.max", "268435456\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/docker/id\n", mounts, read), Some(256 << 20), "the unrelated subtree's limit is not read"); + let both = "40 25 0:30 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n\ + 41 25 0:30 /parent/child /mnt/child rw - cgroup2 cgroup2 rw\n"; + let files = [("/mnt/child/memory.max", "max\n"), ("/sys/fs/cgroup/parent/memory.max", "1073741824\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/parent/child\n", both, read), Some(1 << 30), "the ancestor that only the full mount shows"); + } + + #[test] + fn cgroup_limit_reads_a_mount_above_the_namespace_root() { + let mounts = "40 25 0:30 /.. /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/sys/fs/cgroup/memory.max", "1073741824\n"), ("/sys/fs/cgroup/worker/memory.max", "4096\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/\n", mounts, read), Some(1 << 30), "the namespace root's ancestor"); + assert_eq!(cgroup_limit("0::/worker\n", mounts, read), Some(1 << 30), "the cgroup's directory is hidden"); + let v1 = "31 25 0:27 /../.. /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n"; + let files = [("/sys/fs/cgroup/memory/memory.limit_in_bytes", "536870912\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("5:memory:/\n", v1, read), Some(512 << 20)); + let sibling = "40 25 0:30 /../other /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/sys/fs/cgroup/memory.max", "1048576\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/\n", sibling, read), None, "a sibling of the namespace root is not an ancestor"); + } + + #[test] + fn cgroup_mounts_lists_the_hierarchy_of_the_memory_limit() { + let mounts = "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu\n\ + 31 25 0:27 /a\\040b /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n\ + 32 25 0:28 / /sys/fs/cgroup/unified rw - cgroup2 cgroup2 rw\n"; + assert_eq!(cgroup_mounts(mounts, false), [("/a b".to_owned(), "/sys/fs/cgroup/memory".to_owned())]); + assert_eq!(cgroup_mounts(mounts, true), [("/".to_owned(), "/sys/fs/cgroup/unified".to_owned())]); + assert!(cgroup_mounts("", true).is_empty()); + } + + #[test] + fn mountinfo_paths_have_their_escapes_decoded() { + assert_eq!(mountinfo_unescape(r"/a\040b\011c\012d\134e"), "/a b\tc\nd\\e"); + assert_eq!(mountinfo_unescape(r"/plain\9x\04"), r"/plain\9x\04", "not an escape"); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7ec7277d2..051d30009 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1382,6 +1382,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size. + The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD (on Linux, of the process's cgroup memory limit where that is lower, and in a 32-bit build at most a quarter of the address space), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. From c1164f271fc3fce6271c217e8dda7dc2987fad86 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 19:38:51 -0700 Subject: [PATCH 16/51] Type: Start Lists From Typed Markers turns typed list detection off Preferences > Type gets Start Lists From Typed Markers (`typedLists`, on by default). With it off, a list marker and a space typed with the Type tool at the start of a paragraph (`1. `, `1) `, `A. `, `A) `, `* `, `# `, `> `, `+ `) stay as text and start no list. `text.startList`, `text.setFormat {list}` and the Bullets and Numbered List buttons work as before. The preference reaches the Type tool through `ToolContext::typed_lists`. The label has a row in every catalog. --- crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/prefs.rs | 4 +++ crates/engine/src/tests/textedit.rs | 45 +++++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/context.rs | 3 ++ crates/tools/src/lib.rs | 1 + crates/tools/src/text.rs | 4 +++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 6 +++- 32 files changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index ad52c808a..de1b65755 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -208,6 +208,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool), p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool), p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool), + p!("typedLists", "Type", "Options", "Start Lists From Typed Markers", bool), p!("fontsFolder", "Type", "Options", "Additional Fonts Folder", text), // Units p!("unitsGeneral", "Units", "", "General", choice(UNITS)), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 920ef3d1c..1e5147582 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -97,6 +97,9 @@ pub struct Prefs { /// font is highlighted, in every document (on by default). pub highlight_substituted_fonts: bool, pub placeholder_text: bool, + /// Type › Start Lists From Typed Markers: a list marker and a space typed at the start of a + /// paragraph start a list (on by default). Off, they stay as typed text. + pub typed_lists: bool, // Units pub units_general: String, pub units_stroke: String, @@ -402,6 +405,7 @@ impl Default for Prefs { highlight_alternate_glyphs: false, highlight_substituted_fonts: true, placeholder_text: true, + typed_lists: true, units_general: s("points"), units_stroke: s("points"), units_type: s("points"), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index ed3e2eac3..bb1ac80a0 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1132,6 +1132,51 @@ fn the_caret_follows_undo_and_redo_of_a_typed_list() { } } +/// Type › Start Lists From Typed Markers off: a list marker and a space typed at the start of a +/// paragraph stay as text and start no list; `text.startList` still starts one. +#[test] +fn typed_lists_off_leaves_typed_markers_as_text() { + let v = ViewInfo::default(); + for typed in ["1.", "1)", "A.", "A)", "*", "#", ">", "+"] { + let mut s = session(); + s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + for c in typed.chars().chain(" a".chars()) { + s.tool_text(&c.to_string(), v).unwrap(); + } + let t = obj(&s, id); + assert_eq!((t.plain_text(), t.para_at(0).list), (format!("{typed} a"), None), "{typed}"); + assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"), "{typed}"); + } + let mut s = session(); + s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap(); + let id = text(&mut s, "1. a"); + let r = s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap(); + assert_eq!(r["started"], true); + assert_eq!(obj(&s, id).plain_text(), "a"); + assert!(obj(&s, id).para_at(0).list.is_some()); +} + +/// `typedLists` is a Type preference, on by default: `prefs.list` lists it as a bool, and +/// `prefs.reset` of the Type category turns it back on. +#[test] +fn typed_lists_is_a_type_preference_on_by_default() { + let mut s = Session::new(); + assert!(s.prefs.typed_lists); + let l = s.execute("prefs.list", &json!({})).unwrap(); + let row = l.as_array().unwrap().iter().find(|e| e["key"] == "typedLists").unwrap().clone(); + assert_eq!( + (&row["category"], &row["kind"], &row["label"], &row["value"]), + (&json!("Type"), &json!("bool"), &json!("Start Lists From Typed Markers"), &json!(true)) + ); + s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap(); + assert!(!s.prefs.typed_lists); + s.execute("prefs.reset", &json!({"category": "Type"})).unwrap(); + assert!(s.prefs.typed_lists); +} + /// The same characters later in a paragraph, without the space, typed in one piece with the space /// (a paste), or at the start of a paragraph that is already in a list start nothing. #[test] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1871722c3..23edf6b56 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -332,6 +332,7 @@ impl Session { pen_rubber_band: self.prefs.pen_rubber_band, curvature_rubber_band: self.prefs.curvature_rubber_band, placeholder_text: self.prefs.placeholder_text, + typed_lists: self.prefs.typed_lists, smart_guide_color: rgb(&self.prefs.smart_guide_color, vectorcraft_tools::guides::MAGENTA), alignment_guides: self.prefs.alignment_guides, anchor_path_labels: self.prefs.anchor_path_labels, diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 835303b44..074756d84 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -223,6 +223,9 @@ pub struct ToolContext<'a> { /// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with /// placeholder text, selected. pub placeholder_text: bool, + /// Type → Start Lists From Typed Markers: a list marker and a space typed at the start of a + /// paragraph start a list ([`crate::text::TypeTool`]). + pub typed_lists: bool, /// Smart Guides → Color: the smart guides' lines and labels (RGB). pub smart_guide_color: [u8; 3], /// Smart Guides → Alignment Guides: the lines along the edges and centres the art lines up diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f77ba9dbf..2893c06ab 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -525,6 +525,7 @@ pub(crate) mod testutil { pen_rubber_band: true, curvature_rubber_band: true, placeholder_text: false, + typed_lists: true, smart_guide_color: guides::MAGENTA, alignment_guides: true, anchor_path_labels: true, diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index c7a5a1ad4..9cca37e67 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -521,7 +521,11 @@ impl TypeTool { /// from the start of the caret's paragraph up to it start a list ([`TextObject::typed_list_at`]), /// the typing session ends as one undo step with those characters, and `text.startList` /// removes them and makes the paragraph a list as the next one, so one Undo puts them back. + /// With Type › Start Lists From Typed Markers off, the characters stay as typed. fn start_typed_list(&mut self, cx: &ToolContext) -> Vec { + if !cx.typed_lists { + return vec![]; + } let (Some(id), Some(ty)) = (self.editing, &self.typing) else { return vec![] }; let Some(mut t) = Self::text(cx, id).cloned() else { return vec![] }; // The document does not have the space yet (its preview is applied after this input). diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 92e94aa1e..3ae7712f6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1130,6 +1130,7 @@ font-technology Variable Променливи Live curve fitting Напасване на кривата в реално време Fill Color Цвят на запълване Fill New Type Objects With Placeholder Text Запълване на нови текстови обекти с примесен текст + Start Lists From Typed Markers Започване на списъци от въведени маркери Fill Rule Правило за запълване Fill with Placeholder Text Запълване с примесен текст Fill: Запълване: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 233257897..5fa8b5bd0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3620,6 +3620,7 @@ theme Appearance Motiv Feet Stopy Files Soubory Fill New Type Objects With Placeholder Text Vyplňovat nové textové objekty zástupným textem + Start Lists From Typed Markers Začínat seznamy z napsaných značek Folder Složka Font Preview Size Velikost náhledu písma Get Preferences Získat předvolby diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 94516cb64..382df11e6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3362,6 +3362,7 @@ theme Appearance Udseende Feet Fod Files Filer Fill New Type Objects With Placeholder Text Udfyld nye tekstobjekter med pladsholdertekst + Start Lists From Typed Markers Start lister fra indtastede markører Folder Mappe Font Preview Size Størrelse på skrifttypeforhåndsvisning Get Preferences Hent indstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6dc11392e..dfa4a3a33 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1145,6 +1145,7 @@ font-technology Variable Variabel Stroke When Editable Kontur, wenn bearbeitbar Fill Color Flächenfarbe Fill New Type Objects With Placeholder Text Neue Textobjekte mit Platzhaltertext füllen + Start Lists From Typed Markers Listen aus getippten Aufzählungszeichen beginnen Fill Rule Füllregel Fill with Placeholder Text Mit Platzhaltertext füllen Fill: Fläche: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 267920a0f..2aa0a151f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1103,6 +1103,7 @@ font-technology Variable Μεταβλητές Live curve fitting Προσαρμογή καμπύλης σε πραγματικό χρόνο Fill Color Χρώμα γεμίσματος Fill New Type Objects With Placeholder Text Συμπλήρωση νέων αντικειμένων κειμένου με κείμενο υπόδειξης + Start Lists From Typed Markers Έναρξη λιστών από πληκτρολογημένα σύμβολα Fill Rule Κανόνας γεμίσματος Fill with Placeholder Text Γέμισμα με κείμενο υπόδειξης Fill: Γέμισμα: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 105146217..1db3e9aab 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1173,6 +1173,7 @@ font-technology Variable Variables Stroke When Editable Trazo si es editable Fill Color Color de relleno Fill New Type Objects With Placeholder Text Rellenar nuevos objetos de texto con texto de muestra + Start Lists From Typed Markers Iniciar listas a partir de marcadores escritos Fill Rule Regla de relleno Fill with Placeholder Text Rellenar con texto de muestra Insert Inline Symbol Insertar símbolo en el texto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 821935c27..0752a3fbb 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3558,6 +3558,7 @@ theme Appearance Ulkoasu Feet Jalat Files Tiedostot Fill New Type Objects With Placeholder Text Täytä uudet tekstiobjektit paikkamerkkitekstillä + Start Lists From Typed Markers Aloita luettelot kirjoitetuista merkeistä Folder Kansio Font Preview Size Fontin esikatselukoko Get Preferences Hae asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f7215a99b..755beef3e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1168,6 +1168,7 @@ font-technology Variable Variables Stroke When Editable Contour quand il est modifiable Fill Color Couleur de fond Fill New Type Objects With Placeholder Text Remplir les nouveaux objets texte avec un texte de substitution + Start Lists From Typed Markers Commencer des listes à partir des marqueurs saisis Fill Rule Règle de remplissage Fill with Placeholder Text Remplir avec un texte de substitution Fill: Fond : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9690969c5..eae9829b9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1467,6 +1467,7 @@ digits Malayalam Malajálam Highlight: Kiemelés: Stroke When Editable Körvonal, ha szerkeszthető Fill New Type Objects With Placeholder Text Új szövegobjektumok kitöltése helykitöltő szöveggel + Start Lists From Typed Markers Listák indítása begépelt jelölőkből Fill Rule Kitöltési szabály Fill with Placeholder Text Kitöltés helykitöltő szöveggel Fill: Kitöltés: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 12c1b4c85..433380d53 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3362,6 +3362,7 @@ theme Appearance Tampilan Feet Kaki Files File Fill New Type Objects With Placeholder Text Isi objek teks baru dengan teks placeholder + Start Lists From Typed Markers Mulai daftar dari penanda yang diketik Folder Folder Font Preview Size Ukuran pratinjau font Get Preferences Dapatkan preferensi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 004d41e2c..46c0bc7e5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1175,6 +1175,7 @@ font-technology Variable Variabili Stroke When Editable Traccia se modificabile Fill Color Colore riempimento Fill New Type Objects With Placeholder Text Riempi i nuovi oggetti di testo con testo segnaposto + Start Lists From Typed Markers Avvia elenchi dai marcatori digitati Fill Rule Regola di riempimento Fill with Placeholder Text Riempi con testo segnaposto Fill: Riempimento: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 716de0b24..6df9879ea 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1635,6 +1635,7 @@ digits Malayalam マラヤーラム数字 Files of this standard tag their colours with ICC profiles. この規格のファイルでは、カラーに ICC プロファイルが付けられます。 Fill 塗り Fill New Type Objects With Placeholder Text 新規テキストオブジェクトをダミーテキストで埋める + Start Lists From Typed Markers 入力した記号からリストを開始 Fill Rule 塗りのルール Fill and Stroke 塗りと線 Fill new strokes 新しいストロークに塗りを適用 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e5d0a28c0..b23ea6b0c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3178,6 +3178,7 @@ theme Appearance იერსახე Feet ფუტები Files ფაილები Fill New Type Objects With Placeholder Text ახალი ტექსტური ობიექტების ჩანაცვლების ტექსტით შევსება + Start Lists From Typed Markers სიების დაწყება აკრეფილი მარკერებიდან Folder საქაღალდე Font Preview Size შრიფტის ნიმუშის ზომა Get Preferences პარამეტრების მიღება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a8f2c0db4..4a026cd0d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1625,6 +1625,7 @@ digits Malayalam 말라얄람어 Files of this standard tag their colours with ICC profiles. 이 표준의 파일은 ICC 프로파일로 색상을 태그합니다. Fill 칠 Fill New Type Objects With Placeholder Text 새 문자 개체를 대체 텍스트로 채우기 + Start Lists From Typed Markers 입력한 기호로 목록 시작 Fill Rule 칠 규칙 Fill and Stroke 칠과 선 Fill new strokes 새 선 칠하기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e803314e0..3a0ffaf16 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3376,6 +3376,7 @@ theme Appearance Vormgeving Feet Voet Files Bestanden Fill New Type Objects With Placeholder Text Nieuwe tekstobjecten met plaatshoudertekst vullen + Start Lists From Typed Markers Lijsten starten met getypte opsommingstekens Folder Map Font Preview Size Grootte van lettertypevoorbeelden Get Preferences Voorkeuren ophalen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 591790721..d858f8081 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3497,6 +3497,7 @@ theme Appearance Wygląd Feet Stopy Files Pliki Fill New Type Objects With Placeholder Text Wypełniaj nowe obiekty tekstowe tekstem zastępczym + Start Lists From Typed Markers Rozpoczynaj listy od wpisanych znaczników Folder Folder Font Preview Size Rozmiar podglądu czcionki Get Preferences Pobierz preferencje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cae6a2fd7..49a4edc7c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1045,6 +1045,7 @@ Fill & Stroke Preenchimento e Traçado Fill Color Cor de Preenchimento Fill New Type Objects With Placeholder Text Preencher Novos Objetos de Texto com Texto de Placeholder + Start Lists From Typed Markers Iniciar Listas a Partir de Marcadores Digitados Fill Rule Regra de Preenchimento Fill and Stroke Preenchimento e Traçado Fill new strokes Preencher novos traçados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3f1b8e702..e37f1db9d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1172,6 +1172,7 @@ font-technology Variable Вариативные Stroke When Editable Редактируемая обводка Fill Color Цвет заливки Fill New Type Objects With Placeholder Text Заполнять новые текстовые объекты текстом-заполнителем + Start Lists From Typed Markers Начинать списки с введённых маркеров Fill Rule Правило заливки Fill with Placeholder Text Заполнить текстом-заполнителем Fill: Заливка: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 3362155f0..f0cb484b2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1138,6 +1138,7 @@ font-technology Variable Variabla Live curve fitting Kurvanpassning medan du ritar Fill Color Fyllnadsfärg Fill New Type Objects With Placeholder Text Fyll nya textobjekt med platshållartext + Start Lists From Typed Markers Starta listor från inskrivna punkttecken Fill Rule Fyllningsregel Fill with Placeholder Text Fyll med platshållartext Fill: Fyllning: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9308d7ea6..73ba123e0 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1127,6 +1127,7 @@ font-technology Variable Değişken Merge only with selection Yalnızca seçimle birleştir Fill Color Dolgu Rengi Fill New Type Objects With Placeholder Text Yeni Yazım Nesnelerini Yer Tutucu Metinle Doldurun + Start Lists From Typed Markers Yazılan İşaretlerden Liste Başlat Fill Rule Doldurma Kuralı Fill with Placeholder Text Yer Tutucu Metinle Doldur Fill: Doldurun: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 55edd38d5..fe0edb479 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1169,6 +1169,7 @@ font-technology Variable Варіативні Stroke When Editable Обведення, яке можна редагувати Fill Color Колір заливки Fill New Type Objects With Placeholder Text Заповнювати нові текстові об’єкти текстом-заповнювачем + Start Lists From Typed Markers Починати списки з введених маркерів Fill Rule Правило заливки Fill with Placeholder Text Заповнити текстом-заповнювачем Fill: Заливка: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8ea0991f..a567d484a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1345,6 +1345,7 @@ digits Malayalam Malayalam Highlight: Làm nổi bật: Stroke When Editable Nét khi có thể chỉnh sửa Fill New Type Objects With Placeholder Text Tô đối tượng chữ mới bằng văn bản giữ chỗ + Start Lists From Typed Markers Bắt đầu danh sách từ ký hiệu đã gõ Fill Rule Quy tắc tô Fill with Placeholder Text Tô bằng văn bản giữ chỗ Fill: Tô: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4cf9b672f..be6b5fd2b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1114,6 +1114,7 @@ Stroke When Editable 可编辑时的描边 Fill Color 填色 Fill New Type Objects With Placeholder Text 用占位文本填充新文字对象 + Start Lists From Typed Markers 从键入的标记开始列表 Fill Rule 填充规则 Fill with Placeholder Text 用占位文本填充 Insert Inline Symbol 插入行内符号 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4aee5622d..fd5d3df2b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1139,6 +1139,7 @@ font-technology Variable 可變 Highlight: 強調顯示: Stroke When Editable 可編輯時的筆畫 Fill New Type Objects With Placeholder Text 以預留位置文字填滿新文字物件 + Start Lists From Typed Markers 從輸入的標記開始清單 Fill Rule 填色規則 Fill with Placeholder Text 以預留位置文字填滿 Insert Inline Symbol 插入行內符號 diff --git a/docs/mcp.md b/docs/mcp.md index f7b5d261e..49d1fcef2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3799,6 +3799,9 @@ Pen's second anchor at (150, 431), in line with the square's centre. - `placeholderText` (on by default): type the Type tools place (`pointer_gesture` with `type`, `areaType`…) starts with placeholder text, selected, so `type_text` replaces it; `text.create` and `text.createInPath` take `placeholder: true`. +- `typedLists` (on by default; Type › Start Lists From Typed Markers): a list marker and a space typed with the Type tool + at the start of a paragraph start a list (see `text.startList`). Off, the Type tool leaves them as typed text; + `text.startList`, `text.setFormat {list}` and the Bullets and Numbered List buttons work as before. - `typeSizeIncrement`, `trackingIncrement`, `baselineShiftIncrement`: what `type.step {attribute: "size" | "leading" | "tracking" | "kerning" | "baselineShift", by?}` steps by, on the text range given, the type objects in `ids`, the Type tool's selected text or the selected type objects. `type.size.increase` / `type.size.decrease` (Cmd+Shift+. and @@ -5757,7 +5760,8 @@ whose left and first-line indents are both 0 gets 18 pt and -18 pt. The tool run the byte after the space, as an undo step of its own after the typing: one Undo puts the characters back as text with no list, and typing goes on after them; Redo removes them again, and typing goes on before the paragraph's text. The same characters later in a paragraph start nothing, and so do list characters that arrive in one piece with the space: a -paste, an IME commit, or `type_text` of a longer string. From a script, `text.startList {id, at?}` (`at` defaults to the end of the text) does +paste, an IME commit, or `type_text` of a longer string. With the `typedLists` preference off (Preferences › Type › +Start Lists From Typed Markers), the Type tool starts no list from typed characters. From a script, `text.startList {id, at?}` (`at` defaults to the end of the text) does the same with the text before `at` and returns `started` and `caret`. When the list starts, `caret` is the byte where the paragraph's text now starts; when the text before `at` starts no list, nothing changes, `started` is false and `caret` is `at`. From 8f833f6a1ef0e561a6d934c760d8a2cb10ca40e4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 23:36:30 -0700 Subject: [PATCH 17/51] Tests: Start Lists From Typed Markers on again, and its preferences checkbox - typed_lists_off_leaves_typed_markers_as_text turns the preference back on for each marker and checks that typing it in a new paragraph starts a list with a Bullets and Numbering undo step. - start_lists_from_typed_markers_is_a_saved_type_preference: the Type page shows the checkbox, checked by default; unchecking it turns the preference off in the session and in the saved preferences, and every bundled language translates its label. --- crates/engine/src/tests/textedit.rs | 9 +++++++++ crates/ui-egui/src/prefs_dialog/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index bb1ac80a0..7738e45a4 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1149,6 +1149,15 @@ fn typed_lists_off_leaves_typed_markers_as_text() { let t = obj(&s, id); assert_eq!((t.plain_text(), t.para_at(0).list), (format!("{typed} a"), None), "{typed}"); assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"), "{typed}"); + // Turned back on, the same typing in a new paragraph starts a list. + s.execute("prefs.set", &json!({"key": "typedLists", "value": true})).unwrap(); + key(&mut s, ToolKey::Enter, Mods::default()); + for c in typed.chars().chain(" ".chars()) { + s.tool_text(&c.to_string(), v).unwrap(); + } + let t = obj(&s, id); + assert_eq!((t.plain_text(), t.para_at(0).list, t.para_at(1).list.is_some()), (format!("{typed} a\n"), None, true), "on: {typed}"); + assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Bullets and Numbering"), "on: {typed}"); } let mut s = session(); s.execute("prefs.set", &json!({"key": "typedLists", "value": false})).unwrap(); diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 9e8ce2804..da1814b35 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -16,6 +16,27 @@ fn geometric_snapping_is_a_saved_smart_guides_preference() { assert!(a.run("view.snapping", json!({})).is_err()); } +/// Type › Start Lists From Typed Markers is a checkbox in the Type panel, on by default; turning +/// it off is saved, and every bundled language translates its label. +#[test] +fn start_lists_from_typed_markers_is_a_saved_type_preference() { + const LABEL: &str = "Start Lists From Typed Markers"; + let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + assert!(a.session.prefs.typed_lists); + open(&mut a, Some("Type")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains(LABEL), "{text}"); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields.get("typedLists"), Some(&json!(true))); + a.ui.dialog.as_mut().unwrap().fields.insert("typedLists".into(), json!(false)); + confirm(&mut a).unwrap(); + assert!(!a.session.prefs.typed_lists); + let saved: Prefs = serde_json::from_value(a.ui.engine_prefs.clone()).unwrap(); + assert!(!saved.typed_lists); + for lang in crate::i18n::Lang::all().filter(|l| l.code() != crate::i18n::Lang::EN.code()) { + assert!(crate::i18n::has(lang, LABEL), "{}", lang.code()); + } +} + /// A resting checkbox must have a visible outline in both states and every brightness. #[test] fn preference_checkboxes_draw_outlines_without_hover() { From 455eb807a63817d0f227cbb35303d460ffeb1591 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 01:37:23 -0700 Subject: [PATCH 18/51] i18n: Bosnian and Catalan rows for Start Lists From Typed Markers main added bs and ca as bundled languages, and the preference test checks that every bundled language translates the checkbox label. --- crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2c365fa50..aba170ea4 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3577,6 +3577,7 @@ theme Appearance Izgled Feet Stope Files Datoteke Fill New Type Objects With Placeholder Text Ispuni nove tekstualne objekte tekstom čuvara mjesta + Start Lists From Typed Markers Započni liste iz otkucanih oznaka Folder Folder Font Preview Size Veličina pregleda fonta Get Preferences Preuzmi postavke diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f0c3542ad..370d9b677 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1555,6 +1555,7 @@ digits Malayalam Malaiàlam Fill Line Línia de farciment Fill new strokes Omple els traços nous Fill New Type Objects With Placeholder Text Omple els objectes de text nous amb text de mostra + Start Lists From Typed Markers Comença llistes a partir dels marcadors escrits Fill Rule Regla de farciment Fill with Placeholder Text Omple amb text de mostra Fill: Farciment: From 1e6c67a374edd9a84f77af1485c92a07ccf51a9c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:31:53 +0700 Subject: [PATCH 19/51] i18n: Hindi has the typed-list-markers preference label --- crates/ui-egui/src/i18n/hi.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0f212f4fd..b7cb6fcce 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7960,3 +7960,4 @@ swatch White, Black सफ़ेद, काला swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस + Start Lists From Typed Markers टाइप किए गए चिह्नों से सूचियाँ शुरू करें From 246b8ed8cd4b846e1abb756a30f742e7e3738dfb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 23:28:09 -0700 Subject: [PATCH 20/51] Type: typed `+`, `#` and `>` start lists with a hollow circle, square and dash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed list detection gave every bulleted trigger the filled dot. Now `*` and a space start a list with the filled dot (•), `+` with the hollow circle (◦), `#` with the filled square (▪) and `>` with the dash (–), each one of the existing Bullets presets. `1.`, `1)`, `A.` and `A)` start the same numbered lists as before. Only the list a typed trigger starts changes. Lists already in a document keep their bullets, pasted triggers start no list, and Undo and Redo of a typed list work as before. Docs: docs/mcp.md (typed list paragraph), docs/target-app-parity.md. --- crates/doc/src/text/tabs_lists.rs | 9 ++++--- crates/doc/src/text/tests.rs | 13 ++++----- crates/engine/src/tests/textedit.rs | 42 ++++++++++++++++++++--------- docs/mcp.md | 3 ++- docs/target-app-parity.md | 2 +- 5 files changed, 45 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index b66795e89..7f6382b2f 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -233,13 +233,16 @@ impl ParaList { /// The list that the Type tool starts when `typed` and a space are typed at the start of a /// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B, - /// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts - /// none. + /// C, and "*" starts a bulleted list with the filled dot, "+" one with the hollow circle, "#" + /// one with the filled square and ">" one with the dash. Any other text starts none. pub fn typed(typed: &str) -> Option { match typed { "1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)), "A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)), - "*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)), + "*" => Some(Self::bulleted(Bullet::Dot)), + "+" => Some(Self::bulleted(Bullet::Circle)), + "#" => Some(Self::bulleted(Bullet::Square)), + ">" => Some(Self::bulleted(Bullet::Dash)), _ => None, } } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 2a47ee453..ad45a6477 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -212,22 +212,23 @@ fn number_styles_write_their_numbers_and_roll_over() { } /// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph -/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the -/// filled dot. Other text, the same characters later in a paragraph or without the space, and -/// a paragraph already in a list start nothing. +/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the +/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters +/// later in a paragraph or without the space, and a paragraph already in a list start nothing. #[test] fn typed_list_starts_are_found_at_a_paragraphs_start() { let (arabic, letters, dot) = (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot)); - let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed); - assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some)); + let (circle, square, dash) = (ParaList::bulleted(Bullet::Circle), ParaList::bulleted(Bullet::Square), ParaList::bulleted(Bullet::Dash)); + let typed = ["1.", "1)", "A.", "A)", "*", "+", "#", ">"].map(ParaList::typed); + assert_eq!(typed, [arabic, arabic, letters, letters, dot, circle, square, dash].map(Some)); for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] { assert_eq!(ParaList::typed(other), None, "{other:?}"); } let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default()); assert_eq!(t.typed_list_at(9), Some((6, arabic))); assert_eq!(t.typed_list_at(19), Some((16, letters))); - assert_eq!(t.typed_list_at(27), Some((25, dot))); + assert_eq!(t.typed_list_at(27), Some((25, circle))); // Not at the paragraph's start, not right after the space, or past the end. for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] { assert_eq!(t.typed_list_at(at), None, "{at}"); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 7738e45a4..4d0ade073 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1047,18 +1047,20 @@ fn list_markers(s: &Session, id: NodeId) -> Vec { } /// Typed list detection: at the start of a paragraph, "1.", "1)", "A." or "A)" and a space start -/// a numbered list, and "*", "#", ">" or "+" and a space start a bulleted list with the filled dot. -/// The typed characters go, the paragraph takes the hanging indent, and the text typed next is the -/// list item's. One Undo puts the typed characters back with no list, and typing goes on after -/// them, also after a Backspace that deleted nothing. +/// a numbered list, and "*", "+", "#" or ">" and a space start a bulleted list with the filled dot, +/// the hollow circle, the filled square or the dash. The typed characters go, the paragraph takes +/// the hanging indent, and the text typed next is the list item's. One Undo puts the typed +/// characters back with no list, and typing goes on after them, also after a Backspace that +/// deleted nothing. #[test] fn typing_a_list_start_and_a_space_starts_a_list() { use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; let v = ViewInfo::default(); - let (arabic, letters, dot) = - (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot)); + let (arabic, letters) = (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters)); let cases = [("1.", arabic, "1."), ("1)", arabic, "1."), ("A.", letters, "A."), ("A)", letters, "A.")]; - let bullets = ["*", "#", ">", "+"].map(|b| (b, dot, "\u{2022}")); + let bullets = + [("*", Bullet::Dot, "\u{2022}"), ("+", Bullet::Circle, "\u{25E6}"), ("#", Bullet::Square, "\u{25AA}"), (">", Bullet::Dash, "\u{2013}")] + .map(|(b, bullet, marker)| (b, ParaList::bulleted(bullet), marker)); for (typed, list, marker) in cases.into_iter().chain(bullets) { let mut s = session(); s.select_tool("type", v).unwrap(); @@ -1190,7 +1192,7 @@ fn typed_lists_is_a_type_preference_on_by_default() { /// (a paste), or at the start of a paragraph that is already in a list start nothing. #[test] fn list_starts_typed_elsewhere_or_pasted_start_no_list() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; let v = ViewInfo::default(); let mut s = session(); s.select_tool("type", v).unwrap(); @@ -1205,12 +1207,26 @@ fn list_starts_typed_elsewhere_or_pasted_start_no_list() { for c in "1.Pears".chars() { s.tool_text(&c.to_string(), v).unwrap(); } - key(&mut s, ToolKey::Enter, Mods::default()); - s.tool_text("* ", v).unwrap(); + for pasted in ["* ", "+ ", "# ", "> "] { + key(&mut s, ToolKey::Enter, Mods::default()); + s.tool_text(pasted, v).unwrap(); + } let t = obj(&s, id); - assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* "); - assert_eq!((0..4).map(|i| t.para_at(i).list).collect::>(), [None, None, None, None]); + assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* \n+ \n# \n> "); + assert!((0..7).all(|i| t.para_at(i).list.is_none())); assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering")); + // A paragraph in a bulleted list keeps its bullet when another list start is typed before it. + let id = text(&mut s, "Item"); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "bullet": "dot"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + click(&mut s, 101.0, 95.0); + s.set_tool_option("select", &json!({"start": 0, "end": 0})); + for c in "+ ".chars() { + s.tool_text(&c.to_string(), v).unwrap(); + } + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.para_at(0).list), ("+ Item", Some(ParaList::bulleted(Bullet::Dot)))); + assert_eq!(list_markers(&s, id), ["\u{2022}"]); // A paragraph in a list keeps its list, and the typed characters stay as text. let id = text(&mut s, "Item"); s.execute("text.setFormat", &json!({"ids": [id.0], "list": "number"})).unwrap(); @@ -1245,7 +1261,7 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() { let id = text(&mut s, "x\n# "); let r = s.execute("text.startList", &json!({"id": id.0})).unwrap(); assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string())); - assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(Bullet::Dot))); + assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(Bullet::Square))); let steps = undo_labels(&s).len(); let id = text(&mut s, "1.x"); assert_eq!(s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap()["started"], json!(false)); diff --git a/docs/mcp.md b/docs/mcp.md index 49d1fcef2..2074f47f0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5755,7 +5755,8 @@ next to the same character, and that frame becomes the selection when it was not Typing starts a list too. When the Type tool types a space on its own (one key press, or `type_text {text: " "}`) right after `1.` or `1)`, `A.` or `A)`, or one of `*`, `#`, `>` and `+` at the start of a paragraph that is in no list, the typed characters and the space are removed and the paragraph becomes a numbered list in 1, 2, 3 or A, B, C, or a -bulleted list with the filled dot. A list started with `1)` or `A)` also writes its numbers with a period. A paragraph +bulleted list: `*` gives the filled dot (•), `+` the hollow circle (◦), `#` the filled square (▪) and `>` the dash +(–). A list started with `1)` or `A)` also writes its numbers with a period. A paragraph whose left and first-line indents are both 0 gets 18 pt and -18 pt. The tool runs `text.startList {id, at}`, with `at` the byte after the space, as an undo step of its own after the typing: one Undo puts the characters back as text with no list, and typing goes on after them; Redo removes them again, and typing goes on before the paragraph's text. The same diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 051d30009..037b7d4c2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1048,7 +1048,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1. Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted. While no type is being edited, a press with Shift held makes new type with the horizontal or vertical counterpart of the Type, Area Type or Type on a Path tool chosen, and a press with Alt (Option) held makes it with Type on a Path in place of Area Type or the reverse, in the chosen tool's writing direction; with both keys held, both switch ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). The pointer shows the tool the press acts as, and the tool chosen stays selected. - The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `#`, `>` or `+` makes it a bulleted list with the filled dot. The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. + The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `+`, `#` or `>` makes it a bulleted list with the filled dot, the hollow circle, the filled square or the dash (a VectorCraft choice: the page does not say which bullet each character gives). The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. PDF export writes bullets and numbers as text, in the glyphs and at the places the canvas draws them, in area type, type on a path, right-to-left paragraphs and threaded frames, and a PDF viewer selects, copies and finds them; a marker glyph that stands for no character is drawn as a path. The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting. In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X. From 6fce6d18841ba2851667d5c04e84c0b21e84cb55 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 01:27:20 -0700 Subject: [PATCH 21/51] Type: text.startList names the bullet each typed trigger starts The command catalog said "* ", "# ", "> " and "+ " all start a list with the filled dot. It now names the filled dot, hollow circle, filled square and dash. The text.startList test checks each of the four triggers and the marker the layout draws. --- crates/engine/src/cmd/textedit.rs | 2 +- crates/engine/src/tests/textedit.rs | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 29d1de28f..d8514ef56 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Bullets and Numbering", [], None, - "{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), or \"* \", \"# \", \"> \" or \"+ \" (a bulleted list with the filled dot), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}", + "{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), \"* \" (a bulleted list with the filled dot), \"+ \" (the hollow circle), \"# \" (the filled square) or \"> \" (the dash), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}", has_doc, start_list ), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 4d0ade073..a895655b9 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1258,10 +1258,16 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() { assert_eq!(list_markers(&s, id), ["1."]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!((obj(&s, id).plain_text().as_str(), obj(&s, id).para_at(1).list), ("Fruit\n1) Apples", None)); - let id = text(&mut s, "x\n# "); - let r = s.execute("text.startList", &json!({"id": id.0})).unwrap(); - assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string())); - assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(Bullet::Square))); + for (typed, bullet, marker) in + [("* ", Bullet::Dot, "\u{2022}"), ("+ ", Bullet::Circle, "\u{25E6}"), ("# ", Bullet::Square, "\u{25AA}"), ("> ", Bullet::Dash, "\u{2013}")] + { + let id = text(&mut s, &format!("x\n{typed}")); + let r = s.execute("text.startList", &json!({"id": id.0})).unwrap(); + assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string()), "{typed:?}"); + assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(bullet)), "{typed:?}"); + s.execute("text.editRange", &json!({"id": id.0, "start": 2, "end": 2, "insert": "Item"})).unwrap(); + assert_eq!(list_markers(&s, id), [marker], "{typed:?}"); + } let steps = undo_labels(&s).len(); let id = text(&mut s, "1.x"); assert_eq!(s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap()["started"], json!(false)); From 2464ac02be6abfbe9d31ffae3fd2c3a678f4a62d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 22:59:02 +0700 Subject: [PATCH 22/51] Type: a continued paragraph's list test types `*`, since `#` now starts a square bullet --- crates/doc/src/text/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index ad45a6477..a3f4ab4d2 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -246,7 +246,7 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() { #[test] fn typed_list_starts_skip_a_continued_paragraph() { let dot = ParaList::bulleted(Bullet::Dot); - let mut t = TextObject::point(Point::ZERO, "* Pears\n# ", CharStyle::default()); + let mut t = TextObject::point(Point::ZERO, "* Pears\n* ", CharStyle::default()); assert_eq!(t.typed_list_at(2), Some((0, dot))); t.first_para_continues = true; assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list"); From eaa42374a3d88af0154a5de10e6b45977b78f672 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:47:10 +0700 Subject: [PATCH 23/51] Shortcuts: F2, F3 and F4 also cut, copy and paste on Windows and Linux Squashed from #5041 onto main. --- crates/ui-egui/src/dialogs/shortcut_import.rs | 2 +- .../src/dialogs/tests_shortcut_import.rs | 19 ++ crates/ui-egui/src/keymap.rs | 91 +++++++++- crates/ui-egui/src/shortcut_editor.rs | 163 ++++++++++++++++-- crates/ui-egui/src/shortcuts/tests.rs | 47 +++++ docs/target-app-parity.md | 2 + 6 files changed, 306 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index c65b4e602..00ae1abe7 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -508,7 +508,7 @@ pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { shortcut_editor::open(app); } if let Some(k) = app.ui.dialog.as_mut() { - k.fields.insert("overrides".into(), crate::keymap::stored::to_value(km.overrides())); + k.fields.insert("overrides".into(), crate::keymap::stored::to_value(&shortcut_editor::saved_overrides(km.overrides(), &ov))); k.fields.insert("set".into(), json!(set)); k.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set."))); } diff --git a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs index 61bbc18e8..77842ec8d 100644 --- a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs @@ -500,6 +500,25 @@ fn apply_rows(app: &mut VectorcraftApp, start: &str, rows: &[(&str, &str, &str, (out, dialog(app).fields["overrides"].clone()) } +#[test] +fn resetting_an_imported_function_key_restores_its_edit_default() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut app = app(); + let (out, value) = apply_rows(&mut app, "defaults", &[("Acme Duplicate", "Menus", "edit.duplicate", alias)]); + assert_eq!(out["applied"], 1); + let mut saved = crate::keymap::stored::from_value(value); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + shortcut_editor::reset_one(&mut saved, "edit.duplicate"); + assert_eq!( + crate::keymap::lookup(&crate::keymap::keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), + Some(crate::keymap::command_of(edit)), + ); + } +} + /// Cmd and Ctrl are one key off the Mac: the review lets both rows through, and the later one takes /// the shortcut. #[test] diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 572c6b175..d481ba389 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -132,7 +132,7 @@ fn vc_binding(text: &str) -> Option { /// Every listed entry as the crate sees it, in dispatch priority: commands (engine, then UI), /// panels, tools. Each tool is its own group: tools never cycle. -fn catalogue() -> Vec { +fn catalogue(p: Platform) -> Vec { let rank = |e: &Entry| { if e.is_tool { 2 @@ -159,7 +159,7 @@ fn catalogue() -> Vec { if let Some(d) = shortcut_editor::default_of(&e.key).and_then(vc_binding) { b = b.with_default(d); } - for alias in default_aliases(&e.key) { + for alias in default_aliases(&e.key, p) { if let Some(d) = vc_binding(alias) { b = b.with_default(d); } @@ -170,14 +170,22 @@ fn catalogue() -> Vec { } /// Additional defaults for commands whose primary shortcut remains in their command spec. -fn default_aliases(key: &str) -> &'static [&'static str] { +fn default_aliases(key: &str, p: Platform) -> &'static [&'static str] { match key { + "edit.cut" if !p.is_mac() => &["F2"], + "edit.copy" if !p.is_mac() => &["F3"], + "edit.paste" if !p.is_mac() => &["F4"], "paint.lastColor" => &["Shift+,"], "paint.lastGradient" => &["Shift+."], _ => &[], } } +/// Cut, Copy and Paste with their primary shortcut and the function key [`default_aliases`] adds +/// outside macOS. +pub(crate) const EDIT_FUNCTION_KEYS: [(&str, &str, &str); 3] = + [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")]; + /// On the web, the browser's operating system once egui reports it ([`note_os`]). static WEB_PLATFORM: RwLock> = RwLock::new(None); @@ -220,13 +228,38 @@ pub fn template() -> &'static Keymap { } /// The default keymap of `p`. The chords the operating system takes can't be assigned. -fn keymap_for(p: Platform) -> Keymap { - Keymap::new(catalogue(), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p)) +pub(crate) fn keymap_for(p: Platform) -> Keymap { + Keymap::new(catalogue(p), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p)) } /// The keymap with `overrides` applied. pub fn keymap_with(overrides: &Overrides) -> Keymap { - template().clone().with_overrides(overrides.clone()) + let defaults = template(); + defaults.clone().with_overrides(edit_alias_overrides(overrides, defaults.platform())) +} + +/// When a saved override assigns F2, F3 or F4 elsewhere without changing its edit command, +/// keep only that edit command's primary shortcut so the saved assignment runs. +pub(crate) fn edit_alias_overrides(overrides: &Overrides, p: Platform) -> Overrides { + let mut resolved = overrides.clone(); + if p.is_mac() { + return resolved; + } + for (key, primary, alias) in EDIT_FUNCTION_KEYS { + if overrides.get(key).is_some() { + continue; + } + let Some(binding) = vc_binding(alias) else { continue }; + let assigned_elsewhere = overrides.iter().any(|(other, texts)| { + other != key + && shortcut_editor::entry(&entry_of_stored(other)).is_some() + && texts.iter().any(|text| Binding::parse(text).is_ok_and(|b| b == binding)) + }); + if assigned_elsewhere { + let _ = resolved.insert(key, vec![vc_to_crate(primary)]); + } + } + resolved } /// The command a chord runs, the first for tools sharing it. @@ -424,7 +457,7 @@ mod tests { .filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d))) .filter(|(key, d)| { let mut expected: Vec = vc_binding(d).into_iter().collect(); - expected.extend(default_aliases(key).iter().filter_map(|alias| vc_binding(alias))); + expected.extend(default_aliases(key, platform()).iter().filter_map(|alias| vc_binding(alias))); expected != template().default_bindings(&command_of(key)) }) .collect(); @@ -451,6 +484,50 @@ mod tests { assert!(template().conflicts().is_empty()); } + #[test] + fn edit_function_keys_follow_platform_and_reset_restores_both_bindings() { + for p in [Platform::Windows, Platform::Linux, Platform::Mac] { + let km = keymap_for(p); + for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] { + let command = command_of(key); + let mut expected = vec![vc_binding(primary).unwrap()]; + if !p.is_mac() { + expected.push(vc_binding(alternate).unwrap()); + } + assert_eq!(km.default_bindings(&command), expected, "{p:?} {key}"); + assert_eq!(lookup(&km, &vc_chord(primary).unwrap()), Some(command.clone()), "{p:?} {primary}"); + assert_eq!(lookup(&km, &vc_chord(alternate).unwrap()), (!p.is_mac()).then_some(command.clone()), "{p:?} {alternate}"); + assert_eq!(effective_with(&km, key).as_deref(), Some(primary)); + let mut changed = km.clone(); + shortcut_editor::assign_in(&mut changed, key, Some("Ctrl+Alt+Shift+F12"), true).unwrap(); + assert_eq!(changed.bindings(&command), vec![vc_binding("Ctrl+Alt+Shift+F12").unwrap()]); + changed.reset(&command); + assert_eq!(changed.bindings(&command), expected, "{p:?} {key} reset"); + let mut overrides = Overrides::new(); + set_override(&mut overrides, key, "Ctrl+Alt+Shift+F12"); + shortcut_editor::reset_one(&mut overrides, key); + assert_eq!(km.clone().with_overrides(overrides).bindings(&command), expected, "{p:?} {key} use default"); + } + assert!(km.conflicts().is_empty(), "{p:?}"); + assert!(km.problems().is_empty(), "{p:?}"); + + for (alias, edit, primary) in [("F2", "edit.cut", "Cmd+X"), ("F3", "edit.copy", "Cmd+C"), ("F4", "edit.paste", "Cmd+V")] { + let mut saved = Overrides::new(); + set_override(&mut saved, "tool:pen", alias); + let assigned = keymap_for(p).with_overrides(edit_alias_overrides(&saved, p)); + assert_eq!(lookup(&assigned, &vc_chord(alias).unwrap()), Some(command_of("tool:pen")), "{p:?} saved {alias} assignment"); + assert_eq!(lookup(&assigned, &vc_chord(primary).unwrap()), Some(command_of(edit)), "{p:?} primary {edit} binding"); + shortcut_editor::reset_one(&mut saved, "tool:pen"); + let reset = keymap_for(p).with_overrides(edit_alias_overrides(&saved, p)); + assert_eq!(lookup(&reset, &vc_chord(alias).unwrap()), (!p.is_mac()).then_some(command_of(edit)), "{p:?} reset saved {alias}"); + } + let mut unknown = Overrides::new(); + set_override(&mut unknown, "missing.command", "F2"); + let km = keymap_for(p).with_overrides(edit_alias_overrides(&unknown, p)); + assert_eq!(lookup(&km, &vc_chord("F2").unwrap()), (!p.is_mac()).then_some(command_of("edit.cut")), "{p:?} unknown saved entry"); + } + } + /// The chords the operating system takes are refused, and no default uses one. #[test] fn system_shortcuts_are_refused() { diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index d5ed1ade2..01deb3be8 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -65,14 +65,14 @@ pub(crate) use ui_mirror; /// The keymap with the user's overrides, and the effective text of every entry they change. pub(crate) struct Mirror { pub keymap: Keymap, + source_overrides: Overrides, text: HashMap>, } impl Mirror { fn new(overrides: Overrides) -> Mirror { - let keymap = crate::keymap::template().clone().with_overrides(overrides); - let text = keymap - .overrides() + let keymap = crate::keymap::keymap_with(&overrides); + let text = overrides .iter() .map(|(k, _)| { let key = entry_of_stored(k); @@ -80,7 +80,7 @@ impl Mirror { (key, t) }) .collect(); - Mirror { keymap, text } + Mirror { keymap, source_overrides: overrides, text } } } @@ -113,7 +113,7 @@ pub fn sync(ui: &UiState) { let same = store() .read() .map(|m| match &*m { - Some(m) => *m.keymap.overrides() == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(), + Some(m) => m.source_overrides == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(), None => ui.shortcut_overrides.is_empty(), }) .unwrap_or(false); @@ -612,10 +612,32 @@ pub fn conflicts_in(km: &Keymap) -> Vec<(String, Vec)> { pub fn assign(overrides: &mut Overrides, key: &str, chord: Option<&str>, force: bool) -> Result, String> { let mut km = keymap_with(overrides); let removed = assign_in(&mut km, key, chord, force)?; - *overrides = km.overrides().clone(); + *overrides = saved_overrides(km.overrides(), overrides); Ok(removed) } +/// The overrides to save from a working keymap's `current` overrides. An entry for Cut, Copy or +/// Paste that `source`, the set before the change, did not hold is left out when it is the entry +/// [`crate::keymap::keymap_with`] makes from the other entries of `source` or of `current`. +pub(crate) fn saved_overrides(current: &Overrides, source: &Overrides) -> Overrides { + let mut saved = current.clone(); + for (key, _, _) in crate::keymap::EDIT_FUNCTION_KEYS { + let Some(now) = current.get(key) else { continue }; + if source.get(key).is_some() { + continue; + } + let made = |from: &Overrides| { + let mut rest = from.clone(); + rest.remove(key); + crate::keymap::edit_alias_overrides(&rest, crate::keymap::platform()).get(key).is_some_and(|t| t == now) + }; + if made(current) || made(source) { + saved.remove(key); + } + } + saved +} + /// [`assign`] on a keymap. pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) -> Result, String> { let command = command_of(key); @@ -789,7 +811,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option return Some(Err(e)), }; let km = keymap_with(&app.ui.shortcut_overrides); - let ov = km.overrides(); + let ov = &app.ui.shortcut_overrides; Ok(Value::Array( entries() .iter() @@ -943,7 +965,7 @@ impl DialogView { let backtick = backtick_text(); let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect(); let conflicts = conflicts_in(&keymap).len(); - DialogView { source: stored::to_value(keymap.overrides()), backtick, keymap, texts, conflicts } + DialogView { source: stored::to_value(&overrides), backtick, keymap, texts, conflicts } } /// The view of `d`'s working set, from the cache unless the set or the backtick key's @@ -972,6 +994,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut ok = false; let mut cancel = false; drop_hidden(&mut d, km.overrides()); + let saved = dialog_overrides(&d); let recording = d.str("__recording"); // Record a chord (before widgets see the keys). Modifiers held down are part of the next key's // chord, not a chord of their own. @@ -1089,7 +1112,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { d.fields.insert("__selected".into(), json!(e.key)); } let label = if rec == e.key { tl!("Press keys…").to_string() } else { sc.unwrap_or_else(|| "—".into()) }; - let changed = crate::keymap::has_override(km.overrides(), &e.key); + let changed = crate::keymap::has_override(&saved, &e.key); let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text }); let b = ui .add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key)) @@ -1161,7 +1184,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); if let Cow::Owned(km) = &km { - d.fields.insert("overrides".into(), stored::to_value(km.overrides())); + d.fields.insert("overrides".into(), stored::to_value(&saved_overrides(km.overrides(), &saved))); } let import = d.fields.remove("__import").is_some(); let export = d.fields.remove("__export").is_some(); @@ -1667,6 +1690,126 @@ mod tests { assert_eq!(view.conflicts, 0); } + #[test] + fn edit_dialog_rows_show_primary_and_function_keys() { + for p in [craft_keymap::Platform::Windows, craft_keymap::Platform::Linux, craft_keymap::Platform::Mac] { + let km = crate::keymap::keymap_for(p); + for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] { + let shown = dialog_shortcuts_with(&km, key, None).unwrap(); + let primary = menus::pretty_shortcut(primary); + assert!(shown.contains(&primary), "{p:?} {key}: {shown}"); + assert_eq!(shown.contains(alternate), !p.is_mac(), "{p:?} {key}: {shown}"); + } + } + for (key, primary) in [("edit.cut", "Cmd+X"), ("edit.copy", "Cmd+C"), ("edit.paste", "Cmd+V")] { + let shown = menus::item_shortcut(key, &Value::Null).map(menus::pretty_shortcut); + assert_eq!(shown, Some(menus::pretty_shortcut(primary)), "{key} menu shortcut"); + } + } + + #[test] + fn saved_function_key_assignment_does_not_change_cut_in_the_editor() { + if crate::keymap::platform().is_mac() { + return; + } + let mut saved = Overrides::new(); + crate::keymap::set_override(&mut saved, "tool:pen", "F2"); + let mirror = Mirror::new(saved.clone()); + assert!(!mirror.text.contains_key("edit.cut")); + assert!(conflicts_in(&mirror.keymap).is_empty()); + + let view = DialogView::new(saved.clone()); + assert_eq!(view.source, stored::to_value(&saved)); + assert_eq!(view.conflicts, 0); + assert_eq!(dialog_shortcuts_with(&view.keymap, "edit.cut", None), Some(menus::pretty_shortcut("Cmd+X"))); + + let mut other = saved.clone(); + assign(&mut other, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert!(!crate::keymap::has_override(&other, "edit.cut")); + let (set, imported) = import_json(&export_json("Mine", &other)).unwrap(); + assert_eq!(set, "Mine"); + assert_eq!(imported, other); + assert!(!crate::keymap::has_override(&imported, "edit.cut")); + + let mut km = view.keymap.clone(); + assign_in(&mut km, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert_eq!(saved_overrides(km.overrides(), &saved), other); + } + + #[test] + fn reset_stolen_edit_function_keys_restores_their_defaults() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut saved = Overrides::new(); + assert_eq!(assign(&mut saved, "tool:pen", Some(alias), true).unwrap(), vec![edit]); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + reset_one(&mut saved, "tool:pen"); + assert_eq!(crate::keymap::lookup(&keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut d = Dialog::new("shortcuts", json!({"overrides": {}})); + let source = dialog_overrides(&d); + let mut km = keymap_with(&source); + record(&mut d, &mut km, "tool:pen", alias); + d.fields.insert("overrides".into(), stored::to_value(&saved_overrides(km.overrides(), &source))); + let mut dialog_saved = dialog_overrides(&d); + assert!(!crate::keymap::has_override(&dialog_saved, edit), "{alias} dialog edit override"); + reset_one(&mut dialog_saved, "tool:pen"); + assert_eq!(crate::keymap::lookup(&keymap_with(&dialog_saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut changed = Overrides::new(); + crate::keymap::set_override(&mut changed, edit, "Ctrl+Alt+Shift+F12"); + assign(&mut changed, "tool:pen", Some(alias), true).unwrap(); + assert_eq!(crate::keymap::override_text(&changed, edit).as_deref(), Some("Ctrl+Alt+Shift+F12")); + } + } + + #[test] + fn reassigning_a_saved_function_key_gives_it_back_to_its_edit_command() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut saved = Overrides::new(); + crate::keymap::set_override(&mut saved, "edit.preferences", alias); + assign(&mut saved, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + assert_eq!(crate::keymap::lookup(&keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut source = Overrides::new(); + crate::keymap::set_override(&mut source, "edit.preferences", alias); + let mut d = Dialog::new("shortcuts", json!({"overrides": stored::to_value(&source)})); + let mut km = keymap_with(&source); + record(&mut d, &mut km, "edit.preferences", "Cmd+Alt+Shift+F12"); + let dialog_saved = saved_overrides(km.overrides(), &source); + assert!(!crate::keymap::has_override(&dialog_saved, edit), "{alias} dialog edit override"); + assert_eq!(crate::keymap::lookup(&keymap_with(&dialog_saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + } + } + + #[test] + fn assigning_an_edit_function_key_elsewhere_reports_the_conflict() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let label = entry(edit).map(|e| e.label.clone()).unwrap(); + assert_eq!(conflicts_with(&Overrides::new(), alias, "tool:pen"), vec![edit.to_string()], "{alias}"); + let mut ov = Overrides::new(); + let err = assign(&mut ov, "tool:pen", Some(alias), false).unwrap_err(); + assert!(err.ends_with(&format!("is already used by {label}")), "{alias}: {err}"); + assert!(!crate::keymap::has_override(&ov, "tool:pen"), "{alias}: a refused assignment saves nothing"); + + let mut d = Dialog::new("shortcuts", json!({"overrides": {}})); + let mut km = keymap_with(&Overrides::new()); + record(&mut d, &mut km, "tool:pen", alias); + assert_eq!(d.str("__conflict"), edit, "{alias}"); + assert!(d.str("__message").contains(&label), "{alias}: {}", d.str("__message")); + assert_eq!(crate::keymap::lookup(&km, &crate::keymap::vc_chord(alias).unwrap()), Some(command_of("tool:pen")), "{alias}"); + } + } + #[test] fn entries_cover_tools_and_menu_commands() { assert!(entries().iter().any(|e| e.key == "tool:selection")); diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index b64dd4f7a..3d40a5427 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -28,6 +28,53 @@ fn named_panel_default_chords_toggle_their_panels() { } } +#[test] +fn edit_function_keys_dispatch_outside_text_editing_only() { + if crate::keymap::platform().is_mac() { + return; + } + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let shape = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [shape]})).unwrap(); + crate::shortcut_editor::sync(&app.ui); + frame(&mut app, press(Key::F3, Modifiers::NONE)); + assert_eq!(app.session.clipboard.nodes.len(), 1, "F3 copies the selection"); + frame(&mut app, press(Key::F2, Modifiers::NONE)); + assert!(app.session.active().unwrap().selection.objects.is_empty(), "F2 cuts the selection"); + frame(&mut app, press(Key::F4, Modifiers::NONE)); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "F4 pastes the copied object"); + + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + for key in [Key::F2, Key::F3, Key::F4] { + frame(&mut app, press(key, Modifiers::NONE)); + assert_eq!(text_and_layers(&mut app, id).0, "Hi", "{key:?} leaves edited text alone"); + assert!(app.session.tool_wants_text(), "{key:?} keeps editing"); + } + + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let mut field = String::from("Field text"); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let response = ui.text_edit_singleline(&mut field); + ui.ctx().memory_mut(|m| m.request_focus(response.id)); + }); + out.textures_delta.clear(); + for key in [Key::F2, Key::F3, Key::F4] { + let mut out = ctx.run_ui(egui::RawInput { events: press(key, Modifiers::NONE), ..Default::default() }, |ui| { + handle(&mut app, ui.ctx()); + ui.text_edit_singleline(&mut field); + }); + out.textures_delta.clear(); + assert_eq!(field, "Field text", "{key:?} leaves focused text alone"); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "{key:?} leaves the canvas selection alone"); + } +} + #[test] fn other_panel_shortcuts_keep_plain_panel_params() { for panel in ["character", "swatches"] { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 037b7d4c2..ce03481a2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -760,6 +760,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. + - On Windows and Linux, F2, F3 and F4 run Cut, Copy and Paste alongside Ctrl+X, Ctrl+C and Ctrl+V, and in the web build they do so everywhere except macOS. The Edit menu keeps the Ctrl shortcuts, and Keyboard Shortcuts lists both keys of each row. On macOS only Cmd+X, Cmd+C and Cmd+V are the defaults. F2, F3 and F4 do not run these commands in a focused text field or while the Type tool edits text. A saved shortcut set that assigns one of these keys to another command keeps that assignment. Public reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). + - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). From def24714546cb5769099467ce2f506488d959687 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:32:22 +0700 Subject: [PATCH 24/51] Layers: the rename test allows F2's Cut alternate --- crates/ui-egui/src/panels/layers/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 0bd0efdde..cad2530e5 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -494,14 +494,14 @@ fn rename_opens_the_name_field_of_the_selected_object() { assert_eq!(name(&app), before, "one undo step"); } -/// Rename (`ui.rename`) has no default key, F2 being free: it is listed in Keyboard Shortcuts, +/// Rename (`ui.rename`) has no default key (F2 is Cut's alternate on Windows and Linux): it is listed in Keyboard Shortcuts, /// where a key can be assigned to it. #[test] fn rename_has_no_default_key() { use crate::shortcut_editor::{command_for_key, default_command_shortcut}; crate::shortcut_editor::sync(&crate::state::UiState::default()); assert_eq!(default_command_shortcut("ui.rename"), None); - assert_eq!(command_for_key("F2"), None); + assert_ne!(command_for_key("F2"), Some("ui.rename")); let (mut app, _, _) = two_rects(); let listed = app.run("shortcuts.list", json!({"query": "ui.rename"})).unwrap(); let rows: Vec<_> = listed.as_array().expect("rows").iter().map(|r| r["label"].clone()).collect(); From 023ee9065724389107d8bdd1cd4a3884b1a7f7f5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 17:22:32 -0800 Subject: [PATCH 25/51] Perspective grid: Snap to Grid moves objects at every depth by one plane offset With Snap to Grid on, a move of objects on one perspective plane at different depths applied the snap correction only to the objects at the first object's depth, and every other object moved by the pointer's offset read at its own depth. Those objects left the grid and lost their spacing. Every object on the first object's plane now moves by that object's plane offset plus the correction, so the edge of the joint bounds nearer a gridline lands on it and the objects keep their spacing. A selection at one depth snaps as before, and objects on other planes keep their own offset. --- crates/engine/src/cmd/distortcmds.rs | 2 +- .../engine/src/cmd/distortcmds/perspective.rs | 25 +++++++++----- crates/engine/src/cmd/perspgrid.rs | 2 +- crates/engine/src/tests/persp_select.rs | 34 +++++++++++++++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 9e0841de6..d54238925 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Move in Perspective", [], None, - "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it", + "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction, and moves every object on that plane, at any depth, by the same plane offset; Transform Again repeats it", has_doc, persp_move ), diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 786d0d956..a561e5099 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -285,7 +285,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let constrain = switch_or(p, "constrain", false, C)?; // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. check_switches(p, &["snap"], C)?; - // Snap to Grid: a correction for the objects on the first one's plane and depth. + // Shift: the constrained end point, read on the first object's plane and depth. let snap_to = if constrain && !perp { let doc = &s.doc()?.doc; let g = grid_of(doc); @@ -300,11 +300,20 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { } else { to }; - let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) }; - let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| { + // With Snap to Grid, every object on the first one's plane, at any depth, moves by the first + // one's plane-space offset and its correction, so the objects keep their spacing on the plane. + let fix = if perp { + None + } else { + let doc = &s.doc()?.doc; + crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to)) + .and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction))) + }; + let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| { + let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1); let (direction, dv) = if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) }; - dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| { + dv + fix.filter(|f| f.0 == pl).map_or(vectorcraft_geom::Vec2::ZERO, |f| { if !constrain { return f.2; } @@ -325,7 +334,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) } else { - let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); + let dv = snapped(pl, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) } }; @@ -336,7 +345,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { if perp { g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9) } else { - g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) + g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) } }; let first_still = if copy { None } else { unmoved(s, &ids, still)? }; @@ -350,9 +359,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let again = match perp { _ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})), true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), - false => { - g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy})) - } + false => g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, dv))), "copy": copy})), }; if let Some(a) = again { record_again(s, a)?; diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index 822d88aad..d13e7d8c5 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -428,7 +428,7 @@ fn rulers(s: &mut Session, p: &Value) -> Result { /// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`): /// the plane and depth of the first one and the plane-space correction that lands the edge of the -/// joint bounds of the objects there nearer a gridline on it. +/// joint bounds of the objects on that plane, each measured at its own depth, nearer a gridline on it. pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> { let g = grid_of(doc); if !bool_or(p, "snap", g.snap) { diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index 4ca4b46fa..6a6022fdb 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -228,6 +228,40 @@ fn diagonal_shift_snap_lands_an_edge_on_a_gridline() { assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}"); } +#[test] +fn snap_moves_objects_at_different_depths_by_one_plane_offset() { + let (mut s, _) = persp_session(); + let rect = |s: &mut Session, x: f64| { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()) + }; + let near = rect(&mut s, 240.0); + s.execute("perspective.attach", &json!({"ids": [near.0], "plane": "left"})).unwrap(); + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap(); + let far = rect(&mut s, 150.0); + s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap(); + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap(); + assert_eq!(attached(&s, near), Some((Plane::Left, 0.0))); + assert_eq!(attached(&s, far), Some((Plane::Left, 50.0))); + s.execute("perspective.grid.snap", &json!({"on": true})).unwrap(); + let g = grid(&s); + let (near_before, far_before) = (plane_pts(&s, near, Plane::Left, 0.0), plane_pts(&s, far, Plane::Left, 50.0)); + let b = bounds_of(&near_before).union(bounds_of(&far_before)); + // A move that ends 0.3 pt past a gridline crossing on the near object's plane. + let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v; + let start = bounds_of(&near_before).center(); + let h = g.homography_at(Plane::Left, 0.0).unwrap(); + let from = h.apply(start).unwrap(); + let to = h.apply(start + Vec2::new(past(b.x0), past(b.y0))).unwrap(); + s.execute("perspective.move", &json!({"ids": [near.0, far.0], "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap(); + let near_delta = plane_pts(&s, near, Plane::Left, 0.0)[0] - near_before[0]; + let far_delta = plane_pts(&s, far, Plane::Left, 50.0)[0] - far_before[0]; + assert!((near_delta - far_delta).hypot() < 1e-6, "near {near_delta:?}, far {far_delta:?}"); + let on_line = |v: f64| ((v / g.cell).round() * g.cell - v).abs() < 1e-6; + let moved = b + near_delta; + assert!(on_line(moved.x0) || on_line(moved.x1), "{moved:?}"); + assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}"); +} + #[test] fn constrained_move_uses_each_objects_plane() { let (mut s, right) = persp_session(); diff --git a/docs/mcp.md b/docs/mcp.md index 2074f47f0..6b9de2d5e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6528,7 +6528,8 @@ bounding-box handles scale it in plane space (`perspective.transform`; Shift kee centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch back). Holding Shift during an in-plane move constrains each object's displacement to an axis or 45° diagonal of its own plane. `perspective.move` accepts `constrain: true` (default false) and adjusts Snap to Grid along the constrained -direction on the first object's plane and depth. +direction on the first object's plane and depth. With Snap to Grid, every object on the first object's plane moves +by the same plane offset, whatever its depth, and the edge of their joint bounds nearer a gridline lands on it. `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ce03481a2..3955c28c4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1413,6 +1413,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. + With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing, and the edge of their joint bounds nearer a gridline lands on it. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. From 2049082ce71fbba15b9fca39a74a9e83ab7b5697 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:48:10 +0700 Subject: [PATCH 26/51] Drawing tools: Escape on an arc, spiral or grid drag puts back the options it changed Squashed from #5202 onto main. --- crates/engine/src/tests/toolsettings.rs | 19 +++ crates/engine/src/tooling.rs | 5 + crates/tools/src/draw2/family.rs | 187 +++++++++++++++++++++++- docs/target-app-parity.md | 1 + 4 files changed, 210 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index 3e522a552..6b4ad8706 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -91,6 +91,25 @@ fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() { assert_eq!(s.tool_options()["segments"], json!(14)); } +/// Escape on a grid drag puts back the dividers its arrows changed, in the saved options too, +/// while an option set during the drag stays. +#[test] +fn escape_on_a_drag_saves_the_options_it_put_back() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + select(&mut s, "rectangularGrid"); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap(); + s.tool_key(ToolKey::Up, Mods::default(), v).unwrap(); + // Saves every option, the rows the arrow changed included. + s.set_tool_option("columns", &json!(8)); + assert_eq!(s.prefs.tool_settings["rectangularGrid"]["rows"], json!(6)); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + let saved = &s.prefs.tool_settings["rectangularGrid"]; + assert_eq!((saved["rows"].clone(), saved["columns"].clone()), (json!(5), json!(8))); +} + /// A dropped press (a long touch, a canceled touch, a second finger) keeps nothing it began: the /// tool ends at rest with the options it had before the press, and the options the drag changed /// are not stored. A release stores them. diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 23edf6b56..12d595263 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -578,6 +578,11 @@ impl Session { } let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); self.take_tool_panic()?; + // A key that cancels a gesture may put back options the gesture changed (Escape on an + // arc, spiral or grid drag): keep them. + if acts.contains(&Action::Cancel) { + self.keep_tool_settings(); + } self.apply_actions(acts) } diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 0bbdcf79b..ed1ab47b0 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -12,6 +12,7 @@ //! On a grid, F and V lower and raise //! the skew of the horizontal (rectangular) or radial (polar) dividers, and X and C lower and raise //! that of the vertical or concentric ones. +//! Escape cancels the drag and puts back the options its keys and modifiers changed. use serde_json::{Value, json}; use vectorcraft_geom::Point; @@ -63,6 +64,22 @@ pub struct FamilyTool { /// its own). The Reference Point is `reference`. pub grid_size: (f64, f64), snap: DrawSnap, + /// The options a drag's keys and modifiers change, as they were when the drag began: Escape + /// puts them back. + before_drag: Option, +} + +/// The options that keys and modifiers change while a shape is dragged out. +#[derive(Clone, Copy)] +struct DragOptions { + slope: f64, + closed: bool, + base_along: &'static str, + decay: f64, + segments: u32, + rows: u32, + columns: u32, + skew: [f64; 2], } impl FamilyTool { @@ -99,9 +116,35 @@ impl FamilyTool { fill: false, grid_size: (100.0, 100.0), snap: DrawSnap::default(), + before_drag: None, } } + fn drag_options(&self) -> DragOptions { + DragOptions { + slope: self.slope, + closed: self.closed, + base_along: self.base_along, + decay: self.decay, + segments: self.segments, + rows: self.rows, + columns: self.columns, + skew: self.skew, + } + } + + /// Put back the options a canceled drag changed. + fn restore_drag_options(&mut self, o: DragOptions) { + self.slope = o.slope; + self.closed = o.closed; + self.base_along = o.base_along; + self.decay = o.decay; + self.segments = o.segments; + self.rows = o.rows; + self.columns = o.columns; + self.skew = o.skew; + } + fn label(&self) -> &'static str { match self.id { "spiral" => "Spiral", @@ -264,6 +307,7 @@ impl Tool for FamilyTool { self.began = false; self.spiral_end = None; self.spiral_alt_base = None; + self.before_drag = None; vec![] } PointerKind::Drag => { @@ -279,6 +323,7 @@ impl Tool for FamilyTool { return out; } self.began = true; + self.before_drag = Some(self.drag_options()); out.push(Action::Begin(self.label().into())); } if self.id == "spiral" { @@ -293,6 +338,7 @@ impl Tool for FamilyTool { self.snap.clear(); self.spiral_end = None; self.spiral_alt_base = None; + self.before_drag = None; if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] } } _ => vec![], @@ -364,6 +410,9 @@ impl Tool for FamilyTool { self.began = false; self.spiral_end = None; self.spiral_alt_base = None; + if let Some(o) = self.before_drag.take() { + self.restore_drag_options(o); + } self.snap.clear(); vec![Action::Cancel] } else { @@ -442,6 +491,23 @@ impl Tool for FamilyTool { "height" => self.grid_size.1 = grid_length(v, self.grid_size.1), _ => {} } + // An option set while a shape is dragged out stays when Escape cancels the drag; a value + // the match above ignores leaves the drag's starting value as it was. + if let Some(b) = self.before_drag.as_mut() { + let finite = v.as_f64().is_some_and(f64::is_finite); + match key { + "slope" if finite => b.slope = self.slope, + "closed" if v.is_boolean() => b.closed = self.closed, + "baseAlong" if v.is_string() => b.base_along = self.base_along, + "decay" if v.as_f64().is_some() => b.decay = self.decay, + "segments" => b.segments = self.segments, + "rows" | "concentric" => b.rows = self.rows, + "columns" | "radial" => b.columns = self.columns, + "rowSkew" | "concentricSkew" if finite => b.skew[0] = self.skew[0], + "columnSkew" | "radialSkew" if finite => b.skew[1] = self.skew[1], + _ => {} + } + } } } @@ -672,7 +738,8 @@ mod tests { } /// Switching between Alt and Ctrl/Cmd keeps the held inner end and uses the current segment - /// count when Alt resumes. Escape clears the held end before the next drag. + /// count when Alt resumes. Escape clears the held end and puts back the segments and decay + /// from before the drag. #[test] fn spiral_drag_switches_between_alt_and_ctrl_and_escape_lets_go_of_the_inner_end() { let (d, _) = doc_with_rect(); @@ -699,9 +766,125 @@ mod tests { t.key(&cx, ToolKey::Escape, alt); assert_eq!(t.spiral_end, None); assert_eq!(t.spiral_alt_base, None); + assert_eq!((t.segments, t.decay), (10, 80.0), "the options from before the drag"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "a new drag holds a new inner end"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); + assert_eq!(t.segments, 14, "a new drag holds a new inner end"); + } + + /// Escape during a drag puts back every option the drag's keys changed, for the next drag and + /// the tool's options; a drag that is let go keeps them. + #[test] + fn escape_puts_back_the_options_a_drag_changed() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let keys = |id: &str| -> Vec { + match id { + "arc" => vec![ToolKey::Up, ToolKey::Letter('c'), ToolKey::Letter('f')], + "spiral" => vec![ToolKey::Up], + _ => vec![ToolKey::Up, ToolKey::Right, ToolKey::Letter('v'), ToolKey::Letter('c')], + } + }; + let arc_and_spiral = ["slope", "closed", "baseAlong", "segments", "decay"]; + let grids = ["rows", "columns", "concentric", "radial", "rowSkew", "columnSkew", "concentricSkew", "radialSkew"]; + let pick = |v: &Value| arc_and_spiral.iter().chain(&grids).filter_map(|f| v.get(*f).map(|x| (*f, x.clone()))).collect::>(); + for id in ["arc", "spiral", "rectangularGrid", "polarGrid"] { + let mut t = FamilyTool::new(id); + let preview = |a: Vec| match a.last() { + Some(Action::Preview(_, v)) => v.clone(), + _ => panic!("{id}: {a:?}"), + }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let before = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + let options = t.options(); + let mut changed = before.clone(); + for k in keys(id) { + changed = pick(&preview(t.key(&cx, k, Mods::default()))); + } + assert_ne!(changed, before, "{id}: the keys change the options"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{id}"); + assert_eq!(t.options(), options, "{id}: the tool's options"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let next = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + assert_eq!(next, before, "{id}: the next drag"); + // A drag that is let go keeps what its keys changed. + for k in keys(id) { + t.key(&cx, k, Mods::default()); + } + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let kept = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + assert_eq!(kept, changed, "{id}: a finished drag keeps them"); + t.key(&cx, ToolKey::Escape, Mods::default()); + assert_eq!(pick(&t.options()), changed, "{id}: Escape puts back only what this drag changed"); + } + } + + /// Alt and Ctrl/Cmd change a spiral's segments and decay as it is dragged out; Escape puts + /// both back. + #[test] + fn escape_puts_back_the_spiral_decay_and_segments_alt_and_ctrl_changed() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Mods::default() }; + let ctrl = Mods { cmd: true, ..Mods::default() }; + let mut t = FamilyTool::new("spiral"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(ctrl)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); + assert!(t.segments != 10 && t.decay != 80.0, "{} {}", t.segments, t.decay); + t.key(&cx, ToolKey::Escape, ctrl); + assert_eq!((t.segments, t.decay), (10, 80.0)); + } + + /// An option set while a shape is dragged out (`tool.setOption`) stays when Escape cancels + /// the drag; the options only the drag's keys changed go back. + #[test] + fn escape_keeps_an_option_set_during_the_drag() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = FamilyTool::new("rectangularGrid"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + t.key(&cx, ToolKey::Up, Mods::default()); + t.key(&cx, ToolKey::Right, Mods::default()); + t.set_option("columns", &json!(9)); + t.set_option("rowSkew", &json!(30.0)); + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["rows"].clone(), o["columns"].clone(), o["rowSkew"].clone()), (json!(5), json!(9), json!(30.0))); + let mut t = FamilyTool::new("arc"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + t.key(&cx, ToolKey::Letter('c'), Mods::default()); + t.set_option("slope", &json!(-20.0)); + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["closed"].clone(), o["slope"].clone()), (json!(false), json!(-20.0))); + // A value the option ignores leaves the starting value for Escape. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + t.key(&cx, ToolKey::Up, Mods::default()); + t.key(&cx, ToolKey::Letter('c'), Mods::default()); + t.key(&cx, ToolKey::Letter('f'), Mods::default()); + for (k, v) in [("slope", json!("steep")), ("closed", json!(1)), ("baseAlong", json!(null))] { + t.set_option(k, &v); + } + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["slope"].clone(), o["closed"].clone(), o["baseAlong"].clone()), (json!(-20.0), json!(false), json!("x"))); } /// Alt adjusts the segment count in whole winds while keeping it within 2 to 1000. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3955c28c4..58cac25d7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -839,6 +839,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur While a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules. While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules. While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. + Escape while an arc, spiral or grid is dragged out cancels it and puts back the options its keys, Alt and Ctrl/Cmd changed (slope, open or closed, base axis, segments, decay, dividers and skews), so the next drag and the tool's options start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again. Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. From 8c7e7041fd7142fb25be9f2c70169953644743db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 18:31:22 -0800 Subject: [PATCH 27/51] Title bar: the app bar shows no brand mark under the desktop's own frame On Linux, when the desktop draws the window's frame (KDE, Sway, Hyprland, COSMIC and X11 sessions), the app bar painted the brand mark under the desktop's title bar unless System Title Bar was on. The mark followed the live preference instead of the frame the window has. The desktop app now records the frame it opened the window with (VectorcraftApp::desktop_frame), and system_title_bar reads it. A change to System Title Bar during a session no longer shows or hides the mark; it changes with the frame at the next start. Embedders without a window of their own keep the mark. --- apps/vectorcraft/src/main.rs | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/chrome.rs | 14 +++++++----- crates/ui-egui/src/chrome/tests.rs | 35 ++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 ++++ docs/control-protocol.md | 3 ++- 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 8019276ec..112c71370 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -467,6 +467,7 @@ fn main() -> std::process::ExitCode { rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } app.custom_titlebar = custom_titlebar; + app.desktop_frame = !custom_titlebar; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index a74582972..61057acb1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -123,6 +123,7 @@ impl VectorcraftApp { canvas_rect: None, hover_doc: None, custom_titlebar: false, + desktop_frame: false, last_window_title: String::new(), fullscreen_for_mode: false, graphics_adapter: None, diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ae8fbccc0..135958756 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -129,13 +129,15 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// Whether the window shows the system's title bar instead of the app drawing its own: Windows -/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system -/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor -/// does the web build (the browser tab has no document title), nor an embedder that never -/// turned the preference on. +/// Whether the window shows the system's title bar instead of the app drawing its own +/// ([`VectorcraftApp::desktop_frame`]): Windows with Preferences › User Interface › System Title +/// Bar, and Linux whenever the desktop draws the frame, with or without the preference. The +/// preference is read when the app starts, so changing it later does not change this. macOS +/// always has system decorations, but keeps the in-app brand mark, so it never counts as system +/// mode here; nor does the web build (the browser tab has no document title), nor an embedder +/// without a window of its own. pub fn system_title_bar(app: &VectorcraftApp) -> bool { - !app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32")) + app.desktop_frame && !app.custom_titlebar && !cfg!(any(target_os = "macos", target_arch = "wasm32")) } /// The OS window title: the active document's name suffixed with the app name (a `*` prefix diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 00d790964..55650c46e 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -1230,16 +1230,51 @@ fn the_zoom_hint_follows_animated_zoom() { fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp { let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); app.custom_titlebar = custom_titlebar; + app.desktop_frame = !custom_titlebar; // The preference that makes the shell launch without its own title bar. app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap(); app } +/// How many brand marks one frame of the app bar paints. +fn brand_marks(app: &mut crate::VectorcraftApp) -> usize { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx))).count() +} + #[test] fn without_the_preference_the_mark_stays() { // The default (e.g. the web build): no custom bar, no preference. let app = crate::VectorcraftApp::new(Session::new(), Default::default()); assert!(!super::system_title_bar(&app)); + // An embedder without a window of its own keeps the mark with the preference on too. + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap(); + assert!(!super::system_title_bar(&app)); + assert_eq!(brand_marks(&mut app), 1); +} + +#[test] +fn a_desktop_frame_hides_the_mark_whatever_the_preference_says() { + // Linux on a desktop that draws the frame (KDE, X11): the window has the desktop's title bar + // with System Title Bar off. + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.desktop_frame = true; + app.run("prefs.set", json!({"key": "systemTitleBar", "value": false})).unwrap(); + let hidden = !cfg!(target_os = "macos"); + assert_eq!(super::system_title_bar(&app), hidden); + assert_eq!(brand_marks(&mut app), if hidden { 0 } else { 1 }); + // The preference applies at the next start: changing it now leaves the window's frame, and the mark, as they are. + let mut app = title_test_app(false); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": false})).unwrap(); + assert_eq!(super::system_title_bar(&app), hidden); + let mut app = title_test_app(true); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap(); + assert!(!super::system_title_bar(&app)); + assert_eq!(brand_marks(&mut app), 1); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb8912455..9b054a683 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -198,6 +198,10 @@ pub struct VectorcraftApp { /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, + /// The window has the desktop's own title bar: the OS or the window manager draws its frame, + /// icon and title. Set by the desktop app when it opens the window; `false` for embedders + /// without a window of their own. + pub desktop_frame: bool, /// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`): /// sent again only when it changes, so idle frames don't spam the backend. pub(crate) last_window_title: String, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9357927f0..5f70424ce 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -328,7 +328,8 @@ not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (th modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar -drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged. +drops its brand mark. On Linux the app bar also drops its brand mark whenever the desktop draws the window's frame, with +System Title Bar on or off. On macOS the window buttons are centred on the app bar; the web build is unchanged. Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); From 368f1c1f83c56adb1e48e74593f1d59e835094fb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:48:29 +0700 Subject: [PATCH 28/51] Document Info: Pattern Objects lists the objects painted with patterns Squashed from #5220 onto main. --- crates/engine/src/cmd/docinfo.rs | 242 ++++++++++++++++++++++++++++--- docs/mcp.md | 9 +- docs/target-app-parity.md | 2 + 3 files changed, 233 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 260b981d2..972fd8d1a 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", has_doc, info ), @@ -130,13 +130,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects and the rows of its Repeat objects. +/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the +/// rows of its objects filled or stroked with a pattern. fn sections( d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], repeats: &[(String, String)], + pattern_objects: &[(String, String)], category: Option<&str>, ) -> Vec
{ let names = |k: &str| -> Vec<(String, String)> { @@ -180,7 +182,7 @@ fn sections( "graphicStyles" => names("graphicStyleNames"), "brushes" => names("brushNames"), "spotColors" => names("spotColors"), - "patterns" => names("patternNames"), + "patterns" => pattern_objects.to_vec(), "gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(), "symbols" => names("symbols"), "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), @@ -252,12 +254,67 @@ pub fn report(s: &mut Session, selection_only: bool) -> Result { Ok(v["text"].as_str().unwrap_or_default().to_string()) } -fn paint_kind(p: &Paint) -> Option<&'static str> { - match p { - Paint::Gradient(_) => Some("gradients"), - Paint::Pattern { .. } => Some("patterns"), - _ => None, +/// The type object `n` holds and the runs its layout draws, when it uses a pattern: the runs with a +/// glyph outline (list markers and ruby included) or an underline or strikethrough bar. Spaces and +/// line breaks draw no outline. A type object's characters carry their own fill and stroke, and the +/// type object's own fills and strokes paint on its characters, so only those runs paint. The type +/// is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the renderer lays it out. +fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { + let NodeKind::Text(t) = &n.kind else { return None }; + let is_pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let own = n.appearance.items.iter().any(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => is_pattern(&f.paint), + vectorcraft_doc::AppearanceItem::Stroke(s) => is_pattern(&s.paint), + }); + if !own && !t.runs.iter().any(|r| is_pattern(&r.style.fill) || is_pattern(&r.style.stroke)) { + return None; } + let db = vectorcraft_text::FontDb::global(); + let resolved = d.inline_resolved(t); + let lay = vectorcraft_text::layout(db, &resolved); + let mut painted: BTreeSet = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect(); + painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run)); + Some((t, painted)) +} + +/// The patterns `n`'s own fills and strokes paint, each once. A hidden fill or stroke and a stroke +/// with no width paint nothing, and neither does any fill or stroke of a guide (drawn in the guide +/// color), of a compound path's member (`members`: the compound path's own fills and strokes paint +/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint +/// only where an effect gives them a shape. +fn own_patterns<'a>( + n: &'a Node, + members: &HashSet<*const Node>, + painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet)>, +) -> Vec<&'a str> { + if matches!(n.kind, NodeKind::Path { guide: true, .. }) + || members.contains(&std::ptr::from_ref(n)) + || painted_runs.is_some_and(|(_, r)| r.is_empty()) + { + return vec![]; + } + // The patterns an image's shaped fills and strokes paint (their effects can transform the + // pattern, so they are matched by name). + let shaped: Option> = matches!(n.kind, NodeKind::Image(_)).then(|| { + vectorcraft_render::effects::image_paint(n) + .into_iter() + .filter_map(|(_, _, item)| match item.paint() { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + }) + .collect() + }); + let mut out: Vec<&str> = vec![]; + for item in &n.appearance.items { + let Paint::Pattern { pattern, .. } = item.paint() else { continue }; + let paints = item.visible() + && !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) + && shaped.as_ref().is_none_or(|s| s.contains(pattern)); + if paints && !out.contains(&pattern.as_str()) { + out.push(pattern); + } + } + out } fn info(s: &mut Session, p: &Value) -> Result { @@ -293,9 +350,19 @@ fn info(s: &mut Session, p: &Value) -> Result { // `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order. let mut repeats: Vec = vec![]; let mut repeat_rows: Vec<(String, String)> = vec![]; + // `pattern_objects` and `pattern_rows` hold the `patternObjects` entries and the rows of the objects filled or stroked with a pattern, in walk order. + let mut pattern_objects: Vec = vec![]; + let mut pattern_rows: Vec<(String, String)> = vec![]; // The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once. // The set holds each object's address, so two objects that share an id (as a damaged file can hold) count as two. let mut seen: HashSet<*const Node> = HashSet::new(); + // The members of the document's compound paths: the compound path's own fills and strokes paint them. + let mut members: HashSet<*const Node> = HashSet::new(); + d.walk(|n| { + if let NodeKind::Compound { children, .. } = &n.kind { + members.extend(children.iter().map(|c| std::ptr::from_ref(&**c))); + } + }); for r in roots { r.walk(&mut |n| { if !seen.insert(std::ptr::from_ref(n)) { @@ -345,16 +412,18 @@ fn info(s: &mut Session, p: &Value) -> Result { } // The paint kinds this object's fills and strokes use, each once. let mut kinds = BTreeSet::new(); + // The patterns this object's fills and strokes use, each once. + let mut used_patterns: Vec<&str> = vec![]; + let painted_runs = painted_runs(d, n); + for pattern in own_patterns(n, &members, painted_runs.as_ref()) { + patterns.insert(pattern.to_string()); + used_patterns.push(pattern); + } for item in &n.appearance.items { - let paint = match item { - vectorcraft_doc::AppearanceItem::Fill(f) => &f.paint, - vectorcraft_doc::AppearanceItem::Stroke(s) => &s.paint, - }; - if let Some(k) = paint_kind(paint) { - kinds.insert(k); - } - if let Paint::Pattern { pattern, .. } = paint { - patterns.insert(pattern.clone()); + let paint = item.paint(); + // Objects counts an object with a pattern when Pattern Objects lists it, below. + if let Paint::Gradient(_) = paint { + kinds.insert("gradients"); } if let Paint::Solid { swatch: Some(name), .. } = paint && d.swatch(name).is_some_and(|s| s.spot) @@ -362,6 +431,28 @@ fn info(s: &mut Session, p: &Value) -> Result { spot.insert(name.clone()); } } + // The characters' own paints: an inline graphic draws no outline and does not take its + // run's paint (a list marker set in its run does), and a stroke with no width paints + // nothing. + if let Some((t, painted)) = &painted_runs { + for run in painted.iter().filter_map(|i| t.runs.get(*i)) { + let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); + for paint in std::iter::once(&run.style.fill).chain(stroke) { + if let Paint::Pattern { pattern, .. } = paint { + patterns.insert(pattern.clone()); + if !used_patterns.contains(&pattern.as_str()) { + used_patterns.push(pattern); + } + } + } + } + } + if !used_patterns.is_empty() { + let name = n.display_name(); + pattern_objects.push(json!({ "id": n.id.0, "name": name, "patterns": used_patterns })); + pattern_rows.push((name, used_patterns.join(", "))); + kinds.insert("patterns"); + } for k in kinds { *counts.entry(k).or_default() += 1; } @@ -412,10 +503,11 @@ fn info(s: &mut Session, p: &Value) -> Result { "paragraphStyles": d.para_styles.len(), "patterns": d.patterns.len(), "patternNames": patterns, + "patternObjects": pattern_objects, }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -634,6 +726,120 @@ mod tests { assert_eq!(i["sections"][0]["rows"][0], json!(["", "Grid Repeat, 500 rows × 500 columns, 125000 instances"])); } + /// Document Info › Pattern Objects lists each object filled or stroked with a pattern once, + /// with the patterns it uses, one in a group and a type object whose characters use a pattern + /// included; a pattern no art uses adds no row, and Objects counts the listed objects. With + /// Selection Only it lists the patterned objects in the selection, and the report writes the + /// same rows. + #[test] + fn info_lists_the_pattern_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let tile = square(&mut s, 0.0); + s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap(); + let spare = square(&mut s, 20.0); + s.execute("object.pattern.make", &json!({"ids": [spare], "name": "Unused", "edit": false})).unwrap(); + let (a, b, c) = (square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0)); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + // A type object whose characters are filled with the pattern. + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + // A hidden pattern fill and a pattern stroke with no width paint nothing, so neither object is listed. + let (d, e) = (square(&mut s, 100.0), square(&mut s, 200.0)); + s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Dots"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Dots"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // A type object with no characters paints nothing either. + let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap(); + // Its own fill (a copy of its characters' pattern fill) paints on its characters, so it paints nothing. + s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap(); + // Nor does type of only spaces and a line break, or a guide made from a patterned path. + let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Dots"})).unwrap(); + let guide = square(&mut s, 300.0); + s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [guide]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + // Nor does a compound path's member's own pattern fill: the compound path's own fill paints it. + let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap(); + let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone(); + let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance); + // Nor does an image's pattern fill without an effect that gives it a shape. + let pic = square(&mut s, 360.0); + s.execute("select.set", &json!({"ids": [pic]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let image = s.doc().unwrap().selection.objects[0]; + s.edit("own paint", |d, _| { + d.node_mut(member).unwrap().appearance = paint.clone(); + d.node_mut(image).unwrap().appearance = paint; + Ok(()) + }) + .unwrap(); + // Underlined spaces draw a bar in the pattern, so that type object is listed. + let bar = s.execute("text.create", &json!({"x": 0, "y": 340, "text": " "})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [bar], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [bar]})).unwrap(); + s.execute("text.setFormat", &json!({"underline": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": bar, "name": "Bar"})).unwrap(); + // A list marker set in an inline graphic's run draws in that run's pattern, so that type object is listed. + s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 10, "height": 10})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let listed = s.execute("text.create", &json!({"x": 0, "y": 360, "text": ""})).unwrap()["id"].clone(); + s.execute("text.insertInline", &json!({"id": listed, "at": 0, "symbol": "Mark"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [listed], "list": "bullet"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [listed], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": listed, "name": "Listed"})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!( + i["patternObjects"], + json!([ + {"id": a, "name": "Both", "patterns": ["Dots"]}, + {"id": b, "name": "", "patterns": ["Dots"]}, + {"id": t, "name": "Type", "patterns": ["Dots"]}, + {"id": bar, "name": "Bar", "patterns": ["Dots"]}, + {"id": listed, "name": "Listed", "patterns": ["Dots"]}, + ]) + ); + assert_eq!(i["patternNames"], json!(["Dots"])); + // Objects counts the objects that Pattern Objects lists. + assert_eq!(i["objects"]["patterns"].as_u64(), Some(5), "{}", i["objects"]); + s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]); + let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap(); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("patterns"), Some("Pattern Objects"))); + assert_eq!( + i["sections"][0]["rows"], + json!([["Both", "Dots"], ["", "Dots"], ["Type", "Dots"], ["Bar", "Dots"], ["Listed", "Dots"]]) + ); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap(); + assert_eq!(i["sections"][0]["rows"], json!([["", "Dots"]])); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap(); + assert_eq!((i["sections"][0]["rows"].clone(), i["patternNames"].clone()), (json!([["Type", "Dots"]]), json!(["Dots"]))); + let text = s.execute("document.info", &json!({"category": "patterns", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nPATTERN OBJECTS\nBoth: Dots\n: Dots\nType: Dots\nBar: Dots\nListed: Dots\n"), "{text}"); + assert!(!text.contains("Unused"), "{text}"); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/docs/mcp.md b/docs/mcp.md index 6b9de2d5e..c83b2a2ff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4715,8 +4715,13 @@ document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symb embeddedImages, repeats), `brushNames` (the Brushes panel's brushes; with `selectionOnly`, the brushes the selected objects' strokes use), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in groups and in other repeats included, with its type `radial`, `grid` or `mirror` and the number of instances it makes, -and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection) -and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left +and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection), `patternObjects` +(`[{id, name, patterns}]`: every object filled or stroked with a pattern, once each, those in groups and type objects +whose drawn characters use a pattern included, hidden fills and strokes, strokes with no width, guides, a compound path +member's own fills and strokes, an image's fills and strokes that no Convert to Shape or Outline Object effect gives a +shape, and characters that draw nothing (spaces and line breaks without an underline or strikethrough) left out, with +the patterns it uses, which the patterns category lists as Pattern Objects and `objects.patterns` counts; with `selectionOnly`, those in the selection), +`patternNames` (the patterns those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left out, which objects lists as Path Length in the document's units, say for a cut line or an LED strip along the letters); `format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?, selectionOnly?, category?}`: only `category` when given, else every category) and the package report write. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 58cac25d7..5a65ad956 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1177,6 +1177,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for panel deletion outside the Layers panel (archived copy). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`. Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). + Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). + Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes. With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. From 6d8751405589554eb908e1a5b85e8b5647cd07cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 19:59:21 -0800 Subject: [PATCH 29/51] Selection: show marquee rules and offer enclosed and directional modes --- ROADMAP.md | 2 + crates/engine/src/cmd/prefscmds.rs | 7 +++ crates/engine/src/prefs.rs | 3 ++ crates/engine/src/tests/prefs.rs | 26 +++++++++++ crates/engine/src/tooling.rs | 5 ++ crates/tools/src/context.rs | 12 +++++ crates/tools/src/lib.rs | 5 +- crates/tools/src/select.rs | 48 +++++++++++++++---- crates/tools/src/select/tests.rs | 54 +++++++++++++++++++++- crates/ui-egui/src/canvas/overlays.rs | 23 +++++++++ crates/ui-egui/src/canvas/tests.rs | 59 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 5 ++ crates/ui-egui/src/i18n/bs.tsv | 5 ++ crates/ui-egui/src/i18n/ca.tsv | 5 ++ crates/ui-egui/src/i18n/cs.tsv | 5 ++ crates/ui-egui/src/i18n/da.tsv | 5 ++ crates/ui-egui/src/i18n/de.tsv | 5 ++ crates/ui-egui/src/i18n/el.tsv | 5 ++ crates/ui-egui/src/i18n/es.tsv | 5 ++ crates/ui-egui/src/i18n/fi.tsv | 5 ++ crates/ui-egui/src/i18n/fr.tsv | 5 ++ crates/ui-egui/src/i18n/hu.tsv | 5 ++ crates/ui-egui/src/i18n/id.tsv | 5 ++ crates/ui-egui/src/i18n/it.tsv | 5 ++ crates/ui-egui/src/i18n/ja.tsv | 5 ++ crates/ui-egui/src/i18n/ka.tsv | 5 ++ crates/ui-egui/src/i18n/ko.tsv | 5 ++ crates/ui-egui/src/i18n/nl.tsv | 5 ++ crates/ui-egui/src/i18n/pl.tsv | 5 ++ crates/ui-egui/src/i18n/pt-br.tsv | 5 ++ crates/ui-egui/src/i18n/ru.tsv | 5 ++ crates/ui-egui/src/i18n/sv.tsv | 5 ++ crates/ui-egui/src/i18n/tests.rs | 10 ++++ crates/ui-egui/src/i18n/tr.tsv | 5 ++ crates/ui-egui/src/i18n/uk.tsv | 5 ++ crates/ui-egui/src/i18n/vi.tsv | 5 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 ++ crates/ui-egui/src/prefs_dialog/tests.rs | 18 ++++++++ docs/gaps.md | 3 ++ docs/mcp.md | 2 + docs/ui-parity.md | 3 ++ 42 files changed, 399 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0f9267e0e..8e9336d56 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # VectorCraft Roadmap +> **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. + - Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. - Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index de1b65755..0267725ef 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -156,6 +156,13 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), // Selection & Anchor Display p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")), + p!( + "marqueeSelection", + "Selection & Anchor Display", + "Selection", + "Marquee Selection", + choice(&[("touching", "Touching"), ("enclosed", "Fully Enclosed"), ("directional", "Drag Direction")]) + ), p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool), p!("snapToPointTolerance", "Selection & Anchor Display", "Selection", "Snap to Point", num(1.0, 8.0, "px")), p!("ctrlClickSelectsBehind", "Selection & Anchor Display", "Selection", "Command Click to Select Objects Behind", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1e5147582..fc2530c03 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -67,6 +67,8 @@ pub struct Prefs { pub paste_offset: f64, // Selection & Anchor Display pub selection_tolerance: f64, + /// Selection-tool marquee: touching (default), enclosed, or screen drag direction. + pub marquee_selection: String, pub object_selection_by_path_only: bool, pub snap_to_point_tolerance: f64, pub ctrl_click_selects_behind: bool, @@ -378,6 +380,7 @@ impl Default for Prefs { hold_tool_keys: true, paste_offset: 10.0, selection_tolerance: 3.0, + marquee_selection: "touching".into(), object_selection_by_path_only: false, snap_to_point_tolerance: 2.0, ctrl_click_selects_behind: true, diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 7a17fe413..52755f2cb 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -7,6 +7,32 @@ use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; use crate::*; +/// The saved preference reaches the real tool; older preferences keep Touching and invalid +/// values fail without changing the current setting. +#[test] +fn marquee_selection_preference_drives_the_selection_tool() { + assert_eq!(serde_json::from_value::(json!({})).unwrap().marquee_selection, "touching"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (mode, selected) in [("touching", true), ("enclosed", false), ("directional", false)] { + set_pref(&mut s, "marqueeSelection", json!(mode)); + s.execute("select.none", &json!({})).unwrap(); + for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 150.0), (PointerKind::Up, 150.0, 150.0)] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + assert_eq!(!s.doc().unwrap().selection.objects.is_empty(), selected, "{mode}"); + let persisted = s.prefs.to_json(); + assert_eq!(serde_json::from_value::(persisted).unwrap().marquee_selection, mode); + } + assert!(s.execute("prefs.set", &json!({"key": "marqueeSelection", "value": "unknown"})).is_err()); + assert_eq!(s.prefs.marquee_selection, "directional"); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.marquee_selection, "touching"); +} + #[test] fn every_spec_matches_a_prefs_field_and_back() { let all = Prefs::default().to_json(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 12d595263..c1c4df67d 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -319,6 +319,11 @@ impl Session { slices_locked: self.menu.slices_locked, auto_add_delete: !self.prefs.disable_auto_add_delete, selection_tolerance: self.prefs.selection_tolerance, + marquee_mode: match self.prefs.marquee_selection.as_str() { + "enclosed" => vectorcraft_tools::MarqueeMode::Enclosed, + "directional" => vectorcraft_tools::MarqueeMode::Directional, + _ => vectorcraft_tools::MarqueeMode::Touching, + }, anchor_size: self.prefs.anchor_size, path_only: self.prefs.object_selection_by_path_only, type_path_only: self.prefs.type_selection_by_path_only, diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 074756d84..deb305b65 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -1,5 +1,15 @@ use super::{Document, NodeId, Paint, Point, Selection, Unit, Vec2, distort, select, xform}; +/// Which objects a Selection-tool marquee reaches before E reverses its rule for this drag. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarqueeMode { + #[default] + Touching, + Enclosed, + /// Screen-right takes enclosed objects; screen-left takes touched objects. + Directional, +} + /// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template. #[derive(Clone, Debug, PartialEq)] pub struct PaintDefaults { @@ -120,6 +130,8 @@ pub struct ToolContext<'a> { pub selection: &'a Selection, /// Screen pixels per document point. pub zoom: f64, + /// Selection-tool marquee rule; other selection tools keep their own rules. + pub marquee_mode: MarqueeMode, pub isolation: Option, pub paint: &'a PaintDefaults, pub outline: bool, diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 2893c06ab..f16969907 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -57,7 +57,7 @@ mod context; mod cursor; mod input; -pub use context::{GlyphSnap, PaintDefaults, ScreenFrame, ToolContext}; +pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, ScreenFrame, ToolContext}; pub use cursor::Cursor; pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; @@ -66,6 +66,8 @@ pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; pub enum Overlay { /// Marquee / rubber band rectangle. Marquee(Rect), + /// Selection-tool marquee, with its effective rule and pointer position for feedback. + SelectionMarquee { rect: Rect, enclosed: bool, pointer: Point }, /// A path preview (e.g. pen rubber band) in a colour (RGB). Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool }, /// A line segment (smart guide, handle line). @@ -482,6 +484,7 @@ pub(crate) mod testutil { revision: (0, 0), selection: s, zoom: 1.0, + marquee_mode: MarqueeMode::Touching, isolation: None, paint: p, outline: false, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 6d90e79e3..5a2739139 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -16,8 +16,8 @@ //! The bounding box stands at the selection's own angle after a rotation, so its handles scale //! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of //! scaling its type: the text reflows at its size. -//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and -//! E pressed again makes it take the objects it touches. +//! Marquee Selection defaults to touched objects, with fully enclosed and screen drag-direction +//! alternatives. E reverses the rule for the current drag. The overlay reports the effective rule. //! Escape during a drag (a move or an Alt-drag copy, a scale, a rotation, a marquee, a widget or //! bracket drag) cancels it: the art returns to its state at the press. //! Selected area type and type on a path show an in port and an out port. A click on the out port @@ -55,6 +55,29 @@ use crate::shaperwidget; use crate::typewidget; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; +/// Direction is read on screen, even in a turned view. Within three pixels of the press's +/// horizontal position, retain the previous rule so hand jitter does not flip it. +fn marquee_direction(cx: &ToolContext, start: Point, cur: Point, previous: bool) -> bool { + let dx = cx.screen.and_then(|f| Some(f.to_screen(cur)?.0 - f.to_screen(start)?.0)).unwrap_or((cur.x - start.x) * cx.zoom); + if dx > 3.0 { + true + } else if dx < -3.0 { + false + } else { + previous + } +} + +/// The same effective rule drives artwork, ruler guides and the visible feedback. +fn marquee_enclosed(cx: &ToolContext, inverted: bool, directional_inside: bool) -> bool { + let inside = match cx.marquee_mode { + crate::MarqueeMode::Touching => false, + crate::MarqueeMode::Enclosed => true, + crate::MarqueeMode::Directional => directional_inside, + }; + inside != inverted +} + #[derive(Clone, Debug, Default)] enum State { #[default] @@ -97,9 +120,10 @@ enum State { cur: Point, /// Shift: the objects inside leave the selection if selected, else join it. toggle: bool, - /// E pressed an odd number of times during the drag: the marquee takes only the objects - /// that lie entirely inside it. + /// E pressed an odd number of times during the drag: reverse the chosen rule. enclosed: bool, + /// Last horizontal direction outside the 3 screen-pixel dead zone at the press. + directional_inside: bool, }, /// Dragging a Live Corners widget. Corner(CornerDrag), @@ -952,8 +976,9 @@ impl Tool for SelectionTool { } vec![Action::Preview("object.transform".into(), params)] } - (PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => { - self.state = State::Marquee { start, cur: p, toggle, enclosed }; + (PointerKind::Drag, State::Marquee { start, toggle, enclosed, directional_inside, .. }) => { + self.state = + State::Marquee { start, cur: p, toggle, enclosed, directional_inside: marquee_direction(cx, start, p, directional_inside) }; vec![] } (PointerKind::Drag, State::Corner(mut c)) => { @@ -1019,13 +1044,14 @@ impl Tool for SelectionTool { out.push(Action::Commit); out } - (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => { + (PointerKind::Up, State::Marquee { start, toggle, enclosed, directional_inside, .. }) => { self.state = State::Idle; let r = Rect::from_points(start, p); if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) { return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; } let touched = marquee(cx.doc, r, cx.isolation, false); + let enclosed = marquee_enclosed(cx, enclosed, marquee_direction(cx, start, p, directional_inside)); let ids = if enclosed { inside(cx, r, touched) } else { touched }; let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) })); std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, enclosed)).collect() @@ -1046,7 +1072,11 @@ impl Tool for SelectionTool { } } match &self.state { - State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), + State::Marquee { start, cur, enclosed, directional_inside, .. } => o.push(Overlay::SelectionMarquee { + rect: Rect::from_points(*start, *cur), + enclosed: marquee_enclosed(cx, *enclosed, *directional_inside), + pointer: *cur, + }), State::Corner(c) => o.extend(c.overlays(cx)), State::Pie(d) => o.extend(d.overlays(cx)), State::RotateAll(d) => o.extend(d.overlays(cx)), @@ -1544,7 +1574,7 @@ impl SelectionTool { self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() { State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None, exclude: None } } else { - State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false } + State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false, directional_inside: false } }; } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 242b9f928..76b1d5fbc 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -1307,7 +1307,7 @@ fn escape_cancels_a_marquee() { t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the marquee"); assert!(!t.busy()); - assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee is removed"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), "the marquee is removed"); assert!(t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)).is_empty(), "the release selects nothing"); } @@ -1380,6 +1380,58 @@ fn e_during_a_marquee_takes_only_the_objects_inside() { assert!(t.busy() && !t.claims_key(&cx, e)); } +/// Each rule selects what its feedback promises, with E reversing it for just this gesture. +#[test] +fn marquee_rules_and_feedback_agree() { + use crate::MarqueeMode::{Directional, Enclosed, Touching}; + let (d, id) = doc_with_rect(); + let (s, paint) = (Selection::default(), paint()); + for (mode, right, expected) in + [(Touching, true, true), (Touching, false, true), (Enclosed, true, false), (Directional, true, false), (Directional, false, true)] + { + let c = ToolContext { marquee_mode: mode, ..cx(&d, &s, &paint) }; + for inverted in [false, true] { + let mut tool = SelectionTool::default(); + let (a, b) = if right { ((50.0, 50.0), (150.0, 150.0)) } else { ((150.0, 50.0), (50.0, 150.0)) }; + tool.pointer(&c, &ev(PointerKind::Down, a.0, a.1)); + tool.pointer(&c, &ev(PointerKind::Drag, b.0, b.1)); + if inverted { + tool.key(&c, ToolKey::Letter('e'), Mods::default()); + } + let selected = expected != inverted; + assert!( + tool.overlays(&c).iter().any(|o| matches!(o, Overlay::SelectionMarquee { enclosed, .. } if *enclosed != selected)), + "{mode:?} right={right} E={inverted}" + ); + let out = tool.pointer(&c, &ev(PointerKind::Up, b.0, b.1)); + assert_eq!(out, vec![Action::Exec("select.set".into(), json!({"ids": if selected { vec![id.0] } else { vec![] }}))]); + assert!(!tool.overlays(&c).iter().any(|o| matches!(o, Overlay::SelectionMarquee { .. }))); + } + } +} + +/// Screen direction survives view rotation; near the press's x position, the last mode is kept. +#[test] +fn directional_marquee_uses_screen_direction_and_a_dead_zone() { + let (d, _) = doc_with_rect(); + let (s, paint) = (Selection::default(), paint()); + for angle in [0.0, 45.0, 90.0, 180.0, 270.0] { + for zoom in [0.25, 1.0, 4.0] { + let mut screen = turned_view(angle).unwrap(); + screen.right /= zoom; + screen.down /= zoom; + let c = ToolContext { marquee_mode: crate::MarqueeMode::Directional, screen: Some(screen), zoom, ..cx(&d, &s, &paint) }; + let start = screen.at(20.0, 20.0); + assert!(marquee_direction(&c, start, screen.at(24.0, 60.0), false)); + assert!(!marquee_direction(&c, start, screen.at(16.0, 60.0), true)); + for x in [18.0, 20.0, 22.0] { + assert!(marquee_direction(&c, start, screen.at(x, 60.0), true)); + assert!(!marquee_direction(&c, start, screen.at(x, 60.0), false)); + } + } + } +} + /// With E pressed during the drag, a marquee takes only the artboard guides that lie entirely /// inside it, and no canvas guide, which runs across the whole canvas. #[test] diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8779380ec..a59c0c837 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -275,6 +275,29 @@ pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: match o { // Where the tools select: the document rectangle, turned with the view. Overlay::Marquee(r) => marquee_through(p, &xf.quad(*r)), + Overlay::SelectionMarquee { rect, enclosed, pointer } => { + let quad = xf.quad(*rect); + if *enclosed { + let points: Vec = quad.iter().chain(quad.first()).copied().collect(); + p.add(Shape::line(points.clone(), Stroke::new(3.0, Color32::from_white_alpha(230)))); + p.add(Shape::line(points, Stroke::new(1.0, Color32::from_gray(40)))); + } else { + marquee_through(p, &quad); + } + let (current, other) = if *enclosed { (tl!("Fully Enclosed"), tl!("Touching")) } else { (tl!("Touching"), tl!("Fully Enclosed")) }; + let label = format!("{current} · E: {other}"); + let galley = p.layout(label, egui::FontId::proportional(11.0), t.text, (p.clip_rect().width() - 12.0).max(1.0)); + let size = galley.size() + vec2(12.0, 8.0); + let wanted = xf.to_screen(*pointer) + vec2(14.0, 14.0); + let clip = p.clip_rect(); + let min = egui::pos2( + wanted.x.clamp(clip.min.x, (clip.max.x - size.x).max(clip.min.x)), + wanted.y.clamp(clip.min.y, (clip.max.y - size.y).max(clip.min.y)), + ); + let badge = egui::Rect::from_min_size(min, size); + p.rect_filled(badge, CornerRadius::same(3), t.panel); + p.galley(badge.min + vec2(6.0, 4.0), galley, t.text); + } Overlay::Path { path, color, width, dashed } => { let s = Stroke::new(*width, c32(*color)); if *dashed { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c37d6b868..2c4ef5baa 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2,6 +2,65 @@ use super::*; use vectorcraft_engine::Session; use vectorcraft_geom::Shape as _; +/// The marquee explains E without depending on color. At the canvas edge the hint stays in +/// view, in every brightness, and the two rules have visibly different outlines. +#[test] +fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { + let draw = |ui: &mut egui::Ui| { + let t = Tokens::get(ui.ctx()); + let xf = Xf { rect: ui.max_rect(), zoom: 1.0, center: Point::new(160.0, 100.0), rot: 0.0 }; + let prefs = vectorcraft_engine::Prefs::default(); + for (enclosed, y) in [(false, 80.0), (true, 180.0)] { + overlays::draw_overlays( + ui.painter(), + &xf, + &[Overlay::SelectionMarquee { rect: Rect::new(20.0, y - 60.0, 300.0, y), enclosed, pointer: Point::new(315.0, y) }], + &t, + overlays::HandleLook::of(&prefs), + ); + } + }; + for brightness in crate::theme::Brightness::ALL { + for width in [160.0, 320.0] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, brightness); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(width, 200.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| draw(ui)); + out.textures_delta.clear(); + let text = crate::tests_labels::shapes_text(&out); + assert!(text.contains("Fully Enclosed · E: Touching") && text.contains("Touching · E: Fully Enclosed"), "{text}"); + for shape in &out.shapes { + if let egui::Shape::Text(label) = &shape.shape { + assert!( + label.pos.x >= shape.clip_rect.min.x && label.pos.x + label.galley.size().x <= shape.clip_rect.max.x, + "{brightness:?}: hint clipped" + ); + assert!( + label.pos.y >= shape.clip_rect.min.y && label.pos.y + label.galley.size().y <= shape.clip_rect.max.y, + "{brightness:?}: hint clipped vertically" + ); + } + } + } + } + // Optional visual QA of our own UI. Captures never enter the repository. + if let Some(path) = std::env::var_os("VECTORCRAFT_MARQUEE_CAPTURE") { + let mut ready = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 200.0)).wgpu().build_ui(move |ui| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark); + ready = true; + return; + } + draw(ui); + }); + harness.run_steps(4); + harness.render().unwrap().save(path).unwrap(); + } +} + #[test] fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { let mut observed = Vec::new(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3ae7712f6..e4d558ca1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2980,6 +2980,11 @@ digits Malayalam Малаялам Selected Swatches Избрани образци Selected Swatches Only Само избрани образци Selection Селекция +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Избор с рамка + Touching Докоснати обекти + Fully Enclosed Изцяло вътре + Drag Direction Посока на плъзгане Selection & Anchor Display Показване на селекция и опорни точки Selection Only Само селекция Selection Tool Инструмент „Селекция“ diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index aba170ea4..a4a185058 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -179,6 +179,11 @@ Ungroup Razgrupiši Lock Zaključaj Selection Odabir +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Pravougaoni odabir + Touching Dodirnuti objekti + Fully Enclosed Potpuno obuhvaćeni + Drag Direction Smjer povlačenja All Artwork Above Sav crtež iznad Other Layers Ostali slojevi Unlock All Otključaj sve diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 370d9b677..12457d313 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3798,6 +3798,11 @@ digits Malayalam Malaiàlam Selected Swatches Only (0) Només les mostres seleccionades (0) Selected Symbols Only Només els símbols seleccionats Selection Selecció +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selecció rectangular + Touching Objectes tocats + Fully Enclosed Totalment inclosos + Drag Direction Direcció de l’arrossegament Selection & Anchor Display Selecció i visualització d’ancoratges Selection Only Només la selecció Selection Tool Eina de selecció diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5fa8b5bd0..e20f83c2b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -187,6 +187,11 @@ Ungroup Oddělit Lock Zamknout Selection Výběr +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Obdélníkový výběr + Touching Dotčené objekty + Fully Enclosed Zcela uvnitř + Drag Direction Směr tažení All Artwork Above Veškerou kresbu výše Other Layers Ostatní vrstvy Unlock All Odemknout vše diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 382df11e6..114c3fbdb 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -172,6 +172,11 @@ Ungroup Afgruppér Lock Lås Selection Markering +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rektangelmarkering + Touching Berørte objekter + Fully Enclosed Helt omsluttet + Drag Direction Trækretning All Artwork Above Al grafik ovenfor Other Layers Andre lag Unlock All Lås alle op diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dfa4a3a33..64dcb319a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3022,6 +3022,11 @@ digits Malayalam Malayalam Selected Swatches Ausgewählte Farbfelder Selected Swatches Only Nur ausgewählte Farbfelder Selection Auswahl +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rechteckauswahl + Touching Berührt + Fully Enclosed Vollständig eingeschlossen + Drag Direction Ziehrichtung Selection & Anchor Display Auswahl und Ankerpunktanzeige Selection Only Nur Auswahl Selection Tool Auswahl-Werkzeug diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2aa0a151f..68d70e8f1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1935,6 +1935,11 @@ font-technology Variable Μεταβλητές Select All Επιλογή όλων Select: Επιλογή: Selection Επιλογή +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Ορθογώνια επιλογή + Touching Αντικείμενα που τέμνονται + Fully Enclosed Πλήρως εντός + Drag Direction Κατεύθυνση σύρσης Selection Tool Επιλογή (Selection Tool) Send Backward Μεταφορά ένα βήμα πίσω Send to Back Μεταφορά στο παρασκήνιο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1db3e9aab..84dda1ce8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3062,6 +3062,11 @@ digits Malayalam Malayálam Selected Swatches Muestras seleccionadas Selected Swatches Only Solo muestras seleccionadas Selection Selección +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selección rectangular + Touching Objetos tocados + Fully Enclosed Totalmente incluidos + Drag Direction Dirección de arrastre Selection & Anchor Display Visualización de selección y anclas Selection Only Solo la selección Selection Tool Herramienta Selección diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 0752a3fbb..b781ab310 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -194,6 +194,11 @@ Ungroup Pura ryhmitys Lock Lukitse Selection Valinta +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Suorakulmiovalinta + Touching Kosketetut + Fully Enclosed Kokonaan sisällä + Drag Direction Vetosuunta All Artwork Above Kaikki yllä oleva kuvitus Other Layers Muut tasot Unlock All Avaa kaikki lukitukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 755beef3e..3e2eb9c01 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3054,6 +3054,11 @@ digits Malayalam Malayalam Selected Swatches Nuances sélectionnées Selected Swatches Only Nuances sélectionnées uniquement Selection Sélection +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Sélection rectangulaire + Touching Objets touchés + Fully Enclosed Entièrement inclus + Drag Direction Sens du glissement Selection & Anchor Display Affichage de la sélection et des points d’ancrage Selection Only Sélection uniquement Selection Tool Outil Sélection diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index eae9829b9..36c7a5a6f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3667,6 +3667,11 @@ mark-style Japanese Japán Selected Swatches Only Csak a kijelölt színminták Selected Swatches Only (0) Csak a kijelölt színminták (0) Selection Kijelölés +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Téglalapos kijelölés + Touching Érintett objektumok + Fully Enclosed Teljesen befoglalt + Drag Direction Húzás iránya Selection & Anchor Display Kijelölés és horgonypontok megjelenítése Selection Only Csak a kijelölés Selection Tool Kijelölés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 433380d53..e32b28107 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -172,6 +172,11 @@ Ungroup Buka grup Lock Kunci Selection Seleksi +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Seleksi persegi panjang + Touching Objek tersentuh + Fully Enclosed Sepenuhnya di dalam + Drag Direction Arah seret All Artwork Above Semua karya seni di atas Other Layers Lapisan lain Unlock All Buka kunci semua diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 46c0bc7e5..4e0b5f341 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3098,6 +3098,11 @@ digits Malayalam Malayalam Selected Swatches Campioni selezionati Selected Swatches Only Solo campioni selezionati Selection Selezione +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selezione rettangolare + Touching Oggetti toccati + Fully Enclosed Interamente inclusi + Drag Direction Direzione di trascinamento Selection & Anchor Display Visualizzazione selezione e punti di ancoraggio Selection Only Solo selezione Selection Tool Strumento Selezione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6df9879ea..dc845fa7a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -213,6 +213,11 @@ digits Malayalam マラヤーラム数字 Ungroup グループ解除 Lock ロック Selection 選択 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 矩形選択 + Touching 接触するオブジェクト + Fully Enclosed 完全に囲まれたオブジェクト + Drag Direction ドラッグ方向 All Artwork Above 前面のすべてのアートワーク Other Layers その他のレイヤー Unlock All すべてをロック解除 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b23ea6b0c..5e3262f82 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -146,6 +146,11 @@ Ungroup დაჯგუფების მოხსნა Lock ჩაკეტვა Selection მონიშნული +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection მართკუთხა მონიშვნა + Touching შეხებული ობიექტები + Fully Enclosed სრულად მოქცეული + Drag Direction გადათრევის მიმართულება All Artwork Above ზემოთ მდებარე მთელი ნახატი Other Layers სხვა შრეები Unlock All ყველაფრის განბლოკვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4a026cd0d..8f9e3e286 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -206,6 +206,11 @@ digits Malayalam 말라얄람어 Ungroup 그룹 해제 Lock 잠그기 Selection 선택 영역 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 사각형 선택 + Touching 닿는 개체 + Fully Enclosed 완전히 포함된 개체 + Drag Direction 드래그 방향 All Artwork Above 위의 모든 아트워크 Other Layers 다른 레이어 Unlock All 모두 잠금 해제 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3a0ffaf16..8dccad87c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -186,6 +186,11 @@ Ungroup Groepering opheffen Lock Vergrendelen Selection Selectie +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rechthoekselectie + Touching Geraakt + Fully Enclosed Volledig omsloten + Drag Direction Sleeprichting All Artwork Above Alle artwork erboven Other Layers Andere lagen Unlock All Alles ontgrendelen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d858f8081..c5513ad3e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -152,6 +152,11 @@ Ungroup Rozgrupuj Lock Zablokuj Selection Zaznaczenie +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Zaznaczanie prostokątne + Touching Dotknięte + Fully Enclosed Całkowicie wewnątrz + Drag Direction Kierunek przeciągania All Artwork Above Całą kompozycję powyżej Other Layers Pozostałe warstwy Unlock All Odblokuj wszystko diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 49a4edc7c..f49c6aac2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2564,6 +2564,11 @@ digits Malayalam Malaiala Selected Swatches Amostras Selecionadas Selected Swatches Only Somente Amostras Selecionadas Selection Seleção +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Seleção retangular + Touching Objetos tocados + Fully Enclosed Totalmente contidos + Drag Direction Direção do arrasto Selection & Anchor Display Exibição de Seleção e Âncora Selection Only Somente Seleção Selection Tool Ferramenta Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e37f1db9d..85cf2e0e6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3093,6 +3093,11 @@ digits Malayalam Малаялам Selected Swatches Выбранные образцы Selected Swatches Only Только выбранные образцы Selection Выделение +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Выделение рамкой + Touching Пересекаемые + Fully Enclosed Полностью внутри + Drag Direction Направление перетаскивания Selection & Anchor Display Отображение выделения и опорных точек Selection Only Только выделение Selection Tool Инструмент «Выделение» diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f0cb484b2..d19c210b9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2986,6 +2986,11 @@ digits Malayalam Malayalam Selected Swatches Markerade färgrutor Selected Swatches Only Endast markerade färgrutor Selection Markering +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rektangelmarkering + Touching Berörda objekt + Fully Enclosed Helt omslutna + Drag Direction Dragriktning Selection & Anchor Display Visning av markering och ankarpunkter Selection Only Endast markering Selection Tool Markeringsverktyg diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ed95d4e8e..1ccbe1484 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -184,6 +184,16 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// Preference choices and the marquee's E hint share the same translated labels. +#[test] +fn marquee_selection_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in ["Marquee Selection", "Touching", "Fully Enclosed", "Drag Direction"] { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 73ba123e0..db8509636 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2890,6 +2890,11 @@ digits Malayalam Malayalam Selected Swatches Seçilen Renk Örnekleri Selected Swatches Only Yalnızca Seçilen Renk Örnekleri Selection Seçim +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Dikdörtgen seçim + Touching Dokunulanlar + Fully Enclosed Tamamen içeride + Drag Direction Sürükleme yönü Selection & Anchor Display Seçim ve Bağlantı Ekranı Selection Only Yalnızca Seçim Selection Tool Seçim Aracı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fe0edb479..12fafaa8d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3090,6 +3090,11 @@ digits Malayalam Малаялам Selected Swatches Вибрані зразки Selected Swatches Only Тільки вибрані зразки Selection Виділення +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Виділення рамкою + Touching Перетнуті + Fully Enclosed Повністю всередині + Drag Direction Напрямок перетягування Selection & Anchor Display Відображення виділення та опорних точок Selection Only Тільки виділення Selection Tool Інструмент «Виділення» diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a567d484a..259881305 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3418,6 +3418,11 @@ mark-style Japanese Kiểu Nhật Selected Swatches Mẫu màu đã chọn Selected Swatches Only Chỉ mẫu màu đã chọn Selection Vùng chọn +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Chọn bằng khung + Touching Đối tượng chạm khung + Fully Enclosed Nằm hoàn toàn trong khung + Drag Direction Hướng kéo Selection & Anchor Display Hiển thị vùng chọn & điểm neo Selection Only Chỉ vùng chọn Selection Tool Công cụ Chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index be6b5fd2b..22c78c5ac 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2934,6 +2934,11 @@ digits Malayalam 马拉雅拉姆数字 Selected Swatches 所选色板 Selected Swatches Only 仅限选择的色板 Selection 所选对象 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 框选 + Touching 接触对象 + Fully Enclosed 完全包含 + Drag Direction 拖动方向 Selection & Anchor Display 选择和锚点显示 Selection Only 仅所选内容 Selection Tool 选择工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fd5d3df2b..34506cb1e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3964,6 +3964,11 @@ digits Malayalam 馬來亞拉姆數字 Selected Swatches 選取的色票 Selected Swatches Only 僅限選取的色票 Selection 選取範圍 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 框選 + Touching 接觸物件 + Fully Enclosed 完全包含 + Drag Direction 拖曳方向 Selection & Anchor Display 選取和錨點顯示 Selection Only 僅限選取範圍 Selection Tool 選取工具 diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index da1814b35..8e0db81d9 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -2,6 +2,24 @@ use super::*; use crate::Services; use vectorcraft_engine::Session; +/// The three marquee choices are available in Preferences and saved through the same path as +/// other selection preferences. +#[test] +fn marquee_selection_choices_are_saved_from_preferences() { + let mut app = app(); + open(&mut app, Some("Selection & Anchor Display")); + let text = crate::tests_labels::painted_text(&mut app, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Marquee Selection") && text.contains("Touching"), "{text}"); + app.ui.dialog.as_mut().unwrap().fields.insert("marqueeSelection".into(), json!("directional")); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.marquee_selection, "directional"); + let saved: Prefs = serde_json::from_value(app.ui.engine_prefs.clone()).unwrap(); + assert_eq!(saved.marquee_selection, "directional"); + open(&mut app, Some("Selection & Anchor Display")); + let text = crate::tests_labels::painted_text(&mut app, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Drag Direction"), "{text}"); +} + #[test] fn geometric_snapping_is_a_saved_smart_guides_preference() { let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/docs/gaps.md b/docs/gaps.md index dc6971cf5..40baea54e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,8 @@ # Gaps +> **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G5: dynamic symbols, #4938; estimate 15–26 → 14–24 h) · **Target:** Adobe Illustrator 2026 (30.x; G12: Units › East Asian Type acts, with Q and Ha units; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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 diff --git a/docs/mcp.md b/docs/mcp.md index c83b2a2ff..4908480fa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,7 @@ # VectorCraft MCP server +Selection-tool marquees: `prefs.set {key: "marqueeSelection", value: "touching" | "enclosed" | "directional"}` chooses touched objects (default), fully enclosed objects, or screen-right enclosed / screen-left touched objects. Direction follows the screen when the view is rotated; within three horizontal screen pixels of the press, the last rule remains (initially touching). Without a screen frame, direction uses document x at the current zoom. E reverses the rule for the current drag; Shift toggles the reached objects and Escape cancels. The outline and label show the effective rule. Ruler guides use that rule too; Direct Selection and the document-aligned selection region are unchanged. + `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to draw, inspect and look at VectorCraft documents. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4efbee4aa..7a79a7457 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,5 +1,8 @@ # UI and interaction parity +> **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Color Effects add-on: Find Nearest Swatch, #4923) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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) ## Feature submission evidence From 3405620151c641143b84b4a3e7173849582ae857 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 20:17:56 -0800 Subject: [PATCH 30/51] Docs: update marquee feature status and revision history --- ROADMAP.md | 3 ++- docs/gaps.md | 5 ++++- docs/roadmap.md | 3 +++ docs/target-app-parity.md | 3 +++ docs/ui-parity.md | 5 ++++- 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8e9336d56..d11d8b8f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -225,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | | 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index 40baea54e..9b51d91d9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -2,11 +2,12 @@ > **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G5: dynamic symbols, #4938; estimate 15–26 → 14–24 h) · **Target:** Adobe Illustrator 2026 (30.x; G12: Units › East Asian Type acts, with Q and Ha units; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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 +**Feature evidence last updated:** 2026-11-02. | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -40,6 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | @@ -821,6 +823,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | G15: nearby hits on plain long strokes and outlines check their candidate segments directly; a 200,000-anchor middle hit took about 11–13 ms before and 1.0–1.4 ms after in paired runs. An inside-bounds miss fell from 18.7 ms to 1.6 ms. Estimate unchanged; idle budget run still open. | | 2026-11-05 | minor | G15: an image zoomed in on is separated, colorized or proofed only for the tiles in view (#4155); 100% zoom on that file 8.6 s → 0.05 s. Estimate unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 5e6ee6dda..0e58d2eec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,6 +4,7 @@ ## Feature submission evidence +**Feature evidence last updated:** 2026-11-02. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -36,6 +37,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | @@ -181,6 +183,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep their own units; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5a65ad956..124b6d11f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -4,6 +4,7 @@ ## Feature submission evidence +**Feature evidence last updated:** 2026-11-02. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -31,6 +32,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | @@ -1798,6 +1800,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7a79a7457..80b3f9ef9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -2,11 +2,12 @@ > **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Color Effects add-on: Find Nearest Swatch, #4923) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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) ## Feature submission evidence +**Feature evidence last updated:** 2026-11-02. | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -42,6 +43,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) | | 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color follows the hex and a double-click opens the Color Picker over the dialog, whose OK fills Color (#5364); scores and estimates unchanged | @@ -481,6 +483,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Keyboard shortcuts: Cmd/Ctrl+Alt+. and Cmd/Ctrl+Alt+, (Stroke Weight Up and Down) step each selected stroke's weight, or the next object drawn's (#5797); scores and estimates unchanged | | 2026-11-06 | minor | Properties: Quick Actions has Repeat Options for one selected radial, grid or mirror repeat, opening Object › Repeat › Options with its values (#5181); scores and estimates unchanged | | 2026-11-06 | minor | Control bar: with type selected, a Paragraph: link (the Paragraph panel in a popover) and the Align left, Align center and Align right buttons follow the Character controls; the bar leaves them out from its right end when short of room, after X/Y/W/H and the Align buttons (#5372); scores and estimates unchanged | From e43c9bbbeac4a7a4611a0bf326916acbdc3f5986 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:13:03 +0700 Subject: [PATCH 31/51] Selection: the centre-point and frame tests look for the selection marquee overlay --- crates/tools/src/select/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 76b1d5fbc..e76ce2e21 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2077,7 +2077,7 @@ fn a_dropped_press_keeps_the_text_loaded() { t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); assert!(t.drop_press(&cx).is_empty()); assert!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)).is_empty(), "the release threads nothing"); - assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_))), "the new frame's outline is removed"); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), "the new frame's outline is removed"); assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); // A press on the port that loaded the text puts it down; dropped, it leaves the text loaded. t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); @@ -2295,7 +2295,7 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() { t.pointer(&c, &ev(PointerKind::Down, 400.0, 400.0)); let a = t.pointer(&c, &ev(PointerKind::Drag, 450.0, 450.0)); assert!(a.is_empty(), "{a:?}"); - assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee"); + assert!(matches!(t.overlays(&c).first(), Some(Overlay::SelectionMarquee { .. })), "a marquee"); } /// A Shift-press on a selected object's centre point moves the selection too, held to 45° steps, From 08b0406da0ffe7ba055a1ee841bb8c8fb65cdecf Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:32:46 +0700 Subject: [PATCH 32/51] i18n: Hindi has the marquee selection labels --- crates/ui-egui/src/i18n/hi.tsv | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b7cb6fcce..9f6c6807f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7961,3 +7961,7 @@ swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस Start Lists From Typed Markers टाइप किए गए चिह्नों से सूचियाँ शुरू करें + Marquee Selection मार्की चयन + Touching स्पर्श करते हुए + Fully Enclosed पूरी तरह घिरे हुए + Drag Direction खींचने की दिशा From e981a1d3ae3fffefddc0a20de706f9df33cbf3ff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:34:17 +0700 Subject: [PATCH 33/51] Selection: format the frame test's marquee assertion --- crates/tools/src/select/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index e76ce2e21..518461343 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2077,7 +2077,10 @@ fn a_dropped_press_keeps_the_text_loaded() { t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); assert!(t.drop_press(&cx).is_empty()); assert!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)).is_empty(), "the release threads nothing"); - assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), "the new frame's outline is removed"); + assert!( + !t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), + "the new frame's outline is removed" + ); assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); // A press on the port that loaded the text puts it down; dropped, it leaves the text loaded. t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); From a8f21ac0f6a6635b23830908d48ba07b831d582d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 07:06:16 -0800 Subject: [PATCH 34/51] UI: keep docked Tools beside the canvas with left panels --- ROADMAP.md | 3 +- crates/ui-egui/src/app.rs | 7 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panel_docking.rs | 6 +- crates/ui-egui/src/panel_docking/tests.rs | 79 +++++++++++++++++++++++ crates/ui-egui/src/state.rs | 2 +- crates/ui-egui/src/toolbar.rs | 25 +++++-- crates/ui-egui/src/tools_float.rs | 22 +++++-- docs/gaps.md | 4 ++ docs/target-app-parity.md | 3 +- 10 files changed, 136 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d11d8b8f5..1f43b2144 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -36,6 +36,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 61057acb1..d601e76d1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -454,7 +454,8 @@ impl VectorcraftApp { /// in a floating window, the tab strip's document draws as a pane. pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) { let t = theme::Tokens::get(ui.ctx()); - ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard); + // Tools may already occupy the left side of this panel. + ui.painter().rect_filled(ui.available_rect_before_wrap(), 0.0, t.pasteboard); self.canvas.windows.strip = None; if self.ui.screen_mode < 3 { chrome::doc_tabs(self, ui); @@ -541,9 +542,9 @@ impl VectorcraftApp { // Control bar docked at the bottom has set it to the bar's top. widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); } - if self.ui.toolbar && self.ui.screen_mode < 3 { + if self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.toolbar_pos.is_some() { toolbar::show(self, ui); - } else if self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() { + } else if !self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() { // Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New // Toolbar too. toolbar::show_custom(self, ui.ctx()); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 208abcfeb..6883f48b0 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -596,7 +596,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window. Returns {layout, closed, floating}", + "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel beside the canvas, inside any left-side dock. Returns {layout, closed, floating}", ), ( "window.collapseDock", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 73d981000..a0205be88 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1095,7 +1095,7 @@ fn known(action: &Action) -> bool { } } -/// Draw the canvas and the panels in the space left by the bars and the Tools panel, and apply +/// Draw the canvas, its docked Tools panel and the other panels below the bars, and apply /// what the user did to them. pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let area = area_id(); @@ -1346,6 +1346,7 @@ struct PanelContent<'a> { impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { + crate::toolbar::show_beside_canvas(self.app, ui); self.app.canvas_area(ui); return; } @@ -1389,7 +1390,8 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { - let min = if id == CANVAS { egui::vec2(200.0, 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; + let min = + if id == CANVAS { egui::vec2(200.0 + crate::toolbar::docked_width(&self.app.ui), 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; craft_ui::docking::PanelLimits { min, ..Default::default() } } diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 60b59a2a8..75b47e089 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -284,6 +284,85 @@ fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { assert_eq!(h.app.ui.toolbar_pos, None, "dropped on the left edge, it docks"); } +/// A left-side panel dock leaves the Tools panel between that dock and the document. +#[test] +fn docked_tools_stay_beside_the_canvas_with_left_side_panels() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + for _ in 0..3 { + h.frame(vec![]); + } + let tools = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).unwrap(); + let layers = h.tab("layers").unwrap(); + let canvas = h.app.canvas_rect.unwrap(); + assert!(layers.right() <= tools.left(), "the left dock precedes Tools: {layers:?}, {tools:?}"); + assert!(tools.right() <= canvas.left() && canvas.left() - tools.right() < 40.0, "Tools stays beside the canvas: {tools:?}, {canvas:?}"); +} + +#[test] +fn tools_redock_beside_left_panels_from_either_drop_target() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + for at_window_edge in [false, true] { + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + for _ in 0..3 { + h.frame(vec![]); + } + let zone = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).unwrap(); + let target = if at_window_edge { pos2(12.0, zone.center().y) } else { zone.center() }; + h.drag(pos2(620.0, 307.0), target); + assert_eq!(h.app.ui.toolbar_pos, None, "redock at window edge: {at_window_edge}"); + let tools = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).unwrap(); + assert!(h.tab("layers").unwrap().right() <= tools.left()); + assert!(tools.right() <= h.app.canvas_rect.unwrap().left()); + } +} + +#[test] +fn tools_follow_left_panels_when_resized_hidden_and_restored() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + gesture(&mut h.app, Action::ResizeColumn { panel: "layers".into(), width: 430.0 }).unwrap(); + h.app.ui.toolbar_double = true; + for hidden in [false, true, false] { + if hidden == h.app.ui.dock { + h.app.run("window.panels", json!({})).unwrap(); + } + for _ in 0..3 { + h.frame(vec![]); + } + let tools = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).unwrap(); + if hidden { + assert!(h.tab("layers").is_none()); + assert!(tools.left() < 10.0); + } else { + assert!(h.tab("layers").unwrap().right() <= tools.left()); + } + let canvas = h.app.canvas_rect.unwrap(); + assert!(canvas.left() - tools.left() >= 80.0, "double-width Tools has its own room"); + assert!(canvas.width() > 200.0); + } +} + +#[test] +#[ignore = "manual visual check; set LEFT_TOOLS_SCREENSHOTS to an existing harness directory"] +fn capture_tools_beside_left_panels() { + let dir = std::path::PathBuf::from(std::env::var_os("LEFT_TOOLS_SCREENSHOTS").unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1200.0, 800.0)) + .with_accessibility_check(false) + .wgpu() + .build_ui_state(|ui, app: &mut VectorcraftApp| app.ui(ui), app); + h.run_steps(6); + h.render().unwrap().save(dir.join("tools-inside-left-dock.png")).unwrap(); + h.state_mut().ui.toolbar_double = true; + h.run_steps(3); + h.render().unwrap().save(dir.join("tools-inside-left-dock-double.png")).unwrap(); +} + #[test] fn a_library_panel_opens_renames_and_closes() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 85f87b723..3ab62e449 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -460,7 +460,7 @@ pub struct UiState { #[serde(default, skip_serializing_if = "Option::is_none")] pub dock_width: Option, /// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`: - /// docked at the window's left edge. + /// docked beside the canvas's left edge, inside any left-side panel dock. #[serde(default)] pub toolbar_pos: Option<[f32; 2]>, pub status_bar: bool, diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index be04485d0..82014ddee 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -95,16 +95,33 @@ pub fn remember(app: &mut VectorcraftApp, id: &str) { crate::custom_toolbars::remember(&mut app.ui, id); } +fn panel_width(ui: &crate::state::UiState) -> f32 { + if ui.toolbar_double { 80.0 } else { WIDTH } +} + +/// Space reserved inside the protected canvas panel for a visible docked Tools panel. +pub(crate) fn docked_width(ui: &crate::state::UiState) -> f32 { + if ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() { panel_width(ui) + 6.0 } else { 0.0 } +} + +/// Register redocking targets even while Tools floats; a docked panel follows the canvas's +/// left edge as dock columns resize, collapse or hide. +pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) { + if app.ui.toolbar && app.ui.screen_mode < 3 { + crate::tools_float::set_dock_zones(ui.ctx(), ui.available_rect_before_wrap(), WIDTH); + if app.ui.toolbar_pos.is_none() { + show(app, ui); + } + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // The user's choice alone: a window too short for the tools scrolls them. let cols = if app.ui.toolbar_double { 2 } else { 1 }; // Two 36 pt buttons with 2 pt either side inside the border and separator line (at 76 they // overran the right border). - let w = if cols == 2 { 80.0 } else { WIDTH }; - // Where the panel docks: along the window's left edge, under the bars. - let edge = ui.available_rect_before_wrap(); - ui.ctx().data_mut(|d| d.insert_temp(crate::tools_float::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height())))); + let w = panel_width(&app.ui); match app.ui.toolbar_pos { None => { let margin = egui::Margin { left: 6, right: 0, top: 6, bottom: 6 }; diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 25d86b69f..ebb748ffa 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -1,7 +1,8 @@ -//! The Tools panel floats by its title bar and docks again on the window's left edge +//! The Tools panel floats by its title bar and docks beside the canvas's left edge //! ([`crate::toolbar`]). It stays outside the docking layout: dragging its title bar out of the //! docked panel floats it under the pointer, a floating panel's title bar moves it, and a release -//! over the strip along the window's left edge (lit up) docks it there. Escape or losing the +//! over the canvas's left edge or the window's left edge (lit up) docks it beside the canvas. +//! Escape or losing the //! window's focus puts it back where the drag began. //! //! `window.panel.float {panel: "tools"}` / `window.panel.dock {panel: "tools"}` do the same for @@ -41,11 +42,24 @@ pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) } -/// The strip along the window's left edge where the Tools panel docks, as last laid out. +/// The strip along the canvas's left edge where the Tools panel docks, as last laid out. pub(crate) fn tools_zone_id() -> Id { Id::unique("tools-float-zone") } +fn window_zone_id() -> Id { + Id::unique("tools-float-window-zone") +} + +/// Keep the window-edge gesture while also accepting a drop next to panels docked left. +pub(crate) fn set_dock_zones(ctx: &egui::Context, canvas: Rect, width: f32) { + let window = pos2(ctx.content_rect().left(), canvas.top()); + ctx.data_mut(|d| { + d.insert_temp(tools_zone_id(), Rect::from_min_size(canvas.min, vec2(width, canvas.height()))); + d.insert_temp(window_zone_id(), Rect::from_min_size(window, vec2(width, canvas.height()))); + }); +} + /// `pos` moved so a panel of `size` stays inside `screen` (a position saved on a bigger window). pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 { let [x, y] = pos; @@ -92,7 +106,7 @@ pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: & /// The docking strip under `at`, if any. fn drop_zone(ctx: &egui::Context, at: Pos2) -> Option { - ctx.data(|d| d.get_temp::(tools_zone_id())).filter(|r| r.contains(at)) + ctx.data(|d| [d.get_temp::(tools_zone_id()), d.get_temp::(window_zone_id())].into_iter().flatten().find(|r| r.contains(at))) } fn highlight(ctx: &egui::Context, zone: Rect) { diff --git a/docs/gaps.md b/docs/gaps.md index 9b51d91d9..6e0bce9e5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,8 @@ # Gaps +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) + +Docked Tools stays beside the canvas, inside any left-side panels. Both the canvas edge and the window edge accept floating Tools. Existing saved workspace fields are reused. This is a requested layout choice; [toolbar documentation](https://helpx.adobe.com/illustrator/using/tools.html) and [workspace documentation](https://helpx.adobe.com/illustrator/using/customizing-workspace.html) describe movability and docking, without establishing this exact relative placement. Sidebar resizing (#5348) remains separate. Scores and estimates are unchanged. > **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) @@ -41,6 +44,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 124b6d11f..b4dfd8d85 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -32,6 +32,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | @@ -366,7 +367,7 @@ what keeps it from beta, not from alpha. | Dimension | % | One agent (h) | Kind | Doc | |---|---:|---:|---|---| | Features (depth, weighted) | 75% | 220–360 | estimated | this file | -| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md) | +| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring) | | File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) | | Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | | Localization (the 12 key languages, equally weighted) | 74% | 50–85 | measured from catalogs | [localization-parity.md](localization-parity.md) | From 27c292669fab97248e99f12cad5b222811034e90 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 07:30:14 -0800 Subject: [PATCH 35/51] UI: preserve window-edge Tools redocking across vertical layouts --- crates/ui-egui/src/app.rs | 3 +++ crates/ui-egui/src/panel_docking/tests.rs | 32 +++++++++++++++++++++++ crates/ui-egui/src/tools_float.rs | 10 ++++--- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index d601e76d1..db736fb6f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -542,6 +542,9 @@ impl VectorcraftApp { // Control bar docked at the bottom has set it to the bar's top. widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); } + if self.ui.toolbar && self.ui.screen_mode < 3 { + tools_float::set_window_dock_zone(&ctx, ui.available_rect_before_wrap()); + } if self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.toolbar_pos.is_some() { toolbar::show(self, ui); } else if !self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() { diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 75b47e089..419bd2ff5 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -344,6 +344,38 @@ fn tools_follow_left_panels_when_resized_hidden_and_restored() { } } +#[test] +fn tools_redock_at_window_edge_above_and_below_canvas() { + for above in [true, false] { + let mut h = Harness::new(); + let tabs = |panel: &str| Box::new(Node::Tabs { panels: vec![panel.to_string()], active: 0 }); + h.app.ui.docking = Some(Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Vertical, + size: craft_ui::layout::SplitSize::FixedFirst(180.0), + first: tabs("layers"), + second: Box::new(Node::Split { + axis: craft_ui::layout::SplitAxis::Vertical, + size: craft_ui::layout::SplitSize::FixedSecond(180.0), + first: tabs(CANVAS), + second: tabs("properties"), + }), + }), + floating: vec![], + }); + assert!(valid(&h.layout())); + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + for _ in 0..3 { + h.frame(vec![]); + } + let panel = h.tab(if above { "layers" } else { "properties" }).unwrap(); + let canvas = h.app.canvas_rect.unwrap(); + assert!(panel.center().y < canvas.top() || panel.center().y > canvas.bottom()); + h.drag(pos2(620.0, 307.0), pos2(12.0, panel.center().y)); + assert_eq!(h.app.ui.toolbar_pos, None, "window-edge drop above canvas: {above}"); + } +} + #[test] #[ignore = "manual visual check; set LEFT_TOOLS_SCREENSHOTS to an existing harness directory"] fn capture_tools_beside_left_panels() { diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index ebb748ffa..c6b4422ba 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -51,15 +51,19 @@ fn window_zone_id() -> Id { Id::unique("tools-float-window-zone") } -/// Keep the window-edge gesture while also accepting a drop next to panels docked left. +/// Accept a drop next to panels docked left, independently of the window-edge gesture. pub(crate) fn set_dock_zones(ctx: &egui::Context, canvas: Rect, width: f32) { - let window = pos2(ctx.content_rect().left(), canvas.top()); ctx.data_mut(|d| { d.insert_temp(tools_zone_id(), Rect::from_min_size(canvas.min, vec2(width, canvas.height()))); - d.insert_temp(window_zone_id(), Rect::from_min_size(window, vec2(width, canvas.height()))); }); } +/// The legacy 48-point window-edge target spans the full layout below the bars, including +/// panels above and below the canvas. Its width does not follow the toolbar's column count. +pub(crate) fn set_window_dock_zone(ctx: &egui::Context, window: Rect) { + ctx.data_mut(|d| d.insert_temp(window_zone_id(), Rect::from_min_size(window.min, vec2(48.0, window.height())))); +} + /// `pos` moved so a panel of `size` stays inside `screen` (a position saved on a bigger window). pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 { let [x, y] = pos; From 315ff52ffdeaf5a8bd679526e08a9a435cdcb3b6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 05:57:32 -0800 Subject: [PATCH 36/51] Workspace: make keeping Tools beside the canvas an option, off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docked Tools stays at the window's left edge again, outside any panels docked on the left. Window › Toolbars › Keep Tools Beside the Canvas (`window.toolsBesideCanvas {on?}`) docks it between those panels and the canvas instead. - The setting is its own field in the UI state and in workspaces, and a missing field reads as off, so saved state and workspaces keep Tools at the window's edge. Built-in workspaces have it off. - Off, app.rs draws docked Tools before the docking layout, the canvas reserves no width for it, and only the full-height window-edge strip docks a floating Tools panel. On, the canvas panel draws it, the canvas keeps room for it, and both the window edge and the canvas edge dock it beside the canvas. - Turning the setting off forgets the canvas-edge strip, so a strip laid out while it was on does not dock Tools. A floating Tools panel stays floating when the setting changes. - `window.panel.dock {panel: "tools"}` docks Tools where the setting says. - The menu label has a row in every catalog. Docs: docs/mcp.md, docs/control-protocol.md, the ROADMAP progress log and docs/target-app-parity.md. The headline-numbers row and the paragraphs under the status lines are gone, and scores are unchanged. --- ROADMAP.md | 1 + crates/ui-egui/src/app.rs | 7 +- crates/ui-egui/src/custom_toolbars.rs | 11 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus/run.rs | 5 + crates/ui-egui/src/menus/state.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 8 +- crates/ui-egui/src/panel_docking/tests.rs | 221 +++++++++++++++++----- crates/ui-egui/src/state.rs | 8 +- crates/ui-egui/src/toolbar.rs | 21 +- crates/ui-egui/src/tools_float.rs | 30 ++- crates/ui-egui/src/workspaces.rs | 5 + docs/control-protocol.md | 4 +- docs/mcp.md | 15 ++ docs/target-app-parity.md | 1 + 40 files changed, 295 insertions(+), 69 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1f43b2144..f7d59a3da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index db736fb6f..5b53073a3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -545,7 +545,12 @@ impl VectorcraftApp { if self.ui.toolbar && self.ui.screen_mode < 3 { tools_float::set_window_dock_zone(&ctx, ui.available_rect_before_wrap()); } - if self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.toolbar_pos.is_some() { + if !self.ui.tools_beside_canvas { + // A canvas-edge target left from before the setting was turned off docks nothing. + tools_float::clear_canvas_dock_zone(&ctx); + } + // Beside the canvas, docked Tools draws inside the canvas panel (`toolbar::show_beside_canvas`). + if self.ui.toolbar && self.ui.screen_mode < 3 && (!self.ui.tools_beside_canvas || self.ui.toolbar_pos.is_some()) { toolbar::show(self, ui); } else if !self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() { // Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index adbf1223f..e5008644b 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -523,12 +523,17 @@ pub fn sync(ui: &UiState) { } } -/// Window › Toolbars: Advanced and Double Column, the toolbars made with New Toolbar (each checked -/// while it shows), New Toolbar… and Manage Toolbars…. +/// Window › Toolbars: Advanced, Double Column and Keep Tools Beside the Canvas, the toolbars made +/// with New Toolbar (each checked while it shows), New Toolbar… and Manage Toolbars…. pub fn menu_items() -> Vec { use crate::menus::Item; let c = |label: &'static str, id: &'static str| Item::Cmd(label, id, Value::Null); - let mut v = vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns"), Item::Sep]; + let mut v = vec![ + c("Advanced", "window.toolbarAdvanced"), + c("Double Column", "window.toolbarColumns"), + c("Keep Tools Beside the Canvas", "window.toolsBesideCanvas"), + Item::Sep, + ]; let names = names_store().read().map(|v| v.clone()).unwrap_or_default(); if !names.is_empty() { v.extend(names.into_iter().map(|n| Item::Cmd(n, "window.toolbar.custom", json!({ "name": n })))); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e4d558ca1..981975808 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3120,6 +3120,7 @@ digits Malayalam Малаялам Simulate Colored Paper Симулиране на цветна хартия Simulate Paper Симулиране на хартия Double Column Две колони + Keep Tools Beside the Canvas Инструментите до платното Single Left Quotation Mark Лява единична кавичка Single Quotes: Единични кавички: Single Right Quotation Mark Дясна единична кавичка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a4a185058..8da20248c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -670,6 +670,7 @@ Toolbars Trake s alatima Advanced Napredno Double Column Dvostruka kolona + Keep Tools Beside the Canvas Alati pored platna Actions Akcije Asset Export Izvoz resursa Attributes Atributi diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 12457d313..3ad992e57 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1206,6 +1206,7 @@ Double Bar Barra doble Double Click To Isolate Doble clic per aïllar Double Column Columna doble + Keep Tools Beside the Canvas Eines al costat del llenç Double Left Quotation Marks Cometes dobles esquerres Double Quotes: Cometes dobles: Double Right Quotation Marks Cometes dobles dretes diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e20f83c2b..8cc2a6fba 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -678,6 +678,7 @@ Toolbars Panely nástrojů Advanced Rozšířené Double Column Dva sloupce + Keep Tools Beside the Canvas Nástroje vedle plátna Actions Akce Asset Export Export prvků Attributes Atributy diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 114c3fbdb..978a0971a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -627,6 +627,7 @@ Toolbars Værktøjslinjer Advanced Avanceret Double Column Dobbelt kolonne + Keep Tools Beside the Canvas Værktøjer ved siden af canvaset Actions Handlinger Asset Export Aktiveksport Attributes Attributter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 64dcb319a..374c63918 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3164,6 +3164,7 @@ digits Malayalam Malayalam Simulate Colored Paper Farbiges Papier simulieren Simulate Paper Papier simulieren Double Column Zweispaltig + Keep Tools Beside the Canvas Werkzeuge neben der Arbeitsfläche Single Left Quotation Mark Einfaches öffnendes Anführungszeichen Single Quotes: Einfache Anführungszeichen: Single Right Quotation Mark Einfaches schließendes Anführungszeichen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 68d70e8f1..b42c06626 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3618,6 +3618,7 @@ digits Malayalam Μαλαγιάλαμ Simulate Colored Paper Προσομοίωση χρωματιστού χαρτιού Simulate Paper Προσομοίωση χαρτιού Double Column Διπλή στήλη + Keep Tools Beside the Canvas Εργαλεία δίπλα στον καμβά Single Left Quotation Mark Μονό αριστερό εισαγωγικό Single Quotes: Μονά εισαγωγικά: Single Right Quotation Mark Μονό δεξί εισαγωγικό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 84dda1ce8..67a5a84d2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3204,6 +3204,7 @@ digits Malayalam Malayálam Simulate Colored Paper Simular papel de color Simulate Paper Simular papel Double Column Columna doble + Keep Tools Beside the Canvas Herramientas junto al lienzo Single Left Quotation Mark Comilla simple de apertura Single Quotes: Comillas simples: Single Right Quotation Mark Comilla simple de cierre diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b781ab310..3f8658a21 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -669,6 +669,7 @@ Toolbars Työkalurivit Advanced Lisäasetukset Double Column Kaksi saraketta + Keep Tools Beside the Canvas Työkalut kankaan vieressä Actions Toiminnot Asset Export Resurssien vienti Attributes Määritteet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e2eb9c01..9ba17bcc4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3196,6 +3196,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simuler un papier de couleur Simulate Paper Simuler le papier Double Column Deux colonnes + Keep Tools Beside the Canvas Outils à côté de la zone de travail Single Left Quotation Mark Guillemet simple ouvrant Single Quotes: Guillemets simples : Single Right Quotation Mark Guillemet simple fermant diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 36c7a5a6f..aa3094599 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1111,6 +1111,7 @@ font-technology Variable Változó Double Bar Kettős vonal Double Click To Isolate Elkülönítés dupla kattintással Double Column Két oszlop + Keep Tools Beside the Canvas Eszközök a vászon mellett Double Left Quotation Marks Bal oldali dupla idézőjel Double Quotes: Dupla idézőjelek: Double Right Quotation Marks Jobb oldali dupla idézőjel diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e32b28107..bffe798c5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -627,6 +627,7 @@ Toolbars Bilah alat Advanced Lanjutan Double Column Kolom ganda + Keep Tools Beside the Canvas Alat di samping kanvas Actions Tindakan Asset Export Ekspor aset Attributes Atribut diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4e0b5f341..8f6693d46 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3240,6 +3240,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simula carta colorata Simulate Paper Simula carta Double Column Colonna doppia + Keep Tools Beside the Canvas Strumenti accanto all'area di disegno Single Left Quotation Mark Virgoletta singola di apertura Single Quotes: Virgolette singole: Single Right Quotation Mark Virgoletta singola di chiusura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dc845fa7a..ea4ffac06 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -684,6 +684,7 @@ digits Malayalam マラヤーラム数字 Toolbars ツールバー Advanced 詳細 Double Column 2 列 + Keep Tools Beside the Canvas ツールをキャンバスの横に配置 Actions アクション Asset Export アセットの書き出し Attributes 属性 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5e3262f82..e64fd2fa3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -580,6 +580,7 @@ Toolbars ხელსაწყოთა ზოლები Advanced დამატებითი Double Column ორი სვეტი + Keep Tools Beside the Canvas ხელსაწყოები ტილოს გვერდით Actions მოქმედებები Asset Export რესურსების ექსპორტირება Attributes ატრიბუტები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8f9e3e286..336218c32 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -677,6 +677,7 @@ digits Malayalam 말라얄람어 Toolbars 도구 모음 Advanced 고급 Double Column 2열 + Keep Tools Beside the Canvas 캔버스 옆에 도구 배치 Actions 액션 Asset Export 에셋 내보내기 Attributes 속성 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 8dccad87c..bfbdef06c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -641,6 +641,7 @@ Toolbars Werkbalken Advanced Geavanceerd Double Column Dubbele kolom + Keep Tools Beside the Canvas Gereedschappen naast het canvas Actions Acties Asset Export Assetexport Attributes Kenmerken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c5513ad3e..f9fdfd743 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -628,6 +628,7 @@ Toolbars Paski narzędzi Advanced Zaawansowany Double Column Dwie kolumny + Keep Tools Beside the Canvas Narzędzia obok płótna Actions Operacje Asset Export Eksport zasobów Attributes Atrybuty diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f49c6aac2..92b5aa0c1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2687,6 +2687,7 @@ digits Malayalam Malaiala Simulate Colored Paper Simular Papel Colorido Simulate Paper Simular Papel Double Column Coluna Dupla + Keep Tools Beside the Canvas Ferramentas ao Lado da Tela Single Left Quotation Mark Aspa Esquerda Única Single Quotes: Aspas Simples: Single Right Quotation Mark Aspa Direita Única diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 85cf2e0e6..5d5252ee3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3235,6 +3235,7 @@ digits Malayalam Малаялам Simulate Colored Paper Имитировать цветную бумагу Simulate Paper Имитировать бумагу Double Column Двойной столбец + Keep Tools Beside the Canvas Инструменты рядом с холстом Single Left Quotation Mark Одинарная открывающая кавычка Single Quotes: Одинарные кавычки: Single Right Quotation Mark Одинарная закрывающая кавычка diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d19c210b9..6ef144c58 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3126,6 +3126,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simulera färgat papper Simulate Paper Simulera papper Double Column Dubbel kolumn + Keep Tools Beside the Canvas Verktyg bredvid arbetsytan Single Left Quotation Mark Enkelt vänster citattecken Single Quotes: Enkla citattecken: Single Right Quotation Mark Enkelt höger citattecken diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db8509636..d38231b3c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3029,6 +3029,7 @@ digits Malayalam Malayalam Simulate Colored Paper Renkli Kağıdı Simüle Et Simulate Paper Kağıdı Simüle Et Double Column Çift Sütun + Keep Tools Beside the Canvas Araçları Tuvalin Yanında Tut Single Left Quotation Mark Tek Sol Tırnak İşareti Single Quotes: Tek Alıntılar: Single Right Quotation Mark Tek Sağ Tırnak İşareti diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 12fafaa8d..353cbacd8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3232,6 +3232,7 @@ digits Malayalam Малаялам Simulate Colored Paper Імітувати кольоровий папір Simulate Paper Імітувати папір Double Column Подвійний стовпець + Keep Tools Beside the Canvas Інструменти поруч із полотном Single Left Quotation Mark Одинарна лапка, що відкриває Single Quotes: Одинарні лапки: Single Right Quotation Mark Одинарна лапка, що закриває diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 259881305..f1e49ed9b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -997,6 +997,7 @@ font-technology Variable Biến thiên Dots Chấm Double Click To Isolate Bấm đúp để cô lập Double Column Hai cột + Keep Tools Beside the Canvas Công cụ cạnh vùng vẽ Double Left Quotation Marks Dấu ngoặc kép trái đôi Double Quotes: Dấu ngoặc kép: Double Right Quotation Marks Dấu ngoặc kép phải đôi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 22c78c5ac..8b4599a16 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3073,6 +3073,7 @@ digits Malayalam 马拉雅拉姆数字 Simulate Colored Paper 模拟有色纸张 Simulate Paper 模拟纸张 Double Column 双列 + Keep Tools Beside the Canvas 工具置于画布旁 Single Left Quotation Mark 左单引号 Single Quotes: 单引号: Single Right Quotation Mark 右单引号 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 34506cb1e..822483936 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4106,6 +4106,7 @@ digits Malayalam 馬來亞拉姆數字 Simulate Colored Paper 模擬有色紙張 Simulate Paper 模擬紙張 Double Column 雙欄 + Keep Tools Beside the Canvas 工具置於畫布旁 Single Left Quotation Mark 左單引號 Single Quotes: 單引號: Single Right Quotation Mark 右單引號 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index f44359973..10a0015fe 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -477,6 +477,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.toolbar_advanced), + "window.toolsBesideCanvas" => opt_bool(p, "on").map(|on| { + let on = on.unwrap_or(!app.ui.tools_beside_canvas); + app.ui.tools_beside_canvas = on; + json!(on) + }), "imageTrace.preview" => opt_bool(p, "on").map(|on| { let on = on.unwrap_or(!app.ui.image_trace_preview); app.ui.image_trace_preview = on; diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ecaca0c6e..0c392d191 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -64,6 +64,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { } "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced, + "window.toolsBesideCanvas" => app.ui.tools_beside_canvas, "window.toolbarColumns" => match p.get("toolbar").and_then(Value::as_str) { None => app.ui.toolbar_double, Some(name) => crate::custom_toolbars::find(&app.ui, name).is_some_and(|b| b.double), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 6883f48b0..b3e091493 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -461,6 +461,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", ), ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ( + "window.toolsBesideCanvas", + "Keep Tools Beside the Canvas", + "", + "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state", + ), ( "window.floatTools", "Float Tool Group", @@ -596,7 +602,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel beside the canvas, inside any left-side dock. Returns {layout, closed, floating}", + "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on. Returns {layout, closed, floating}", ), ( "window.collapseDock", diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 419bd2ff5..98c625743 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -274,7 +274,7 @@ fn switching_workspace_during_a_tab_drag_keeps_the_new_workspace_layout() { #[test] fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { let mut h = Harness::new(); - let zone = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).expect("the Tools panel's docking strip"); + let zone = docked_tools(&h); let title = zone.min + vec2(20.0, 12.0); h.drag(title, pos2(600.0, 400.0)); let [x, y] = h.app.ui.toolbar_pos.expect("the Tools panel floats"); @@ -284,70 +284,186 @@ fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { assert_eq!(h.app.ui.toolbar_pos, None, "dropped on the left edge, it docks"); } -/// A left-side panel dock leaves the Tools panel between that dock and the document. -#[test] -fn docked_tools_stay_beside_the_canvas_with_left_side_panels() { - let mut h = Harness::new(); - h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); +/// The docked Tools panel as last drawn. +fn docked_tools(h: &Harness) -> Rect { + h.ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).expect("the docked Tools panel") +} + +fn settle(h: &mut Harness) { for _ in 0..3 { h.frame(vec![]); } - let tools = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).unwrap(); - let layers = h.tab("layers").unwrap(); - let canvas = h.app.canvas_rect.unwrap(); - assert!(layers.right() <= tools.left(), "the left dock precedes Tools: {layers:?}, {tools:?}"); - assert!(tools.right() <= canvas.left() && canvas.left() - tools.right() < 40.0, "Tools stays beside the canvas: {tools:?}, {canvas:?}"); +} + +fn keep_tools_beside_canvas(h: &mut Harness, on: bool) { + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({"on": on})).unwrap(), json!(on)); + settle(h); +} + +/// Where docked Tools sits with a left-side dock: `Some(true)` between the dock and the canvas, +/// `Some(false)` at the window's edge outside the dock. +fn tools_place(h: &Harness) -> Option { + let tools = docked_tools(h); + let layers = h.tab("layers")?; + let canvas = h.app.canvas_rect?; + if layers.right() <= tools.left() && tools.right() <= canvas.left() && canvas.left() - tools.right() < 40.0 { + Some(true) + } else if tools.left() < 10.0 && tools.right() <= layers.left() { + Some(false) + } else { + None + } +} + +/// Off by default: docked Tools stays at the window's edge, outside a left-side dock, and the +/// setting moves it between the dock and the canvas and back. +#[test] +fn docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas() { + let mut h = Harness::new(); + assert!(!h.app.ui.tools_beside_canvas); + assert_eq!(crate::menus::checked(&h.app, "window.toolsBesideCanvas", &json!({})), Some(false)); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + settle(&mut h); + assert_eq!(tools_place(&h), Some(false), "Tools, then the left dock, then the canvas"); + assert_eq!(crate::toolbar::docked_width(&h.app.ui), 0.0, "the canvas reserves no room for Tools"); + keep_tools_beside_canvas(&mut h, true); + assert_eq!(crate::menus::checked(&h.app, "window.toolsBesideCanvas", &json!({})), Some(true)); + assert_eq!(tools_place(&h), Some(true), "the left dock, then Tools, then the canvas"); + keep_tools_beside_canvas(&mut h, false); + assert_eq!(tools_place(&h), Some(false), "back at the window's edge"); } #[test] -fn tools_redock_beside_left_panels_from_either_drop_target() { +fn keep_tools_beside_the_canvas_toggles_and_rejects_a_value_that_is_not_true_or_false() { + let mut h = Harness::new(); + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({})).unwrap(), json!(true)); + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({})).unwrap(), json!(false)); + assert!(h.app.run("window.toolsBesideCanvas", json!({"on": "yes"})).is_err()); + assert!(!h.app.ui.tools_beside_canvas, "a bad value changes nothing"); +} + +/// Off, only the window's edge docks a floating Tools panel; on, the canvas's edge does too, and +/// both dock it beside the canvas. +#[test] +fn tools_redock_at_the_targets_of_the_chosen_placement() { + for beside in [false, true] { + let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + for at_window_edge in [false, true] { + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + settle(&mut h); + let canvas = h.app.canvas_rect.unwrap(); + let canvas_edge = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())); + assert_eq!(canvas_edge.is_some(), beside, "a canvas-edge target only while kept beside the canvas"); + let canvas_edge = canvas_edge.map_or(pos2(canvas.left() + 12.0, canvas.center().y), |r| r.center()); + let target = if at_window_edge { pos2(12.0, canvas.center().y) } else { canvas_edge }; + h.drag(pos2(620.0, 307.0), target); + if !beside && !at_window_edge { + assert!(h.app.ui.toolbar_pos.is_some(), "the canvas's edge does not dock Tools while the setting is off"); + continue; + } + assert_eq!(h.app.ui.toolbar_pos, None, "beside: {beside}, at the window edge: {at_window_edge}"); + settle(&mut h); + assert_eq!(tools_place(&h), Some(beside), "beside: {beside}, at the window edge: {at_window_edge}"); + } + } +} + +/// Turning the setting off while Tools floats forgets the canvas-edge target, and turning it on +/// leaves a floating Tools panel floating. +#[test] +fn toggling_while_tools_floats_keeps_it_floating_and_drops_the_canvas_target() { let mut h = Harness::new(); h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); - for at_window_edge in [false, true] { - h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); - for _ in 0..3 { - h.frame(vec![]); - } - let zone = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).unwrap(); - let target = if at_window_edge { pos2(12.0, zone.center().y) } else { zone.center() }; - h.drag(pos2(620.0, 307.0), target); - assert_eq!(h.app.ui.toolbar_pos, None, "redock at window edge: {at_window_edge}"); - let tools = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).unwrap(); - assert!(h.tab("layers").unwrap().right() <= tools.left()); - assert!(tools.right() <= h.app.canvas_rect.unwrap().left()); - } + keep_tools_beside_canvas(&mut h, true); + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + settle(&mut h); + let zone = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).expect("the canvas-edge target"); + keep_tools_beside_canvas(&mut h, false); + assert!(h.app.ui.toolbar_pos.is_some(), "turning it off leaves Tools floating"); + assert!(h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).is_none(), "the canvas-edge target is gone"); + h.drag(pos2(620.0, 307.0), zone.center()); + assert!(h.app.ui.toolbar_pos.is_some(), "the old canvas-edge strip docks nothing"); + let [x, y] = h.app.ui.toolbar_pos.unwrap(); + keep_tools_beside_canvas(&mut h, true); + assert_eq!(h.app.ui.toolbar_pos, Some([x, y]), "turning it on leaves Tools floating where it was"); + keep_tools_beside_canvas(&mut h, false); + h.drag(pos2(x + 20.0, y + 7.0), pos2(12.0, zone.center().y)); + assert_eq!(h.app.ui.toolbar_pos, None); + settle(&mut h); + assert_eq!(tools_place(&h), Some(false)); +} + +/// The setting is kept with a workspace; state and workspaces saved without it keep Tools at the +/// window's edge. +#[test] +fn keep_tools_beside_the_canvas_is_saved_and_off_in_state_saved_without_it() { + let mut saved = serde_json::to_value(crate::state::UiState { tools_beside_canvas: true, ..Default::default() }).unwrap(); + assert_eq!(saved["tools_beside_canvas"], json!(true)); + let reloaded: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert!(reloaded.tools_beside_canvas); + saved.as_object_mut().unwrap().remove("tools_beside_canvas"); + let old: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert!(!old.tools_beside_canvas && old.toolbar_pos.is_none(), "older state docks Tools at the window's edge"); + + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + keep_tools_beside_canvas(&mut h, true); + let mut workspace = serde_json::to_value(crate::workspaces::capture(&h.app.ui, "Mine")).unwrap(); + assert_eq!(workspace["toolsBesideCanvas"], json!(true)); + workspace.as_object_mut().unwrap().remove("toolsBesideCanvas"); + let old: crate::workspaces::Workspace = serde_json::from_value(workspace).unwrap(); + assert!(!old.tools_beside_canvas, "a workspace saved without it docks Tools at the window's edge"); + + h.app.run("window.workspace.new", json!({"name": "Mine"})).unwrap(); + h.app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + settle(&mut h); + assert!(!h.app.ui.tools_beside_canvas, "built-in workspaces keep Tools at the window's edge"); + h.app.run("window.workspace", json!({"name": "Mine"})).unwrap(); + settle(&mut h); + assert!(h.app.ui.tools_beside_canvas); + assert_eq!(tools_place(&h), Some(true), "the saved workspace brings Tools back beside the canvas"); } #[test] fn tools_follow_left_panels_when_resized_hidden_and_restored() { - let mut h = Harness::new(); - h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); - gesture(&mut h.app, Action::ResizeColumn { panel: "layers".into(), width: 430.0 }).unwrap(); - h.app.ui.toolbar_double = true; - for hidden in [false, true, false] { - if hidden == h.app.ui.dock { - h.app.run("window.panels", json!({})).unwrap(); + for (beside, double) in [(false, false), (false, true), (true, false), (true, true)] { + let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + gesture(&mut h.app, Action::ResizeColumn { panel: "layers".into(), width: 430.0 }).unwrap(); + h.app.ui.toolbar_double = double; + let width = if double { 80.0 } else { 48.0 }; + for hidden in [false, true, false] { + if hidden == h.app.ui.dock { + h.app.run("window.panels", json!({})).unwrap(); + } + settle(&mut h); + let tools = docked_tools(&h); + assert!((tools.width() - (width + 6.0)).abs() < 1.0, "{} columns: {tools:?}", if double { 2 } else { 1 }); + if hidden { + assert!(h.tab("layers").is_none()); + assert!(tools.left() < 10.0); + } else { + assert_eq!(tools_place(&h), Some(beside), "beside: {beside}, double: {double}"); + assert!(h.tab("layers").unwrap().width() > 0.0); + } + let canvas = h.app.canvas_rect.unwrap(); + assert!(canvas.left() - tools.left() >= width, "Tools has its own room: {tools:?}, {canvas:?}"); + assert!(canvas.width() > 200.0); } - for _ in 0..3 { - h.frame(vec![]); - } - let tools = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).unwrap(); - if hidden { - assert!(h.tab("layers").is_none()); - assert!(tools.left() < 10.0); - } else { - assert!(h.tab("layers").unwrap().right() <= tools.left()); - } - let canvas = h.app.canvas_rect.unwrap(); - assert!(canvas.left() - tools.left() >= 80.0, "double-width Tools has its own room"); - assert!(canvas.width() > 200.0); } } +/// A layout with panels above or below the canvas: the full-height strip along the window's left +/// edge docks Tools in both placements; off, Tools runs the window's height beside those panels, +/// and on, it stays in the canvas's row. #[test] fn tools_redock_at_window_edge_above_and_below_canvas() { - for above in [true, false] { + for (beside, above) in [(false, true), (false, false), (true, true), (true, false)] { let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); let tabs = |panel: &str| Box::new(Node::Tabs { panels: vec![panel.to_string()], active: 0 }); h.app.ui.docking = Some(Layout { root: Some(Node::Split { @@ -372,7 +488,17 @@ fn tools_redock_at_window_edge_above_and_below_canvas() { let canvas = h.app.canvas_rect.unwrap(); assert!(panel.center().y < canvas.top() || panel.center().y > canvas.bottom()); h.drag(pos2(620.0, 307.0), pos2(12.0, panel.center().y)); - assert_eq!(h.app.ui.toolbar_pos, None, "window-edge drop above canvas: {above}"); + assert_eq!(h.app.ui.toolbar_pos, None, "window-edge drop, beside: {beside}, above the canvas: {above}"); + settle(&mut h); + let tools = docked_tools(&h); + let panel = h.tab(if above { "layers" } else { "properties" }).unwrap(); + assert!(tools.left() < 10.0 && tools.right() <= panel.left() + 60.0, "{tools:?}"); + match (beside, above) { + (false, true) => assert!(tools.top() <= panel.top(), "Tools runs past the panel above: {tools:?}, {panel:?}"), + (false, false) => assert!(tools.bottom() >= panel.bottom(), "Tools runs past the panel below: {tools:?}, {panel:?}"), + (true, true) => assert!(tools.top() >= panel.bottom(), "Tools stays below the panel above: {tools:?}, {panel:?}"), + (true, false) => assert!(tools.bottom() <= panel.top(), "Tools stays above the panel below: {tools:?}, {panel:?}"), + } } } @@ -382,6 +508,7 @@ fn capture_tools_beside_left_panels() { let dir = std::path::PathBuf::from(std::env::var_os("LEFT_TOOLS_SCREENSHOTS").unwrap()); let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.toolsBesideCanvas", json!({"on": true})).unwrap(); app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); let mut h = egui_kittest::Harness::builder() .with_size(vec2(1200.0, 800.0)) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3ab62e449..64ead689f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -460,9 +460,14 @@ pub struct UiState { #[serde(default, skip_serializing_if = "Option::is_none")] pub dock_width: Option, /// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`: - /// docked beside the canvas's left edge, inside any left-side panel dock. + /// docked at the window's left edge, or beside the canvas with [`Self::tools_beside_canvas`]. #[serde(default)] pub toolbar_pos: Option<[f32; 2]>, + /// The docked Tools panel sits between the left-side panels and the canvas instead of at the + /// window's left edge (Window › Toolbars › Keep Tools Beside the Canvas, + /// `window.toolsBesideCanvas`). Off by default, and off in state saved without it. + #[serde(default)] + pub tools_beside_canvas: bool, pub status_bar: bool, pub dock: bool, /// The bars that Tab (`window.dock`) hid, which the next Tab shows again; none when Tab has not @@ -992,6 +997,7 @@ impl Default for UiState { docking_generation: 0, dock_width: None, toolbar_pos: None, + tools_beside_canvas: false, status_bar: true, dock: true, tab_hid: None, diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 82014ddee..dce5912ba 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -95,19 +95,26 @@ pub fn remember(app: &mut VectorcraftApp, id: &str) { crate::custom_toolbars::remember(&mut app.ui, id); } +/// The docked Tools panel's rect as last laid out; removed while it floats. +pub(crate) fn docked_rect_id() -> egui::Id { + egui::Id::unique("toolbar-docked-rect") +} + fn panel_width(ui: &crate::state::UiState) -> f32 { if ui.toolbar_double { 80.0 } else { WIDTH } } -/// Space reserved inside the protected canvas panel for a visible docked Tools panel. +/// Space reserved inside the protected canvas panel for a visible Tools panel docked beside the +/// canvas (`UiState::tools_beside_canvas`); none while Tools docks at the window's edge. pub(crate) fn docked_width(ui: &crate::state::UiState) -> f32 { - if ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() { panel_width(ui) + 6.0 } else { 0.0 } + if ui.tools_beside_canvas && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() { panel_width(ui) + 6.0 } else { 0.0 } } -/// Register redocking targets even while Tools floats; a docked panel follows the canvas's -/// left edge as dock columns resize, collapse or hide. +/// With `UiState::tools_beside_canvas` on, the canvas-edge redocking target, registered even +/// while Tools floats, and the docked panel, which follows the canvas's left edge as dock columns +/// resize, collapse or hide. With it off, `app.rs` draws Tools before the docking layout. pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) { - if app.ui.toolbar && app.ui.screen_mode < 3 { + if app.ui.tools_beside_canvas && app.ui.toolbar && app.ui.screen_mode < 3 { crate::tools_float::set_dock_zones(ui.ctx(), ui.available_rect_before_wrap(), WIDTH); if app.ui.toolbar_pos.is_none() { show(app, ui); @@ -125,11 +132,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { match app.ui.toolbar_pos { None => { let margin = egui::Margin { left: 6, right: 0, top: 6, bottom: 6 }; - egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| { + let docked = egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| { crate::theme::card(ui, &t, margin, |ui| body(app, ui, cols, None)); }); + ui.ctx().data_mut(|d| d.insert_temp(docked_rect_id(), docked.response.rect)); } Some(pos) => { + ui.ctx().data_mut(|d| d.remove::(docked_rect_id())); // Floating, kept inside the window, its tools scrolling when they don't fit under it. let ctx = ui.ctx().clone(); let id = egui::Id::unique("toolbar-floating"); diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index c6b4422ba..13108368f 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -1,9 +1,10 @@ -//! The Tools panel floats by its title bar and docks beside the canvas's left edge +//! The Tools panel floats by its title bar and docks again on the window's left edge //! ([`crate::toolbar`]). It stays outside the docking layout: dragging its title bar out of the //! docked panel floats it under the pointer, a floating panel's title bar moves it, and a release -//! over the canvas's left edge or the window's left edge (lit up) docks it beside the canvas. -//! Escape or losing the -//! window's focus puts it back where the drag began. +//! over the strip along the window's left edge (lit up) docks it there. With Keep Tools Beside the +//! Canvas on (`UiState::tools_beside_canvas`), a release over the canvas's left edge docks it too, +//! and either release docks it beside the canvas. Escape or losing the window's focus puts it back +//! where the drag began. //! //! `window.panel.float {panel: "tools"}` / `window.panel.dock {panel: "tools"}` do the same for //! agents. Its position is kept with the preferences and in user workspaces, and a position saved @@ -42,7 +43,8 @@ pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) } -/// The strip along the canvas's left edge where the Tools panel docks, as last laid out. +/// The strip along the canvas's left edge where the Tools panel docks while Keep Tools Beside the +/// Canvas is on, as last laid out. pub(crate) fn tools_zone_id() -> Id { Id::unique("tools-float-zone") } @@ -58,6 +60,12 @@ pub(crate) fn set_dock_zones(ctx: &egui::Context, canvas: Rect, width: f32) { }); } +/// Forget the canvas-edge target, so a strip laid out while Keep Tools Beside the Canvas was on +/// does not dock Tools after it is turned off. +pub(crate) fn clear_canvas_dock_zone(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::(tools_zone_id())); +} + /// The legacy 48-point window-edge target spans the full layout below the bars, including /// panels above and below the canvas. Its width does not follow the toolbar's column count. pub(crate) fn set_window_dock_zone(ctx: &egui::Context, window: Rect) { @@ -108,9 +116,13 @@ pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: & } } -/// The docking strip under `at`, if any. -fn drop_zone(ctx: &egui::Context, at: Pos2) -> Option { - ctx.data(|d| [d.get_temp::(tools_zone_id()), d.get_temp::(window_zone_id())].into_iter().flatten().find(|r| r.contains(at))) +/// The docking strip under `at`, if any: the window's left edge, and with `beside_canvas` the +/// canvas's left edge as well. +fn drop_zone(ctx: &egui::Context, at: Pos2, beside_canvas: bool) -> Option { + ctx.data(|d| { + let canvas = d.get_temp::(tools_zone_id()).filter(|_| beside_canvas); + [canvas, d.get_temp::(window_zone_id())].into_iter().flatten().find(|r| r.contains(at)) + }) } fn highlight(ctx: &egui::Context, zone: Rect) { @@ -134,7 +146,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } let at = ctx.input(|i| i.pointer.interact_pos()); - let zone = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_zone(ctx, at)); + let zone = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_zone(ctx, at, app.ui.tools_beside_canvas)); if ctx.input(|i| i.pointer.primary_down()) { if let Some(p) = at.map(|at| at - m.grab) && p.x.is_finite() diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index d7590ff3b..d13e6a688 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -57,6 +57,9 @@ pub struct Workspace { pub docking_collapsed: std::collections::BTreeSet, /// Where the Tools panel floats (built-in workspaces: docked). pub toolbar_pos: Option<[f32; 2]>, + /// The docked Tools panel sits beside the canvas, inside any left-side panels (built-in + /// workspaces and workspaces saved without it: at the window's left edge). + pub tools_beside_canvas: bool, /// Main dock column width in screen points while `docking` is none (#1753). #[serde(default, skip_serializing_if = "Option::is_none")] pub dock_width: Option, @@ -197,6 +200,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { docking_icons: Default::default(), docking_collapsed: Default::default(), toolbar_pos: ui.toolbar_pos, + tools_beside_canvas: ui.tools_beside_canvas, dock_width: ui.dock_width, toolbars: ui.custom_toolbars.clone(), } @@ -223,6 +227,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.docking_hidden = w.docking_hidden.clone(); ui.docking_hidden_groups = w.docking_hidden_groups.clone(); ui.toolbar_pos = w.toolbar_pos; + ui.tools_beside_canvas = w.tools_beside_canvas; ui.dock_width = w.dock_width; crate::state::sanitize_toolbar_pos(&mut ui.toolbar_pos); ui.custom_toolbars = w.toolbars.clone(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5f70424ce..a799f22ff 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -429,7 +429,9 @@ below). Dragging a tab, a group's drag bar or an icon out of the dock and droppi floats it; dropping it on a group's tabs, on a group's upper or lower edge or beside a column docks it there. On the desktop each floating window is an OS window of its own; on the web and on Wayland it floats inside the app window. `ui.toolbar_pos` in `ui.inspect` is the Tools panel's corner (null: docked); `window.panel.float` and -`window.panel.dock` with `panel: "tools"` float it at `x`, `y` or dock it on the window's left edge. +`window.panel.dock` with `panel: "tools"` float it at `x`, `y` or dock it on the window's left edge. With +`window.toolsBesideCanvas {on?}` (Window › Toolbars › Keep Tools Beside the Canvas, off by default; `ui.tools_beside_canvas`) +the docked Tools panel sits between left-side panels and the canvas, and `window.panel.dock` docks it there. Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection (fields `preset`: a preset name, setting it loads that preset's options; the option keys of diff --git a/docs/mcp.md b/docs/mcp.md index 4908480fa..79159150e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1693,6 +1693,21 @@ changes neither option. Behavior reference: [View artwork](https://helpx.adobe.c {"name":"invoke_menu","arguments":{"command":"ui.navigatorPanelOptions","params":{"color":"Light Blue","solidDashes":true}}} ``` +## Tools panel placement + +By default the docked Tools panel sits at the window's left edge, outside any panels docked on the left. +`window.toolsBesideCanvas {on?}` (Window › Toolbars › Keep Tools Beside the Canvas, remote mode, `invoke_menu`) +docks it between those panels and the canvas instead (`on: true`), back at the window's edge (`on: false`), or +toggles it (no `on`), and answers the new state. A value of `on` that is not true or false is an error and changes +nothing. With the setting on, a floating Tools panel docks when it is dropped on the canvas's left edge as well as +on the window's left edge, and `window.panel.dock {panel: "tools"}` docks it beside the canvas. A floating Tools +panel stays floating when the setting changes. The setting is VectorCraft's own layout option, off by default; it is +kept with the workspace, and built-in workspaces and workspaces saved without it keep Tools at the window's edge. + +```json +{"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} +``` + ## Ruler guides Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4dfd8d85..6a5cf1bb7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -695,6 +695,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). + - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). From 4ca33bc7a396b5df8cf7960ff85f1ea31de272bd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 06:45:06 -0800 Subject: [PATCH 37/51] Toolbars: document that a null on toggles Keep Tools Beside the Canvas window.toolsBesideCanvas reads on with opt_bool, which treats null like an omitted value. docs/mcp.md and the params text now say that an omitted or null on toggles the setting and that any other non-boolean value is an error. --- crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/mcp.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index b3e091493..5813da723 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -465,7 +465,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.toolsBesideCanvas", "Keep Tools Beside the Canvas", "", - "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state", + "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted or null; any other value is an error); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state", ), ( "window.floatTools", diff --git a/docs/mcp.md b/docs/mcp.md index 79159150e..65c48aa48 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1698,7 +1698,7 @@ changes neither option. Behavior reference: [View artwork](https://helpx.adobe.c By default the docked Tools panel sits at the window's left edge, outside any panels docked on the left. `window.toolsBesideCanvas {on?}` (Window › Toolbars › Keep Tools Beside the Canvas, remote mode, `invoke_menu`) docks it between those panels and the canvas instead (`on: true`), back at the window's edge (`on: false`), or -toggles it (no `on`), and answers the new state. A value of `on` that is not true or false is an error and changes +toggles it (`on` omitted or null), and answers the new state. Any other value of `on` is an error and changes nothing. With the setting on, a floating Tools panel docks when it is dropped on the canvas's left edge as well as on the window's left edge, and `window.panel.dock {panel: "tools"}` docks it beside the canvas. A floating Tools panel stays floating when the setting changes. The setting is VectorCraft's own layout option, off by default; it is From d5e1c004701df141a5cdc92fb554a4d52a5b857a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 18:00:09 -0800 Subject: [PATCH 38/51] File formats: HEIC and HEIF images open and place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open and File › Place read HEIC and HEIF still images (.heic, .heif) through heic-rs 0.1.4, the pure-Rust decoder photocraft-heif uses, and store the pixels as PNG. Single pictures and grids of tiles, 8 to 10 bits, with or without an alpha image, are read with the container's rotation, mirror and crop applied. A grid without a color box of its own takes its tiles'. When neither the image nor its tiles have an nclx box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an nclx box or, without a color box, by the stream, is converted to sRGB. The EXIF resolution tags size the image; without them it is 72 ppi. When the container's rotations turn the image an odd number of quarter turns, the resolution's axes swap with the pixels'. Image sequences, images that aren't HEVC-coded, grids of cropped tiles and images past the pixel allowance are refused with a message before the pixels are decoded. The allowance counts each coded picture at the size its sequence parameter sets give it. The decoder is in the engine's `heif` feature, which is on by default and listed by the desktop, CLI and web apps. A build without it still recognizes the files and refuses them with a message. --- ASSETS.md | 12 + Cargo.lock | 11 + apps/vectorcraft-cli/Cargo.toml | 2 +- apps/vectorcraft-web/Cargo.toml | 2 +- apps/vectorcraft/Cargo.toml | 2 +- crates/engine/Cargo.toml | 13 +- crates/engine/src/cmd/fileio.rs | 3 + crates/engine/src/cmd/fileio/formats.rs | 14 +- crates/engine/src/cmd/fileio/heifread.rs | 627 ++++++++++++++++++ crates/engine/src/cmd/fileio/load.rs | 10 + crates/engine/src/cmd/fileio/ppi.rs | 5 +- .../engine/src/cmd/fileio/tests_heifread.rs | 620 +++++++++++++++++ .../tests/fixtures/heif/quadrants-10bit.heic | Bin 0 -> 486 bytes .../tests/fixtures/heif/quadrants-300ppi.heic | Bin 0 -> 817 bytes .../tests/fixtures/heif/quadrants-alpha.heic | Bin 0 -> 1178 bytes .../tests/fixtures/heif/quadrants-grid.heic | Bin 0 -> 754 bytes .../tests/fixtures/heif/quadrants-grid16.heic | Bin 0 -> 891 bytes .../engine/tests/fixtures/heif/quadrants.heic | Bin 0 -> 682 bytes .../fixtures/heif/tones-p3-icc-only.heic | Bin 0 -> 1212 bytes .../tests/fixtures/heif/tones-p3-icc.heic | Bin 0 -> 1231 bytes .../tests/fixtures/heif/tones-p3-nclx.heic | Bin 0 -> 598 bytes crates/engine/tests/fixtures/heif/tones.heic | Bin 0 -> 598 bytes .../fixtures/heif/wide-rotated-300x150.heic | Bin 0 -> 787 bytes .../tests/fixtures/heif/wide-rotated.heic | Bin 0 -> 652 bytes crates/engine/tests/import_fuzz.rs | 27 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/file-format-parity.md | 4 +- docs/mcp.md | 4 +- docs/releasing.md | 2 +- docs/target-app-parity.md | 5 +- 51 files changed, 1416 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/heifread.rs create mode 100644 crates/engine/src/cmd/fileio/tests_heifread.rs create mode 100644 crates/engine/tests/fixtures/heif/quadrants-10bit.heic create mode 100644 crates/engine/tests/fixtures/heif/quadrants-300ppi.heic create mode 100644 crates/engine/tests/fixtures/heif/quadrants-alpha.heic create mode 100644 crates/engine/tests/fixtures/heif/quadrants-grid.heic create mode 100644 crates/engine/tests/fixtures/heif/quadrants-grid16.heic create mode 100644 crates/engine/tests/fixtures/heif/quadrants.heic create mode 100644 crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic create mode 100644 crates/engine/tests/fixtures/heif/tones-p3-icc.heic create mode 100644 crates/engine/tests/fixtures/heif/tones-p3-nclx.heic create mode 100644 crates/engine/tests/fixtures/heif/tones.heic create mode 100644 crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic create mode 100644 crates/engine/tests/fixtures/heif/wide-rotated.heic diff --git a/ASSETS.md b/ASSETS.md index 34ff73361..17ab680ff 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -368,6 +368,18 @@ Generated-in-code art is original and has no file to list. This covers the defau | `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` | | `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` | | `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` | +| `crates/engine/tests/fixtures/heif/quadrants.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 red, green, blue and white quadrants encoded with libheif's `heif-enc` 1.23.2 (x265); SHA-256 `122d14963fe899daa736cd216902cf4fc8f2192b3a0ba81f94004adcd71a0ff5` | +| `crates/engine/tests/fixtures/heif/quadrants-alpha.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with a transparent right half, encoded with `heif-enc` 1.23.2; SHA-256 `55b5a537401ead742714c3a1bc526559ec8c1ba97cdb7f43ac63f4037167475b` | +| `crates/engine/tests/fixtures/heif/quadrants-10bit.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants at 10 bits, encoded with `heif-enc` 1.23.2; SHA-256 `11014848c6a838e5799d87e034eff1b06b816712ed70ba451bd582d72741774f` | +| `crates/engine/tests/fixtures/heif/quadrants-grid.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 128 × 128 quadrants as a 2 × 2 grid of 64 × 64 tiles, encoded with `heif-enc` 1.23.2; SHA-256 `5802f8715110954eb9c58d4bdcc10d1200426a48cf726f1b88dfb60da825ba7a` | +| `crates/engine/tests/fixtures/heif/quadrants-300ppi.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with EXIF resolution tags of 300 ppi, encoded with `heif-enc` 1.23.2; SHA-256 `6a344a6de04fc5fceaee46b6e0028b12478c90bdcb6f6f14ed8c86a44cb203d7` | +| `crates/engine/tests/fixtures/heif/wide-rotated.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 16 red and blue halves stored with a quarter-turn rotation, encoded with `heif-enc` 1.23.2; SHA-256 `e3a2c3a9beb4066c13d6deb8e1311805c6aac485fde8784f1ee317dd95b89973` | +| `crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the rotated halves with EXIF resolution tags of 300 ppi across and 150 ppi down, encoded with `heif-enc` 1.23.2; SHA-256 `79ad7945cd2c1ea9d3e70ac9b93d9a162fd8ce37541a0f3de44e12f04745921d` | +| `crates/engine/tests/fixtures/heif/tones.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants of four mid tones, encoded with `heif-enc` 1.23.2; SHA-256 `29ed7d75fbfbc4c298eb2da3a6b62625a150cd6bfe5b7ff1d847437501b1b4b1` | +| `crates/engine/tests/fixtures/heif/tones-p3-nclx.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones labeled Display P3 in an `nclx` color box, encoded with `heif-enc` 1.23.2; SHA-256 `baeb32ffa58ac1ac29a00c9e388b9281f4acb4695c4681e4933d0ad061447101` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile (`vectorcraft_color::cms::icc_bytes`), encoded with `heif-enc` 1.23.2; SHA-256 `472e1a123b9515926a8985a04c3f680387e8c410dbf8f7ff0cfb8cb99c89e93b` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile as their only color box, encoded with `heif-enc` 1.23.2; SHA-256 `2654fdd21e56641107db20b3f4d0a66a86420ac040d42ddf55e20f7e789d5c55` | +| `crates/engine/tests/fixtures/heif/quadrants-grid16.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each cropped from a 64 × 64 picture, encoded with `heif-enc` 1.23.2; SHA-256 `bd114ee66cdac10142729dc9dddfa1cd96993a5cefa54c2f25b65496652f007f` | ## Assets embedded by the PDF backend diff --git a/Cargo.lock b/Cargo.lock index 72d591b91..287a23f0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2286,6 +2286,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heic-rs" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" +dependencies = [ + "rayon", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -6246,10 +6255,12 @@ dependencies = [ "crc32fast", "flate2", "hayro-jpeg2000", + "heic-rs", "image", "kurbo 0.13.1", "log", "proptest", + "rayon", "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index f7faf7a92..376f43190 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-format = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-svg = { workspace = true } diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index ff4bcef86..6e4dbd7f0 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -14,7 +14,7 @@ name = "vectorcraft-web" path = "src/main.rs" [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } # The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft. craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 170a7e5c7..4ab382d5c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -15,7 +15,7 @@ wgpu = ["eframe/wgpu", "dep:winsafe"] accessibility = ["eframe/accesskit"] [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index eddf966db..0ebcb71c5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,10 +9,14 @@ description = "VectorCraft engine: sessions, history, selection, the command reg workspace = true [features] -default = ["bundled-fonts"] +default = ["bundled-fonts", "heif"] # The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at # run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`. bundled-fonts = ["vectorcraft-text/bundled-fonts"] +# HEIC and HEIF images open and place, decoded by heic-rs (an HEVC decoder). A build without this +# feature still recognizes the files and refuses them with a message. The apps list it beside +# `bundled-fonts`. +heif = ["dep:heic-rs", "dep:rayon"] [dependencies] # Which paths stored in documents may be opened. @@ -37,6 +41,8 @@ vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } +# Pure Rust, no unsafe; pinned at the version photocraft-heif uses. +heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true } flate2 = { workspace = true } chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). @@ -54,6 +60,11 @@ log = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["std", "process"] } +# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true } +rayon = { version = "1.12", optional = true } + [dev-dependencies] vectorcraft-affinity = { workspace = true, features = ["synth"] } crc32fast = { workspace = true } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index ae2ecde66..3f881b8e4 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -33,6 +33,7 @@ pub(crate) mod imagemap; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +mod heifread; mod jp2read; mod metafile; mod naming; @@ -203,6 +204,8 @@ mod tests_psd; #[cfg(test)] mod tests_psdread; +#[cfg(test)] +mod tests_heifread; #[cfg(test)] mod tests_jp2read; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 33ecfa8ab..ef87ea1b6 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -503,6 +503,8 @@ pub const FORMATS: &[Format] = &[ reader("pxr", "Pixar", &["pxr"], "image/x-pixar", true), // JP2 and raw codestreams are decoded to PNG; JPX composition is not supported. reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true), + // HEVC-coded HEIF still images, decoded to PNG; image sequences are not read. + reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true), ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -537,6 +539,8 @@ pub const OPEN_EXTS: &[&str] = &[ "j2k", "j2c", "jpc", + "heic", + "heif", "psd", "pdd", "psb", @@ -554,11 +558,13 @@ pub const OPEN_EXTS: &[&str] = &[ /// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to /// raster editors, so Finder and file managers don't offer a vector app for them, and there is no -/// `.epsf`, `.ps`, Targa, PCX, Pixar or JPEG 2000 type, nor one for the `.pns`, `.dib` and `.rle` names of +/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. -pub const UNASSOCIATED_EXTS: &[&str] = - &["psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc"]; +pub const UNASSOCIATED_EXTS: &[&str] = &[ + "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", + "heic", "heif", +]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares /// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order. @@ -621,6 +627,8 @@ pub const PLACE_EXTS: &[&str] = &[ "j2k", "j2c", "jpc", + "heic", + "heif", "psd", "pdd", "psb", diff --git a/crates/engine/src/cmd/fileio/heifread.rs b/crates/engine/src/cmd/fileio/heifread.rs new file mode 100644 index 000000000..4867022de --- /dev/null +++ b/crates/engine/src/cmd/fileio/heifread.rs @@ -0,0 +1,627 @@ +//! HEIC and HEIF images for File › Open and File › Place, decoded with heic-rs when the engine's +//! `heif` feature is on (the default). The image is bounded before decoding and converted to RGBA +//! for the common PNG storage path. A build without the feature still recognizes the files and +//! refuses them with a message. + +#[cfg(all(test, feature = "heif"))] +pub(super) use decoder::{active_sps, picture_bytes, sps_signal, sps_size}; +#[cfg(feature = "heif")] +pub(super) use decoder::{decode, quarter_turned, with_exif}; + +/// The brands heic-rs 0.1.4 reads: HEVC-coded images and image sequences (ISO/IEC 23008-12 annex +/// B) and the general image and sequence brands. +const BRANDS: [&[u8; 4]; 7] = [b"heic", b"heix", b"heim", b"heis", b"hevc", b"mif1", b"msf1"]; + +/// Recognize a HEIF file by its `ftyp` box: one of [`BRANDS`] as the major brand or a compatible +/// one, and no AVIF brand (`avif`, `avis`), as heic-rs refuses a file that lists one. The box is +/// read as JP2 boxes are ([`super::jp2read::boxes`]): ISO BMFF sizes, the 64-bit one included. +pub(super) fn is_heif(bytes: &[u8]) -> bool { + let Some((b"ftyp", ftyp)) = super::jp2read::boxes(bytes).next() else { return false }; + let Some(major) = ftyp.get(..4).and_then(|b| <&[u8; 4]>::try_from(b).ok()) else { return false }; + if ftyp.len() < 8 { + return false; + } + let compatible = ftyp.get(8..).unwrap_or_default().as_chunks::<4>().0; + let brands = || std::iter::once(major).chain(compatible); + !brands().any(|brand| matches!(brand, b"avif" | b"avis")) && brands().any(|brand| BRANDS.contains(&brand)) +} + +/// Without the `heif` feature every HEIF file is refused. +#[cfg(not(feature = "heif"))] +pub(super) fn decode(_bytes: &[u8], _max_alloc: u64) -> Result { + Err("HEIC and HEIF images can't be opened in this build".into()) +} + +/// Without the `heif` feature no image is decoded, so none is turned. +#[cfg(not(feature = "heif"))] +pub(super) fn quarter_turned(_bytes: &[u8]) -> bool { + false +} + +/// Without the `heif` feature no resolution is read. +#[cfg(not(feature = "heif"))] +pub(super) fn with_exif(_bytes: &[u8], _read: impl FnOnce(&[u8]) -> Option) -> Option { + None +} + +#[cfg(feature = "heif")] +mod decoder { + use heic_rs::context::Context; + use heic_rs::{DecodeOptions, Error, MatrixCoefficients, Nclx, PixelLayout, Range}; + + /// The message for a file that isn't a readable HEIF image. + const UNREADABLE: &str = "the HEIC or HEIF file can't be read"; + /// The message for an image sequence, an overlay, an image that isn't HEVC-coded, a grid whose + /// tiles are cropped, turned or mirrored, or an HEVC coding tool the decoder doesn't implement. + const UNSUPPORTED: &str = "the HEIC or HEIF file holds a kind of image that can't be read"; + + /// Decode the primary image within `max_alloc` bytes, counting the decoder's planes, the RGBA + /// output and the copy that rotating or cropping makes, with the container's rotation, mirror + /// and crop applied. An embedded RGB ICC profile, or `nclx` Display P3 primaries with the sRGB + /// curve, is converted to sRGB; other `nclx` values are read as sRGB. A panic inside the + /// decoder is reported as an unreadable file. + pub(in super::super) fn decode(bytes: &[u8], max_alloc: u64) -> Result { + crate::guard::catch_panic(|| decode_unguarded(bytes, max_alloc)).unwrap_or_else(|_| Err(UNREADABLE.into())) + } + + /// Whether the container's rotations of the primary image add up to an odd number of quarter + /// turns, so its width and height swap. The decoder applies every `irot` the item lists. + pub(in super::super) fn quarter_turned(bytes: &[u8]) -> bool { + let turned = || { + let ctx = Context::open(bytes).ok()?; + let props = ctx.props(ctx.meta.primary).ok()?; + let quarters = props.transforms.iter().filter(|t| matches!(t, heic_rs::props::Transform::Rotate(r) if r.swaps_axes())).count(); + Some(quarters % 2 == 1) + }; + crate::guard::catch_panic(turned).ok().flatten().unwrap_or(false) + } + + /// `read` of the primary image's EXIF block, a TIFF structure inside `bytes`, for its + /// resolution tags. + pub(in super::super) fn with_exif(bytes: &[u8], read: impl FnOnce(&[u8]) -> Option) -> Option { + let find = || { + let ctx = Context::open(bytes).ok()?; + ctx.exif(ctx.meta.primary).ok().flatten() + }; + crate::guard::catch_panic(find).ok().flatten().and_then(read) + } + + fn decode_unguarded(bytes: &[u8], max_alloc: u64) -> Result { + let refused = |e: Error| match e { + Error::Unsupported(_) | Error::MissingBox("hvcC" | "meta") => UNSUPPORTED.to_string(), + _ => UNREADABLE.to_string(), + }; + let info = heic_rs::probe(bytes).map_err(refused)?; + let ctx = Context::open(bytes).map_err(refused)?; + if unsupported_items(&ctx).ok_or(UNREADABLE)? { + return Err(UNSUPPORTED.into()); + } + // The stored size, before a crop: the size the decoder holds. + let too_large = || format!("an image of {} × {} pixels is too large to draw", info.coded_width, info.coded_height); + let declared = u64::from(info.coded_width).checked_mul(u64::from(info.coded_height)).ok_or_else(too_large)?; + // A grid is composed at the size its own data gives, which need not match `ispe`. + let grid = grid_pixels(&ctx).ok_or(UNREADABLE)?; + // A single picture is converted at the size its stream gives, which need not match `ispe` + // either. + let coded = declared.max(grid).max(stream_pixels(&ctx).ok_or(UNREADABLE)?); + let shown = u64::from(info.width).checked_mul(u64::from(info.height)).ok_or_else(too_large)?; + // The RGBA output, and a second RGBA image when the decoder rotates, mirrors or crops it, + // which is no larger than the first. + let output = coded.checked_mul(8).ok_or_else(too_large)?; + let pictures = picture_bytes_in(&ctx).ok_or(UNREADABLE)?; + // An alpha grid is composed into a frame of 16-bit planes (at most three samples a pixel), + // and so is the image's grid when it is decoded with a color description of its tiles or + // its stream. A single picture is converted in place. + let composed = if grid > 0 { coded.checked_mul(if info.has_alpha { 12 } else { 6 }).ok_or_else(too_large)? } else { 0 }; + if coded == 0 || shown == 0 || output.checked_add(pictures).and_then(|n| n.checked_add(composed)).is_none_or(|n| n > max_alloc) { + return Err(too_large()); + } + let max_pixels = max_alloc / 4; + let decoded = match tile_nclx(&ctx).or_else(|| stream_nclx(&ctx)) { + Some(nclx) => decode_with_nclx(&ctx, nclx, max_pixels), + None => heic_rs::decode(bytes, &DecodeOptions::default().with_layout(PixelLayout::Rgba8).with_max_pixels(Some(max_pixels))), + } + .map_err(|e| match e { + Error::PixelLimit { .. } | Error::BoxTooLarge { .. } => too_large(), + e => refused(e), + })?; + let mut img = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.data).ok_or(UNREADABLE)?; + if let Some(profile) = source_profile(&ctx) { + for pixel in img.pixels_mut() { + let [r, g, b, a] = pixel.0; + let device = [r, g, b].map(|v| f32::from(v) / 255.0); + if let Some(rgb) = profile.to_srgb_into(&device, vectorcraft_color::cms::Intent::RelativeColorimetric) { + let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8); + pixel.0 = [r, g, b, a]; + } + } + } + Ok(img) + } + + /// The primary image and its alpha image, when it has one. + fn primary_and_alpha(ctx: &Context<'_>) -> Option> { + let primary = ctx.meta.primary; + Some(std::iter::once(primary).chain(ctx.alpha_item(primary).ok()?)) + } + + /// Whether the primary image or its alpha image is neither an HEVC-coded item (`hvc1`, `hev1`) + /// nor a grid of them, or is a grid with a tile that carries a crop, rotation or mirror. heic-rs + /// 0.1.4 decodes any item with an `hvcC` whatever its type, and composes a grid from its tiles + /// as coded, without their transforms: an encoder that pads tiles smaller than its coding block + /// (64 × 64 for x265) and crops them back with `clap` gets the wrong pixels. + fn unsupported_items(ctx: &Context<'_>) -> Option { + let hevc = |id: u32| ctx.meta.item(id).is_some_and(|info| info.is_hevc()); + for item in primary_and_alpha(ctx)? { + let Some((_, tiles)) = ctx.grid(item).ok()? else { + if !hevc(item) { + return Some(true); + } + continue; + }; + for tile in tiles { + if !hevc(tile) || !ctx.props(tile).ok()?.transforms.is_empty() { + return Some(true); + } + } + } + Some(false) + } + + /// The pixels of the larger of the primary image's grid and its alpha image's grid, at the + /// output size each grid gives; 0 when neither is a grid. + fn grid_pixels(ctx: &Context<'_>) -> Option { + let mut largest = 0u64; + for item in primary_and_alpha(ctx)? { + if let Some((grid, _)) = ctx.grid(item).ok()? { + largest = largest.max(u64::from(grid.output_width) * u64::from(grid.output_height)); + } + } + Some(largest) + } + + /// The pixels of the larger of the primary image and its alpha image when either is a single + /// picture, at the size of its largest sequence parameter set (before its conformance window); + /// 0 when both are grids. `None` when a size can't be read. + fn stream_pixels(ctx: &Context<'_>) -> Option { + let mut largest = 0u64; + for item in primary_and_alpha(ctx)? { + if ctx.grid(item).ok()?.is_some() { + continue; + } + // An item without `hvcC` isn't decoded: heic-rs refuses the file. + let Some(hvcc) = ctx.props(item).ok()?.hvcc else { continue }; + for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) { + let (width, height, _) = sps_size(nal)?; + largest = largest.max(width.checked_mul(height)?); + } + } + Some(largest) + } + + /// The first tile's `nclx` when the primary image is a grid without an `nclx` of its own and + /// its tiles carry one. heic-rs 0.1.4 converts a grid's samples to RGB with the grid item's + /// `nclx` alone, and with its BT.709 limited-range default when the grid has none + /// (tbraun96/heic-rs#6). + fn tile_nclx(ctx: &Context<'_>) -> Option { + let primary = ctx.meta.primary; + if ctx.props(primary).ok()?.nclx.is_some() { + return None; + } + let (_, tiles) = ctx.grid(primary).ok()??; + ctx.props(*tiles.first()?).ok()?.nclx + } + + /// The color description in the HEVC stream's video usability information (ITU-T H.265 + /// E.2.1) of the primary image, or of its grid's first tile, when neither carries an `nclx` + /// (a file whose only color box is an ICC profile, or one without a color box). heic-rs 0.1.4 + /// reads only the container's `nclx` and otherwise converts with BT.709 limited range. + fn stream_nclx(ctx: &Context<'_>) -> Option { + let primary = ctx.meta.primary; + if ctx.props(primary).ok()?.nclx.is_some() { + return None; + } + let coded = match ctx.grid(primary).ok()? { + Some((_, tiles)) => *tiles.first()?, + None => primary, + }; + let props = ctx.props(coded).ok()?; + if props.nclx.is_some() { + return None; + } + let hvcc = props.hvcc?; + let data = ctx.item_data(coded).ok()?; + let slices = hvcc.split_nals(&data).ok()?; + active_sps(&hvcc.parameter_sets(), &slices).and_then(sps_signal) + } + + /// The SPS the first slice of `slices` uses: the one its PPS names (ITU-T H.265 7.4.3.3.1). + pub(in super::super) fn active_sps<'a>(parameter_sets: &[&'a [u8]], slices: &[&[u8]]) -> Option<&'a [u8]> { + let slice = slices.iter().find(|nal| nal_type(nal).is_some_and(|t| t < 32))?; + let bits = &mut Bits { data: &payload(slice), at: 0 }; + // first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag in an IRAP picture (types 16 + // to 23), slice_pic_parameter_set_id. + bits.skip(1)?; + if nal_type(slice).is_some_and(|t| (16..=23).contains(&t)) { + bits.skip(1)?; + } + let pps_id = bits.exp_golomb()?; + // pps_pic_parameter_set_id, pps_seq_parameter_set_id. + let pps_ids = |nal: &[u8]| { + let bits = &mut Bits { data: &payload(nal), at: 0 }; + Some((bits.exp_golomb()?, bits.exp_golomb()?)) + }; + let sps_id = parameter_sets.iter().filter(|nal| nal_type(nal) == Some(PPS)).find_map(|nal| pps_ids(nal).filter(|(id, _)| *id == pps_id))?.1; + let sps_ids = |nal: &[u8]| { + let rbsp = sps_payload(nal)?; + sps_head(&mut Bits { data: &rbsp, at: 0 }).map(|(_, id, ..)| id) + }; + parameter_sets.iter().copied().find(|nal| sps_ids(nal) == Some(sps_id)) + } + + /// The primary image decoded as heic-rs 0.1.4 decodes it (a grid's tiles one after another), + /// with its samples converted to RGBA with `nclx`, the alpha image applied and the image's + /// rotation, mirror and crop applied. + fn decode_with_nclx(ctx: &Context<'_>, nclx: Nclx, max_pixels: u64) -> heic_rs::Result { + use heic_rs::hevc::Frame; + + fn coded(ctx: &Context<'_>, id: u32) -> heic_rs::Result { + let props = ctx.props(id)?; + let hvcc = props.hvcc.as_ref().ok_or(Error::MissingBox("hvcC"))?; + let data = ctx.item_data(id)?; + let frame = heic_rs::hevc::decode_still(&hvcc.parameter_sets(), &hvcc.split_nals(&data)?)?; + frame.validate()?; + Ok(frame) + } + fn item(ctx: &Context<'_>, id: u32, max_pixels: u64) -> heic_rs::Result { + match ctx.grid(id)? { + Some((grid, tiles)) => { + // The tiles decode in parallel on native, as heic-rs's own decode does. + #[cfg(not(target_arch = "wasm32"))] + let frames = { + use rayon::prelude::*; + tiles.par_iter().map(|&t| coded(ctx, t)).collect::>>()? + }; + #[cfg(target_arch = "wasm32")] + let frames = tiles.iter().map(|&t| coded(ctx, t)).collect::>>()?; + heic_rs::grid::compose(&grid, &frames, max_pixels) + } + None => coded(ctx, id), + } + } + + let primary = ctx.meta.primary; + let props = ctx.props(primary)?; + let frame = item(ctx, primary, max_pixels)?; + let alpha = ctx.alpha_item(primary)?.map(|a| item(ctx, a, max_pixels)).transpose()?; + // `None`: rayon's global pool on native, where heic-rs is built with `parallel`. + let image = heic_rs::color::convert(&frame, alpha.as_ref(), nclx, PixelLayout::Rgba8, max_pixels, None)?; + heic_rs::transform::apply_all(image, &props.transforms) + } + + /// The bytes heic-rs 0.1.4 allocates for the coded pictures of `bytes` it decodes (see + /// [`picture_bytes_in`]); `None` when they can't be read. + #[cfg(test)] + pub(in super::super) fn picture_bytes(bytes: &[u8]) -> Option { + picture_bytes_in(&Context::open(bytes).ok()?) + } + + /// The bytes heic-rs 0.1.4 allocates for the coded pictures it decodes: the primary image's, + /// or each tile of its grid, and its alpha image's the same way. Each picture is sized by the + /// largest of its sequence parameter sets (the size the decoder allocates, rounded up to whole + /// 64-sample coding blocks), not by the container, and takes three sets of 16-bit planes: the + /// picture, a filtered copy and the frame returned. `None` when an item's sizes can't be read. + fn picture_bytes_in(ctx: &Context<'_>) -> Option { + let mut total = 0u64; + for item in primary_and_alpha(ctx)? { + let coded = match ctx.grid(item).ok()? { + Some((_, tiles)) => tiles, + None => vec![item], + }; + for id in coded { + let props = ctx.props(id).ok()?; + // An item without `hvcC` isn't decoded: heic-rs refuses the file. + let Some(hvcc) = props.hvcc else { continue }; + // The slices pick their SPS through the PPS, so the largest one is counted. Every + // SPS must be read. + let mut largest = None; + for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) { + let (width, height, chroma) = sps_size(nal)?; + let blocks = |n: u64| n.div_ceil(64) * 64; + // Bytes a sample position takes in one set of planes: luma and the two chroma + // planes. + let planes = match chroma { + 0 => 2, + 1 => 3, + 2 => 4, + _ => 6, + }; + let picture = blocks(width).checked_mul(blocks(height))?.checked_mul(planes * 3)?; + largest = largest.max(Some(picture)); + } + total = total.checked_add(largest?)?; + } + } + Some(total) + } + + /// The NAL unit type of an HEVC sequence parameter set. + const SPS: u8 = 33; + + /// The type of an HEVC NAL unit, from its header. + fn nal_type(nal: &[u8]) -> Option { + nal.first().map(|b| (b >> 1) & 0x3f) + } + + /// The NAL unit type of an HEVC picture parameter set. + const PPS: u8 = 34; + + /// A NAL unit's payload, without its two-byte header and the emulation prevention bytes (the 03 + /// of 00 00 03), up to its first 4,096 bytes. + fn payload(nal: &[u8]) -> Vec { + let mut rbsp = Vec::with_capacity(256); + let mut zeros = 0; + for &b in nal.get(2..).unwrap_or_default().iter().take(4096) { + if b == 3 && zeros >= 2 { + zeros = 0; + continue; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + rbsp.push(b); + } + rbsp + } + + /// An SPS NAL unit's [`payload`]; `None` for another NAL unit. + fn sps_payload(nal: &[u8]) -> Option> { + (nal_type(nal) == Some(SPS)).then(|| payload(nal)) + } + + /// The fields of an SPS (ITU-T H.265 7.3.2.2) up to the picture height: + /// `sps_max_sub_layers_minus1`, `sps_seq_parameter_set_id`, the width, the height and + /// `chroma_format_idc`. + fn sps_head(bits: &mut Bits<'_>) -> Option<(u64, u64, u64, u64, u64)> { + // sps_video_parameter_set_id, sps_max_sub_layers_minus1, sps_temporal_id_nesting_flag. + bits.skip(4)?; + let sub_layers = bits.read(3)?; + bits.skip(1)?; + // profile_tier_level (7.3.3): the general profile, tier and level, then the sub-layers'. + bits.skip(96)?; + let mut present = Vec::new(); + for _ in 0..sub_layers { + present.push((bits.read(1)?, bits.read(1)?)); + } + if sub_layers > 0 { + bits.skip(2 * (8 - sub_layers as usize))?; + } + for (profile, level) in present { + bits.skip(88 * profile as usize + 8 * level as usize)?; + } + let id = bits.exp_golomb()?; + let chroma = bits.exp_golomb()?; + if chroma == 3 { + bits.skip(1)?; + } + let width = bits.exp_golomb()?; + let height = bits.exp_golomb()?; + Some((sub_layers, id, width, height, chroma)) + } + + /// The picture width, height and `chroma_format_idc` of an HEVC sequence parameter set NAL + /// unit, before its conformance window; `None` for another NAL unit. + pub(in super::super) fn sps_size(nal: &[u8]) -> Option<(u64, u64, u64)> { + let rbsp = sps_payload(nal)?; + let (_, _, width, height, chroma) = sps_head(&mut Bits { data: &rbsp, at: 0 })?; + Some((width, height, chroma)) + } + + /// The video signal type of an HEVC sequence parameter set NAL unit's VUI (ITU-T H.265 E.2.1) + /// as an `nclx`: the full-range flag and, when the VUI describes them, the color primaries, + /// transfer characteristics and matrix coefficients (otherwise 2, unspecified). `None` when the + /// SPS has no VUI, the VUI has no video signal type, or the SPS can't be read that far. + pub(in super::super) fn sps_signal(nal: &[u8]) -> Option { + let rbsp = sps_payload(nal)?; + let bits = &mut Bits { data: &rbsp, at: 0 }; + let (sub_layers, ..) = sps_head(bits)?; + skip_to_vui(bits, sub_layers)?; + // vui_parameters_present_flag, then aspect_ratio_info_present_flag (an Extended_SAR + // aspect_ratio_idc of 255 adds its 32-bit ratio) and overscan_info_present_flag. + if bits.read(1)? == 0 { + return None; + } + if bits.read(1)? == 1 && bits.read(8)? == 255 { + bits.skip(32)?; + } + if bits.read(1)? == 1 { + bits.skip(1)?; + } + // video_signal_type_present_flag: video_format, video_full_range_flag and the color + // description. + if bits.read(1)? == 0 { + return None; + } + bits.skip(3)?; + let range = if bits.read(1)? == 1 { Range::Full } else { Range::Limited }; + let (primaries, transfer, matrix) = if bits.read(1)? == 1 { (bits.read(8)?, bits.read(8)?, bits.read(8)?) } else { (2, 2, 2) }; + let code = |v: u64| u16::try_from(v).ok(); + let matrix_code = code(matrix)?; + Some(Nclx { primaries: code(primaries)?, transfer: code(transfer)?, matrix: MatrixCoefficients::from_code(matrix_code), matrix_code, range }) + } + + /// Skips the fields of an SPS after [`sps_head`], up to `vui_parameters_present_flag`. + fn skip_to_vui(bits: &mut Bits<'_>, sub_layers: u64) -> Option<()> { + // conformance_window_flag and its four offsets. + if bits.read(1)? == 1 { + for _ in 0..4 { + bits.exp_golomb()?; + } + } + // bit_depth_luma_minus8, bit_depth_chroma_minus8. + bits.exp_golomb()?; + bits.exp_golomb()?; + let poc_bits = bits.exp_golomb()?.checked_add(4).filter(|&n| n <= 16)?; + // sps_sub_layer_ordering_info_present_flag, then three values for each sub-layer given. + let ordered = if bits.read(1)? == 1 { sub_layers + 1 } else { 1 }; + for _ in 0..ordered * 3 { + bits.exp_golomb()?; + } + // The coding and transform block sizes and the transform hierarchy depths. + for _ in 0..6 { + bits.exp_golomb()?; + } + // scaling_list_enabled_flag, sps_scaling_list_data_present_flag. + if bits.read(1)? == 1 && bits.read(1)? == 1 { + skip_scaling_lists(bits)?; + } + // amp_enabled_flag, sample_adaptive_offset_enabled_flag. + bits.skip(2)?; + // pcm_enabled_flag: the PCM sample depths, block sizes and loop filter flag. + if bits.read(1)? == 1 { + bits.skip(8)?; + bits.exp_golomb()?; + bits.exp_golomb()?; + bits.skip(1)?; + } + skip_reference_picture_sets(bits)?; + // long_term_ref_pics_present_flag: each picture's POC bits and used flag. + if bits.read(1)? == 1 { + let count = bits.exp_golomb()?; + if count > 32 { + return None; + } + for _ in 0..count { + bits.skip(poc_bits as usize + 1)?; + } + } + // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag. + bits.skip(2) + } + + /// Skips `scaling_list_data()` (ITU-T H.265 7.3.4). + fn skip_scaling_lists(bits: &mut Bits<'_>) -> Option<()> { + for size in 0..4 { + // The 32 × 32 lists are given for matrices 0 and 3 only. + for _ in 0..if size == 3 { 2 } else { 6 } { + if bits.read(1)? == 0 { + // scaling_list_pred_matrix_id_delta. + bits.exp_golomb()?; + } else { + // scaling_list_dc_coef_minus8, then each scaling_list_delta_coef (se(v) codes, + // as long as ue(v) codes). + if size > 1 { + bits.exp_golomb()?; + } + for _ in 0..if size == 0 { 16 } else { 64 } { + bits.exp_golomb()?; + } + } + } + } + Some(()) + } + + /// Skips `num_short_term_ref_pic_sets` and each `st_ref_pic_set()` (ITU-T H.265 7.3.7) of an + /// SPS. A set predicted from the one before it has one or two flags for each of that set's + /// pictures and one more. + fn skip_reference_picture_sets(bits: &mut Bits<'_>) -> Option<()> { + let sets = bits.exp_golomb()?; + if sets > 64 { + return None; + } + // NumDeltaPocs of each set read so far. + let mut pictures: Vec = Vec::new(); + for index in 0..sets { + let predicted = index != 0 && bits.read(1)? == 1; + let count = if predicted { + // delta_rps_sign, abs_delta_rps_minus1. + bits.skip(1)?; + bits.exp_golomb()?; + let mut count = 0; + for _ in 0..=*pictures.last()? { + // used_by_curr_pic_flag, and use_delta_flag when it is 0. + if bits.read(1)? == 1 || bits.read(1)? == 1 { + count += 1; + } + } + count + } else { + let (negative, positive) = (bits.exp_golomb()?, bits.exp_golomb()?); + if negative > 16 || positive > 16 { + return None; + } + // Each picture's delta_poc_s0_minus1 or delta_poc_s1_minus1 and its used flag. + for _ in 0..negative + positive { + bits.exp_golomb()?; + bits.skip(1)?; + } + negative + positive + }; + pictures.push(count); + } + Some(()) + } + + /// A big-endian bit reader over an RBSP. + struct Bits<'a> { + data: &'a [u8], + at: usize, + } + + impl Bits<'_> { + fn bit(&mut self) -> Option { + let byte = self.data.get(self.at / 8)?; + let bit = (byte >> (7 - self.at % 8)) & 1; + self.at += 1; + Some(u64::from(bit)) + } + + fn read(&mut self, n: u32) -> Option { + (0..n).try_fold(0u64, |v, _| Some(v << 1 | self.bit()?)) + } + + fn skip(&mut self, n: usize) -> Option<()> { + self.at = self.at.checked_add(n)?; + (self.at <= self.data.len() * 8).then_some(()) + } + + /// An unsigned Exp-Golomb code, ue(v), of at most 32 leading zeros. + fn exp_golomb(&mut self) -> Option { + let mut zeros = 0; + while self.bit()? == 0 { + zeros += 1; + if zeros > 32 { + return None; + } + } + Some((1u64 << zeros) - 1 + self.read(zeros)?) + } + } + + /// `nclx` color primaries for Display P3 (SMPTE EG 432-1) and transfer characteristics for + /// the sRGB curve (IEC 61966-2-1), ITU-T H.273. + const P3_PRIMARIES: u16 = 12; + const SRGB_TRANSFER: u16 = 13; + + /// The profile the decoded RGB values are in when it isn't sRGB: the primary item's RGB ICC + /// profile, else Display P3 when its `nclx` names P3 primaries with the sRGB curve. A grid + /// without its own `colr` takes its first tile's, as cameras write it on the tiles. A profile + /// that can't be read, or isn't RGB, is left out and the values are read as sRGB. Without any + /// `colr`, the HEVC stream's color description ([`stream_nclx`]) is read the same way. + fn source_profile(ctx: &Context<'_>) -> Option { + use vectorcraft_color::cms::{self, IccProfile, ProfileKind}; + + let id = ctx.meta.primary; + let own = ctx.props(id).ok()?; + let described = |p: &heic_rs::props::ItemProps<'_>| p.icc.is_some() || p.nclx.is_some(); + let tile = || ctx.grid(id).ok().flatten().and_then(|(_, tiles)| tiles.first().and_then(|&t| ctx.props(t).ok())).filter(described); + let props = if described(&own) { Some(own) } else { tile() }; + if let Some(icc) = props.as_ref().and_then(|p| p.icc) { + return IccProfile::from_bytes(None, icc).ok().filter(|p| p.kind == ProfileKind::Rgb); + } + let nclx = match props { + Some(props) => props.nclx?, + None => stream_nclx(ctx)?, + }; + (nclx.primaries == P3_PRIMARIES && nclx.transfer == SRGB_TRANSFER).then(|| cms::icc::builtin_rgb(cms::DISPLAY_P3)).flatten() + } +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ae63ba918..03bfde85b 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -103,6 +103,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if super::jp2read::is_jpeg2000(bytes) { return format("jpeg2000"); } + if super::heifread::is_heif(bytes) { + return format("heif"); + } if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } @@ -726,6 +729,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// [`super::pcxread::decode`], which refuses an image past [`MAX_RASTER_ALLOC`] before decoding. /// A Pixar picture file is recognized by its magic number ([`super::pxrread::is_pxr`]) and read by /// [`super::pxrread::decode`], which does the same. +/// A HEIC or HEIF file is recognized by its `ftyp` brands ([`super::heifread::is_heif`]) and read +/// by [`super::heifread::decode`], which does the same. /// A Targa file, which starts with no signature, is recognized by its header /// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores /// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels @@ -744,6 +749,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { if super::jp2read::is_jpeg2000(bytes) { return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); } + if super::heifread::is_heif(bytes) { + // The EXIF resolution describes the stored pixels; a quarter turn swaps its axes. + let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) }); + return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + } if super::pxrread::is_pxr(bytes) { return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); } diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index 5d6d25f26..9539a81c7 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -1,6 +1,7 @@ //! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and //! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX -//! header's HDpi and VDpi, or JP2 capture and display resolution boxes. Place +//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF +//! resolution tags. Place //! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it //! as a `pHYs` chunk. @@ -26,6 +27,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { super::pcxread::resolution(bytes) } else if super::jp2read::is_jpeg2000(bytes) { jp2(bytes) + } else if super::heifread::is_heif(bytes) { + super::heifread::with_exif(bytes, tiff) } else { None }; diff --git a/crates/engine/src/cmd/fileio/tests_heifread.rs b/crates/engine/src/cmd/fileio/tests_heifread.rs new file mode 100644 index 000000000..9820164da --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_heifread.rs @@ -0,0 +1,620 @@ +//! HEIC and HEIF Open, Place, pixel, color and resolution regressions using original synthetic +//! fixtures (quadrants of four colors, lossy HEVC, so colors are compared within a tolerance). + +#[cfg(feature = "heif")] +use serde_json::json; + +use super::heifread::is_heif; +use super::*; + +const QUADRANTS: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants.heic"); +const ALPHA: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-alpha.heic"); +const TEN_BIT: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-10bit.heic"); +const GRID: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid.heic"); +const GRID16: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid16.heic"); +const PPI300: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-300ppi.heic"); +const ROTATED: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated.heic"); +const ROTATED_300X150: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated-300x150.heic"); +const TONES: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones.heic"); +const TONES_P3_NCLX: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-nclx.heic"); +const TONES_P3_ICC: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc.heic"); +const TONES_P3_ICC_ONLY: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc-only.heic"); + +const EVERY_FIXTURE: [&[u8]; 12] = + [QUADRANTS, ALPHA, TEN_BIT, GRID, GRID16, PPI300, ROTATED, ROTATED_300X150, TONES, TONES_P3_NCLX, TONES_P3_ICC, TONES_P3_ICC_ONLY]; + +/// Red, green, blue and white: the top-left, top-right, bottom-left and bottom-right quadrants. +#[cfg(feature = "heif")] +const COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255; 4]]; + +/// The centers of the four quadrants of a square image `side` pixels wide. +#[cfg(feature = "heif")] +fn centers(side: u32) -> [(u32, u32); 4] { + let (a, b) = (side / 4, side * 3 / 4); + [(a, a), (b, a), (a, b), (b, b)] +} + +#[cfg(feature = "heif")] +fn decoded(bytes: &[u8]) -> image::RgbaImage { + let raster = load::raster_image(bytes).unwrap(); + image::load_from_memory(&raster.blob.bytes).unwrap().to_rgba8() +} + +#[cfg(feature = "heif")] +fn close(got: [u8; 4], want: [u8; 4], tolerance: u8) -> bool { + got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= tolerance) +} + +#[cfg(feature = "heif")] +fn assert_quadrants(img: &image::RgbaImage, side: u32, what: &str) { + assert_eq!(img.dimensions(), (side, side), "{what}"); + for ((x, y), want) in centers(side).into_iter().zip(COLORS) { + let got = img.get_pixel(x, y).0; + assert!(close(got, want, 8), "{what} at ({x}, {y}): {got:?} != {want:?}"); + } +} + +#[cfg(feature = "heif")] +#[test] +fn quadrants_open_and_place_as_their_pixels() { + assert_quadrants(&decoded(QUADRANTS), 32, "quadrants.heic"); + for name in ["photo.heic", "photo.heif", "unknown.bin"] { + let mut s = Session::new(); + let encoded = vectorcraft_format::base64_encode(QUADRANTS); + let opened = s.execute("document.open", &json!({"name": name, "dataBase64": encoded})).unwrap(); + assert_eq!(opened["format"], "heif", "{name}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(32.0, 32.0)); + let blob = s.doc().unwrap().doc.images.values().next().unwrap(); + assert_quadrants(&image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), 32, name); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let placed = s.execute("file.place", &json!({"name": name, "dataBase64": encoded})).unwrap(); + assert_eq!(placed["format"], "heif", "{name}"); + } + assert!(load::texture_image(QUADRANTS).is_ok()); + // The general brand alone, without `heic`. + let mut general = QUADRANTS.to_vec(); + assert_eq!(&QUADRANTS[8..28], b"heic\0\0\0\0mif1heicmiaf"); + general[8..12].copy_from_slice(b"mif1"); + general[20..24].copy_from_slice(b"miaf"); + assert_quadrants(&decoded(&general), 32, "mif1 only"); +} + +#[test] +fn signatures_and_every_extension() { + for bytes in EVERY_FIXTURE { + assert!(is_heif(bytes)); + assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif")); + } + for ext in ["heic", "heif"] { + assert_eq!(load::detect(&format!("a.{ext}"), b"invalid").map(|f| f.id), Some("heif")); + assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext) && UNASSOCIATED_EXTS.contains(&ext), "{ext}"); + } + // A brand only among the compatible ones counts, and so do the general brands alone. + assert!(is_heif(b"\0\0\0\x14ftypabcd\0\0\0\0heic")); + assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1heix")); + assert!(is_heif(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc")); + assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1miaf")); + // A 64-bit box size (ISO/IEC 14496-12 4.2). + assert!(is_heif(b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0")); + let mut extended = b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0".to_vec(); + extended.extend_from_slice(&QUADRANTS[QUADRANTS.windows(4).position(|w| w == b"meta").unwrap() - 4..]); + assert_eq!(load::detect("unknown.bin", &extended).map(|f| f.id), Some("heif")); + // AVIF, with its brand major or compatible, and brands heic-rs doesn't read. + assert!(!is_heif(b"\0\0\0\x18ftypavif\0\0\0\0mif1miaf")); + assert!(!is_heif(b"\0\0\0\x1cftypmif1\0\0\0\0mif1avifmiaf")); + assert!(!is_heif(b"\0\0\0\x1cftypheic\0\0\0\0mif1heicavis")); + assert!(!is_heif(b"\0\0\0\x18ftyphevx\0\0\0\0hevxiso8")); + assert!(!is_heif(b"\0\0\0\x18ftypisom\0\0\0\0isomavc1")); + // A box shorter than its brands, longer than the file, or of another type. + assert!(!is_heif(b"\0\0\0\x0cftypheic")); + assert!(!is_heif(b"\0\0\0\x40ftypheic\0\0\0\0mif1")); + assert!(!is_heif(b"\0\0\0\x14styphei\0\0\0\0\0heic")); + assert!(!is_heif(b"")); +} + +#[cfg(feature = "heif")] +#[test] +fn alpha_10_bit_and_grid_images_keep_their_pixels() { + let alpha = decoded(ALPHA); + assert_eq!(alpha.dimensions(), (32, 32)); + for ((x, y), want) in centers(32).into_iter().zip(COLORS) { + let got = alpha.get_pixel(x, y).0; + // The left half is opaque; the right half is transparent. + if x < 16 { + assert!(close(got, want, 8), "({x}, {y}): {got:?} != {want:?}"); + } else { + assert!(got[3] <= 8, "({x}, {y}): {got:?}"); + } + } + assert_quadrants(&decoded(TEN_BIT), 32, "10-bit"); + // Four 64 × 64 tiles, one for each quadrant. + assert_quadrants(&decoded(GRID), 128, "2 × 2 grid"); +} + +#[cfg(feature = "heif")] +#[test] +fn a_grid_of_cropped_tiles_is_refused() { + // 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each coded 64 × 64 and cropped back + // with its own clap, which heic-rs 0.1.4 leaves out when it composes the grid. + let error = load::raster_image(GRID16).err().unwrap().to_string(); + assert!(error.contains("kind of image that can't be read"), "{error}"); +} + +#[cfg(feature = "heif")] +#[test] +fn a_grid_without_its_own_color_box_uses_its_tiles() { + // The grid item's associations (ipma) list ispe (1), pixi (5) and colr (3); the tiles carry + // the same colr. Pointing the grid's third association at its ispe leaves the grid without one. + let mut tiles_only = GRID.to_vec(); + assert_eq!(&GRID[565..571], &[0, 1, 3, 1, 0x85, 3], "item 1 with three associations, the last colr"); + tiles_only[570] = 1; + assert_quadrants(&decoded(&tiles_only), 128, "colr on the tiles only"); +} + +#[cfg(feature = "heif")] +#[test] +fn the_containers_rotation_is_applied() { + // A 32 × 16 image, red then blue, stored with a quarter turn clockwise. + let img = decoded(ROTATED); + assert_eq!(img.dimensions(), (16, 32)); + assert!(close(img.get_pixel(8, 4).0, [255, 0, 0, 255], 8), "{:?}", img.get_pixel(8, 4)); + assert!(close(img.get_pixel(8, 27).0, [0, 0, 255, 255], 8), "{:?}", img.get_pixel(8, 27)); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED)})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(16.0, 32.0)); +} + +#[cfg(feature = "heif")] +#[test] +fn the_containers_mirror_and_crop_are_applied() { + // The rotated image's irot (property 6) turned to no rotation, and to an imir of the same + // size: mirrored left to right (ISO/IEC 23008-12 6.5.12, axis 0). + assert_eq!(&ROTATED[384..393], b"\0\0\0\x09irot\x03"); + let mut stored = ROTATED.to_vec(); + stored[392] = 0; + let mut mirrored = ROTATED.to_vec(); + mirrored[388..393].copy_from_slice(b"imir\0"); + let (stored, mirrored) = (decoded(&stored), decoded(&mirrored)); + assert_eq!((stored.dimensions(), mirrored.dimensions()), ((32, 16), (32, 16))); + let red = [255, 0, 0, 255]; + assert!(close(stored.get_pixel(8, 8).0, red, 8) && !close(stored.get_pixel(24, 8).0, red, 8), "{:?}", stored.get_pixel(8, 8)); + for (x, y) in [(4, 4), (8, 8), (20, 12), (27, 3)] { + assert!(close(mirrored.get_pixel(x, y).0, stored.get_pixel(31 - x, y).0, 8), "({x}, {y})"); + } + // The quadrants' clap (property 4) takes the top-left 32 × 32 of the 64 × 64 coded picture. + // Rewritten to its top-left 16 × 16: a center offset of -24 (ISO/IEC 14496-12 12.1.4) leaves + // the red quadrant alone. Its edge next to green is left out, as lossy chroma blends there. + assert_eq!(&QUADRANTS[329..337], b"\0\0\0\x28clap"); + let mut cropped = QUADRANTS.to_vec(); + let clap: Vec = [16u32, 1, 16, 1, (-48i32).cast_unsigned(), 2, (-48i32).cast_unsigned(), 2].iter().flat_map(|v| v.to_be_bytes()).collect(); + cropped[337..369].copy_from_slice(&clap); + let cropped = decoded(&cropped); + assert_eq!(cropped.dimensions(), (16, 16)); + for (x, y) in [(0, 0), (8, 8), (12, 0), (0, 12), (12, 12)] { + assert!(close(cropped.get_pixel(x, y).0, red, 8), "({x}, {y}): {:?}", cropped.get_pixel(x, y)); + } +} + +#[cfg(feature = "heif")] +#[test] +fn exif_resolution_sizes_the_image_and_72_ppi_without_it() { + let got = ppi::resolution(PPI300).unwrap(); + assert!((got.0 - 300.0).abs() < 0.01 && (got.1 - 300.0).abs() < 0.01, "{got:?}"); + assert_eq!(ppi::resolution(QUADRANTS), None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(PPI300)})).unwrap(); + let size = s.doc().unwrap().doc.artboards[0].rect.size(); + assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}"); + let blob = s.doc().unwrap().doc.images.values().next().unwrap(); + let stored = ppi::resolution(&blob.bytes).unwrap(); + assert!((stored.0 - 300.0).abs() < 0.01 && (stored.1 - 300.0).abs() < 0.01); + // 32 × 16 pixels at 300 ppi across and 150 down, turned a quarter: 16 × 32 pixels at 150 ppi + // across and 300 down, the same 7.68 × 7.68 pt either way. + assert_eq!(ppi::resolution(ROTATED_300X150), Some((300.0, 150.0)), "as stored"); + let img = load::raster_image(ROTATED_300X150).unwrap(); + assert_eq!((img.width, img.height, img.ppi), (16, 32, Some((150.0, 300.0)))); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED_300X150)})).unwrap(); + let size = s.doc().unwrap().doc.artboards[0].rect.size(); + assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}"); +} + +#[cfg(feature = "heif")] +#[test] +fn display_p3_images_are_converted_to_srgb() { + use vectorcraft_color::cms::{DISPLAY_P3, Intent, icc::builtin_rgb}; + let p3 = builtin_rgb(DISPLAY_P3).unwrap(); + let plain = decoded(TONES); + // The ICC-only file's samples are full-range BT.601, which only its HEVC stream says. + for (what, bytes) in [("nclx", TONES_P3_NCLX), ("ICC", TONES_P3_ICC), ("ICC only", TONES_P3_ICC_ONLY)] { + let converted = decoded(bytes); + for (x, y) in centers(32) { + let [r, g, b, a] = plain.get_pixel(x, y).0; + let rgb = p3.to_srgb_into(&[r, g, b].map(|v| f32::from(v) / 255.0), Intent::RelativeColorimetric).unwrap(); + let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8); + let got = converted.get_pixel(x, y).0; + assert!(close(got, [r, g, b, a], 2), "{what} at ({x}, {y}): {got:?} != {:?}", [r, g, b, a]); + } + } + // The orange quadrant changes, so the conversion ran. + assert!(!close(decoded(TONES_P3_NCLX).get_pixel(8, 8).0, plain.get_pixel(8, 8).0, 4)); +} + +#[cfg(feature = "heif")] +#[test] +fn display_p3_named_by_the_stream_alone_is_converted_to_srgb() { + // The nclx file's item associates hvcC, colr (property 2), ispe, pixi and clap. Pointing the + // colr association at the ispe leaves the item without a color box, and its HEVC stream's + // video signal type, which names Display P3 with the sRGB curve, describes the samples. + let ipma = TONES_P3_NCLX.windows(4).position(|w| w == b"ipma").unwrap(); + let at = ipma + 4 + 4 + 4 + 2 + 1 + 1; + assert_eq!(&TONES_P3_NCLX[at - 4..at + 4], &[0, 1, 5, 0x81, 2, 3, 5, 0x84], "item 1 with five associations, the second colr"); + let mut stream_only = TONES_P3_NCLX.to_vec(); + stream_only[at] = 3; + let want = decoded(TONES_P3_NCLX); + let got = decoded(&stream_only); + for (x, y) in centers(32) { + assert!(close(got.get_pixel(x, y).0, want.get_pixel(x, y).0, 2), "at ({x}, {y}): {:?} != {:?}", got.get_pixel(x, y), want.get_pixel(x, y)); + } +} + +#[cfg(feature = "heif")] +#[test] +fn two_quarter_turns_keep_the_resolution_axes() { + // The rotated image's ipma lists its irot once; a second association with the same irot turns + // it a half turn, so the EXIF resolution keeps its axes. + let ipma = ROTATED_300X150.windows(4).position(|w| w == b"ipma").unwrap(); + let count = ipma + 4 + 4 + 4 + 2; + let irot = ROTATED_300X150.windows(4).position(|w| w == b"irot").unwrap(); + let ipco = ROTATED_300X150.windows(4).position(|w| w == b"ipco").unwrap(); + // The irot's property index: its place among the boxes of ipco, counted from 1. + let mut index = 1u8; + let mut at = ipco + 4; + while at < irot - 4 { + index += 1; + at += u32::from_be_bytes(ROTATED_300X150[at..at + 4].try_into().unwrap()) as usize; + } + assert_eq!(at, irot - 4, "irot is a box of ipco"); + let n = usize::from(ROTATED_300X150[count]); + assert!(ROTATED_300X150[count + 1..count + 1 + n].contains(&(0x80 | index)), "the item's irot association, essential"); + let mut twice = ROTATED_300X150.to_vec(); + twice[count] += 1; + twice.insert(count + 1 + n, 0x80 | index); + // The ipma, iprp and meta boxes each grow by one byte, and the iloc offsets move with them. + let grow = |bytes: &mut Vec, name: &[u8; 4]| { + let at = bytes.windows(4).position(|w| w == name).unwrap() - 4; + let size = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) + 1; + bytes[at..at + 4].copy_from_slice(&size.to_be_bytes()); + }; + for name in [b"ipma", b"iprp", b"meta"] { + grow(&mut twice, name); + } + let iloc = twice.windows(4).position(|w| w == b"iloc").unwrap(); + assert_eq!(&twice[iloc + 4..iloc + 8], &[0, 0, 0, 0], "iloc version 0"); + let sizes = twice[iloc + 8]; + assert_eq!((sizes >> 4, sizes & 0xf), (4, 4), "4-byte offsets and lengths"); + assert_eq!(twice[iloc + 9] >> 4, 4, "4-byte base offsets"); + let items = u16::from_be_bytes([twice[iloc + 10], twice[iloc + 11]]); + // Each item: its id, data reference index and base offset, which moves, then its extents. + let mut at = iloc + 12; + for _ in 0..items { + at += 2 + 2; + let offset = u32::from_be_bytes(twice[at..at + 4].try_into().unwrap()) + 1; + twice[at..at + 4].copy_from_slice(&offset.to_be_bytes()); + at += 4; + let extents = u16::from_be_bytes([twice[at], twice[at + 1]]); + at += 2 + 8 * usize::from(extents); + } + let img = load::raster_image(&twice).unwrap(); + assert_eq!((img.width, img.height, img.ppi), (32, 16, Some((300.0, 150.0))), "a half turn keeps the stored axes"); +} + +#[cfg(feature = "heif")] +#[test] +fn damaged_unsupported_and_oversized_files_are_refused() { + let error = |bytes: &[u8]| load::raster_image(bytes).err().unwrap().to_string(); + for cut in [16, 40, QUADRANTS.len() / 2, QUADRANTS.len() - 5] { + assert!(load::raster_image(&QUADRANTS[..cut]).is_err(), "{cut}"); + } + // An ftyp and nothing else, as an image sequence starts (its `moov` would follow). + assert!(error(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc").contains("kind of image that can't be read")); + assert!(error(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic\0\0\0\x08free").contains("HEIC or HEIF file")); + // The primary item's type (infe) turned from HEVC to AV1, with its hvcC kept, and with its + // hvcC turned into an av1C. + let mut av1 = QUADRANTS.to_vec(); + assert_eq!((&QUADRANTS[137..141], &QUADRANTS[176..180]), (&b"hvc1"[..], &b"hvcC"[..])); + av1[137..141].copy_from_slice(b"av01"); + assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1)); + av1[176..180].copy_from_slice(b"av1C"); + assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1)); + // The image spatial extents (`ispe`) claim 60,000 × 60,000 pixels. + let mut huge = QUADRANTS.to_vec(); + let at = huge.windows(4).position(|w| w == b"ispe").unwrap() + 8; + huge[at..at + 4].copy_from_slice(&60_000u32.to_be_bytes()); + huge[at + 4..at + 8].copy_from_slice(&60_000u32.to_be_bytes()); + assert!(error(&huge).contains("60000 × 60000 pixels is too large to draw"), "{}", error(&huge)); + // A single 64 × 64 coded picture (cropped to 32 × 32) takes its RGBA output and copy, + // 64 × 64 × 8, and its planes, 64 × 64 × 9: no composed frame is counted. + assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17).is_ok()); + assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17 - 1).unwrap_err().contains("too large to draw")); + // The same picture with an ispe of 32 × 32 (its crop's size): its stream still decodes + // 64 × 64, and that size is counted. + let mut small = QUADRANTS.to_vec(); + let at = small.windows(4).position(|w| w == b"ispe").unwrap() + 8; + assert_eq!(&small[at..at + 8], b"\0\0\0\x40\0\0\0\x40"); + small[at..at + 4].copy_from_slice(&32u32.to_be_bytes()); + small[at + 4..at + 8].copy_from_slice(&32u32.to_be_bytes()); + let refused = super::heifread::decode(&small, 64 * 64 * 17 - 1).map(|img| img.dimensions()); + assert!(refused.as_ref().is_err_and(|e| e.contains("too large to draw")), "{refused:?}"); + // A 128 × 128 grid of four 64 × 64 tiles adds its composed frame, 128 × 128 × 6. + let grid = 128 * 128 * (8 + 6) + 4 * 64 * 64 * 9; + assert!(super::heifread::decode(GRID, grid).is_ok()); + assert!(super::heifread::decode(GRID, grid - 1).unwrap_err().contains("too large to draw")); + // The grid's ispe (property 1) claims 16 × 16 pixels; its grid data still composes 128 × 128, + // and that size is counted. + let mut understated = GRID.to_vec(); + assert_eq!(&GRID[358..370], b"ispe\0\0\0\0\0\0\0\x80", "property 1 is the grid's 128 × 128 ispe"); + understated[366..370].copy_from_slice(&16u32.to_be_bytes()); + understated[370..374].copy_from_slice(&16u32.to_be_bytes()); + assert!(super::heifread::decode(&understated, grid - 1).unwrap_err().contains("too large to draw")); +} + +/// Appends `value` as `n` bits, most significant first. +#[cfg(feature = "heif")] +fn put(bits: &mut Vec, value: u64, n: u32) { + bits.extend((0..n).rev().map(|i| i < 64 && value >> i & 1 == 1)); +} + +/// Appends `value` as an unsigned Exp-Golomb code. +#[cfg(feature = "heif")] +fn put_ue(bits: &mut Vec, value: u64) { + let len = 64 - (value + 1).leading_zeros(); + put(bits, 0, len - 1); + put(bits, value + 1, len); +} + +/// The fields of SPS 0 of `width` × `height` 4:2:0 samples with `sub_layers` sub-layers, each +/// with its profile and level, up to its height. +#[cfg(feature = "heif")] +fn sps_head(width: u64, height: u64, sub_layers: u64) -> Vec { + sps_head_with_id(width, height, sub_layers, 0) +} + +/// [`sps_head`] for SPS `id`. +#[cfg(feature = "heif")] +fn sps_head_with_id(width: u64, height: u64, sub_layers: u64, id: u64) -> Vec { + let mut bits = Vec::new(); + put(&mut bits, 0, 4); + put(&mut bits, sub_layers, 3); + put(&mut bits, 1, 1); + put(&mut bits, 0, 96); + for _ in 0..sub_layers { + put(&mut bits, 0b11, 2); + } + if sub_layers > 0 { + put(&mut bits, 0, 2 * (8 - sub_layers as u32)); + } + for _ in 0..sub_layers { + put(&mut bits, 0, 88 + 8); + } + put_ue(&mut bits, id); + put_ue(&mut bits, 1); + put_ue(&mut bits, width); + put_ue(&mut bits, height); + bits +} + +/// An SPS NAL unit of `bits` (see [`nal`]). +#[cfg(feature = "heif")] +fn sps_nal(bits: Vec) -> Vec { + nal(33, bits) +} + +/// A NAL unit of type `kind` holding `bits`, with its stop bit, and emulation prevention bytes +/// inserted as a stream carries them. +#[cfg(feature = "heif")] +fn nal(kind: u8, mut bits: Vec) -> Vec { + put(&mut bits, 1, 1); + while !bits.len().is_multiple_of(8) { + bits.push(false); + } + let rbsp: Vec = bits.chunks(8).map(|b| b.iter().fold(0u8, |v, &bit| v << 1 | u8::from(bit))).collect(); + let mut nal = vec![kind << 1, 1]; + let mut zeros = 0; + for b in rbsp { + if zeros >= 2 && b <= 3 { + nal.push(3); + zeros = 0; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + nal.push(b); + } + nal +} + +#[cfg(feature = "heif")] +fn sps(width: u64, height: u64, sub_layers: u64) -> Vec { + sps_nal(sps_head(width, height, sub_layers)) +} + +/// A 64 × 64 SPS that uses every optional part before the VUI's video signal type: a conformance +/// window, sub-layer ordering for two sub-layers, explicit scaling lists, PCM, a reference picture +/// set predicted from another and a long-term picture, then an Extended_SAR aspect ratio and +/// overscan information. `signal` is the full-range flag and the color description, if any. +#[cfg(feature = "heif")] +fn sps_with_vui(signal: Option<(u64, Option<[u64; 3]>)>) -> Vec { + sps_with_vui_and_id(signal, 0) +} + +/// [`sps_with_vui`] for SPS `id`. +#[cfg(feature = "heif")] +fn sps_with_vui_and_id(signal: Option<(u64, Option<[u64; 3]>)>, id: u64) -> Vec { + let mut bits = sps_head_with_id(64, 64, 1, id); + put(&mut bits, 1, 1); + for offset in [0, 2, 0, 2] { + put_ue(&mut bits, offset); + } + put_ue(&mut bits, 0); + put_ue(&mut bits, 0); + // 8 bits of picture order count. + put_ue(&mut bits, 4); + put(&mut bits, 1, 1); + for _ in 0..2 * 3 { + put_ue(&mut bits, 1); + } + for value in [0, 3, 0, 3, 1, 1] { + put_ue(&mut bits, value); + } + // Scaling lists: matrix 1 of each size given explicitly, the others copied. + put(&mut bits, 0b11, 2); + for size in 0..4 { + for matrix in 0..if size == 3 { 2 } else { 6 } { + if matrix == 1 { + put(&mut bits, 1, 1); + if size > 1 { + put_ue(&mut bits, 5); + } + for _ in 0..if size == 0 { 16 } else { 64 } { + put_ue(&mut bits, 3); + } + } else { + put(&mut bits, 0, 1); + put_ue(&mut bits, 0); + } + } + } + // AMP off, SAO on, PCM with its depths, block sizes and loop filter flag. + put(&mut bits, 0b01, 2); + put(&mut bits, 1, 1); + put(&mut bits, 0x77, 8); + put_ue(&mut bits, 0); + put_ue(&mut bits, 1); + put(&mut bits, 0, 1); + // Two short-term sets: one negative and one positive picture, then a set predicted from it + // with three entries (unused with use_delta_flag, used, unused). + put_ue(&mut bits, 2); + put_ue(&mut bits, 1); + put_ue(&mut bits, 1); + for _ in 0..2 { + put_ue(&mut bits, 0); + put(&mut bits, 1, 1); + } + put(&mut bits, 1, 1); + put(&mut bits, 0, 1); + put_ue(&mut bits, 0); + put(&mut bits, 0b01100, 5); + // One long-term picture: 8 bits of picture order count and its used flag. + put(&mut bits, 1, 1); + put_ue(&mut bits, 1); + put(&mut bits, 0xab, 8); + put(&mut bits, 1, 1); + put(&mut bits, 0b11, 2); + // VUI: Extended_SAR 1:1 and overscan information. + put(&mut bits, 1, 1); + put(&mut bits, 1, 1); + put(&mut bits, 255, 8); + put(&mut bits, 0x0001_0001, 32); + put(&mut bits, 1, 1); + put(&mut bits, 0, 1); + match signal { + Some((full, description)) => { + put(&mut bits, 1, 1); + put(&mut bits, 5, 3); + put(&mut bits, full, 1); + put(&mut bits, u64::from(description.is_some()), 1); + for code in description.into_iter().flatten() { + put(&mut bits, code, 8); + } + } + None => put(&mut bits, 0, 1), + } + // No chroma location, field or display window information, timing or restrictions. + put(&mut bits, 0, 8); + sps_nal(bits) +} + +#[cfg(feature = "heif")] +#[test] +fn picture_sizes_come_from_the_sequence_parameter_sets() { + use super::heifread::{picture_bytes, sps_size}; + for sub_layers in [0, 1, 6] { + for (w, h) in [(32, 32), (4032, 3024), (65535, 65535)] { + let nal = sps(w, h, sub_layers); + assert!(nal.windows(3).any(|w| w == [0, 0, 3]), "the profile's zeros need emulation prevention"); + assert_eq!(sps_size(&nal), Some((w, h, 1)), "{w} × {h}, {sub_layers} sub-layers"); + } + } + let mut vps = sps(32, 32, 0); + vps[0] = 32 << 1; + assert_eq!(sps_size(&vps), None); + assert_eq!(sps_size(&sps(32, 32, 0)[..8]), None); + // 4:2:0 is 3 bytes a sample position in each of three sets of planes, in 64-sample blocks. + assert_eq!(picture_bytes(QUADRANTS), Some(64 * 64 * 9)); + assert_eq!(picture_bytes(GRID), Some(4 * 64 * 64 * 9)); + assert!(picture_bytes(ALPHA).unwrap() > 64 * 64 * 9, "the alpha image is counted"); +} + +#[cfg(feature = "heif")] +#[test] +fn the_color_description_comes_from_the_streams_video_signal_type() { + use super::heifread::{sps_signal, sps_size}; + use heic_rs::{MatrixCoefficients, Range}; + let signal = |nal: &[u8]| sps_signal(nal).map(|n| (n.primaries, n.transfer, n.matrix, n.matrix_code, n.range)); + let described = sps_with_vui(Some((1, Some([12, 13, 6])))); + assert_eq!(sps_size(&described), Some((64, 64, 1))); + assert_eq!(signal(&described), Some((12, 13, MatrixCoefficients::Bt601, 6, Range::Full))); + assert_eq!(signal(&sps_with_vui(Some((0, None)))), Some((2, 2, MatrixCoefficients::Unspecified, 2, Range::Limited))); + assert_eq!(signal(&sps_with_vui(None)), None); + assert_eq!(signal(&sps(64, 64, 0)), None, "cut short before its VUI"); + // The ICC-only fixture's stream: heif-enc writes full-range BT.601. + let ctx = heic_rs::context::Context::open(TONES_P3_ICC_ONLY).unwrap(); + let props = ctx.props(ctx.meta.primary).unwrap(); + assert!(props.nclx.is_none() && props.icc.is_some()); + let sets = props.hvcc.unwrap().parameter_sets(); + let nal = sets.into_iter().find(|nal| nal[0] >> 1 & 0x3f == 33).unwrap(); + assert_eq!(sps_signal(nal).map(|n| (n.matrix, n.range)), Some((MatrixCoefficients::Bt601, Range::Full))); +} + +#[cfg(feature = "heif")] +#[test] +fn the_slices_picture_parameter_set_picks_the_sps() { + use super::heifread::{active_sps, sps_signal}; + use heic_rs::Range; + let full = sps_with_vui_and_id(Some((1, Some([1, 13, 6]))), 0); + let limited = sps_with_vui_and_id(Some((0, Some([1, 13, 1]))), 1); + let pps = |id: u64, sps: u64| { + let mut bits = Vec::new(); + put_ue(&mut bits, id); + put_ue(&mut bits, sps); + nal(34, bits) + }; + // first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag for an IDR picture (type 19), + // slice_pic_parameter_set_id. + let slice = |kind: u8, pps: u64| { + let mut bits = vec![true]; + if kind == 19 { + bits.push(false); + } + put_ue(&mut bits, pps); + nal(kind, bits) + }; + let (pps0, pps3) = (pps(0, 0), pps(3, 1)); + let sets = [full.as_slice(), limited.as_slice(), pps0.as_slice(), pps3.as_slice()]; + let (idr3, trail0, idr5) = (slice(19, 3), slice(1, 0), slice(19, 5)); + assert_eq!(active_sps(&sets, &[&idr3]), Some(limited.as_slice())); + assert_eq!(active_sps(&sets, &[&trail0]), Some(full.as_slice())); + assert_eq!(active_sps(&sets, &[&idr5]), None, "no PPS 5"); + assert_eq!(active_sps(&sets, &[&pps0]), None, "no slice"); + assert_eq!(active_sps(&sets, &[&idr3]).and_then(sps_signal).map(|n| (n.matrix_code, n.range)), Some((1, Range::Limited))); +} + +#[cfg(not(feature = "heif"))] +#[test] +fn a_build_without_the_decoder_refuses_heif_files_with_a_message() { + for bytes in EVERY_FIXTURE { + assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif")); + let error = load::raster_image(bytes).err().unwrap().to_string(); + assert!(error.contains("can't be opened in this build"), "{error}"); + } + assert_eq!(ppi::resolution(PPI300), None); +} diff --git a/crates/engine/tests/fixtures/heif/quadrants-10bit.heic b/crates/engine/tests/fixtures/heif/quadrants-10bit.heic new file mode 100644 index 0000000000000000000000000000000000000000..ab588a1d8f8e8a0e6b5fcfabe57bcbbf7c7848c9 GIT binary patch literal 486 zcmZQzV30{GsVvAy&8%Pmg51nBLl8SRGck>Ufw3kxwImTF4g`uBDLF+DCIdr3W^xIP z4WpGZbMlix92W-$Mj!+N#sv@>$aDi@<;=`Hps_#zk`)DVQkkHESx{5}WZ%gwNX`e+r9frQj4T`=c`%Us!0_kquU`xd{LBeJ9pVm*JdFSUgA_0` z%-sPZ8RT{`IV1w*w44~hIuQ~J6dGA>=B@L}p54&a40H%HgAs^e5=a6nXLDgZSbU%p zXp3-ieoj$da!v(Ml$U|60VpJrSzM3`q(Q&|iZzmR5v$g=)ez_@$pfGmQU|i3(BSpyg-?n(5 zX_j^E-*;#FXMdh~{{P!H|Nq}I3Yp*E&-F5Yxs37u=Lg#s9{m1mPW|aAYij&@cZe@C Jll#a2008Wxa^(O3 literal 0 HcmV?d00001 diff --git a/crates/engine/tests/fixtures/heif/quadrants-300ppi.heic b/crates/engine/tests/fixtures/heif/quadrants-300ppi.heic new file mode 100644 index 0000000000000000000000000000000000000000..0e9a72342f911aea15b4c36d08da283784588662 GIT binary patch literal 817 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>UfpK4MYDpqU90(LMQgVtQOa_L6%;XXn z8%CRC=H!D7adBW^VqgRbGd_aQKo%!Z3<#KB!$cU|fLx2r%sik)APFEA1#(is%7D5v z%90I15->qVkP_F5Ot5mkg3OX!kSy4=znKL^1wi)Q%!1^6AYBI3>&(au5`qA^4-9|) z{`kSbz|Wij)FI)($iw*mKS%*H!vqikL~^^B91?+&T272$tw=Hp6dGBly?U~2=MIhx z0|lUBW)Q)km;_YL=E8We_&_Jn9^vHtoT9wsoC=^QF9TZxP)H=RxF8isgMb4RYb56+ zf?TEm#GpU|)Bpefe*k1N!Dt{`pdhm%6G$^Kb8v6~DT&O2+(Zx`2pAa{SsR&{SzCZ? zsm!8ONDwkG@Fk}dCj-L?C=XN=o|}>gj3q`^4aW6sJ5q#<|Lr&rv`3<@{rfsY|Lo5z zum69WrT_n1!b0Zv_x+g6Ursjv|M{Hx^Ml_%-SPi)-s|{XlY-|z&wq%0_HTwO$KUxM zB2{0T+nUuWd!97^dF1#1?(Y}wZE;n+bJc9yVZROEKcD>3nEOr4nfHP@b9Y)@ORVro zA@S*_Ry4H*8h&+g{G3bEoO zD-gV-`+v{>{MkWkkBCR+g>GD{weH|0UfhjvTwImTF4g`uBDLF+DCIdr3W^xIP z4WmsmbMnE4xHvE{F))IJnff6#0|O%`Pz(r|XTU{3e2dJ?JfKBD0OE-PIjLY}K;0Q- z$%Y^agdk7`CdgNiS&|Eq1)0XUDzl)d07y4w79{5b#ma!noEe#cLQo+0f#J{JA3qov z_?Z)cIwTwzc^Loy2Pt4?m;fSxNNyLCLn2U8%ZU-J6-j1+LL=+6S5KDh+`*Aypa4|N z3?dj5lYq+ETo?})ALs;HB%GX|Q{>f-o0udaEvqL-NIyEBm!!kySmq{M@(b`TsuuDk{`(XV_ODcuDvF zp8xr?gVr7qkIW0*xK?Z3!Ar_(7J7b9>O1(i^5`9JyLqBE=XIQ%o_$eG{4dk=U{_RZ zuaNd8t%*F+TvO}WuRXbN?fQ%0Z;J&EH`lN;GYe&8=j&Bm-3JV+e;SPIcnj*pwSWK8 zTP6O-w&2$FsQYsy)IXlon!+-@*KGcqsUHvMp58etM`*h<(?tEcJvPq!6Q4ZfmkgZz z-u`6Rfjw_3&!lEQnVt52r~dKyt#77XZ01_AB+WXm&fwpg?$sO*9sdV~O@3{;*?OA& zYv+o4FYoitvH!c(eD3?Z|BUbRappgNX+L43XU literal 0 HcmV?d00001 diff --git a/crates/engine/tests/fixtures/heif/quadrants-grid.heic b/crates/engine/tests/fixtures/heif/quadrants-grid.heic new file mode 100644 index 0000000000000000000000000000000000000000..5997b3dda3255e69cf13706f26cb09037fabfdca GIT binary patch literal 754 zcmZXSF;5gh6vyA}?7|i(s0T4>IM7Bz1z&)T(D)IE4eQ<>`;yzc$-2=q6n8@Gt!zvT zc2r1XVn_(uk=WSST}7qD1RLXpK*s;v++NPyo85VDe(%luPj;4w?o{&^Dv3EFSjyE! z6Q>+jiJZr2oQHqd*B2ik_!(-Z|hKIXYQr<=Je5ny}EO2!Z*doX7Yn4s2^VyU$XtX0hDe; zjapWZYE9rPG}Z5s5zTQH z+nazwh0_fPy7=T0v$>niwD-5r?fc@x*r#Z}qrYd%T8reoe71htI=PoDe@0o6{?^(# s*81||z1F|fXu6|+{u%#!^ker-p9f2W-PdjNleZ=Pv9wmcHnZLR2Uw$$asU7T literal 0 HcmV?d00001 diff --git a/crates/engine/tests/fixtures/heif/quadrants-grid16.heic b/crates/engine/tests/fixtures/heif/quadrants-grid16.heic new file mode 100644 index 0000000000000000000000000000000000000000..dcbcccacf9b91cd9644d227437434377a4e3853d GIT binary patch literal 891 zcmb7?O-L0{6vxk<`{wZ$T7o8zc*%RXNDTx@3)(cK+n`mgil%up&V?D@W!{uV5Luc; zn{=TN2CrRz z+%}m3XfCt5r0+7_0#HU=)7K?OE7(QHi(CNC3_tM`ZD2(mAt8{*1>iHMWDs=vG*E%y ziY(C#C=pQJ9KuyHgQKZOS~EP^%rJyw)~1QJ=^ zzGrkDhFJe7#0HK+tdPJHocXT&j%GVXEoHyAfcs7TpvS zxr4XUePq5o{|w7C8YRaYFq|Qz^^nFZhkDcSEO(pX=(4c}+;Ui<3L(CsuOuotXG07X zXeN_Exxl!q%QG4doRX&$6!kGprAL9rnu=A86(G+%Ga4CoQ)F&G)=c4SD8A(om)!8T zg7a(F%o literal 0 HcmV?d00001 diff --git a/crates/engine/tests/fixtures/heif/quadrants.heic b/crates/engine/tests/fixtures/heif/quadrants.heic new file mode 100644 index 0000000000000000000000000000000000000000..ebf7f1d454372ac8a14466a703104521902f4821 GIT binary patch literal 682 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>Ufw49>wImTF4g`uBDLF+DCIdr3W^xIP z4WpGZbMnE4xHvE{0wEAEE`-oP7AKIaoSB&iG!`TQ#G*h>Dif5KQI>27V)7MamgIsM zU?qPu3yKPW?7Nu-$@xIK45-YRkr^Ze0dgN0{`~#%gMopcIRU6c!hw;8@&A920%nE@ zAOeWwb}>050wuMa7{OYRWELnivQB&TWZBLg92o`*K*h`;fwg#Y(NM>-Z~!TZ%!1rR5FZGD;l|p?#LU_PWD4e{Bmx7OkyV3nJ==~HA>)5L&I3)8 zsB8be&d@*m^UCZ0-)8Cm|CX?j`Tc!ACi9n*&HsNsXa4-)_fL2HKb`kFe%GYn`Ooto zVxRq+;mYxM{)b4_*XFinb;_P6&3_*G{lEMBg?n3E74KX%+jiJ*!}rf8e>CQP6LaRh zV9wl~R@V|Md{Rh!`l%I7ZGnbgT^v8>)iuvn^b#|DcP4T|xD%UceB7BwzibPSpZgX) z|KI0dMTPq94EqWMFX{f@^FM!f(Ap#7k$IsT*J`ahcu9H9LeK9>eFy(m9=+pjH&4{& zypEI8voETN|7DsU?23x*71G|MHIYY}Yid3FwI>&@U4Ie$ZLz@N<{EZpW}%Gie7%aR F`vAB{*t7ru literal 0 HcmV?d00001 diff --git a/crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic b/crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic new file mode 100644 index 0000000000000000000000000000000000000000..9671f346a4ae72a87069f79b41d69ae0be3e2469 GIT binary patch literal 1212 zcmbu7Ye*DP6vxlZ=(?%fgElb`9g{*r%55PkG8fx2q=#5i8dBrztUI*pPP3cZi;C<+ z)CZ*!3nHVips4J_k_BZ-SO#V9z62!&DN&N-1C8l_cM=1MK6Ga8J->VI|K2(Go*|+X zZ+N%nQ&gE?0oChZ98e`MkVlshS!NFklj)T-9_9K2O!;AYxUJt>grRep_lkjr|-R zCYmN}$BJYZm&h3g69`ntI=rd3d6$IA`FUJ)=^ryq;-T1vzK_kVx2(Qw5n_RLlwJnB^o^m|VxW+~gaKtHT;bDhg66LfShH+;iR`7_ZC5I@!bt0Cz zjA%wY(Zk0fuXB;P_S0M;q7QC-N_ z+D#%AlJhV6?5KV{1)-ABYtZXhT|qtyQx>ATu#*DsMO?w-hXUVGLmKWQYA*DwWy*dD z^???J8TYvV_h5e9gQsh1jS5eK)yn>rWK|1DYz)J{8heNrVr#*710D%=rJI@Db<2{{ zp~$-xhB310>G@Id!-j>YI_n1q(u|k$^0(z$pE&Mzue&$$qx9jMx7%;@jJ13@?Km)c z@xhtX-jM`nPgPQHU*yuBva^M~%WqAe?d-kaC@9+5z4(=qzo^~2!+Pbc$F;h;rQ-P4 z`u@hii?po=IWcqiT$iowMD3ZOuFd?_1)n>LtvNRP$&qKaZ%r}#bC2vzwH#}BleXr0 Y{j9R)eZs)^;)tu$vL>UZV`=-6U#7YcdjJ3c literal 0 HcmV?d00001 diff --git a/crates/engine/tests/fixtures/heif/tones-p3-icc.heic b/crates/engine/tests/fixtures/heif/tones-p3-icc.heic new file mode 100644 index 0000000000000000000000000000000000000000..da0c41068d2360fb5a9267911a403c605fc69991 GIT binary patch literal 1231 zcmbu7Ye*DP6vxlZ*zV@5vQ2u3ZEb;s(Aq*D0(HxUULs}hf^?kSbq8j5ud}OJ&_gOL zh>%|NpzK4IAEFW}(kQYA^dKn=kpzn0wR(yOS75^r16z@A=(x|99rxdoB@W z1Y(<6(61^43#ow|&LLF}5J>|ee@y0a*tDQe(@i8|suCOGAwON!BJq!SJtW|PB}_Iw z@Cuq!Rm1o!jL%NMz%TwD1*?=Cj;TzIg*b4DRHZVV!LL%85<#pAvI0Ru=(lBlq`@Cw zz7UO(O0i<1M@SY7g9{|8;~n127SSVPa@GPNzVwfo(~g=^N8Y_Y+kDLybU7fFct_L9 zAh&ph*66LhL}IlP(R8Lqkh-YxA1AnQQsJ0hyl{bI?Yi|2>pSS<8zz#K=x};mJi-R< znA&#dh^c=El63qKhhu}(UT$&*=Q5L#8QGPf?1RP&&em5J7l3(Aj&hUhIG34xiSwoy zLp6mbV7sC!&#E`zK0D5h$cpV*hdD)lK$?SFM1zqh*knREiQK zjbz$P4q8U;zvx|YeGx^VQqVK#b*wI@AjK#f(Mo*D4k4J+7o8uZmHvrWFaD_yiaKi}B1 zw(wrf!4oGNZEbzw*(WUfw49>wImTF4g`uBDLF+DCIdr3W^xIP z4WpGZbMnE4xHvE{0wEAEE`-oP=2jq9&dkgM8Vdv4l@$A&?j^A8gquOWv;*i?cBF4maYt4#h5k5y7C(oX{g5%t~JpH-X_=I_P zCBH6UP+^rn@K3rzsGv{TA@N91npoANCG`iD1?25#^_Ydn7yg&=UOFj*XCAj*LgPKp V`3!&lOf8;ZcyJQOJ%e+Fd;mgLrT_o{ literal 0 HcmV?d00001 diff --git a/crates/engine/tests/fixtures/heif/tones.heic b/crates/engine/tests/fixtures/heif/tones.heic new file mode 100644 index 0000000000000000000000000000000000000000..3ab9e509bf8648cddbcbf91c1a6c20832d21a5d3 GIT binary patch literal 598 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>Ufw49>wImTF4g`uBDLF+DCIdr3W^xIP z4WpGZbMnE4xHvE{0wEAEE`-oP=2jq9&dkgM8Vdv1aqeB7{@iPP!o0hZ zUl%Z_u*x6!CtV>_(5LK>cqAxItm@H{`h&^>^7gZO%);Xf|I2tUos_{dkJ~Px@t)^= UhChF%7EdrdIEmw)!MQ>{0K+1sod5s; literal 0 HcmV?d00001 diff --git a/crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic b/crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic new file mode 100644 index 0000000000000000000000000000000000000000..b18e5b9ee67da2df7c56df037e363a51f6186857 GIT binary patch literal 787 zcmZQzV30{GsVvAy%}izhg51nBLl8SRGck>Uf$?~5YDpqU90(LMQgVtQOa_L6%;XXn z8%CRC=H!D7adBW^VqgRbGroqg^RcWxfYq3c|eOm5&(au5`qA^4-9|) z{`kSbz|Wij)FI)($iw*mKS%*H!vqikL~^^B91?+&nof*htw=Hp6dGBly;?Wx*#pG| z2O1a{cz~Fhp(P2Zn$3mrVDZ6Dpf$qD`8h>-$vG83QCQ z0tgs^es5%AW^HK$a-=egQXyf32hV>VB^K}B`>5}}kz9K^KO%Z7 zgZ|yKd+vI_D*kZyi*iOO|CU(S(xhl}9*65Xzc=5g-7K2^P+^La^ocv{38u3GZ;O3Q z@tF6q=ckZ@&2p}=4ZnIsLK%xT%1ITqXH0WxU|?L5^HXAhgYKy-Hm>`_=GI(F>S}st zykiH$Tn$Ej0bj`@(QJWFkJ*ZA_sr0^{Nrh+)wemn%T!$UOuXaOdCEr4Dq^NXspi^= zTk~YD*5p*}d62PF59mV%Utb0-AjQGJ%qYdc3S@!8*oaXY&bDII0L3>@92Cl6z^DVH JKUfw3(&wImTF4g`uBDLF+DCIdr3W^xIP z4WpGZbMnE4xHvE{0wEAEu7c1&<|80h&dkgM8VdvkZ@q+Vf_Ceq=1=W z0*C-2xm`>Si9ktBCq}SVB$)*YjjYpNt(*1if#QM#4GauCK+Me0k_1%E=E8We_+Tf{ z8sX&poT9wsoC=^QF9RD;34=&xaX~7O1_1{s)=17t1UXCrh(X>H0MY;d|9=3anSk^K z5Dmlv1(_9@K$?M>gM$M|ab_0fmjE5gAemW^n+TEs0$}*DH8L@?wzL5`uX0loL7{z5 zgK<6ECRyh11(TSH|Lr&rG+Uys{rlQ<{mx%1N=yGOdiwwWnw#b|`|iJR@ciddV)6dH zkNWN#$+f5RBciu5=-)lN=dSmw;tzMfC}))NZ;5p+O^P<>ak#GYd-ILj&7$cK6{aXj zpSZ)GU^*-Cw%ErMk9i+^ehMkrEawW_@T*58l(A@|oK#VJ#x$1(2F4{hKP478=$^V_ zY@Rd9g%@+9dn60>W&kT*rKb~e()), 0..8)) { + let mut bytes = match which { + 0 => include_bytes!("fixtures/heif/quadrants.heic").to_vec(), + 1 => include_bytes!("fixtures/heif/quadrants-alpha.heic").to_vec(), + 2 => include_bytes!("fixtures/heif/quadrants-grid.heic").to_vec(), + // Its stream's VUI gives the color description. + _ => include_bytes!("fixtures/heif/tones-p3-icc-only.heic").to_vec(), + }; + for (at, byte) in edits { + if let Some(slot) = bytes.get_mut(at) { *slot = byte; } + } + bytes.truncate(cut); + let result = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + let params = json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(&bytes)}); + let _ = no_panic(s.execute("document.open", ¶ms)); + let _ = no_panic(s.execute("file.place", ¶ms)); + }); + prop_assert!(result.is_ok(), "a.heic: panicked: {:?}", result.err()); + } +} + /// The rich document with a hidden layer, saved as SVG with its editing data. fn saved_svg() -> String { let mut d = rich_doc(); diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8da20248c..4fd69733d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5029,6 +5029,9 @@ imageTrace Outline Obris @msg the Pixar file's image data is damaged slikovni podaci Pixar datoteke su oštećeni @msg the JPEG 2000 file can't be read datoteku JPEG 2000 nije moguće pročitati @msg the JPEG 2000 file has an unsupported color space or JPX content datoteka JPEG 2000 ima nepodržan prostor boja ili JPX sadržaj +@msg the HEIC or HEIF file can't be read datoteku HEIC ili HEIF nije moguće pročitati +@msg the HEIC or HEIF file holds a kind of image that can't be read datoteka HEIC ili HEIF sadrži vrstu slike koju nije moguće pročitati +@msg HEIC and HEIF images can't be opened in this build HEIC i HEIF slike nije moguće otvoriti u ovoj verziji programa @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD slika je prevelika za čitanje ({_1} × {_2} piksela) @msg zipped 32-bit PSD image data with prediction can't be read zipovani 32-bitni PSD podaci slike s predviđanjem ne mogu se pročitati @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitne PSD datoteke u ovom načinu boja ne mogu se pročitati diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3ad992e57..f2a467fc5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6313,6 +6313,9 @@ breakText Glyphs Glifs @msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el fitxer JPEG 2000 té un espai de color o contingut JPX no compatible +@msg the HEIC or HEIF file can't be read no es pot llegir el fitxer HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read el fitxer HEIC o HEIF conté un tipus d'imatge que no es pot llegir +@msg HEIC and HEIF images can't be opened in this build les imatges HEIC i HEIF no es poden obrir en aquesta compilació @msg You must choose a side pattern. Heu de triar un patró lateral. @msg invalid pattern tile choice opció de mosaic de patró no vàlida @msg no pattern named `{choice}` no hi ha cap patró anomenat `{choice}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8cc2a6fba..15cd33ed0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5091,6 +5091,9 @@ imageTrace Outline Obrys @msg the Pixar file's image data is damaged obrazová data souboru Pixar jsou poškozená @msg the JPEG 2000 file can't be read soubor JPEG 2000 nelze přečíst @msg the JPEG 2000 file has an unsupported color space or JPX content soubor JPEG 2000 obsahuje nepodporovaný barevný prostor nebo obsah JPX +@msg the HEIC or HEIF file can't be read soubor HEIC nebo HEIF nelze přečíst +@msg the HEIC or HEIF file holds a kind of image that can't be read soubor HEIC nebo HEIF obsahuje druh obrázku, který nelze přečíst +@msg HEIC and HEIF images can't be opened in this build obrázky HEIC a HEIF nelze v tomto sestavení otevřít @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD je příliš velký na přečtení ({_1} × {_2} obrazových bodů) @msg zipped 32-bit PSD image data with prediction can't be read zazipovaná 32bitová obrazová data PSD s predikcí nelze přečíst @msg {depth}-bit PSD files in this colour mode can't be read {depth}bitové soubory PSD v tomto barevném režimu nelze přečíst diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 374c63918..e61e26c81 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4832,6 +4832,9 @@ theme Appearance Erscheinungsbild @msg the Pixar file's image data is damaged die Bilddaten der Pixar-Datei sind beschädigt @msg the JPEG 2000 file can't be read die JPEG-2000-Datei kann nicht gelesen werden @msg the JPEG 2000 file has an unsupported color space or JPX content die JPEG-2000-Datei enthält einen nicht unterstützten Farbraum oder JPX-Inhalt +@msg the HEIC or HEIF file can't be read die HEIC- oder HEIF-Datei kann nicht gelesen werden +@msg the HEIC or HEIF file holds a kind of image that can't be read die HEIC- oder HEIF-Datei enthält eine Bildart, die nicht gelesen werden kann +@msg HEIC and HEIF images can't be opened in this build HEIC- und HEIF-Bilder können in diesem Build nicht geöffnet werden @msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel) @msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b42c06626..bad5543ba 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4772,6 +4772,9 @@ digits Malayalam Μαλαγιάλαμ @msg the Pixar file's image data is damaged τα δεδομένα εικόνας του αρχείου Pixar είναι κατεστραμμένα @msg the JPEG 2000 file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content το αρχείο JPEG 2000 έχει μη υποστηριζόμενο χρωματικό χώρο ή περιεχόμενο JPX +@msg the HEIC or HEIF file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου HEIC ή HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read το αρχείο HEIC ή HEIF περιέχει είδος εικόνας που δεν είναι δυνατό να διαβαστεί +@msg HEIC and HEIF images can't be opened in this build οι εικόνες HEIC και HEIF δεν μπορούν να ανοίξουν σε αυτή την έκδοση @msg the PSD image is too large to read ({_1} × {_2} pixels) η εικόνα PSD είναι πολύ μεγάλη για ανάγνωση ({_1} × {_2} εικονοστοιχεία) @msg zipped 32-bit PSD image data with prediction can't be read τα συμπιεσμένα δεδομένα εικόνας PSD 32 bit με πρόβλεψη δεν διαβάζονται @msg {depth}-bit PSD files in this colour mode can't be read τα αρχεία PSD {depth} bit δεν διαβάζονται σε αυτήν τη λειτουργία χρώματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 67a5a84d2..651886e05 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4811,6 +4811,9 @@ axis Both Ambos @msg the Pixar file's image data is damaged los datos de imagen del archivo Pixar están dañados @msg the JPEG 2000 file can't be read no se puede leer el archivo JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el archivo JPEG 2000 tiene un espacio de color o contenido JPX no compatible +@msg the HEIC or HEIF file can't be read no se puede leer el archivo HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read el archivo HEIC o HEIF contiene un tipo de imagen que no se puede leer +@msg HEIC and HEIF images can't be opened in this build las imágenes HEIC y HEIF no se pueden abrir en esta compilación @msg the PSD image is too large to read ({_1} × {_2} pixels) la imagen PSD es demasiado grande para leerla ({_1} × {_2} píxeles) @msg zipped 32-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 32 bits comprimidos en ZIP con predicción @msg {depth}-bit PSD files in this colour mode can't be read los archivos PSD de {depth} bits en este modo de color no se pueden leer diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3f8658a21..f79d04cea 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4993,6 +4993,9 @@ imageTrace Outline Ääriviiva @msg the Pixar file's image data is damaged Pixar-tiedoston kuvadata on vahingoittunut @msg the JPEG 2000 file can't be read tätä JPEG 2000 -tiedostoa ei voi lukea @msg the JPEG 2000 file has an unsupported color space or JPX content tässä JPEG 2000 -tiedostossa on tukematon väriavaruus tai JPX-sisältö +@msg the HEIC or HEIF file can't be read tätä HEIC- tai HEIF-tiedostoa ei voi lukea +@msg the HEIC or HEIF file holds a kind of image that can't be read tässä HEIC- tai HEIF-tiedostossa on kuvatyyppi, jota ei voi lukea +@msg HEIC and HEIF images can't be opened in this build HEIC- ja HEIF-kuvia ei voi avata tässä versiossa @msg the PSD image is too large to read ({_1} × {_2} pixels) tämä PSD-kuva on liian suuri luettavaksi ({_1} × {_2} pikseliä) @msg zipped 32-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 32-bittistä PSD-kuvadataa ei voi lukea @msg {depth}-bit PSD files in this colour mode can't be read tässä väritilassa olevia {depth}-bittisiä PSD-tiedostoja ei voi lukea diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9ba17bcc4..b9c9c1165 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4819,6 +4819,9 @@ axis Both Les deux @msg the Pixar file's image data is damaged les données d'image du fichier Pixar sont endommagées @msg the JPEG 2000 file can't be read le fichier JPEG 2000 ne peut pas être lu @msg the JPEG 2000 file has an unsupported color space or JPX content le fichier JPEG 2000 contient un espace colorimétrique ou un contenu JPX non pris en charge +@msg the HEIC or HEIF file can't be read le fichier HEIC ou HEIF ne peut pas être lu +@msg the HEIC or HEIF file holds a kind of image that can't be read le fichier HEIC ou HEIF contient un type d'image qui ne peut pas être lu +@msg HEIC and HEIF images can't be opened in this build les images HEIC et HEIF ne peuvent pas être ouvertes dans cette version @msg the PSD image is too large to read ({_1} × {_2} pixels) l'image PSD est trop grande pour être lue ({_1} × {_2} pixels) @msg zipped 32-bit PSD image data with prediction can't be read les données d'image PSD 32 bits compressées en ZIP avec prédiction ne peuvent pas être lues @msg {depth}-bit PSD files in this colour mode can't be read les fichiers PSD {depth} bits dans ce mode colorimétrique ne peuvent pas être lus diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index aa3094599..2de1183cc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5335,6 +5335,9 @@ imageTrace Outline Körvonal @msg the Pixar file's image data is damaged a Pixar-fájl képadatai sérültek @msg the JPEG 2000 file can't be read a JPEG 2000 fájl nem olvasható @msg the JPEG 2000 file has an unsupported color space or JPX content a JPEG 2000 fájl nem támogatott színteret vagy JPX-tartalmat tartalmaz +@msg the HEIC or HEIF file can't be read a HEIC- vagy HEIF-fájl nem olvasható +@msg the HEIC or HEIF file holds a kind of image that can't be read a HEIC- vagy HEIF-fájl olyan képtípust tartalmaz, amely nem olvasható +@msg HEIC and HEIF images can't be opened in this build a HEIC- és HEIF-képek ebben a buildben nem nyithatók meg @msg the PSD image is too large to read ({_1} × {_2} pixels) a PSD-kép túl nagy a beolvasáshoz ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open a PDF mentése párbeszédablak nem nyílt meg @msg the scale is not a usable number a méretarány nem használható szám diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f6693d46..49f8130ae 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4853,6 +4853,9 @@ axis Both Entrambi @msg the Pixar file's image data is damaged i dati immagine del file Pixar sono danneggiati @msg the JPEG 2000 file can't be read impossibile leggere il file JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content il file JPEG 2000 contiene uno spazio colore o un contenuto JPX non supportato +@msg the HEIC or HEIF file can't be read impossibile leggere il file HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read il file HEIC o HEIF contiene un tipo di immagine che non può essere letto +@msg HEIC and HEIF images can't be opened in this build le immagini HEIC e HEIF non possono essere aperte in questa build @msg the PSD image is too large to read ({_1} × {_2} pixels) l'immagine PSD è troppo grande per essere letta ({_1} × {_2} pixel) @msg zipped 32-bit PSD image data with prediction can't be read i dati immagine PSD a 32 bit compressi ZIP con previsione non possono essere letti @msg {depth}-bit PSD files in this colour mode can't be read i file PSD a {depth} bit in questo metodo colore non possono essere letti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ea4ffac06..94b2e408a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4894,6 +4894,9 @@ swatch Bright Violet ビビッドバイオレット @msg the Pixar file's image data is damaged Pixar ファイルの画像データが壊れています @msg the JPEG 2000 file can't be read JPEG 2000 ファイルを読み込めません @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ファイルには未対応の色空間または JPX コンテンツがあります +@msg the HEIC or HEIF file can't be read HEIC または HEIF ファイルを読み込めません +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC または HEIF ファイルには読み込めない種類の画像が含まれています +@msg HEIC and HEIF images can't be opened in this build このビルドでは HEIC と HEIF の画像を開けません @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 画像が大きすぎて読み込めません({_1} × {_2} ピクセル) @msg zipped 32-bit PSD image data with prediction can't be read 予測付き ZIP 圧縮の 32 bit PSD 画像データは読み込めません @msg {depth}-bit PSD files in this colour mode can't be read このカラーモードの {depth} bit PSD ファイルは読み込めません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e64fd2fa3..77b99070f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4510,6 +4510,9 @@ font-technology Variable ცვლადი @msg the Pixar file's image data is damaged Pixar ფაილის გამოსახულების მონაცემები დაზიანებულია @msg the JPEG 2000 file can't be read JPEG 2000 ფაილის წაკითხვა შეუძლებელია @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ფაილს აქვს მხარდაუჭერელი ფერთა სივრცე ან JPX შიგთავსი +@msg the HEIC or HEIF file can't be read HEIC ან HEIF ფაილის წაკითხვა შეუძლებელია +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC ან HEIF ფაილი შეიცავს გამოსახულების ტიპს, რომლის წაკითხვაც შეუძლებელია +@msg HEIC and HEIF images can't be opened in this build ამ ვერსიაში HEIC და HEIF გამოსახულებების გახსნა შეუძლებელია @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD გამოსახულება ზედმეტად დიდია წასაკითხად ({_1} × {_2} პიქსელი) @msg zipped 32-bit PSD image data with prediction can't be read zip-ით შეკუმშული 32-ბიტიანი PSD გამოსახულების მონაცემების წაკითხვა პროგნოზირებით ვერ ხერხდება @msg {depth}-bit PSD files in this colour mode can't be read ამ ფერის რეჟიმში {depth}-ბიტიანი PSD ფაილების წაკითხვა ვერ ხერხდება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 336218c32..ca570d3cc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4821,6 +4821,9 @@ theme Appearance 외관 @msg the Pixar file's image data is damaged Pixar 파일의 이미지 데이터가 손상되었습니다 @msg the JPEG 2000 file can't be read JPEG 2000 파일을 읽을 수 없습니다 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 파일에 지원되지 않는 색 공간 또는 JPX 콘텐츠가 있습니다 +@msg the HEIC or HEIF file can't be read HEIC 또는 HEIF 파일을 읽을 수 없습니다 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 또는 HEIF 파일에 읽을 수 없는 종류의 이미지가 있습니다 +@msg HEIC and HEIF images can't be opened in this build 이 빌드에서는 HEIC 및 HEIF 이미지를 열 수 없습니다 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 이미지가 너무 커서 읽을 수 없습니다({_1} × {_2}픽셀) @msg zipped 32-bit PSD image data with prediction can't be read 예측이 적용된 ZIP 압축 32비트 PSD 이미지 데이터는 읽을 수 없습니다 @msg {depth}-bit PSD files in this colour mode can't be read 이 색상 모드의 {depth}비트 PSD 파일은 읽을 수 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f9fdfd743..668a55ed7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4917,6 +4917,9 @@ imageTrace Outline Kontur @msg the Pixar file's image data is damaged dane obrazu pliku Pixar są uszkodzone @msg the JPEG 2000 file can't be read nie można odczytać pliku JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content plik JPEG 2000 zawiera nieobsługiwaną przestrzeń barw lub zawartość JPX +@msg the HEIC or HEIF file can't be read nie można odczytać pliku HEIC lub HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read plik HEIC lub HEIF zawiera rodzaj obrazu, którego nie można odczytać +@msg HEIC and HEIF images can't be opened in this build obrazów HEIC i HEIF nie można otworzyć w tej wersji @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD jest zbyt duży, aby go odczytać ({_1} × {_2} pikseli) @msg zipped 32-bit PSD image data with prediction can't be read nie można odczytać spakowanych 32-bitowych danych obrazu PSD z predykcją @msg {depth}-bit PSD files in this colour mode can't be read nie można odczytać {depth}-bitowych plików PSD w tym trybie koloru diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 92b5aa0c1..73cf2c8ee 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4355,6 +4355,9 @@ axis Both Ambos @msg the Pixar file's image data is damaged os dados de imagem do arquivo Pixar estão danificados @msg the JPEG 2000 file can't be read não é possível ler o arquivo JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content o arquivo JPEG 2000 contém um espaço de cores ou conteúdo JPX sem suporte +@msg the HEIC or HEIF file can't be read não é possível ler o arquivo HEIC ou HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read o arquivo HEIC ou HEIF contém um tipo de imagem que não pode ser lido +@msg HEIC and HEIF images can't be opened in this build imagens HEIC e HEIF não podem ser abertas nesta versão @msg the PSD image is too large to read ({_1} × {_2} pixels) a imagem PSD é grande demais para ser lida ({_1} × {_2} pixels) @msg zipped 32-bit PSD image data with prediction can't be read dados de imagem PSD de 32 bits compactados em zip com previsão não podem ser lidos @msg {depth}-bit PSD files in this colour mode can't be read arquivos PSD de {depth} bits neste modo de cor não podem ser lidos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5d5252ee3..9b462f1b9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4850,6 +4850,9 @@ axis Both Оба @msg the Pixar file's image data is damaged данные изображения в файле Pixar повреждены @msg the JPEG 2000 file can't be read не удается прочитать файл JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 содержит неподдерживаемое цветовое пространство или содержимое JPX +@msg the HEIC or HEIF file can't be read не удается прочитать файл HEIC или HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC или HEIF содержит изображение такого типа, которое не удается прочитать +@msg HEIC and HEIF images can't be opened in this build изображения HEIC и HEIF нельзя открыть в этой сборке @msg the PSD image is too large to read ({_1} × {_2} pixels) изображение PSD слишком велико для чтения ({_1} × {_2} пикс.) @msg zipped 32-bit PSD image data with prediction can't be read 32-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются @msg {depth}-bit PSD files in this colour mode can't be read {depth}-битные файлы PSD в этом цветовом режиме не читаются diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6ef144c58..44dfd2563 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4728,6 +4728,9 @@ axis Both Båda @msg the Pixar file's image data is damaged Pixar-filens bilddata är skadade @msg the JPEG 2000 file can't be read JPEG 2000-filen kan inte läsas @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000-filen har en färgrymd eller ett JPX-innehåll som inte stöds +@msg the HEIC or HEIF file can't be read HEIC- eller HEIF-filen kan inte läsas +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC- eller HEIF-filen innehåller en typ av bild som inte kan läsas +@msg HEIC and HEIF images can't be opened in this build HEIC- och HEIF-bilder kan inte öppnas i den här versionen @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-bilden är för stor för att läsas ({_1} × {_2} pixlar) @msg zipped 32-bit PSD image data with prediction can't be read zippad 32-bitars PSD-bilddata med prediktion kan inte läsas @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitars PSD-filer i det här färgläget kan inte läsas diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d38231b3c..6ce1191c8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4561,6 +4561,9 @@ axis Both Her İkisi @msg the Pixar file's image data is damaged Pixar dosyasının görüntü verileri bozuk @msg the JPEG 2000 file can't be read JPEG 2000 dosyası okunamıyor @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 dosyasında desteklenmeyen bir renk alanı veya JPX içeriği var +@msg the HEIC or HEIF file can't be read HEIC veya HEIF dosyası okunamıyor +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC veya HEIF dosyası okunamayan türde bir görüntü içeriyor +@msg HEIC and HEIF images can't be opened in this build HEIC ve HEIF görüntüleri bu derlemede açılamıyor @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD görüntüsü okunamayacak kadar büyük ({_1} × {_2} piksel) @msg zipped 32-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 32 bit PSD görüntü verileri okunamıyor @msg {depth}-bit PSD files in this colour mode can't be read Bu renk modunda {depth}-bit PSD dosyaları okunamaz diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 353cbacd8..81d168096 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4837,6 +4837,9 @@ axis Both Обидві @msg the Pixar file's image data is damaged дані зображення у файлі Pixar пошкоджені @msg the JPEG 2000 file can't be read не вдається прочитати файл JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 містить непідтримуваний колірний простір або вміст JPX +@msg the HEIC or HEIF file can't be read не вдається прочитати файл HEIC або HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC або HEIF містить зображення такого типу, яке не вдається прочитати +@msg HEIC and HEIF images can't be opened in this build зображення HEIC і HEIF не можна відкрити в цій збірці @msg the PSD image is too large to read ({_1} × {_2} pixels) зображення PSD завелике для читання ({_1} × {_2} пікс.) @msg zipped 32-bit PSD image data with prediction can't be read 32-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються @msg {depth}-bit PSD files in this colour mode can't be read {depth}-бітні файли PSD у цьому колірному режимі не читаються diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f1e49ed9b..3b1169439 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4994,6 +4994,9 @@ theme Appearance Giao diện @msg the Pixar file's image data is damaged dữ liệu ảnh của tệp Pixar bị hỏng @msg the JPEG 2000 file can't be read không thể đọc tệp JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content tệp JPEG 2000 có không gian màu hoặc nội dung JPX không được hỗ trợ +@msg the HEIC or HEIF file can't be read không thể đọc tệp HEIC hoặc HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read tệp HEIC hoặc HEIF chứa loại ảnh không thể đọc được +@msg HEIC and HEIF images can't be opened in this build không thể mở ảnh HEIC và HEIF trong bản dựng này @msg the PSD image is too large to read ({_1} × {_2} pixels) ảnh PSD quá lớn để đọc ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open hộp thoại Lưu PDF không mở @msg the scale is not a usable number tỷ lệ không phải là số dùng được diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8b4599a16..d9fa2f182 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4604,6 +4604,9 @@ imageTrace Outline 轮廓 @msg the Pixar file's image data is damaged Pixar 文件的图像数据已损坏 @msg the JPEG 2000 file can't be read 无法读取 JPEG 2000 文件 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 文件包含不受支持的色彩空间或 JPX 内容 +@msg the HEIC or HEIF file can't be read 无法读取 HEIC 或 HEIF 文件 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 文件包含无法读取的图像类型 +@msg HEIC and HEIF images can't be opened in this build 此版本无法打开 HEIC 和 HEIF 图像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 图像过大,无法读取 ({_1} × {_2} 像素) @msg zipped 32-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 32 位 PSD 图像数据 @msg {depth}-bit PSD files in this colour mode can't be read 无法读取此颜色模式下的 {depth} 位 PSD 文件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 822483936..6a34a843a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2454,6 +2454,9 @@ file-filter Templates 範本 @msg the Pixar file's image data is damaged Pixar 檔案的影像資料已損毀 @msg the JPEG 2000 file can't be read 無法讀取 JPEG 2000 檔案 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 檔案包含不支援的色彩空間或 JPX 內容 +@msg the HEIC or HEIF file can't be read 無法讀取 HEIC 或 HEIF 檔案 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 檔案包含無法讀取的影像類型 +@msg HEIC and HEIF images can't be opened in this build 此版本無法開啟 HEIC 和 HEIF 影像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 影像過大,無法讀取 ({_1} × {_2} 像素) @msg zipped 32-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 32 位元 PSD 影像資料 @msg {depth}-bit PSD files in this colour mode can't be read 無法讀取此色彩模式的 {depth} 位元 PSD 檔案 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7c8a4d227..f716d693b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,6 +17,8 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| +| Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | @@ -251,7 +253,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | PSB | open | yes (merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | -| HEIC / HEIF | open, place | no | no | | +| HEIC / HEIF | open, place | yes (HEVC-coded still images: single pictures and grids of tiles, 8 to 10 bits, with an alpha image; image sequences, other codings and grids of cropped tiles are refused) | no | `.heic`, `.heif`; decoded by heic-rs in the engine's default `heif` feature (a build without it refuses the files with a message); the container's rotation, mirror and crop are applied; without an `nclx` box the HEVC stream's video signal type gives the matrix and range; an RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; EXIF resolution tags size the image, or 72 ppi; bounded decoding; `tests_heifread.rs` | | PCX | open, place | yes (1 bit in the header palette's first two colors, black and white without them; 1 bit in two to four planes and 4 bits with up to 16 colors; 2 bits in the CGA colors the header's CGA palette settings pick; 8 bits with the 256-color palette, gray without one; 24-bit color in three planes) | no | the header's HDpi and VDpi size the image; a file of another depth or plane count, cut short or past the pixel allowance is refused with a message; tests in `crates/engine/src/cmd/fileio/tests_pcxread.rs` and `crates/engine/tests/import_fuzz.rs` | | JPEG 2000 | open, place | yes (JP2 and raw codestream images; multiple codestreams and JPX composition or layers are refused) | no | `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c`, `.jpc`; resolution uses the valid `resd` (display) value on each axis, or else the valid `resc` (capture) value, or 72 ppi; bounded decoding; `tests_jp2read.rs` | | PICT | open, place (legacy) | no | no | rarely used | diff --git a/docs/mcp.md b/docs/mcp.md index 65c48aa48..08b0eb8de 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -461,6 +461,8 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | +| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version`, `preview` or `precision`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | @@ -4417,7 +4419,7 @@ image `object.rasterize` makes with the same options. ## Glass textures Glass (`distort.glass`) takes an image file as its surface in `params.textureImage`, as `{path}` or -`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar or JPEG 2000 file, decoded within the pixel allowance the +`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000 or HEIC/HEIF file, decoded within the pixel allowance the renderer draws images in, counted as RGBA. `effect.apply` and `effect.setParams` store the image's gray levels in the effect as a grayscale PNG in base64 and set `texture: "loaded"` unless the params give a `texture`. `effect.list` reports the stored string, and a command given that string stores it unchanged. Any other string, a file that is not an image, or an image past diff --git a/docs/releasing.md b/docs/releasing.md index b28ada6d6..64c177618 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -164,7 +164,7 @@ to the target triple, so host build scripts aren't affected. AppStream metainfo, the MIME type for VectorCraft documents, the icons and the licence files. **Document types.** The desktop entry's `MimeType` lists every format File › Open reads but PSD, -PSB, `.ps` PostScript, Targa, PCX, Pixar and JPEG 2000, so file managers list VectorCraft under Open With for them. +PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, so file managers list VectorCraft under Open With for them. `ai.storyteller.vectorcraft.mime.xml` declares the types that shared-mime-info lacks: VectorCraft documents and templates, `.ait` templates and Affinity files (`application/x-affinity`). An `.af` file gets the Affinity type by its content, and `.afdesign` and `.afpub` files by their extension. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6a5cf1bb7..3c3087fff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -624,7 +624,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/). - **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending. -- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar and JPEG 2000, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. +- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Automation:** @@ -1329,6 +1329,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). + - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers "opening or placing a HEIF file"): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). @@ -1474,7 +1475,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it. -- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar or JPEG 2000 file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. +- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000 or HEIC/HEIF file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. - **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Crystallize Cell Size 3..300 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. - **Color Halftone in raster pixels (#3260):** Max Radius is 4–127 px of the Document Raster Effects resolution, as public documentation gives it, shown in px whatever the document units; documents from before open with their radii in points converted (document version 3), and a file saved for an older app version holds them in points again. From bd78bb0b11ea8f5ee63cda100dd064bcdadab75f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 23:49:09 -0800 Subject: [PATCH 39/51] Docs: paraphrase the HEIF help-page reference --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3c3087fff..14ff46ad8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1329,7 +1329,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). - - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers "opening or placing a HEIF file"): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). + - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). From 488248814575cfb411a416f75680d54d2204dc35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 00:30:04 -0800 Subject: [PATCH 40/51] Type: upright glyphs on a vertical path face the path's start In vertical type on a path, every glyph was turned a quarter turn with its top toward the path's end. An upright glyph's characters then read backwards along the path, so a ligature's font caret other than its middle did not line up with the glyph as drawn. An upright glyph whose top faces the path's end now turns half a turn about the centre of its cell, so its top faces the path's start. It keeps the same stretch of the path. Glyphs lying on their side, and upright glyphs that the Skew, 3D Ribbon and Stair Step effects already turn this way on a path running to the left, are unchanged. The ligature stops follow the top of the cell, so the caret, a click and the selection meet the drawn caret line. --- crates/text/src/layout.rs | 39 +++++++++---- crates/text/src/lib.rs | 2 +- crates/text/src/tests/ligature_carets.rs | 71 +++++++++++++++++++++++- 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 1ff72bdd2..77a55236d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -332,9 +332,31 @@ impl Ctx<'_> { Affine::IDENTITY }; let mut m = pre * local; + // Vertical type on a path: an upright glyph's cell in font units, as (its length with the + // Proportional Metrics change to it, the height of its top above the baseline). + let path_cell = (self.vertical && self.on_path && g.inline.is_none() && g.tcy.is_none() && takes_cell(g) && stands_upright(g) && g.sy != 0.0) + .then(|| { + let (cell, top) = upright_extent(g); + let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / g.sy; + (cell + proportional, top) + }); if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m; + if let Some((cell, top)) = path_cell { + // An upright glyph whose top then faces the path's end turns half a turn about the + // centre of its cell (without its Character Rotation), so that its top faces the + // path's start and the characters of an upright ligature read along the path in + // order. The effects that keep the glyph's up direction fixed already turn it so on + // a path that runs to the left. + let turned = m * unturned; + let tangent = Vec2::new(angle.cos(), angle.sin()); + let along = |y: f64| (turned * Point::new(0.0, y) - origin).dot(tangent); + if along(-top) > along(cell - top) { + let centre = turned * Point::new(g.face.advance(g.gid) * 0.5, cell * 0.5 - top); + m = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * m; + } + } } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up // by its Up/Down and right by its Left/Right (line x runs down the column and line y @@ -408,20 +430,17 @@ impl Ctx<'_> { } else { 0.0 }; - // On a path, an upright glyph turns about its pen: its cell runs along the path between - // where the glyph's transform sets the cell's foot and its top, moved by the shaper's - // placement and a baseline shift, and slanted by the path's effect. - let (width, width_start) = match (upright && self.on_path, g.sy) { - (true, sy) if sy != 0.0 => { - // In font units, with the Proportional Metrics change to its length. - let (cell, top) = upright_extent(g); - let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / sy; + // On a path, an upright glyph's cell runs along the path from where the glyph's transform + // sets the cell's top to its foot, moved by the shaper's placement and a baseline shift, and + // slanted by the path's effect. + let (width, width_start) = match path_cell { + Some((cell, top)) => { let tangent = Vec2::new(angle.cos(), angle.sin()); let along = |y: f64| (m * Point::new(0.0, y) - origin).dot(tangent); - let (start, end) = (along(cell + proportional - top), along(-top)); + let (start, end) = (along(-top), along(cell - top)); if start.is_finite() && end > start { (end - start, start) } else { (width, width_start) } } - _ => (width, width_start), + None => (width, width_start), }; if let Some(art) = g.inline.as_ref().and_then(|ib| ib.art) { self.out.inlines.push(InlineGlyph { run: g.run, byte: g.byte, glyph: self.out.glyphs.len(), xf: m, bounds: m.transform_rect_bbox(art) }); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d7bc1d444..ce315645e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -170,7 +170,7 @@ pub struct PositionedGlyph { /// taken off, plus the manual kerning of right-to-left text, which moves the glyph right so that /// the space it adds lies on the glyph's left. 0 for a glyph standing upright or set across the /// column in vertical type, and for an inline graphic. An upright glyph in vertical type on a - /// path turns about its pen, and its cell starts one cell length before the cell's top: 0.12 em + /// path stands with its top toward the path's start, and its cell starts at its top: 0.12 em /// before the pen without the font's vertical metrics. pub width_start: f64, } diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index 929ba50bc..af2ebae63 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -628,9 +628,10 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } -/// On a path, an upright ligature in vertical type turns about its pen, so its cell runs from -/// 0.12 em before the pen to 0.88 em after it, with or without the font's vertical metrics. The -/// inner stop lies at the middle of that cell, and the first character's selection ends there. +/// On a path, an upright ligature in vertical type stands with its top toward the path's start, and +/// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical +/// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends +/// there. #[test] fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { for (font, family) in [(ligature_caret_font(None), LIGATURE_CARET_FAMILY), (vertical_ligature_caret_font(900), VERTICAL_LIGATURE_CARET_FAMILY)] { @@ -688,6 +689,70 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { } } +/// On a path, an upright ligature in vertical type reads along the path in order: the top of its +/// cell comes first, and its font caret (600 units down the cell) is drawn where the caret, a click +/// and the selection put the stop, on a path running either way with any path effect. A glyph lying +/// on its side keeps its top toward the path's end. +#[test] +fn an_upright_ligature_on_a_path_reads_along_it_in_order() { + use vectorcraft_doc::PathEffect; + let db = db_with(vertical_ligature_caret_font(280)); + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + for (to, effect) in [ + (1000.0, PathEffect::Rainbow), + (-1000.0, PathEffect::Rainbow), + (-1000.0, PathEffect::Skew), + (-1000.0, PathEffect::Ribbon3d), + (-1000.0, PathEffect::StairStep), + (-1000.0, PathEffect::Gravity), + ] { + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((to, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = effect; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + assert!((f - 0.6).abs() < 1e-9, "{f}"); + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let along = |p: Point| (p - g.origin).dot(tangent); + // Font units, y down: the cell's top is 880 units above the baseline and its foot 120 below. + let (top, foot) = (along(g.xf * Point::new(0.0, -880.0)), along(g.xf * Point::new(0.0, 120.0))); + assert!(top < foot, "{to} {effect:?}: the top comes first along the path: {top} {foot}"); + let (start, width) = (g.width_start, g.width); + assert!((top - start).abs() < 1e-6 && (foot - top - width).abs() < 1e-6, "{to} {effect:?}: {top} {foot} {start} {width}"); + let drawn = along(g.xf * Point::new(0.0, -280.0)); + assert!((drawn - (top + (foot - top) * 0.6)).abs() < 1e-6, "{to} {effect:?}: {drawn} against {top} {foot}"); + let (a, b) = caret_position(&l, 1); + let caret = along(a.midpoint(b)); + assert!((caret - drawn).abs() < 1e-6, "{to} {effect:?}: the caret stands at the drawn stop: {caret} against {drawn}"); + assert_eq!(hit_byte(&l, g.origin + tangent * (drawn + 0.3)), 1, "{to} {effect:?}"); + assert_eq!(hit_byte(&l, g.origin + tangent * (drawn - 0.3)), 1, "{to} {effect:?}"); + assert_eq!(hit_byte(&l, g.origin + tangent * 0.3), 0, "{to} {effect:?}"); + let q = selection_quads(&l, 0, 1); + assert_eq!(q.len(), 1, "{q:?}"); + let (q0, q1) = (along(q[0][0]), along(q[0][1])); + assert!((q0.min(q1) - drawn.min(0.0)).abs() < 1e-6 && (q0.max(q1) - drawn.max(0.0)).abs() < 1e-6, "{to} {effect:?}: {q:?} against {drawn}"); + } + // Without Standard Vertical Roman Alignment the letter lies on its side: its top still faces + // the path's end and its advance runs across the path, to its right. + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = PathEffect::Rainbow; + t.runs[0].style.standard_vertical_roman = false; + let l = layout(&db, &t); + let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let tangent = Vec2::new(s.angle.cos(), s.angle.sin()); + let right = Vec2::new(-tangent.y, tangent.x); + let (pen, up, ahead) = (s.xf * Point::ZERO, s.xf * Point::new(0.0, -500.0), s.xf * Point::new(500.0, 0.0)); + assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}"); +} + /// Tracking that makes an upright ligature's advance negative leaves it upright: it keeps its /// caret down its cell, and its cell starts where it does without tracking, with or without the /// font's vertical metrics. From f4e51d33d3e22e304ccac8e9e428bc6578ee392b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 01:17:13 -0800 Subject: [PATCH 41/51] Type: spell center in the upright path glyph comment --- crates/text/src/layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 77a55236d..942bc4dbe 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -345,7 +345,7 @@ impl Ctx<'_> { m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m; if let Some((cell, top)) = path_cell { // An upright glyph whose top then faces the path's end turns half a turn about the - // centre of its cell (without its Character Rotation), so that its top faces the + // center of its cell (without its Character Rotation), so that its top faces the // path's start and the characters of an upright ligature read along the path in // order. The effects that keep the glyph's up direction fixed already turn it so on // a path that runs to the left. From 8b4ef958ac09aa4cd389262a0d02f2a11f09b511 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 23:48:49 -0800 Subject: [PATCH 42/51] Type: a ligature's font caret on a vertical path divides the proportional cell Proportional Metrics changes an upright glyph's cell on a vertical path, and its width runs along that changed cell. Its font ligature carets were still divided by the unchanged cell, so the caret, a click and the selection missed the drawn stop. They now divide the same length the width uses. --- crates/text/src/layout.rs | 21 +++++++++---- crates/text/src/test_fonts.rs | 38 ++++++++++++++++++++++++ crates/text/src/tests/ligature_carets.rs | 33 +++++++++++++++++++- 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 942bc4dbe..6e9334041 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -57,8 +57,9 @@ fn continues_cluster(prev: Option<&SGlyph>, g: Option<&SGlyph>) -> bool { /// The caret stops inside glyph `g` when it alone stands for several characters (grapheme clusters) /// of `text`, a ligature ([`PositionedGlyph::carets`]); `rtl` when its logical leading edge is its /// right edge; `upright` when it stands upright in vertical type, its carets then down its cell -/// ([`upright_extent`]). Empty for a glyph of one character. -fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usize, f64)> { +/// ([`upright_extent`]) as a share of `upright`, the length in font units its own width runs down. +/// Empty for a glyph of one character. +fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: Option) -> Vec<(usize, f64)> { use unicode_segmentation::UnicodeSegmentation; let Some(s) = text.get(g.byte..g.byte + g.len).filter(|_| g.len > 1) else { return Vec::new() }; let starts: Vec = s.grapheme_indices(true).skip(1).map(|(i, _)| g.byte + i).collect(); @@ -66,12 +67,13 @@ fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usi return Vec::new(); } let parts = starts.len() + 1; - let from_font = if upright { + let from_font = if let Some(length) = upright { let (cell, top) = upright_extent(g); g.face .vertical_ligature_carets(g.gid, starts.len(), top, cell) - .filter(|c| c.len() == starts.len() && cell > 0.0) - .map(|c| c.into_iter().map(|d| d / cell).collect::>()) + .filter(|c| c.len() == starts.len() && length > 0.0) + .map(|c| c.into_iter().map(|d| d / length).collect::>()) + .filter(|c| c.iter().all(|f| *f > 0.0 && *f < 1.0)) } else { let advance = g.face.advance(g.gid); g.face @@ -465,7 +467,14 @@ impl Ctx<'_> { bend: None, unturned, pivot, - carets: if g.inline.is_some() { Vec::new() } else { ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, upright) }, + // On a path its carets divide the cell its width runs along, with the Proportional + // Metrics change to it. + carets: if g.inline.is_some() { + Vec::new() + } else { + let length = upright.then(|| path_cell.map_or_else(|| upright_extent(g).0, |(cell, _)| cell)); + ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, length) + }, width, width_start, }); diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 2f591b6e0..00c994467 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -855,6 +855,44 @@ pub fn vertical_ligature_caret_font(caret: i16) -> Option> { Some(builder.build()) } +/// [`vertical_ligature_caret_font`] with its one caret at y = `caret` font units, f + i joined by +/// `ccmp` (which shaping applies top to bottom too, unlike `liga`), and its `GPOS` replaced by a +/// `vpal` single adjustment that changes the ligature glyph's advance down the column by +/// `y_advance` font units. `None` when it can't be built. +pub fn proportional_vertical_ligature_caret_font(caret: i16, y_advance: i16) -> Option> { + use write_fonts::tables::gpos::{Gpos, PositionLookup, PositionLookupList, SinglePos, ValueRecord}; + use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let base = vertical_ligature_caret_font(caret)?; + let font = skrifa::FontRef::new(&base).ok()?; + let gid = |c| Some(GlyphId16::new(source_sans_gid(c)?)); + let ligature = gid(LIGATURE_CARET_GLYPH)?; + let langsys = || Script::new(Some(LangSys::new(vec![0])), vec![]); + let scripts = || ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), langsys()), ScriptRecord::new(Tag::new(b"latn"), langsys())]); + let subst = LigatureSubstFormat1::new( + CoverageFormat1::new(vec![gid('f')?]).into(), + vec![LigatureSet::new(vec![Ligature::new(ligature, vec![gid('i')?])])], + ); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"ccmp"), Feature::new(None, vec![0]))]); + let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); + let gsub = Gsub::new(scripts(), features, lookups); + let pos = SinglePos::format_1(CoverageFormat1::new(vec![ligature]).into(), ValueRecord::new().with_y_advance(y_advance)); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"vpal"), Feature::new(None, vec![0]))]); + let lookups = PositionLookupList::new(vec![PositionLookup::Single(Lookup::new(Default::default(), vec![pos]))]); + let mut builder = FontBuilder::new(); + builder.add_table(&gsub).ok()?; + builder.add_table(&Gpos::new(scripts(), features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`variable_ligature_caret_font`]. pub const VARIABLE_LIGATURE_CARET_FAMILY: &str = "Varicaret Sans"; diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index af2ebae63..f669b07f4 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -8,7 +8,8 @@ use vectorcraft_geom::PathData; use crate::test_fonts::{ LIGATURE_CARET_FAMILY, LIGATURE_CARET_GLYPH, VARIABLE_LIGATURE_CARET_FAMILY, VERTICAL_LIGATURE_CARET_FAMILY, lam_alef_family, lam_alef_font, - ligature_caret_font, placed_ligature_caret_font, source_sans_gid, variable_ligature_caret_font, vertical_ligature_caret_font, + ligature_caret_font, placed_ligature_caret_font, proportional_vertical_ligature_caret_font, source_sans_gid, variable_ligature_caret_font, + vertical_ligature_caret_font, }; use crate::*; @@ -753,6 +754,36 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() { assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}"); } +/// On a path, an upright ligature whose cell Proportional Metrics shortens (`vpal`, from 1000 units +/// to 800) keeps its font caret (600 units down the cell) where it is drawn: the inner stop lies at +/// 600 / 800 of its width, and the caret stands at the drawn stop. +#[test] +fn an_upright_ligature_on_a_path_keeps_its_font_caret_on_a_proportional_cell() { + use vectorcraft_doc::PathEffect; + let db = db_with(proportional_vertical_ligature_caret_font(280, -200)); + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + t.runs[0].style.proportional_metrics = true; + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = PathEffect::Rainbow; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + assert!((g.width - 80.0).abs() < 1e-6, "the cell is 800 units at 100 pt: {}", g.width); + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + assert!((f - 0.75).abs() < 1e-9, "{f}"); + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let along = |p: Point| (p - g.origin).dot(tangent); + let (top, drawn) = (along(g.xf * Point::new(0.0, -880.0)), along(g.xf * Point::new(0.0, -280.0))); + assert!((top - g.width_start).abs() < 1e-6 && (drawn - top - 60.0).abs() < 1e-6, "{top} {drawn} {}", g.width_start); + let (a, b) = caret_position(&l, 1); + let caret = along(a.midpoint(b)); + assert!((caret - drawn).abs() < 1e-6, "the caret stands at the drawn stop: {caret} against {drawn}"); +} + /// Tracking that makes an upright ligature's advance negative leaves it upright: it keeps its /// caret down its cell, and its cell starts where it does without tracking, with or without the /// font's vertical metrics. From df4cd5f9da9b3219c0a6bc34874fdfdfa35b62e1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 13:50:42 +0700 Subject: [PATCH 43/51] Docs: keep the status entries of the PRs before this one --- docs/gaps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index 6e0bce9e5..202124347 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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) Docked Tools stays beside the canvas, inside any left-side panels. Both the canvas edge and the window edge accept floating Tools. Existing saved workspace fields are reused. This is a requested layout choice; [toolbar documentation](https://helpx.adobe.com/illustrator/using/tools.html) and [workspace documentation](https://helpx.adobe.com/illustrator/using/customizing-workspace.html) describe movability and docking, without establishing this exact relative placement. Sidebar resizing (#5348) remains separate. Scores and estimates are unchanged. > **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. From 08f03e606db2abf66805d321cd67164265d6312a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 13:51:21 +0700 Subject: [PATCH 44/51] Docs: one status line in the roadmap and UI parity docs, with #5313's entry --- docs/ui-parity.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 80b3f9ef9..3fd9b1fc2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,6 @@ # UI and interaction parity -> **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; 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-06 · **Change:** minor (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) ## Feature submission evidence From 5de9fc2954e0f7bad3399ee7123de5be12a252c8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 13:52:33 +0700 Subject: [PATCH 45/51] Docs: drop the header lines and rows the older PRs repeated --- ROADMAP.md | 4 +--- docs/gaps.md | 18 +----------------- docs/image-trace-adjustments.md | 9 --------- docs/mcp.md | 2 -- docs/roadmap.md | 8 -------- docs/target-app-parity.md | 12 ------------ docs/ui-parity.md | 6 ------ 7 files changed, 2 insertions(+), 57 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f7d59a3da..504324eaf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,5 @@ # VectorCraft Roadmap -> **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. - - Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. - Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. @@ -16,7 +14,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON diff --git a/docs/gaps.md b/docs/gaps.md index 202124347..a73d82c73 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,12 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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) - -Docked Tools stays beside the canvas, inside any left-side panels. Both the canvas edge and the window edge accept floating Tools. Existing saved workspace fields are reused. This is a requested layout choice; [toolbar documentation](https://helpx.adobe.com/illustrator/using/tools.html) and [workspace documentation](https://helpx.adobe.com/illustrator/using/customizing-workspace.html) describe movability and docking, without establishing this exact relative placement. Sidebar resizing (#5348) remains separate. Scores and estimates are unchanged. -> **QoL update (2026-11-02):** Selection-tool marquees offer Touching (default), Fully Enclosed and Drag Direction in Preferences › Selection & Anchor Display. Drag Direction uses screen left/right, including rotated views, retaining its last rule within three horizontal screen pixels of the press. E reverses the rule for the current drag. A solid enclosed outline or dashed touching outline and a translated label show the active rule. The selection region remains aligned to the document axes. This is a VectorCraft addition; scores and estimates are unchanged. - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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-07 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; 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 @@ -827,7 +821,6 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| -| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | G15: nearby hits on plain long strokes and outlines check their candidate segments directly; a 200,000-anchor middle hit took about 11–13 ms before and 1.0–1.4 ms after in paired runs. An inside-bounds miss fell from 18.7 ms to 1.6 ms. Estimate unchanged; idle budget run still open. | | 2026-11-05 | minor | G15: an image zoomed in on is separated, colorized or proofed only for the tiles in view (#4155); 100% zoom on that file 8.6 s → 0.05 s. Estimate unchanged. | @@ -909,15 +902,6 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-22 | minor | Affinity import: master pages, tables, paragraph formats, text scale, shadows, glows, outlines, overlays, underlines, strikethrough, CMYK pixel layers through the document's profile and editable embedded documents, text placed as Affinity places it (AF.5, from storytold/designcraft#291). No entry closed, estimates unchanged. Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | | 2026-10-21 | minor | G17: filter plug-ins keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own; engine and unit regressions. No entry closed, estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | G8: stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw: Roughen, Zig Zag, Free Distort, Twist, the warps and Transform build within it, and an effect that would still pass it is left out (#3578). No entry closed, estimates unchanged. | -| 2026-10-22 | minor | G13: gradient Method (30.0), Classic or Perceptual, in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports (with Dither, a Perceptual fill takes its noise); G13 estimate 14–24 h → 13–23 h. | -| 2026-10-22 | minor | G8: SVG images in SVG images share the depth cap (any data URL type usvg reads as SVG) and SVGZ images one 512 MB unpacking cap; past either, an image is left out with a warning (#3562, #3563). No entry closed, estimates unchanged. | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | G19: PDF export asks viewers to display the document title only when File Info has a Document Title or an explicit export title is supplied; untitled documents keep the PDF file name in the viewer title bar (#3646); scores and estimates unchanged. | -| 2026-10-22 | minor | G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged. | -| 2026-10-22 | minor | G13: gradient Dither (30.0) in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports; G13 estimate 15–25 h → 14–24 h. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index d0ac9fda7..92e5f42ac 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -32,15 +32,6 @@ white beside a cream), at most six; scattered specks of a colour add none. Adobe its own palettes are chosen; this is VectorCraft's own method (see [image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)). -**Flat Logo colors.** Flat Logo traces white as a color of its own beside the colors named in -`logoColors`, and takes a named color with every channel 245 or more as that white. Each pixel is read -as a blend of the traced colors and goes to the color with the largest share, so a near-white area can -go to a named light color instead of white. With `"logoWhite": false` in the params of `imageTrace.make` or -`imageTrace.makeAndExpand`, it traces the named colors only: white areas take the nearest named color, -a named white or near-white is used as named, and enclosed transparent areas stay holes instead of -being painted white. When Flat Logo finds the colors itself, `logoWhite` has no effect, and with -`ignoreWhite` white areas are left out whatever `logoWhite` says. - **Document Library color groups.** With Palette › Document Library and Library › Document Swatches, the panel's **Colors** menu traces with every document swatch (**All**) or with the colors of one color group from the Swatches panel, as the public diff --git a/docs/mcp.md b/docs/mcp.md index 08b0eb8de..366f31921 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,7 +1,5 @@ # VectorCraft MCP server -Selection-tool marquees: `prefs.set {key: "marqueeSelection", value: "touching" | "enclosed" | "directional"}` chooses touched objects (default), fully enclosed objects, or screen-right enclosed / screen-left touched objects. Direction follows the screen when the view is rotated; within three horizontal screen pixels of the press, the last rule remains (initially touching). Without a screen frame, direction uses document x at the current zoom. E reverses the rule for the current drag; Shift toggles the reached objects and Escape cancels. The outline and label show the effective rule. Ruler guides use that rule too; Direct Selection and the document-aligned selection region are unchanged. - `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to draw, inspect and look at VectorCraft documents. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0e58d2eec..22c648c69 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,7 +4,6 @@ ## Feature submission evidence -**Feature evidence last updated:** 2026-11-02. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -138,7 +137,6 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; Flat Logo with named colors can leave out white with `logoWhite: false`; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 | @@ -183,7 +181,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| -| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep their own units; scores and estimates unchanged. | @@ -195,8 +192,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-03 | minor | M7: Touch Type moves right-to-left characters along their line while keeping text editable; first-logical-character restriction retained. Estimates unchanged. | | 2026-10-30 | patch | M5: opaque solid-fill knockout paths avoid redundant erase/add layers in hard-edge frames and supersampled raster export; exact-pixel regression coverage retains analytical-AA rounding and translucent knockout behavior; scores and estimates unchanged | | 2026-11-03 | minor | M7: retain composition trimming in rotated character placement and rotation metadata; estimates unchanged. | -| 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | -| 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-10-29 | minor | M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged | | 2026-10-26 | minor | M4/G9: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and estimates unchanged | | 2026-10-25 | minor | M10/G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); estimates unchanged | @@ -215,9 +210,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation. Scores and estimates unchanged. | | 2026-10-21 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset (UI.7); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 14ff46ad8..94b8b60c3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -4,7 +4,6 @@ ## Feature submission evidence -**Feature evidence last updated:** 2026-11-02. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -1803,7 +1802,6 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| -| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | @@ -1852,12 +1850,10 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-31 | minor | Selection tools: with Alt held at the press, a Direct Selection marquee selects every path it touches whole and keeps the Group Selection pointer (#4890); a press on a selected object's centre point selects the object whole and a drag from it moves it (#4891); regression tests in `direct/tests.rs`; scores and estimates unchanged. | | 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): evidence added; scores unchanged. | | 2026-10-31 | minor | Guides and snapping: Snap to Perpendicular and Snap to Tangent apply to an existing line's end dragged with Direct Selection, as the public pages describe for editing existing lines (the end anchor of a straight segment, not mid-line anchors or curved segments); a regression test covers both snaps, both toggles and non-snapping controls (#2530); scores and estimates unchanged. | -| 2026-10-30 | minor | File formats: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; evidence added; scores unchanged. | | 2026-10-31 | minor | UI chrome: Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; evidence added; score and hours unchanged (estimated). | | 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path; evidence added; scores unchanged. | | 2026-10-30 | patch | Performance: opaque solid-fill knockout paths avoid redundant erase/add compositing in hard-edge frames, including supersampled raster export; analytical antialiasing keeps its existing rounding; scores and estimates unchanged | -| 2026-10-30 | minor | File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; evidence added; scores unchanged. | | 2026-10-31 | minor | Path Precision Tools add-on (a VectorCraft addition): live Path Effects; evidence added; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): text tools; evidence added; scores unchanged. | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | @@ -1948,14 +1944,6 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). | | 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Method, Classic or Perceptual (Gradient panel menu, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | Dimension tool: in a CMYK document a dimension's lines, arrowheads and label are drawn in CMYK, black as K only (`0 0 0 1`) and other colours through the active colour settings, instead of an RGB black that printed as a four-ink rich black (#3233); RGB documents unchanged; `dimensions::dimension_colours_follow_the_document_colour_model`. Scores and hours unchanged. | -| 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Dither (Gradient panel box, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). | -| 2026-10-22 | minor | #3546: `--automation-read-root` and `--automation-write-root` may each be given more than once (`vectorcraft-cli mcp` and the desktop app); a path inside any root of its kind is allowed, every confinement check unchanged. Scores and estimates unchanged. | -| 2026-10-22 | minor | #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | | 2026-10-21 | minor | Plug-ins: filters keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own (G17); engine and unit regressions. Scores and hours unchanged. | | 2026-10-21 | minor | Print: PDF/X-1a and PDF/X-3 flattening in a CMYK document rasterizes as ink amounts, so CMYK colours under an image with see-through pixels keep their numbers, K included, instead of going through RGB (#2160); own-fixture test; scores and hours unchanged (estimated) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3fd9b1fc2..b7b2250c5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,11 +1,9 @@ # UI and interaction parity > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; 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-06 · **Change:** minor (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) ## Feature submission evidence -**Feature evidence last updated:** 2026-11-02. | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -481,7 +479,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| -| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Keyboard shortcuts: Cmd/Ctrl+Alt+. and Cmd/Ctrl+Alt+, (Stroke Weight Up and Down) step each selected stroke's weight, or the next object drawn's (#5797); scores and estimates unchanged | | 2026-11-06 | minor | Properties: Quick Actions has Repeat Options for one selected radial, grid or mirror repeat, opening Object › Repeat › Options with its values (#5181); scores and estimates unchanged | | 2026-11-06 | minor | Control bar: with type selected, a Paragraph: link (the Paragraph panel in a popover) and the Align left, Align center and Align right buttons follow the Character controls; the bar leaves them out from its right end when short of room, after X/Y/W/H and the Align buttons (#5372); scores and estimates unchanged | @@ -598,9 +595,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | Gradient panel: the Method menu, Classic or Perceptual (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-22 | minor | Gradient panel: the Dither box (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | Gradient panel: the Method menu, Classic or Perceptual (Illustrator 30.0); scores and estimates unchanged. | -| 2026-10-22 | minor | Gradient panel: the Dither box (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset returns to the saved layout; score unchanged (estimated) | | 2026-10-21 | minor | Panels dragged by the mouse join a dock group as tabs, split it top/bottom or become icons in the icon column; dock tabs close from their context menu; score unchanged (estimated) | From 6b0a4c48b0cbadf1a1230a8dea53c8983bbcc58a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:52:39 +0700 Subject: [PATCH 46/51] Docs: keep this batch's status entries after replaying onto main --- docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 22c648c69..68b15aa3d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 94b8b60c3..66983a171 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence From f5be571d4742411f25724fbb8125ba8f0f06c308 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:25:27 +0700 Subject: [PATCH 47/51] .ai import: Bas Relief opens with its settings Squashed from #5227 onto main. --- crates/engine/tests/import_fuzz.rs | 54 +++++++ crates/eps/src/import/ai/style.rs | 115 +++++++++++++- crates/eps/src/import/ai/style/adapter.rs | 179 ++++++++++++++++++++++ crates/eps/src/import/ai/style/tests.rs | 178 +++++++++++++++++++++ crates/eps/src/import/ai/tests.rs | 45 ++++++ docs/file-format-parity.md | 34 ++-- docs/target-app-parity.md | 1 + 7 files changed, 584 insertions(+), 22 deletions(-) create mode 100644 crates/eps/src/import/ai/style/adapter.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 59d079bcd..81d75ca70 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4720,6 +4720,13 @@ fn arb_ai_token() -> impl Strategy { "(Fill Style Filter) 0 0 /Filter ,", "(Adobe Outer Glow) 1 0 /Filter ,", "(Adobe Drop Shadow) 1 0 /Filter ,", + "(PSAdapter_plugin_BsRl) 1 0 /Filter ,", + "/Binary : /ASCII85Decode ,", + "/Binary : /ASCII85Decode ,\n%!!!!\"z~>\n; (data) ,", + "\n%zzzz~>\n", + "; (data) ,", + "8 /Int (dataSize) ,", + "8 /Real (dataSize) ,", "(Adobe Pathfinder) 1 0 /Filter ,", "0 /Int (Command) ,", "0.5 /Real (Mix) ,", @@ -5229,6 +5236,53 @@ proptest! { survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?; } } + + /// A filter-gallery effect's settings block in an art style: fields of any key, marker, type and + /// value, objects nested in objects, any version and `dataSize`, and its ASCII85 lines damaged or cut. + #[test] + fn ai_filter_gallery_settings_never_panic( + fields in prop::collection::vec(arb_settings_field(), 0..8), + inner in prop::collection::vec(arb_settings_field(), 0..4), + nesting in 0usize..4, + version in prop_oneof![Just(1u32), any::()], + size in prop_oneof![Just(None), any::().prop_map(Some)], + cut in 0usize..600, + edits in prop::collection::vec((0usize..600, prop::sample::select(vec!['%', '~', '>', 'z', 'v', '!', 'u', '\n', ';', ' '])), 0..6), + ) { + use vectorcraft_testkit::ai; + let object = |body: Vec| [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &(body.len() as u32 + 4).to_be_bytes(), &1u32.to_be_bytes(), &body].concat(); + let mut nested = inner.concat(); + for _ in 0..nesting { + nested = object(nested); + } + let data = [version.to_be_bytes().to_vec(), fields.concat(), nested].concat(); + let lines: String = ascii85(&data).as_bytes().chunks(60).map(|c| format!("%{}\n", String::from_utf8_lossy(c))).collect(); + let size = size.unwrap_or(data.len() as i64); + let block = mutate_text(&format!("/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n{size} /Int (dataSize) ,\n"), cut, &edits); + let styles = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(R) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(PSAdapter_plugin_BsRl) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{block}; /Dict ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ); + let hidden = ai::rect(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let art = format!("0 Ae\nu\n0 1 1 0 1 0 0 Xa\n{}f\nU\n%_{hidden}f\n1 (R) XW\n", ai::rect(0.0, 0.0, 10.0, 10.0)); + let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art))); + survive("filter-gallery settings", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("filter-gallery settings in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } +} + +/// A field of a filter-gallery settings descriptor: a key, a marker, a type and a value, each one +/// that is read or anything else. +fn arb_settings_field() -> impl Strategy> { + let key = prop_oneof![prop::sample::select(vec![*b"GEfk", *b"Dtl ", *b"Smth", *b"LghD", *b"Xtra"]), any::<[u8; 4]>()]; + let marker = prop_oneof![Just(0u32), Just(0x2000), Just(0x8000_0000), any::()]; + let kind = prop_oneof![prop::sample::select(vec![*b"long", *b"doub", *b"bool", *b"LghD", *b"GEft"]), any::<[u8; 4]>()]; + let value = prop_oneof![ + prop::sample::select(vec![b"LDBt".to_vec(), b"BsRl".to_vec(), 8i32.to_be_bytes().to_vec(), vec![1], f64::NAN.to_be_bytes().to_vec()]), + prop::collection::vec(any::(), 0..12), + ]; + (key, marker, kind, value).prop_map(|(k, m, t, v)| [&k[..], &m.to_be_bytes(), &t, &v].concat()) } // ---------- font files a folder search reads ---------- diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 9699e3ac6..04ddf27c1 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -160,8 +160,8 @@ pub(super) struct ArtStyle { pub plain: bool, /// It is one fill and one stroke at most, in solid colours or the object's own gradient, with /// no transparency or effects of their own, and only Drop Shadow, Inner Glow, Outer Glow, - /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke) - /// and some Distort & Transform effects ([`any_object_takes`]) on the object + /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke), + /// Bas Relief read from its settings block and some Distort & Transform effects ([`any_object_takes`]) on the object /// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. pub simple: bool, @@ -277,6 +277,8 @@ struct Filter { xy: Option<(usize, f32)>, /// `… isolate knockout … Xy`. xy_group: Option<(bool, Knockout)>, + /// Its settings block's bytes (`/Binary : /ASCII85Decode ,`), `Err` when it isn't read. + data: Option, ()>>, } impl Default for Filter { @@ -304,6 +306,7 @@ impl Default for Filter { plugin_object: None, xy: None, xy_group: None, + data: None, } } } @@ -383,12 +386,18 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { let mut style_entry: Option = None; // A name of a basic filter's that may be an entry's type, with the string that follows it. let mut other_entry: Option> = None; + // The last name was `/Binary`. + let mut binary = false; let mut lex = Lexer::new(data); while let Some(t) = lex.next_token() { if let (Some(a), Tok::Num(v)) = (array.as_mut(), &t.tok) { a.push(V::Num(*v)); continue; } + let after_binary = binary; + if let Tok::Name(n) = &t.tok { + binary = *n == b"Binary"; + } match t.tok { Tok::Num(v) => operands.push(V::Num(v)), Tok::Str(s) | Tok::Hex(s) => { @@ -397,6 +406,11 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { } operands.push(V::Str(s)); } + // A basic filter's settings block: its bytes are on the comment lines the lexer skips. + Tok::Name(b"ASCII85Decode") if after_binary && in_basic => { + keep_block(&mut lex, data.len(), filters.last_mut()); + operands.clear(); + } Tok::Name(n) => match n { b"KnownStyle" => { finish(name.take(), &mut filters); @@ -520,6 +534,62 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { styles } +/// Reads the settings block that `lex` (over `data_len` bytes) has just reached into `filter`'s +/// `data`; a second block on one filter, or a block that isn't read, leaves it `Err`. +fn keep_block(lex: &mut Lexer, data_len: usize, filter: Option<&mut Filter>) { + let block = binary_block(lex, data_len).ok_or(()); + if let Some(f) = filter { + f.data = Some(if f.data.is_some() { Err(()) } else { block }); + } +} + +/// The bytes of the settings block whose `/Binary : /ASCII85Decode` `lex` (over `data_len` bytes) +/// has just read: the rest of that line is its `,`, each line after it holds ASCII85 after a `%` +/// until `~>`, and `; (data) ,` follows. `lex` is moved past the `~>` when the lines up to it are +/// the block's and fit in [`adapter::MAX_ENCODED`] bytes, and past `; (data)` when they follow. +/// `None` when the block is malformed or decodes to more than [`adapter::MAX_DECODED`] bytes. +fn binary_block(lex: &mut Lexer, data_len: usize) -> Option> { + let from = lex.offset(); + let text = lex.slice(from, from.saturating_add(adapter::MAX_ENCODED).min(data_len)); + let mut lines = text.split(|b| matches!(b, b'\r' | b'\n')); + let first = lines.next()?; + if first.trim_ascii() != b"," { + return None; + } + // Where the line being read starts in `text`. + let mut at = first.len() + 1; + let mut encoded = vec![]; + for line in lines { + let start = at; + at += line.len() + 1; + if line.is_empty() { + continue; + } + // A line that isn't the block's ends it unread, the lexer left before it. + let digits = line.strip_prefix(b"%")?; + if let Some(i) = super::super::lex::find(digits, b"~>") { + encoded.extend_from_slice(digits.get(..i + 2)?); + lex.seek(from + start + 1 + i + 2); + return block_data(lex, &encoded); + } + encoded.extend_from_slice(digits); + } + None +} + +/// The bytes of a settings block's ASCII85 `encoded`, when `; (data)` follows it in `lex`. When it +/// doesn't, `lex` is left before those tokens, so the next style's `/KnownStyle` is still read. +fn block_data(lex: &mut Lexer, encoded: &[u8]) -> Option> { + let at = lex.offset(); + let data = crate::ps::ascii85_decode_within(std::str::from_utf8(encoded).ok()?, adapter::MAX_DECODED)?; + let (close, key) = (lex.next_token().map(|t| t.tok), lex.next_token().map(|t| t.tok)); + if close == Some(Tok::Word(b";")) && key == Some(Tok::Str(b"data".to_vec())) { + return Some(data); + } + lex.seek(at); + None +} + /// A paint operator of a fill's or stroke's style. fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { let last = vals.iter().rev().find_map(V::num); @@ -1068,8 +1138,14 @@ fn effect(f: &Filter, under: Under) -> Option { TWIST => ("distort.twist", json!({"angle": real(f, "angle", 0.0, -3600.0..=3600.0)?})), ZIG_ZAG => ("distort.zigZag", zig_zag(f)?), GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})), - // Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in. - BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})), + BAS_RELIEF => ( + "sketch.basRelief", + match &f.data { + // Without a settings block, the effect's defaults stand in. + None => json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}), + Some(data) => adapter::bas_relief(&descriptor(f, data)?)?, + }, + ), SHAPE_EFFECTS => return shape_effect(f), PATHFINDER => return pathfinder_effect(f), _ => return None, @@ -1077,6 +1153,18 @@ fn effect(f: &Filter, under: Under) -> Option { Some(Effect { id: id.into(), params, visible: f.visible }) } +/// The fields of filter `f`'s settings block `data`, when it was read and its `dataSize`, written +/// as `/Int`, is its length. +fn descriptor(f: &Filter, data: &Result, ()>) -> Option> { + let data = data.as_ref().ok()?; + (f.kinds.get("dataSize") == Some(&Kind::Int)).then_some(())?; + let size = f.params.get("dataSize").copied()?; + if size != data.len() as f64 { + return None; + } + adapter::decode(data) +} + /// The Effect › Pathfinder effect of [`PATHFINDER_COMMANDS`] that an integer `Command` names, when /// every key of [`PATHFINDER_OPTIONS`] is there with its kind and value (Soft Mix's `Mix` from 0.01 /// to 1, as its Mixing Rate in percent) and the only other key is the `DisplayString` text, each @@ -1389,6 +1477,21 @@ fn any_object_takes(e: &Effect) -> bool { } } +/// Does every Bas Relief filter in `filters` have a settings block, so that it reads from its +/// settings and not from its defaults standing in? +fn bas_relief_read(filters: &[Filter]) -> bool { + filters.iter().all(|f| f.name != BAS_RELIEF || f.data.is_some()) +} + +/// Is `e` read on the whole object: Bas Relief when `bas_relief` says its settings were read, and +/// otherwise what [`any_object_takes`] says? +fn object_takes(e: &Effect, bas_relief: bool) -> bool { + if e.id == "sketch.basRelief" { + return bas_relief; + } + any_object_takes(e) +} + /// Do Free Distort params `p`'s corners (top left, top right, bottom right, bottom left) make a /// parallelogram, so that the bilinear map is affine? fn affine(p: &serde_json::Value) -> bool { @@ -1443,13 +1546,14 @@ fn style(filters: &[Filter]) -> ArtStyle { b.plain = false; } st.plain = b.plain; + let bas_relief = bas_relief_read(filters); let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count(); st.simple = st.plain && count(true) <= 1 && count(false) <= 1 && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. })) && st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty()) - && st.effects.iter().all(any_object_takes) + && st.effects.iter().all(|e| object_takes(e, bas_relief)) // An effect that moves the outline is drawn on its own: with others, their order and what // scales with it aren't kept. && (!st.moves_outline() || st.effects.len() == 1) @@ -1458,5 +1562,6 @@ fn style(filters: &[Filter]) -> ArtStyle { st } +mod adapter; #[cfg(test)] mod tests; diff --git a/crates/eps/src/import/ai/style/adapter.rs b/crates/eps/src/import/ai/style/adapter.rs new file mode 100644 index 000000000..c06122e56 --- /dev/null +++ b/crates/eps/src/import/ai/style/adapter.rs @@ -0,0 +1,179 @@ +//! The settings of a filter-gallery effect in an art style: a basic filter's `/Binary : +//! /ASCII85Decode ,` block, whose comment lines hold the bytes until `~>`, then `; (data) ,` and the +//! byte count `n /Int (dataSize) ,`. The bytes are a descriptor, all numbers big-endian: +//! +//! ```text +//! version (u32, 1) +//! field* key (4 chars) marker (u32) … +//! marker 0: type (4 chars: long, doub, bool) value (i32, f64 or one byte) +//! marker 0x2000: an enumeration: type (4 chars) value (4 chars) +//! marker 0x80000000: an object: class (4 chars) length (u32) and that many bytes, a descriptor +//! ``` +//! +//! A filter-gallery effect's first field is `GEfk`, an enumeration of type `GEft` whose value is the +//! effect's four-character code. The keys and the enumerations' values are the ones the Photoshop +//! Actions Event Guide lists for Bas Relief, and `GEfk` and `GEft` are the filter gallery's in +//! published scripting examples. Anything else (another marker or type, a field past the end, bytes after the last field, more +//! than [`MAX_FIELDS`] fields or objects nested more than [`MAX_DEPTH`] deep) isn't read. + +use serde_json::{Value as Json, json}; + +/// Most bytes a block's text is read from (its comment lines and `~>`). +pub(super) const MAX_ENCODED: usize = 6 * 1024; +/// Most bytes a block decodes to. +pub(super) const MAX_DECODED: usize = 4 * 1024; +/// Most fields read in one descriptor, nested ones included. +const MAX_FIELDS: usize = 32; +/// Most levels of objects in objects. +const MAX_DEPTH: usize = 2; + +/// A field's value. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Value { + Long(i32), + Doub(f64), + Bool(bool), + /// An enumeration's type and value. + Enum([u8; 4], [u8; 4]), + /// An object's class and fields. + Object([u8; 4], Vec<(Key, Value)>), +} + +/// A field's key. +pub(super) type Key = [u8; 4]; + +/// The fields of descriptor `data`; `None` when it isn't one read here. +pub(super) fn decode(data: &[u8]) -> Option> { + let mut count = 0; + fields(data, 0, &mut count) +} + +fn fields(data: &[u8], depth: usize, count: &mut usize) -> Option> { + let mut r = Reader { data, at: 0 }; + if r.u32()? != 1 { + return None; + } + let mut out = vec![]; + while r.at < data.len() { + *count += 1; + if *count > MAX_FIELDS { + return None; + } + let key = r.four()?; + let value = match r.u32()? { + 0 => match &r.four()? { + b"long" => Value::Long(i32::from_be_bytes(r.four()?)), + b"doub" => Value::Doub(f64::from_be_bytes(r.eight()?)).finite()?, + b"bool" => match r.take(1)? { + [0] => Value::Bool(false), + [1] => Value::Bool(true), + _ => return None, + }, + _ => return None, + }, + 0x2000 => Value::Enum(r.four()?, r.four()?), + 0x8000_0000 if depth < MAX_DEPTH => { + let class = r.four()?; + let n = usize::try_from(r.u32()?).ok()?; + Value::Object(class, fields(r.take(n)?, depth + 1, count)?) + } + _ => return None, + }; + out.push((key, value)); + } + Some(out) +} + +impl Value { + fn finite(self) -> Option { + match self { + Value::Doub(v) if !v.is_finite() => None, + v => Some(v), + } + } +} + +struct Reader<'a> { + data: &'a [u8], + at: usize, +} + +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let end = self.at.checked_add(n)?; + let bytes = self.data.get(self.at..end)?; + self.at = end; + Some(bytes) + } + + fn four(&mut self) -> Option<[u8; 4]> { + self.take(4)?.try_into().ok() + } + + fn eight(&mut self) -> Option<[u8; 8]> { + self.take(8)?.try_into().ok() + } + + fn u32(&mut self) -> Option { + Some(u32::from_be_bytes(self.four()?)) + } +} + +/// The fields of a filter-gallery effect `code`, its `GEfk` field taken off; `None` when that field +/// names another effect. +fn gallery<'a>(fields: &'a [(Key, Value)], code: &[u8; 4]) -> Option<&'a [(Key, Value)]> { + match fields.split_first() { + Some((([b'G', b'E', b'f', b'k'], Value::Enum(kind, value)), rest)) => (kind == b"GEft" && value == code).then_some(rest), + _ => Some(fields), + } +} + +/// A whole number in `range`. +fn long(v: &Value, range: std::ops::RangeInclusive) -> Option { + match v { + Value::Long(n) if range.contains(n) => Some(f64::from(*n)), + _ => None, + } +} + +/// Sketch › Bas Relief's params (`sketch.basRelief`) from its fields: `Dtl ` (detail), `Smth` +/// (smoothness) and `LghD` (the light's direction), each once; `None` when one is missing, out of +/// range or not read here, or another field is there. +pub(super) fn bas_relief(fields: &[(Key, Value)]) -> Option { + let (mut detail, mut smoothness, mut light) = (None, None, None); + for (key, value) in gallery(fields, b"BsRl")? { + let slot = match key { + b"Dtl " => &mut detail, + b"Smth" => &mut smoothness, + b"LghD" => &mut light, + _ => return None, + }; + if slot.is_some() { + return None; + } + *slot = Some(match key { + b"LghD" => json!(light_direction(value)?), + _ => json!(long(value, 1..=15)?), + }); + } + Some(json!({"detail": detail?, "smoothness": smoothness?, "lightDirection": light?})) +} + +/// The light direction of a `LghD` enumeration's value (`typeLightDirection`). +fn light_direction(v: &Value) -> Option<&'static str> { + let Value::Enum(kind, value) = v else { return None }; + if kind != b"LghD" { + return None; + } + Some(match value { + b"LDBt" => "bottom", + b"LDBL" => "bottomLeft", + b"LDLf" => "left", + b"LDTL" => "topLeft", + b"LDTp" => "top", + b"LDTR" => "topRight", + b"LDRg" => "right", + b"LDBR" => "bottomRight", + _ => return None, + }) +} diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index da9a83ad7..0845ca0d9 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1051,6 +1051,184 @@ fn a_hidden_fill_comes_in_hidden() { assert_eq!(st.looks.iter().map(|l| l.visible).collect::>(), [false, true]); } +/// A descriptor (version 1) of `fields`. +fn descriptor(fields: &[Vec]) -> Vec { + [vec![0, 0, 0, 1], fields.concat()].concat() +} + +fn long_field(key: &[u8; 4], v: i32) -> Vec { + [&key[..], &[0; 4], b"long", &v.to_be_bytes()].concat() +} + +fn enum_field(key: &[u8; 4], kind: &[u8; 4], v: &[u8; 4]) -> Vec { + [&key[..], &0x2000u32.to_be_bytes(), kind, v].concat() +} + +/// Bas Relief's fields: the filter-gallery code, `detail`, `smoothness` and the light direction `light`. +fn relief_fields(detail: i32, smoothness: i32, light: &[u8; 4]) -> Vec> { + vec![enum_field(b"GEfk", b"GEft", b"BsRl"), long_field(b"Dtl ", detail), long_field(b"Smth", smoothness), enum_field(b"LghD", b"LghD", light)] +} + +/// A settings block of `data`: the ASCII85 on comment lines, then `; (data) ,`. +fn block(data: &[u8]) -> String { + let lines: String = crate::ps::ascii85(data).lines().map(|l| format!("%{l}\n")).collect(); + format!("/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n") +} + +/// The style `name`: a black fill and Bas Relief on the object, whose dictionary holds `body`. +fn relief_text(name: &str, body: &str) -> String { + let stack = compound("Stack Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Conduit Filter", "")]); + let relief = basic("PSAdapter_plugin_BsRl", body); + format!("/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack, relief])) +} + +/// The style of [`relief_text`]. +fn relief_style(body: &str) -> ArtStyle { + art_styles(relief_text("Relief", body).as_bytes()).remove("Relief").unwrap_or_default() +} + +#[test] +fn a_settings_block_without_its_end_leaves_the_next_style_alone() { + // GUARDS: the block's `~>` was looked for past its own lines, in the next style's block, which was skipped. + let data = descriptor(&relief_fields(8, 1, b"LDBt")); + let cut = format!("{}{} /Int (dataSize) ,\n", block(&data).replace("~>", ""), data.len()); + let whole = format!("{}{} /Int (dataSize) ,\n", block(&data), data.len()); + let styles = art_styles(format!("{}{}", relief_text("Cut", &cut), relief_text("Whole", &whole)).as_bytes()); + assert!(styles["Cut"].effects.is_empty(), "{:?}", styles["Cut"]); + assert!(styles["Whole"].simple, "{:?}", styles["Whole"]); + assert_eq!(styles["Whole"].effects[0].params["detail"], json!(8.0)); +} + +#[test] +fn a_settings_block_without_its_data_key_leaves_the_next_style_alone() { + // GUARDS: the tokens after the block's `;` were taken as its `(data)` key, the closing of its + // dictionary or the next style's `/KnownStyle` among them, and the next style was lost. + let data = descriptor(&relief_fields(8, 1, b"LDBt")); + let whole = format!("{}{} /Int (dataSize) ,\n", block(&data), data.len()); + let keyless = block(&data).replace("(data) ,\n", ""); + let next = relief_text("Whole", &whole); + // A style cut short right after its block's `;`, the next style's `/KnownStyle` following. + let short = relief_text("Cut", &keyless); + let short = short.get(..short.find("~>\n;").unwrap_or_default() + 4).unwrap_or_default(); + for text in [ + format!("{short}\n{next}"), + format!("{}{next}", relief_text("Cut", &keyless)), + format!("{}{next}", relief_text("Cut", &format!("{keyless}{} /Int (dataSize) ,\n", data.len()))), + ] { + let styles = art_styles(text.as_bytes()); + assert!(styles.get("Whole").is_some_and(|s| s.simple), "{styles:?}"); + assert_eq!(styles["Whole"].effects[0].params["detail"], json!(8.0)); + assert!(!styles.get("Cut").is_some_and(|s| s.simple), "{styles:?}"); + } +} + +/// [`relief_style`] with the settings block of `data` and a `dataSize` of its length. +fn relief_with(data: &[u8]) -> ArtStyle { + relief_style(&format!("{}300 /Int (PrevDres) ,\n{} /Int (dataSize) ,\n1 /Bool (go) ,\n", block(data), data.len())) +} + +#[test] +fn bas_relief_reads_its_settings_block() { + // GUARDS: the effect's defaults stood in for the settings, and an object other than type with it + // came in as its drawn look. + for (code, light) in [ + (b"LDBt", "bottom"), + (b"LDBL", "bottomLeft"), + (b"LDLf", "left"), + (b"LDTL", "topLeft"), + (b"LDTp", "top"), + (b"LDTR", "topRight"), + (b"LDRg", "right"), + (b"LDBR", "bottomRight"), + ] { + let st = relief_with(&descriptor(&relief_fields(8, 1, code))); + assert!(st.plain && st.simple, "{st:?}"); + let [e] = st.effects.as_slice() else { panic!("{st:?}") }; + assert_eq!((e.id.as_str(), &e.params), ("sketch.basRelief", &json!({"detail": 8.0, "smoothness": 1.0, "lightDirection": light}))); + } + // Without the filter-gallery code, and with the fields in another order. + let [_, detail, smoothness, light] = <[Vec; 4]>::try_from(relief_fields(15, 15, b"LDTp")).unwrap(); + let st = relief_with(&descriptor(&[light, smoothness, detail])); + assert!(st.simple, "{st:?}"); + assert_eq!(st.effects[0].params, json!({"detail": 15.0, "smoothness": 15.0, "lightDirection": "top"})); +} + +#[test] +fn bas_relief_without_a_settings_block_keeps_the_effects_defaults_on_type_only() { + let st = relief_style("300 /Int (PrevDres) ,\n"); + assert!(st.plain && !st.simple, "{st:?}"); + assert_eq!(st.effects[0].params, json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})); +} + +#[test] +fn bas_relief_with_settings_not_read_here_keeps_the_drawn_look() { + let good = || relief_fields(8, 1, b"LDBt"); + let with = |i: usize, field: Vec| { + let mut f = good(); + f[i] = field; + descriptor(&f) + }; + let cases: Vec<(&str, Vec)> = vec![ + ("detail 0", with(1, long_field(b"Dtl ", 0))), + ("detail 16", with(1, long_field(b"Dtl ", 16))), + ("smoothness -1", with(2, long_field(b"Smth", -1))), + ("an unknown direction", with(3, enum_field(b"LghD", b"LghD", b"LDXx"))), + ("a direction of another type", with(3, enum_field(b"LghD", b"Dirc", b"LDBt"))), + ("a detail that isn't a whole number", with(1, [&b"Dtl "[..], &[0; 4], b"doub", &8.0f64.to_be_bytes()].concat())), + ("another effect's code", with(0, enum_field(b"GEfk", b"GEft", b"Crst"))), + ("an unknown key", [descriptor(&good()), long_field(b"Xtra", 1)].concat()), + ("a key twice", [descriptor(&good()), long_field(b"Dtl ", 8)].concat()), + ("a missing key", descriptor(&good()[..3])), + ("a byte after the last field", [descriptor(&good()), vec![0]].concat()), + ("a cut field", descriptor(&good()).split_last().unwrap().1.to_vec()), + ("version 2", [vec![0, 0, 0, 2], good().concat()].concat()), + ]; + for (what, data) in cases { + let st = relief_with(&data); + assert!(!st.plain && !st.simple && st.effects.is_empty(), "{what}: {st:?}"); + } + let data = descriptor(&good()); + let encoded = block(&data); + for (what, body) in [ + ("a wrong dataSize", format!("{encoded}{} /Int (dataSize) ,\n", data.len() + 1)), + ("no dataSize", encoded.clone()), + ("a dataSize of another kind", format!("{encoded}{} /Real (dataSize) ,\n", data.len())), + ("no ~>", format!("{}{} /Int (dataSize) ,\n", encoded.replace("~>", ""), data.len())), + ("a digit past u", format!("{}{} /Int (dataSize) ,\n", encoded.replacen("%", "%v", 2), data.len())), + ("a line without %", format!("{}{} /Int (dataSize) ,\n", encoded.replacen("\n%", "\n", 1), data.len())), + ("no (data) key", format!("{}{} /Int (dataSize) ,\n", encoded.replace("(data)", "(other)"), data.len())), + ("two blocks", format!("{encoded}{encoded}{} /Int (dataSize) ,\n", data.len())), + ] { + let st = relief_style(&body); + assert!(!st.plain && st.effects.is_empty(), "{what}: {st:?}"); + } +} + +#[test] +fn settings_blocks_are_bounded() { + // More than the decoded limit, though its text is short (`z` is four zero bytes). + let zeros = format!("/Binary : /ASCII85Decode ,\n%{}~>\n; (data) ,\n4100 /Int (dataSize) ,\n", "z".repeat(1025)); + assert!(relief_style(&zeros).effects.is_empty()); + // An end further than the encoded limit. + let far = format!("/Binary : /ASCII85Decode ,\n{}%~>\n; (data) ,\n0 /Int (dataSize) ,\n", "%!!!!!\n".repeat(1000)); + assert!(relief_style(&far).effects.is_empty()); + // Objects nest two deep at most, and a descriptor holds 32 fields at most. + let object = |inner: Vec| [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &(inner.len() as u32).to_be_bytes(), &inner].concat(); + let two = descriptor(&[object(descriptor(&[object(descriptor(&[long_field(b"Valu", 1)]))]))]); + assert!(adapter::decode(&two).is_some()); + let three = descriptor(&[object(descriptor(&[object(descriptor(&[object(descriptor(&[]))]))]))]); + assert!(adapter::decode(&three).is_none()); + assert!(adapter::decode(&descriptor(&vec![long_field(b"Valu", 1); 32])).is_some()); + assert!(adapter::decode(&descriptor(&vec![long_field(b"Valu", 1); 33])).is_none()); + let long_object = [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &u32::MAX.to_be_bytes()].concat(); + assert!(adapter::decode(&descriptor(&[long_object])).is_none()); + let nan = [&b"Valu"[..], &[0; 4], b"doub", &f64::NAN.to_be_bytes()].concat(); + assert!(adapter::decode(&descriptor(&[nan])).is_none()); + let flag = |b: u8| [&b"Valu"[..], &[0; 4], b"bool", &[b]].concat(); + assert!(adapter::decode(&descriptor(&[flag(1)])).is_some()); + assert!(adapter::decode(&descriptor(&[flag(2)])).is_none()); +} + /// A Convert to Shape filter whose dictionary holds `keys`. fn shape(keys: &str) -> String { basic("Adobe Shape Effects", &format!("(Shape) /UnicodeString (DisplayString) ,\n{keys}")) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index e227ec12b..831be2edd 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5900,6 +5900,51 @@ fn a_path_with_a_pathfinder_effect_keeps_its_drawn_look() { assert_eq!(effects, 0); } +#[test] +fn an_object_with_bas_relief_comes_in_with_its_settings() { + // GUARDS: Bas Relief's settings block wasn't read, so the object came in as its drawn look. + let settings: Vec = [ + &[0, 0, 0, 1][..], + b"Dtl ", + &[0; 4], + b"long", + &5i32.to_be_bytes(), + b"Smth", + &[0; 4], + b"long", + &9i32.to_be_bytes(), + b"LghD", + &0x2000u32.to_be_bytes(), + b"LghD", + b"LDTR", + ] + .concat(); + let lines: String = crate::ps::ascii85(&settings).lines().map(|l| format!("%{l}\n")).collect(); + let style = format!( + "%AI9_BeginArtStyles\n\ + /KnownStyle :\n(Carved) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\ + /BasicFilter :\n(PSAdapter_plugin_BsRl) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n\ + {} /Int (dataSize) ,\n; /Dict ;\n /Part ,\n;\n\ + %AI9_EndArtStyles\n", + settings.len() + ); + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_0 0 0 1 k\n%_{hidden_rect}f\n1 (Carved) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{style}{}", layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 1); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + let [relief] = a[0].appearance.effects.as_slice() else { panic!("{:#?}", a[0].appearance) }; + assert_eq!( + (relief.id.as_str(), &relief.params), + ("sketch.basRelief", &serde_json::json!({"detail": 5.0, "smoothness": 9.0, "lightDirection": "topRight"})) + ); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + #[test] fn an_object_with_convert_to_shape_comes_in_as_itself_with_the_effect_live() { // GUARDS: `Adobe Shape Effects` wasn't read, so the object came in as its drawn look. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f716d693b..9ab9f7fb2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -215,23 +215,23 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 66983a171..eee501ef9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1307,6 +1307,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). From d61a7abc960ebc7302802bf91bde96128198beab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:25:34 +0700 Subject: [PATCH 48/51] Save: include a PDF in new native files by default Squashed from #5320 onto main. --- apps/vectorcraft-cli/src/main.rs | 7 +- apps/vectorcraft-cli/src/perf.rs | 106 ++++++++++++- crates/engine/src/cmd/fileio/load.rs | 11 +- crates/engine/src/cmd/fileio/native.rs | 16 +- crates/engine/src/cmd/fileio/pdf.rs | 28 +++- crates/engine/src/cmd/fileio/save.rs | 35 +++- crates/engine/src/cmd/fileio/tests.rs | 15 +- crates/engine/src/cmd/fileio/tests_svg.rs | 6 +- crates/engine/src/cmd/recovery.rs | 14 +- crates/engine/src/tests/nativefile.rs | 6 +- crates/engine/src/tests/recovery.rs | 51 ++++++ crates/engine/src/tests/saveoptions.rs | 143 ++++++++++++++++- crates/engine/tests/plugin_export.rs | 15 +- crates/format/src/lib.rs | 149 +++++++++++++++++- .../ui-egui/src/dialogs/save_options/tests.rs | 1 + crates/ui-egui/src/tests/nativeoptions.rs | 2 +- docs/mcp.md | 6 +- docs/plugins.md | 4 +- 18 files changed, 579 insertions(+), 36 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 2d8df4592..3a8f18cd8 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -128,12 +128,17 @@ USAGE: Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. - vectorcraft-cli perf [--paths N] [--deep] + vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]] Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded, unless the report says the machine is busy. --deep also times work with no budget yet and prints memory use (the working set on Windows), with the peak so far on Windows and Linux. + --native-io instead measures the application's native save/open commands (one warmup, + five samples, no disk I/O), including preview, PDF, compression and API base64 costs. + It reports the default and explicit PDF opt-out, with and without preview/compression; + --artboards repeats the same artwork + on 1 to 4 overlapping artboards. This mode allows at most 50000 paths. "; /// The usage text plus the formats `document.open` reads and `document.export` writes. diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 3ff82512d..e533d00b7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { v.get(v.len() / 2).copied().unwrap_or(0.0) } +/// Exercise the application's native save/open commands, including previews, PDF output, +/// compression and API base64 conversion. Session setup and validation are outside timing. +/// Five measured runs follow one warmup; no filesystem or GPU window is involved. +fn native_io(doc: &Document) -> Result<(), String> { + for (label, compatible, preview) in [ + ("default options", None, false), + ("PDF disabled", Some(false), false), + ("preview+gzip", None, true), + ("preview+gzip, PDF disabled", Some(false), true), + ] { + let mut session = vectorcraft_engine::Session::new(); + session.add_document(doc.clone(), None); + let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview}); + if let Some(value) = compatible { + params["pdfCompatible"] = json!(value); + } + let (mut saves, mut opens) = (Vec::new(), Vec::new()); + let (mut size, mut has_pdf, mut compressed) = (0, false, false); + for run in 0..6 { + let start = Instant::now(); + let saved = session.execute("file.saveAs", ¶ms).map_err(|e| e.to_string())?; + let save_ms = start.elapsed().as_secs_f64() * 1000.0; + let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?; + let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?; + size = bytes.len(); + has_pdf = vectorcraft_format::pdf_content(&bytes).is_some(); + if compatible == Some(false) && has_pdf { + return Err("native save ignored the explicit PDF opt-out".into()); + } + compressed = vectorcraft_format::is_compressed(&bytes); + if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview { + return Err("native save did not apply the requested preview/compression options".into()); + } + let args = json!({"name":"perf.vectorcraft","dataBase64":data}); + let mut opened = vectorcraft_engine::Session::new(); + let start = Instant::now(); + opened.execute("document.open", &args).map_err(|e| e.to_string())?; + let open_ms = start.elapsed().as_secs_f64() * 1000.0; + if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() { + return Err("native round trip changed the artboard count".into()); + } + if run > 0 { + saves.push(save_ms); + opens.push(open_ms); + } + } + saves.sort_by(f64::total_cmp); + opens.sort_by(f64::total_cmp); + let save = saves.get(2).copied().ok_or("no save measurements")?; + let open = opens.get(2).copied().ok_or("no open measurements")?; + outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}"); + } + Ok(()) +} + /// 1-minute load average (macOS / Linux), if available. #[cfg(not(windows))] fn load_average() -> Option { @@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> { } pub fn run(args: &[String]) -> Result<(), String> { - if crate::help_requested(args, &["--paths"]) { + if crate::help_requested(args, &["--paths", "--artboards"]) { return crate::help(); } let mut n = 50_000usize; let mut want_deep = false; + let mut want_native = false; + let mut artboards = None::; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--deep" => want_deep = true, + "--native-io" => want_native = true, + "--artboards" => { + artboards = + Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?) + } "--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?, other => return Err(format!("unknown perf option `{other}`")), } } + if want_native && n > 50_000 { + return Err("--native-io allows at most 50000 paths".into()); + } + if want_native && want_deep { + return Err("--native-io and --deep are separate measurement modes".into()); + } + if artboards.is_some() && !want_native { + return Err("--artboards requires --native-io".into()); + } + let artboards = artboards.unwrap_or(1); let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); // The figure is the 1-minute load average, or on Windows the cores busy over one second. #[cfg(not(windows))] @@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> { outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); } let t = Instant::now(); - let doc = synthetic(n)?; + let mut doc = synthetic(n)?; outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + if want_native { + let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?; + // Overlapping artboards intentionally repeat the same paths on every PDF page. + for index in 1..artboards { + let mut page = first.clone(); + page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?; + page.name = format!("Perf {}", index + 1); + doc.artboards.push(page); + } + outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O"); + return native_io(&doc); + } let mut rows: Vec<(&str, f64, f64)> = vec![]; let opts = RenderOptions::default(); @@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> { mod tests { use super::*; + #[test] + fn native_io_exercises_the_application_save_and_open_commands() { + native_io(&synthetic(3).unwrap()).unwrap(); + } + + #[test] + fn native_io_rejects_conflicting_modes_and_excessive_workloads() { + for (args, expected) in [ + (vec!["--native-io", "--deep"], "separate measurement modes"), + (vec!["--native-io", "--paths", "50001"], "at most 50000"), + (vec!["--native-io", "--artboards", "5"], "1 to 4"), + (vec!["--artboards", "1"], "requires --native-io"), + (vec!["--artboards", "2"], "requires --native-io"), + ] { + assert!(run(&args.into_iter().map(str::to_owned).collect::>()).unwrap_err().contains(expected)); + } + } + #[test] fn busy_share_leaves_out_idle_time() { // In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time, diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 03bfde85b..7ff22689c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -29,6 +29,8 @@ pub struct Loaded { /// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art /// that it carries (its layers), not from its PDF part alone. pub editing_data: bool, + /// Whether the native source declared a PDF; other formats carry no native save preference. + pub native_pdf_compatible: Option, } impl Loaded { @@ -341,6 +343,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let mut converted = false; let mut preview_only = false; let mut editing_data = false; + let mut native_pdf_compatible = None; let (mut doc, mut warnings, restored) = match format.id { // EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read. _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { @@ -353,6 +356,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } "vectorcraft" | "template" => { let (d, info, warnings) = native_file(bytes)?; + native_pdf_compatible = Some(info.pdf_compatible); converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; (d, warnings, false) } @@ -500,7 +504,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result doc.title = title; } warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); - Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) + Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data, native_pdf_compatible }) } /// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's @@ -593,7 +597,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// Make a loaded file the new active document; `links` is what became of its linked files, and /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { - let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs); + let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing // fallbacks must ask before overwriting the richer source they could not fully read. let losses: Vec = warnings @@ -630,6 +634,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, include_linked: bool, embed_profiles: Option, - pdf_compatible: bool, + pdf_compatible: Option, } /// What [`save_options`] leaves to [`encode`] (rendering and encoding take time). @@ -171,7 +171,9 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> so.profiles = embedded_profiles(doc); } so.check().map_err(|e| bad(cmd, e.to_string()))?; - Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible })) + let pdf = o.pdf_compatible.unwrap_or(true); + so.pdf_compatible = Some(pdf); + Ok((so, Extras { preview: o.preview, pdf })) } /// The `precision` option `v`: a whole number of decimal places, 0 to @@ -213,9 +215,11 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp if extras.preview { so.preview = preview_png(&drawn)?; } - if extras.pdf { - // Every artboard as PDF pages (no editing data: the file is the native document). - so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0); + if extras.pdf && !drawn.artboards.is_empty() { + // Every artboard as PDF pages (no editing data: the file is the native document), dated by + // the document's File Info, so the same document and dates give the same bytes. + let at = drawn.metadata.modified.or(drawn.metadata.created); + so.pdf = Some(super::pdf::encode_at(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }), at)?.0); } drop(drawn); let native = match full { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 785764db9..52e7489dd 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -240,9 +240,13 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result Result { + options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time()) +} + +/// [`options`] modified at `now` (Unix seconds) instead of the time of writing. +fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option) -> Result { let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; - let now = vectorcraft_doc::metadata::output_time(); Ok(PdfOptions { settings: settings(cmd, p)?, artboards, @@ -265,9 +269,14 @@ pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { + encode_at(cmd, doc, p, None) +} + +/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; warnings.extend(drawn_warnings); warnings.extend(overflow_warning(cmd, &full, p)?); Ok((bytes, warnings)) @@ -294,7 +303,20 @@ pub(super) fn encode_carrying( native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, ) -> Result<(Vec, Vec)> { - let mut opts = options(cmd, doc, p)?; + encode_carrying_at(cmd, doc, p, None, native, private) +} + +/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of +/// writing. +fn encode_carrying_at( + cmd: &str, + doc: &Document, + p: &Value, + modified: Option, + native: impl FnOnce() -> Result>, + private: impl FnOnce() -> Result)>>, +) -> Result<(Vec, Vec)> { + let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 0eefa45c0..893b783bf 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -270,7 +270,18 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { } } // None given: the ones the document was last saved with in this format. - let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + // Remember the effective choice, including the encoder's default, so another document's + // later save cannot change what this one writes. Invalid non-null values still reach validation. + if matches!(format.id, "vectorcraft" | "template") { + let pdf = match options.get("pdfCompatible") { + None | Some(Value::Null) => Some(native_pdf_preference(s, Some(st)).unwrap_or(true)), + _ => None, + }; + if let Some(pdf) = pdf { + options.insert("pdfCompatible".into(), json!(pdf)); + } + } // Save writes the document's own file; the other modes only where they are told to. let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); @@ -336,6 +347,15 @@ fn same_value(a: &Value, b: &Value) -> bool { } } +/// Native documents and templates share only their PDF choice. Other source formats use the +/// application's last native choice, keeping an AI file's separate PDF option out of it. +fn native_pdf_preference(s: &Session, st: Option<&DocState>) -> Option { + st.filter(|d| matches!(d.format, "vectorcraft" | "template")) + .and_then(|d| d.save_options.get("pdfCompatible")) + .and_then(Value::as_bool) + .or_else(|| s.prefs.native_save_options.get("pdfCompatible").and_then(Value::as_bool)) +} + /// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`) /// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is /// left out, and one that moved is named by its new number. When none of the artboards the save @@ -1028,12 +1048,15 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); + if let Some(pdf) = native_pdf_compatible { + s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf)); + } Ok(json!({ "path": path })) } @@ -1053,11 +1076,17 @@ fn format_options(s: &mut Session, p: &Value) -> Result { .or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone())); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); + let native_pdf = matches!(f.id, "vectorcraft" | "template").then(|| native_pdf_preference(s, st)).flatten(); let mut v = f.to_json(); if let Some(options) = v["options"].as_object_mut() { for (name, o) in options.iter_mut() { let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]); o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone(); + if name == "pdfCompatible" + && let Some(pdf) = native_pdf + { + o["value"] = json!(pdf); + } } } v["saveFormats"] = @@ -1072,7 +1101,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index fca92d73f..84513cd2c 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -680,7 +680,9 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] { let path = d.join(name).to_string_lossy().to_string(); - let p = json!({"path": path, "compress": compress}); + // A PDF-compatible file's PDF is dated to the second it is written, so the byte comparison + // uses files without one. + let p = json!({"path": path, "compress": compress, "pdfCompatible": false}); let r = s.execute("file.saveCopy", &p).unwrap(); let written = std::fs::read(&path).unwrap(); assert_eq!(r["bytes"], written.len(), "{r}"); @@ -690,6 +692,17 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { assert_eq!(written, job.encode().unwrap().files[0].1, "{name}"); assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate")); } + // A save with the default options streams a file that carries its PDF. + let path = d.join("d.vectorcraft").to_string_lossy().to_string(); + let p = json!({"path": path}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let written = std::fs::read(&path).unwrap(); + assert_eq!(r["bytes"], written.len(), "{r}"); + assert!(vectorcraft_format::pdf_content(&written).is_some()); + let plan = save_plan(&s, SaveMode::Copy, &p).unwrap(); + let job = save_job(&mut s, plan).unwrap(); + assert!(job.streams()); + assert!(vectorcraft_format::pdf_content(&job.encode().unwrap().files[0].1).is_some()); // A writer that fails: the save fails with its error. let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap(); let job = save_job(&mut s, plan).unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 73ee604a9..eb3989597 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -224,7 +224,11 @@ fn save_writes_svg_and_remembers_its_options() { let native = dir.join("doc.vectorcraft").to_string_lossy().to_string(); s.execute("document.save", &json!({"path": native})).unwrap(); assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap())); - assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options"); + assert_eq!( + Value::Object(s.doc().unwrap().save_options.clone()), + json!({"pdfCompatible":true}), + "a native save retains its PDF choice without SVG options" + ); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 1ad456b94..8b5731b33 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -565,6 +565,10 @@ struct Meta { path: Option, /// The format Save writes for it ([`SAVE_FORMATS`]). format: String, + /// A document's own native PDF choice before the recovery copy omits its PDF. None keeps + /// inheritance from the current application preference. Older copies did not record a choice. + #[serde(skip_serializing_if = "Option::is_none")] + native_pdf_compatible: Option, /// When the copy was written (Unix seconds; none without a clock). saved: Option, /// The file it was read from when that import left things out of the document, and what @@ -821,7 +825,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec) -> Result<(Vec, file: &str) -> R st.imported_from = meta.imported_from.clone(); st.import_losses = meta.import_losses.clone(); st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); + if let Some(pdf) = meta.native_pdf_compatible { + st.save_options.insert("pdfCompatible".into(), json!(pdf)); + } st.mark_unsaved(); // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); diff --git a/crates/engine/src/tests/nativefile.rs b/crates/engine/src/tests/nativefile.rs index e3a5588b7..4156dc759 100644 --- a/crates/engine/src/tests/nativefile.rs +++ b/crates/engine/src/tests/nativefile.rs @@ -222,7 +222,8 @@ fn a_version_1_documents_twist_turns_as_it_was_saved() { /// A save records the date the document was made in, so the same commands twice gave different /// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and -/// `SOURCE_DATE_EPOCH` for an export that takes no date param. +/// `SOURCE_DATE_EPOCH` for an export that takes no date param. The PDF a native file carries by +/// default is dated by the document's dates, not by the time of writing. #[test] fn a_pinned_date_makes_saves_byte_identical() { let once = |t: i64| { @@ -232,6 +233,9 @@ fn a_pinned_date_makes_saves_byte_identical() { saved(&mut s, json!({})) }; let at = 1_791_208_980; + let pdf = vectorcraft_format::pdf_content(&once(at)).expect("a native save carries a PDF by default"); + let info = vectorcraft_pdf::file_info(&pdf, None).unwrap(); + assert_eq!((info.created, info.modified), (Some(at), Some(at)), "the PDF has the document's dates"); assert_eq!(once(at), once(at), "the same document and date give the same bytes"); // The date really is in the bytes, so the comparison above is not vacuous. assert_ne!(once(at), once(at + 1), "another date shows in the bytes"); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 56c8d899d..9fe2d846c 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -59,6 +59,57 @@ fn restored(r: serde_json::Value) -> usize { r["restored"].as_array().unwrap().len() } +#[test] +fn native_recovery_copies_leave_out_the_pdf() { + let (mut s, store) = session(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", r["saved"][0]["file"].as_str().unwrap()); + let bytes = store.read(&file).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), s.doc().unwrap().doc.node_count()); +} + +#[test] +fn restored_recovery_copies_keep_the_original_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-pdf-preference"); + for pdf in [false, true] { + let (mut s, store) = session(); + s.execute("file.saveAs", &json!({"path": dir.file(&format!("original-{pdf}.vectorcraft")), "pdfCompatible": pdf})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + let copied = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", copied["saved"][0]["file"].as_str().unwrap()); + assert!(vectorcraft_format::pdf_content(&store.read(&file).unwrap()).is_none()); + drop(s); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(pdf))); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&bytes).is_some(), pdf); + } +} + +#[test] +fn recovered_unsaved_documents_keep_inheriting_the_current_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-inherited-pdf-choice"); + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file("other.vectorcraft"),"pdfCompatible":false})).unwrap(); + assert!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().is_empty()); + let latest_preferences = s.prefs.clone(); + drop(s); + let mut s = relaunch(&store); + // The host loads its latest preferences before offering recovery. + s.prefs = latest_preferences; + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); +} + #[test] fn recovery_save_writes_modified_documents_once() { let (mut s, store) = session(); diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index 9324b1f4b..a5b19563a 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -157,7 +157,146 @@ fn pdf_compatible_files_carry_a_pdf() { assert!(pdf.starts_with(b"%PDF")); let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); assert_eq!(info["pages"], 3); - assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none()); + assert!( + vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": false})).unwrap()["dataBase64"])).is_none() + ); +} + +/// A native save is portable to the other apps by default. The option is still an explicit +/// opt-out, and a remembered opt-out survives later saves of the document. +#[test] +fn native_saves_carry_all_artboards_as_pdf_by_default() { + let mut s = three_boards(); + for compress in [false, true] { + let result = s.execute("file.saveCopy", &json!({"compress": compress})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let pdf = vectorcraft_format::pdf_content(&file).expect("the default native save carries a PDF"); + let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + assert_eq!(info["pages"], 3); + assert_eq!(vectorcraft_format::load(&file).unwrap().artboards.len(), 3); + } + let dir = Folder::new("pdf-opt-out"); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + let options = s.execute("file.formatOptions", &json!({"format":"vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({"compress": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], false); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.save", &json!({"pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); +} + +#[test] +fn native_saves_without_artboards_keep_the_editable_document() { + let mut s = three_boards(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let restored = vectorcraft_format::load(&file).unwrap(); + assert!(restored.artboards.is_empty()); + assert_eq!(restored.node_count(), s.doc().unwrap().doc.node_count()); + assert!(vectorcraft_format::pdf_content(&file).is_none()); + s.execute("document.open", &json!({"name": "no-artboards.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], true); + s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&result["dataBase64"])).is_some()); +} + +#[test] +fn native_template_saves_keep_the_document_pdf_preference() { + let mut s = three_boards(); + let dir = Folder::new("template-pdf-preference"); + s.execute("file.saveAs", &json!({"path": dir.file("compact.vectorcraft"), "pdfCompatible": false, "version": 3})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "template"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false, "the save dialog must offer the same retained choice"); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template"})).unwrap(); + let bytes = bytes_of(&r["dataBase64"]); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!( + vectorcraft_format::load_file(&bytes).unwrap().info.version, + vectorcraft_format::VERSION, + "unrelated format options are not carried over" + ); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template", "pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&r["dataBase64"])).is_some()); +} + +#[test] +fn a_default_native_save_keeps_its_pdf_after_another_document_opts_out() { + let dir = Folder::new("default-native-pdf-choice"); + for (index, options) in [json!({}), json!({"options":{"pdfCompatible":null}})].into_iter().enumerate() { + let mut s = three_boards(); + let path = dir.file(&format!("first-{index}.vectorcraft")); + let mut params = options.as_object().unwrap().clone(); + params.insert("path".into(), json!(path)); + s.execute("file.saveAs", &Value::Object(params)).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file(&format!("other-{index}.vectorcraft")),"pdfCompatible":false})).unwrap(); + s.execute("document.activate", &json!({"index":0})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), "the first document keeps the PDF it was saved with"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(true))); + } +} + +#[test] +fn a_null_nested_pdf_option_keeps_the_saved_opt_out() { + let dir = Folder::new("null-nested-pdf-option"); + let mut s = three_boards(); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("document.save", &json!({"options": {"pdfCompatible": null}})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none(), "null leaves the saved choice"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(false))); +} + +#[test] +fn reopening_native_files_keeps_each_files_pdf_compatibility() { + let dir = Folder::new("pdf-reopened-options"); + let mut s = three_boards(); + let pdf = dir.file("portable.vectorcraft"); + let compact = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": pdf, "pdfCompatible": true})).unwrap(); + s.execute("file.saveAs", &json!({"path": compact, "pdfCompatible": false})).unwrap(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + let copy = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(©["dataBase64"])).is_none(), "new files use the last native preference"); + for (path, expected) in [(&pdf, true), (&compact, false)] { + s.execute("document.open", &json!({"path": path})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], expected); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(path).unwrap()).is_some(), expected); + } +} + +#[test] +fn reverting_native_files_updates_their_pdf_compatibility() { + let dir = Folder::new("pdf-revert-options"); + for saved in [false, true] { + let mut s = three_boards(); + let path = dir.file(if saved { "with-pdf.vectorcraft" } else { "compact.vectorcraft" }); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": saved})).unwrap(); + let replacement = s.execute("file.saveCopy", &json!({"pdfCompatible": !saved})).unwrap(); + std::fs::write(&path, bytes_of(&replacement["dataBase64"])).unwrap(); + s.execute("shape.rectangle", &json!({"x": 70, "y": 20, "width": 8, "height": 8})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], !saved); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), !saved); + } } #[test] @@ -429,7 +568,7 @@ fn format_options_list_the_save_options() { assert_eq!(v["options"][k]["value"], value, "{k}"); } let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); - assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true)); + assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(true), true)); // Saved with them, a document remembers them for Save. let dir = Folder::new("remember"); let path = dir.file("r.vectorcraft"); diff --git a/crates/engine/tests/plugin_export.rs b/crates/engine/tests/plugin_export.rs index 1a49f800a..df06b5ee2 100644 --- a/crates/engine/tests/plugin_export.rs +++ b/crates/engine/tests/plugin_export.rs @@ -69,9 +69,8 @@ fn an_exporter_adds_a_write_only_format_while_it_is_installed() { assert!(e.to_string().contains("unknown format"), "{e}"); } -/// The exporter is given the document as a native file's JSON: what `document.save` writes before -/// the data of large images, which stays behind (compact, where a save of a small document is -/// indented). +/// The exporter receives the native document JSON without optional save renditions or preference +/// metadata. Large image bytes stay in the saved file's tail; plug-in input stays compact. #[test] fn the_exporter_is_given_the_native_file_s_json() { let mut s = session(); @@ -94,8 +93,14 @@ fn the_exporter_is_given_the_native_file_s_json() { let saved = exported(&s.execute("document.save", &json!({})).unwrap()); assert!(saved.len() > given.len() + png.len(), "the image data follows the JSON in the saved file"); let doc: Value = serde_json::from_slice(&given).unwrap(); - let saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap(); - assert_eq!(doc, saved_json, "the saved file's JSON"); + let mut saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap(); + assert!(saved_json.get("pdf").is_some(), "a default application save carries a PDF"); + assert_eq!(saved_json["pdfCompatible"], true); + assert!(doc.get("pdf").is_none() && doc.get("pdfCompatible").is_none(), "the plug-in input omits optional save members"); + let saved_members = saved_json.as_object_mut().unwrap(); + saved_members.remove("pdf"); + saved_members.remove("pdfCompatible"); + assert_eq!(doc, saved_json, "the native document JSON without optional save members"); assert!(!given.contains(&b'\n'), "without indentation"); assert_eq!((&doc["format"], &doc["version"]), (&json!("vectorcraft"), &json!(vectorcraft_format::VERSION))); let images = doc["images"].as_object().unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f82cd4eb1..b3aa7eab7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -45,11 +45,13 @@ mod stream; mod unpack; use std::collections::{BTreeMap, HashSet}; +use std::fmt; use std::io::Write; use std::sync::Arc; use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail}; -use serde::Serialize; +use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserializer, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] @@ -146,6 +148,9 @@ pub struct SaveOptions { pub profiles: BTreeMap>, /// Create PDF-Compatible File: a PDF of the document to carry. pub pdf: Option>, + /// The application's PDF save preference, retained even when there are no artboards to draw. + /// `None` omits this metadata, preserving the lower-level writer's existing output. + pub pdf_compatible: Option, /// Decimal places to round path anchors and handles to ([`Document::round_geometry`]), at /// most [`MAX_PRECISION`] (a larger value rounds to that many); `None` writes them exactly. pub precision: Option, @@ -164,6 +169,7 @@ impl Default for SaveOptions { include_linked: false, profiles: BTreeMap::new(), pdf: None, + pdf_compatible: None, precision: None, } } @@ -327,6 +333,9 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For env.field(&mut out, "format", &if o.version < 3 { LEGACY_EXTENSION } else { EXTENSION })?; env.field(&mut out, "version", &o.version)?; env.field(&mut out, "generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; + if let Some(compatible) = o.pdf_compatible { + env.field(&mut out, "pdfCompatible", &compatible)?; + } if let Some(png) = &o.preview { env.field(&mut out, "preview", &Image::inline("image/png", png))?; } @@ -470,6 +479,9 @@ pub struct FileInfo { pub version: u32, /// Written under the project's former name (`"format": "drawcraft"`). pub legacy: bool, + /// The saved `pdfCompatible` preference, or the presence of a non-null `pdf` member in + /// files without a boolean preference. The PDF payload is not read or validated. + pub pdf_compatible: bool, } impl FileInfo { @@ -511,7 +523,8 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result ReadError::Envelope(e) => e.into(), e => FormatError::NotVectorcraft(e.to_string()), })?; - let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }; + let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; + let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION, pdf_compatible }; // A version 1 document saved before VectorCraft 0.21.0, or for an app before it (an older file // version): its Twist angles turned the other way. The app that wrote it is read from the JSON // before the data after it is taken; the walk's depth is bounded by the JSON reader's nesting @@ -602,6 +615,92 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result Ok(NativeFile { doc, depth_notes, info, profiles }) } +/// The saved PDF preference, falling back to the presence of its payload without retaining or +/// decoding it. The array form has neither member. Repeated members use their last value. +fn pdf_member(json: &[u8]) -> Result { + struct PdfMember; + impl<'de> Visitor<'de> for PdfMember { + type Value = bool; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a native file envelope") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut present = false; + let mut preference = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "pdfCompatible" => preference = map.next_value_seed(Preference)?, + "pdf" => present = map.next_value::>()?.is_some(), + _ => { + map.next_value::()?; + } + } + } + Ok(preference.unwrap_or(present)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + while seq.next_element::()?.is_some() {} + Ok(false) + } + } + serde_json::Deserializer::from_slice(json).deserialize_any(PdfMember) +} + +/// A `pdfCompatible` value: `Some` for a boolean, `None` for null or any other value, which is +/// skipped without being kept, as an unknown member is. +struct Preference; + +impl<'de> DeserializeSeed<'de> for Preference { + type Value = Option; + + fn deserialize>(self, d: D) -> Result, D::Error> { + d.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for Preference { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("any value") + } + + fn visit_bool(self, v: bool) -> Result, E> { + Ok(Some(v)) + } + + fn visit_i64(self, _: i64) -> Result, E> { + Ok(None) + } + + fn visit_u64(self, _: u64) -> Result, E> { + Ok(None) + } + + fn visit_f64(self, _: f64) -> Result, E> { + Ok(None) + } + + fn visit_str(self, _: &str) -> Result, E> { + Ok(None) + } + + fn visit_unit(self) -> Result, E> { + Ok(None) + } + + fn visit_seq>(self, seq: A) -> Result, A::Error> { + IgnoredAny.visit_seq(seq).map(|_| None) + } + + fn visit_map>(self, map: A) -> Result, A::Error> { + IgnoredAny.visit_map(map).map(|_| None) + } +} + /// The file's first `generator` member, the app that wrote it (`None`: it has none, it is not a /// string, or the file is not an object of members). The other members, and any further /// `generator`, are skipped without being kept, as reading the file skips them. @@ -671,17 +770,59 @@ mod tests { assert!(load(br#"{"format":"other","version":1,"document":{}}"#).is_err()); } + #[test] + fn file_info_keeps_pdf_presence_without_reading_its_payload() { + let d = Document::new(10.0, 10.0); + for version in [3, 4] { + for compress in [false, true] { + for present in [false, true] { + let options = SaveOptions { version, compress, pdf: present.then(|| vec![7; 128 * 1024]), ..SaveOptions::default() }; + let bytes = save_with(&d, &options).unwrap(); + assert_eq!(load_info(&bytes).unwrap().1.pdf_compatible, present); + } + } + } + let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let array = serde_json::json!([EXTENSION, VERSION, value["document"], value["images"], {}]); + assert!(!load_info(&serde_json::to_vec(&array).unwrap()).unwrap().1.pdf_compatible); + value["pdf"] = serde_json::json!({"mime":"application/pdf", "data":"not base64"}); + assert!(load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible); + value["pdf"] = serde_json::Value::Null; + value["unrecognized"] = serde_json::json!({"pdf":{"data":"nested"}}); + assert!(!load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible); + // A preference that is not a boolean is skipped like an unknown member: the file opens, + // and the presence of the PDF decides. + for odd in [serde_json::json!(1), serde_json::json!("yes"), serde_json::json!([true]), serde_json::json!({"on": true})] { + for (pdf, present) in [(serde_json::Value::Null, false), (serde_json::json!({"mime":"application/pdf"}), true)] { + value["pdf"] = pdf; + value["pdfCompatible"] = odd.clone(); + assert_eq!(load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible, present, "{odd}"); + } + } + } + + #[test] + fn file_info_keeps_the_pdf_preference_without_artboards() { + let mut d = Document::new(10.0, 10.0); + d.artboards.clear(); + for compatible in [false, true] { + let bytes = save_with(&d, &SaveOptions { pdf_compatible: Some(compatible), ..SaveOptions::default() }).unwrap(); + assert_eq!(load_info(&bytes).unwrap().1.pdf_compatible, compatible); + assert!(pdf_content(&bytes).is_none()); + } + } + #[test] fn file_info_tells_old_files() { let d = Document::new(10.0, 10.0); let current = save(&d, false); - assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false }); + assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false, pdf_compatible: false }); assert!(!load_info(¤t).unwrap().1.is_old()); let text = String::from_utf8(current).unwrap(); let legacy = text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1); assert!(load_info(legacy.as_bytes()).unwrap().1.is_old()); let v1 = text.replacen(&format!("\"version\":{VERSION}"), "\"version\":1", 1); - assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false }); + assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false, pdf_compatible: false }); assert!(load_info(v1.as_bytes()).unwrap().1.is_old()); } diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 1f33575e7..959079968 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -221,6 +221,7 @@ fn save_a_copy_and_template_suggest_their_names() { assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); assert!(app.ui.dialog.is_none(), "the native format has no options"); assert!(vectorcraft_format::sniff(&written.borrow()[0].1)); + assert!(vectorcraft_format::pdf_content(&written.borrow()[0].1).is_some()); assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); app.run("file.saveAsTemplate", json!({})).unwrap(); let pick = &picks.borrow()[1]; diff --git a/crates/ui-egui/src/tests/nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs index 59b8c91de..5f136d514 100644 --- a/crates/ui-egui/src/tests/nativeoptions.rs +++ b/crates/ui-egui/src/tests/nativeoptions.rs @@ -32,7 +32,7 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!( (d.str("format").as_str(), d.bool("separateArtboards"), d.bool("embedProfiles"), d.bool("pdfCompatible")), - ("vectorcraft", false, true, false) + ("vectorcraft", false, true, true) ); assert!(written.borrow().is_empty(), "nothing is written before OK"); frame(&mut app); diff --git a/docs/mcp.md b/docs/mcp.md index 366f31921..0c42c160f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4976,8 +4976,12 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. - `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files; opening the file installs them where they are missing (a warning names one that can't be used). -- `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files +- `pdfCompatible`: native files (default true) also carry a PDF of every artboard (`pdf` in the file). This makes saves + larger and adds PDF rendering work. Set it to false to leave the PDF out; Save remembers that choice. `.ai` files (default true) with false write blank pages around the native document (smaller; other apps show empty pages). + Native files without artboards and Data Recovery copies omit the PDF. + Reopening a native file keeps its saved PDF preference, falling back to whether it declared a PDF in older files. + Existing files saved without a PDF remain compact. - `.ai` only: `compress` (default true) is the PDF option `compression.compressText`. - The editing copy of a `.ai` or `.ait` file (`editingData`) names its active artboard (`CropAreaActive`): the active artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..66866efcd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -282,8 +282,8 @@ object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry. -An **exporter** gets something else: the document as a native `.vectorcraft` file's JSON, as `document.save` writes it -but without indentation (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, +An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF +renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks `version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. From cd8e98c6cd85b26427df2ae72cf742fcc2efcc78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:25:53 +0700 Subject: [PATCH 49/51] DXF: a CNC / CAM preset sets the export options for cutting software Squashed from #5500 onto main. --- crates/cad/src/lib.rs | 72 +++++++++++++++ crates/cad/src/tests.rs | 24 +++++ crates/engine/src/cmd/fileio/dxf.rs | 19 +++- crates/engine/src/cmd/fileio/tests_dxf.rs | 26 ++++++ crates/ui-egui/src/dialogs.rs | 7 +- crates/ui-egui/src/dialogs/dxf_options.rs | 106 +++++++++++++++++----- crates/ui-egui/src/dialogs/tests_dxf.rs | 66 ++++++++++++++ crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/control-protocol.md | 8 +- docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 11 files changed, 309 insertions(+), 32 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 3fc4bcb3b..603521160 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -308,6 +308,78 @@ impl Default for DxfOptions { } } +/// A named set of DXF options (the `dxfPreset` param): the options it sets, and the ones a set of +/// options matches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DxfPreset { + /// The options as chosen (the export's defaults for those left out). + #[default] + Custom, + /// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight + /// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize + /// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths), + /// and placed images as PNG. + Cnc, +} + +impl DxfPreset { + pub const ALL: [Self; 2] = [Self::Custom, Self::Cnc]; + + /// The `dxfPreset` param value. + pub fn id(self) -> &'static str { + match self { + Self::Custom => "custom", + Self::Cnc => "cnc", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::Cnc => "CNC / CAM", + } + } + + /// A preset by id (`custom`, `cnc`), any case. + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id())) + } + + /// The options the preset sets (Custom: the defaults); the region and crop are the defaults. + pub fn options(self) -> DxfOptions { + match self { + Self::Custom => DxfOptions::default(), + Self::Cnc => DxfOptions { + version: DxfVersion::R2000, + unit: Unit::Millimeters, + scale: 1.0, + scale_lineweights: false, + colors: ColorDepth::Aci256, + raster: RasterFormat::Png, + preserve: Preserve::Editability, + alter_paths: false, + outline_text: true, + ..DxfOptions::default() + }, + } + } + + /// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom. + pub fn of(o: &DxfOptions) -> Self { + let c = Self::Cnc.options(); + let same = o.version == c.version + && o.unit == c.unit + && o.scale == c.scale + && o.scale_lineweights == c.scale_lineweights + && o.colors == c.colors + && o.raster == c.raster + && o.preserve == c.preserve + && o.alter_paths == c.alter_paths + && o.outline_text == c.outline_text; + if same { Self::Cnc } else { Self::Custom } + } +} + /// An image file a drawing links to, to be written next to it under `name`. #[derive(Clone, Debug)] pub struct DxfImage { diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index d99858511..5c57fa634 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() { assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err()); } +#[test] +fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() { + let mut d = text_doc("Cut", 20.0); + let layer = d.layers[0].id; + let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0); + d.insert(Some(layer), usize::MAX, frame).unwrap(); + let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() }; + let out = export(&d, &o).unwrap(); + let p = pairs(&out.bytes); + assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4")); + let k = kinds(&out); + assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}"); + assert!(k.len() > 2, "the glyphs' outlines: {k:?}"); + // The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline. + assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert!(out.images.is_empty()); + assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); + assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); + assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 4320cbb9c..32b87d80d 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfOptions, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::{Document, Unit}; use vectorcraft_geom::Rect; @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", has_doc, export_dxf )] @@ -33,6 +33,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS, + FormatOption { + name: "dxfPreset", + ty: "string", + default: "\"custom\"", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { name: "unit", @@ -120,6 +126,7 @@ impl Unsupported { struct Params { #[serde(flatten)] boards: ArtboardPick, + dxf_preset: Option, version: Option, unit: Option, scale: Option, @@ -150,7 +157,8 @@ fn choice(field: &str, v: Option, read: impl Fn(&str) -> Option, d /// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice. fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() }; - let d = DxfOptions::default(); + // The options a preset sets are the defaults of those `p` leaves out. + let d = choice("dxfPreset", q.dxf_preset, DxfPreset::from_id, DxfPreset::Custom, "custom or cnc")?.options(); let scale = q.scale.unwrap_or(d.scale); if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) { return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end()))); @@ -181,6 +189,11 @@ pub fn check(p: &Value) -> std::result::Result<(), String> { parse(p).map(|_| ()).map_err(|e| e.to_string()) } +/// The DXF options in `p` (the region and crop left at their defaults). +pub fn options(p: &Value) -> std::result::Result { + parse(p).map(|(o, _)| o).map_err(|e| e.to_string()) +} + /// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner /// (default the first), or with `use_artboards` one drawing per chosen artboard (default all) /// holding the art over it. diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 298adeb11..849767683 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -63,6 +63,32 @@ fn export_writes_dxf_of_the_chosen_version() { assert_eq!(header(&text(&ser), "$INSUNITS").as_deref(), Some("1")); } +#[test] +fn the_cnc_preset_sets_the_options_that_are_not_given() { + let mut s = two_boards(); + let cnc = text(&s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap()); + assert_eq!(header(&cnc, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&cnc, "$INSUNITS").as_deref(), Some("4")); + // document.export takes it too, beside a PDF preset's name. + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc"})).unwrap(); + assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some("AC1015")); + let o = dxf::options(&json!({"dxfPreset": "CNC", "unit": "in"})).unwrap(); + assert_eq!( + (o.version, o.unit, o.colors, o.preserve, o.alter_paths, o.outline_text), + ( + vectorcraft_cad::DxfVersion::R2000, + vectorcraft_doc::Unit::Inches, + vectorcraft_cad::ColorDepth::Aci256, + vectorcraft_cad::Preserve::Editability, + false, + true + ) + ); + assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); + let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); + assert!(e.contains("custom or cnc"), "{e}"); +} + #[test] fn use_artboards_writes_one_drawing_per_artboard() { let mut s = two_boards(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a7766dd8d..d7e65781a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -473,7 +473,8 @@ pub fn accepts_field(d: &Dialog, field: &str) -> bool { /// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is, /// an Edit Data Set field set this way is no longer the row's empty text value, and a Layer /// Options setting set this way for several rows is sent to every row (Template also sets Lock -/// and Dim Images, as its checkbox does). +/// and Dim Images, as its checkbox does). A DXF Options preset set this way sets its fields, as +/// one picked from its menu does, and the preset it shows follows the DXF options set this way. pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == brush_options::KIND { brush_art_options::note_set(d, &field); @@ -484,7 +485,11 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == layer_options::KIND { layer_options::note_set(d, &field, &value); } + let dxf = (d.kind == dxf_options::KIND).then(|| field.clone()); d.fields.insert(field, value); + if let Some(field) = dxf { + dxf_options::note_set(d, &field); + } } /// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index dc6a54d16..cc5262393 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -1,13 +1,15 @@ -//! DXF Options (Export As → DXF): version, scale (1 unit of the art = N drawing units) and -//! lineweights, number of colours, the format of placed images, Preserve Appearance or Maximize -//! Editability, and Export Selected Art Only, Alter Paths for Appearance and Outline Text. +//! DXF Options (Export As → DXF): a preset (Custom, or CNC / CAM, which sets the options below for +//! cutting software and shows Custom again once one is changed), version, scale (1 unit of the +//! art = N drawing units) and lineweights, number of colours, the format of placed images, +//! Preserve Appearance or Maximize Editability, and Export Selected Art Only, Alter Paths for +//! Appearance and Outline Text. //! //! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF //! options of `document.exportDxf`. OK remembers the options for next time and writes the file //! (asking where when there is no path); bad options keep the dialog open. use serde_json::{Map, Value, json}; -use vectorcraft_cad::{ColorDepth, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::Unit; use vectorcraft_engine::cmd::fileio; @@ -32,40 +34,95 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Fields that are not remembered between exports (they belong to one export). const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; +/// The preset field (the `dxfPreset` param). +const PRESET: &str = "dxfPreset"; +/// The preset the fields were last set from or matched (a change of [`PRESET`] sets its fields). +const SHOWN_PRESET: &str = "__shownPreset"; + /// The options a first DXF Options dialog starts from (the export's defaults). fn defaults() -> Map { + let mut m = preset_fields(DxfPreset::Custom); + m.insert(PRESET.into(), json!(DxfPreset::Custom.id())); + m.insert("selectedOnly".into(), json!(false)); + m +} + +/// The fields `preset` sets (Custom: the export's defaults). +fn preset_fields(preset: DxfPreset) -> Map { + let o = preset.options(); let mut m = Map::new(); for (k, v) in [ - ("version", json!(DxfVersion::default().id())), - ("unit", json!(Unit::Millimeters.label())), - ("scale", json!(1)), - ("scaleLineweights", json!(false)), - ("colors", json!(ColorDepth::default().id())), - ("rasterFormat", json!(RasterFormat::default().id())), - ("preserve", json!(Preserve::default().id())), - ("alterPaths", json!(false)), - ("outlineText", json!(false)), - ("selectedOnly", json!(false)), + ("version", json!(o.version.id())), + ("unit", json!(o.unit.label())), + ("scale", json!(o.scale)), + ("scaleLineweights", json!(o.scale_lineweights)), + ("colors", json!(o.colors.id())), + ("rasterFormat", json!(o.raster.id())), + ("preserve", json!(o.preserve.id())), + ("alterPaths", json!(o.alter_paths)), + ("outlineText", json!(o.outline_text)), ] { m.insert(k.into(), v); } m } +/// Show the preset the fields match: CNC / CAM while every field it sets is as it sets it, else +/// Custom. An unknown preset stays as it is, for OK to refuse. +fn show_matching_preset(d: &mut Dialog) { + if DxfPreset::from_id(&d.str(PRESET)).is_none() { + return; + } + let preset = fileio::dxf::options(&super::form::params(d)).map_or(DxfPreset::Custom, |o| DxfPreset::of(&o)); + d.fields.insert(PRESET.into(), json!(preset.id())); + d.fields.insert(SHOWN_PRESET.into(), json!(preset.id())); +} + +/// A preset picked since the fields were last matched, or any preset when `force` is set, sets +/// its fields; Custom puts back the export's defaults. +fn apply_picked_preset(d: &mut Dialog, force: bool) { + if (force || !d.str(PRESET).eq_ignore_ascii_case(&d.str(SHOWN_PRESET))) + && let Some(p) = DxfPreset::from_id(&d.str(PRESET)) + { + d.fields.extend(preset_fields(p)); + } +} + +/// `field` was set (`ui.dialog.set`): a preset sets its fields now, even the one shown, so fields +/// set after it keep their values, and any other field shows the preset the fields then match, as +/// drawing does. +pub(super) fn note_set(d: &mut Dialog, field: &str) { + if field == PRESET { + apply_picked_preset(d, true); + } + show_matching_preset(d); +} + /// Open DXF Options for an export whose params (path, artboard choice, any DXF option) are -/// `params`, over the options used last. +/// `params`, over the options used last. A `dxfPreset` in `params` sets its fields under the +/// options `params` gives. pub fn open(app: &mut VectorcraftApp, params: &Value) { let mut fields = defaults(); - for given in [&app.ui.dxf_options, params] { - if let Some(o) = given.as_object() { - fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + if let Some(o) = app.ui.dxf_options.as_object() { + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + if let Some(o) = params.as_object() { + if let Some(p) = o.get(PRESET).and_then(Value::as_str).and_then(DxfPreset::from_id) { + fields.extend(preset_fields(p)); } + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); } fields.insert("format".into(), json!("dxf")); - app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields))); + let mut d = Dialog::new(KIND, Value::Object(fields)); + show_matching_preset(&mut d); + app.ui.dialog = Some(d); } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let presets: Vec<(&str, &str)> = DxfPreset::ALL.iter().map(|p| (p.id(), p.label())).collect(); + choice(ui, d, PRESET, tl!("Preset:"), &presets); + apply_picked_preset(d, false); + ui.add_space(4.0); let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect(); choice(ui, d, "version", tl!("Version:"), &versions); ui.add_space(4.0); @@ -113,6 +170,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { Preserve::Editability => tl!("Type stays text, and every stroke is a line with its lineweight and dashes."), }, ); + show_matching_preset(d); false } @@ -146,14 +204,20 @@ fn note(ui: &mut egui::Ui, text: &str) { /// Check the options, remember them, and write the file(s). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + // A preset or field set since the dialog last drew (the control channel sets fields without + // drawing) counts as drawn. + let mut d = d.clone(); + apply_picked_preset(&mut d, false); + show_matching_preset(&mut d); + let d = &d; let params = super::form::params(d); fileio::dxf::check(¶ms)?; // Nothing selected keeps the dialog open too. if let Some(st) = app.session.active() { fileio::export_source(st, ¶ms).map_err(|e| e.to_string())?; } - app.ui.dxf_options = - Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); + let remembered = params.as_object().map(|o| o.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone()))); + app.ui.dxf_options = Value::Object(remembered.into_iter().flatten().collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); io::export(app, Some("dxf"), path, ¶ms) diff --git a/crates/ui-egui/src/dialogs/tests_dxf.rs b/crates/ui-egui/src/dialogs/tests_dxf.rs index 252c67f7e..22429b164 100644 --- a/crates/ui-egui/src/dialogs/tests_dxf.rs +++ b/crates/ui-egui/src/dialogs/tests_dxf.rs @@ -40,6 +40,66 @@ fn export_as_dxf_shows_dxf_options_then_writes_and_remembers_them() { assert!(app.ui.dxf_options.get("useArtboards").is_none()); } +#[test] +fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { + let (mut app, written) = app(1); + app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap(); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "custom"); + set(&mut app, "dxfPreset", json!("cnc")); + frame(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + let fields = |d: &crate::state::Dialog| { + ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText"].map(|k| d.str(k)).join(" ") + }; + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + // Any change to a field the preset sets shows Custom, and keeps the other fields. + set(&mut app, "colors", json!("true")); + frame(&mut app); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true"); + set(&mut app, "colors", json!("256")); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc", "matching every field again"); + confirm(&mut app).unwrap(); + let dxf = text(&written.borrow()[0].1); + let lines: Vec<&str> = dxf.lines().collect(); + let header = |var: &str| lines.iter().position(|l| *l == var).and_then(|i| lines.get(i + 2)).map(|v| v.trim()); + assert_eq!((header("$ACADVER"), header("$INSUNITS")), (Some("AC1015"), Some("4"))); + // The next DXF Options start from the preset; Custom puts back the defaults. + crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc"); + set(&mut app, "dxfPreset", json!("custom")); + frame(&mut app); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false"); + // A preset the command gives sets the fields it does not give. + app.ui.dialog = None; + app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "cnc", "unit": "Inches"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true"); + // A preset set and confirmed without drawing (the control channel) sets its fields too. + set(&mut app, "dxfPreset", json!("cnc")); + confirm(&mut app).unwrap(); + assert_eq!((app.ui.dxf_options["dxfPreset"].as_str(), app.ui.dxf_options["unit"].as_str()), (Some("cnc"), Some("Millimeters"))); + // `ui.dialog.set` sets the preset's fields at once, so a field set after it keeps its value. + app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "custom"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + set_field(d, "dxfPreset".into(), json!("cnc")); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + set_field(d, "unit".into(), json!("Inches")); + assert_eq!(d.str("dxfPreset"), "custom"); + // Setting the preset again sets its fields again. + set_field(d, "dxfPreset".into(), json!("cnc")); + assert_eq!(d.str("unit"), "Millimeters"); + set_field(d, "unit".into(), json!("Inches")); + // Setting Custom while Custom is shown still puts back the defaults. + let mut reset = d.clone(); + set_field(&mut reset, "dxfPreset".into(), json!("custom")); + assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false"); + confirm(&mut app).unwrap(); + let remembered = ["dxfPreset", "version", "unit"].map(|k| app.ui.dxf_options[k].as_str()); + assert_eq!(remembered, [Some("custom"), Some("2000"), Some("Inches")]); +} + #[test] fn bad_dxf_options_keep_the_dialog_open() { let (mut app, written) = app(1); @@ -47,6 +107,12 @@ fn bad_dxf_options_keep_the_dialog_open() { set(&mut app, "scale", json!(-1)); assert!(confirm(&mut app).is_err()); assert_eq!(kind(&app), Some("dxfOptions")); + set(&mut app, "scale", json!(1)); + set(&mut app, "dxfPreset", json!("laser")); + frame(&mut app); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("custom or cnc"), "{e}"); + set(&mut app, "dxfPreset", json!("custom")); set(&mut app, "scale", json!(10)); // Export Selected Art Only with nothing selected. app.run("select.none", json!({})).unwrap(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5813da723..94f375c4f 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1245,7 +1245,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.dxfOptionsDialog", "DXF Options Dialog", "", - "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last, a dxfPreset's options under them; the dxfPreset field shows cnc while every field it sets matches, else custom); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", ), ( "links.editOriginal", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a799f22ff..3fcec09b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -669,9 +669,11 @@ with. DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or `ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are -`document.export` params: `version`, `unit` (a unit name such as `Millimeters`), `scale`, `scaleLineweights`, `colors` -(`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, `editability`), `selectedOnly`, -`alterPaths`, `outlineText`, plus `path` and the artboard choice. `ui.dialog.confirm` checks them (a bad value, or +`document.export` params: `dxfPreset` (`custom`, `cnc`), `version`, `unit` (a unit name such as `Millimeters`), `scale`, +`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, +`editability`), `selectedOnly`, `alterPaths`, `outlineText`, plus `path` and the artboard choice. Setting `dxfPreset` to +`cnc` sets the fields it sets (see `document.exportDxf`), and `dxfPreset` reads `custom` again once one of them is +changed; setting it to `custom` puts back the defaults. `ui.dialog.confirm` checks them (a bad value, or Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to `dwg` shows, and on confirm answers, the hint to export DXF instead. diff --git a/docs/mcp.md b/docs/mcp.md index 0c42c160f..284a5e887 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4649,8 +4649,12 @@ and every stroke a line. `alterPaths` writes as filled outlines those strokes (w wider than the widest lineweight (2.11 mm), and every stroke in R13 and R14, which have no lineweights; it keeps other strokes as lines. `outlineText` outlines type, `selectedOnly` writes only the selected objects in their layers (every export takes it), and `useArtboards: true` writes one drawing per chosen -artboard holding the art over it. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks, -raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with +artboard holding the art over it. `dxfPreset: "cnc"` (CNC / CAM; default `"custom"`) sets the options for cutting +software that the call does not give: version `2000` (the oldest version that has both lightweight polylines and lineweights), `unit: +"mm"` and `scale: 1`, `colors: 256` (all version 2000 holds), `preserve: "editability"` with `alterPaths: false` (every +stroke a center line), `outlineText: true` (type as paths), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, +which shows Custom again once a field it sets is changed. What DXF can't hold (gradients and patterns as one colour, +blending, opacity masks, raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with no linked file, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such images. @@ -4661,6 +4665,7 @@ opened. ```json {"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}} {"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}} +{"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/sign.dxf","dxfPreset":"cnc"}}} ``` ## DXF import diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eee501ef9..c388ee2fd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1336,7 +1336,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); CNC preset and VCarve QA remain pending. + - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, PNG placed images), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. From 122b10143ce748dc7a6747d14fbaecd6cec38c00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 07:16:11 -0800 Subject: [PATCH 50/51] Layers: a document opened after a close keeps its thumbnail jobs Closing a document told the worker to forget every row whose document was not open at that moment. The worker keeps that test until it is idle, so while a job still ran, a document opened afterward had its rows dropped and they stayed without thumbnails. The worker now forgets only the documents that closed. --- crates/ui-egui/src/panels/layers/thumbs.rs | 40 +++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 49ce4b61b..4fcdc03e4 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -266,18 +266,17 @@ impl Thumbs { return; } let open: HashSet = uids.iter().copied().collect(); - let closed = self.open_docs.as_ref().is_some_and(|before| before.iter().any(|uid| !open.contains(uid))); + let closed: HashSet = self.open_docs.as_ref().map(|before| before.difference(&open).copied().collect()).unwrap_or_default(); let stale = self.entries.keys().chain(self.pending.keys()).any(|key| !open.contains(&key.0)) || self.showing.is_some_and(|uid| !open.contains(&uid)); self.entries.retain(|key, _| open.contains(&key.0)); self.pending.retain(|key, _| open.contains(&key.0)); self.showing = self.showing.filter(|uid| open.contains(uid)); - if closed { + if !closed.is_empty() { // Images finished before now are dropped with the rest of the queue: the open - // documents' rows are asked for again. + // documents' rows are asked for again. Documents opened later keep their jobs. if let Some(w) = &self.worker { - let open = open.clone(); - w.forget(move |key| !open.contains(&key.0)); + w.forget(move |key| closed.contains(&key.0)); if w.poll().is_none() { self.worker = None; } @@ -536,6 +535,37 @@ mod tests { assert_eq!(done.key.0, next, "no worker keeps the closed document's image"); } + #[test] + fn a_document_opened_while_a_job_runs_after_a_close_gets_its_thumbnails() { + let ctx = egui::Context::default(); + let Some(worker) = ThumbWorker::start(&ctx, "test") else { return }; + let mut thumbs = Thumbs { worker: Some(worker), worker_started: true, ..Default::default() }; + thumbs.retain_open(&[1, 2]); + // A job of document 2 is running. + let (started, running) = std::sync::mpsc::channel::<()>(); + let (release, held) = std::sync::mpsc::channel::<()>(); + let blocker = (2, NodeId(1)); + let job: crate::thumb_worker::JobFn = Box::new(move |_| { + started.send(()).ok(); + held.recv().ok(); + None + }); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(blocker, (blocker, Weak::new(), 1), job))); + running.recv().expect("the job starts"); + // Document 1 closes, then document 3 opens and one of its rows is queued. + thumbs.retain_open(&[2]); + thumbs.retain_open(&[2, 3]); + let opened = (3, NodeId(2)); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(opened, (opened, Weak::new(), 1), Box::new(|_| None)))); + // A job of document 2 queued after it marks the end of the queue. + let last = (2, NodeId(3)); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(last, (last, Weak::new(), 1), Box::new(|_| None)))); + release.send(()).ok(); + let worker = thumbs.worker.as_ref().expect("the worker is kept"); + let order: Vec<(u64, NodeId)> = (0..3).map_while(|_| worker.wait().map(|d| d.key.0)).take_while(|key| *key != last).collect(); + assert_eq!(order, [blocker, opened], "document 3's job comes back before the last one"); + } + /// An open zigzag of `anchors` anchors. fn line(anchors: usize) -> PathData { let pts: Vec = (0..anchors).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64 * 50.0)).collect(); From 434c032f9e48c81f4ab99227fcb1c5fa88bd87a1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:26:11 +0700 Subject: [PATCH 51/51] Symbols: Direct Selection sets the solid fill and stroke of one object in a dynamic instance Squashed from #5962 onto main. --- crates/doc/src/hit.rs | 251 ++++++ crates/doc/src/hit/tests.rs | 125 +++ crates/doc/src/lib.rs | 2 +- crates/doc/src/selection.rs | 48 ++ crates/doc/src/selection/tests.rs | 76 ++ crates/doc/src/swatches.rs | 27 +- crates/doc/src/symbol_nine_slice.rs | 12 + crates/doc/src/symbol_overrides.rs | 40 + crates/engine/src/cmd.rs | 9 +- crates/engine/src/cmd/appearance.rs | 38 +- crates/engine/src/cmd/brushsym.rs | 12 +- crates/engine/src/cmd/brushsym/dynamic.rs | 67 +- crates/engine/src/cmd/brushsym/symbols.rs | 2 + crates/engine/src/cmd/distortcmds.rs | 11 +- crates/engine/src/cmd/edit.rs | 40 +- crates/engine/src/cmd/fileio/export.rs | 5 +- crates/engine/src/cmd/freeform.rs | 6 +- crates/engine/src/cmd/globaledit.rs | 5 +- crates/engine/src/cmd/gradient.rs | 6 +- crates/engine/src/cmd/graph/edit.rs | 8 +- crates/engine/src/cmd/intertwine.rs | 17 +- crates/engine/src/cmd/live/common.rs | 8 +- crates/engine/src/cmd/maskedit.rs | 21 +- crates/engine/src/cmd/object/transform.rs | 5 + crates/engine/src/cmd/objects_on_path.rs | 6 +- crates/engine/src/cmd/opacitymask.rs | 40 +- crates/engine/src/cmd/paint.rs | 130 ++- crates/engine/src/cmd/path.rs | 6 +- crates/engine/src/cmd/patterncmds.rs | 5 +- crates/engine/src/cmd/place.rs | 8 +- crates/engine/src/cmd/plugin.rs | 10 +- crates/engine/src/cmd/recolor.rs | 7 +- crates/engine/src/cmd/stroke.rs | 25 +- crates/engine/src/cmd/stylelib.rs | 3 + crates/engine/src/cmd/symboledit.rs | 4 + crates/engine/src/command_sets.rs | 5 + crates/engine/src/lib.rs | 4 + crates/engine/src/session.rs | 2 +- crates/engine/src/session/documents.rs | 1 + crates/engine/src/session/edit.rs | 2 +- crates/engine/src/session/execute.rs | 10 +- crates/engine/src/tests/dynamic_symbols.rs | 871 ++++++++++++++++++++- crates/engine/src/tooling.rs | 18 + crates/tools/src/direct.rs | 46 +- crates/tools/src/direct/tests.rs | 162 ++++ crates/tools/src/xform/gradient.rs | 5 + crates/tools/src/xform/gradient/tests.rs | 25 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/gaps.md | 6 +- docs/mcp.md | 30 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 2 +- 78 files changed, 2198 insertions(+), 106 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b3b4394a2..ea3575bf6 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -936,6 +936,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option } } +/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and +/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance +/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. +struct InstanceArt { + art: Node, + map: Option, + xf: vectorcraft_geom::Affine, +} + +impl InstanceArt { + /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that + /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, + /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. + fn of(doc: &Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); + let map = doc.symbol_slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + return None; + } + Some(Self { art, map, xf: *xf }) + } + + /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the + /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell + /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides + /// object by object. + fn whole(&self, n: &Node, doc: &Document, whole: Option) -> Option { + whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + } + + /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by + /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when + /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as + /// [`Document::place_symbol_art`] refuses it. + fn place(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } + + /// Object `n` of the art placed on the canvas without clip groups + /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the + /// same limit ([`Document::place_symbol_art_unclipped`]). + fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } +} + +/// `n` transformed by `xf`, its strokes at their own weight. +fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { + let mut out = n.clone(); + out.transform(xf, false); + out +} + +/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's +/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the +/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the +/// outline of the clipping path of a clip group or a clipping layer picks that path before the art +/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed +/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper +/// group's original shapes, which the canvas does not draw, and guide paths with +/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice +/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not +/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. +pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { + struct Find<'a> { + doc: &'a Document, + art: &'a InstanceArt, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + } + impl Find<'_> { + fn hit(&self, n: &Node, whole: Option) -> bool { + self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + } + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + fn find(&self, n: &Node, path: &mut Vec, whole: Option) -> bool { + if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { + return false; + } + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole); + } + let whole = self.art.whole(n, self.doc, whole); + if n.clips() + && let Some(first) = children.first() + { + let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + if self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && clip_outline(&clip, self.p, self.opt).is_some() + { + path.push(0); + return true; + } + if !clip_contains(&clip, self.p) { + return false; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if self.find(c, path, whole) { + return true; + } + path.pop(); + } + false + } + } + let art = InstanceArt::of(doc, inst)?; + let symbols = doc.symbol_boxes(); + let mut path = vec![]; + Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) +} + +/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), +/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a +/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip +/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An +/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut +/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`] +/// picks it. +/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. +pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { + let art = InstanceArt::of(doc, inst)?; + let part = crate::symbol_overrides::part(&art.art, path)?; + let mut out = BezPath::new(); + let mut limit = None; + let mut whole = None; + let mut n = &art.art; + for &i in path { + let children = n.children()?; + whole = art.whole(n, doc, whole); + if i != 0 + && n.clips() + && let Some(first) = children.first() + && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + { + match meet(Some(clip), limit) { + Some(r) => limit = Some(r), + None => return Some(out), + } + } + n = children.get(usize::try_from(i).ok()?)?; + } + outline_within(&art.place(part, doc, whole)?, limit, &mut out); + Some(out) +} + +/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one. +fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { + use vectorcraft_geom::kurbo::Shape; + if let Some(pd) = n.path_data() { + match limit { + Some(r) => push_cut(out, &pd.to_bezpath(), r), + None => out.extend(pd.to_bezpath()), + } + } else if n.children().is_none() + && let Some(b) = n.geometric_bounds() + && let Some(b) = meet(Some(b), limit) + { + out.extend(b.to_path(0.1)); + } + let Some(children) = n.children() else { return }; + if n.clips() + && let Some((first, rest)) = children.split_first() + { + let inner = match first.geometric_bounds() { + Some(c) => match meet(Some(c), limit) { + Some(r) => Some(r), + None => return, + }, + None => limit, + }; + rest.iter().for_each(|c| outline_within(c, inner, out)); + return; + } + children.iter().for_each(|c| outline_within(c, limit, out)); +} + +/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap. +fn meet(a: Option, b: Option) -> Option { + let a = a?; + let Some(b) = b else { return Some(a) }; + let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1)); + (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r) +} + +/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`. +fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) { + use vectorcraft_geom::kurbo::PathEl; + let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::); + vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| { + let to = match el { + PathEl::MoveTo(q) => { + (at, start, last) = (q, q, None); + return; + } + PathEl::LineTo(q) => q, + PathEl::ClosePath => start, + _ => return, + }; + if let Some((a, b)) = cut_segment(at, to, r) { + if last != Some(a) { + out.move_to(a); + } + out.line_to(b); + last = Some(b); + } + at = to; + }); +} + +/// The part of segment `a` to `b` inside `r`, `None` when none of it is. +fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let d = b - a; + let (mut t0, mut t1) = (0.0_f64, 1.0_f64); + for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] { + if p.abs() < 1e-12 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + (t0 <= t1).then(|| (a + d * t0, a + d * t1)) +} + /// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is /// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an /// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`. diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 9a5c40478..495dc227b 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1373,3 +1373,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() { assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}"); assert_eq!(checked, 201); } + +/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does: +/// Direct Selection picks an object only where the mask shows it, and outlines it there. +#[test] +fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it"); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}"); + // Its outline inside the group around the layer is cut too. + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}"); +} + +/// In a dynamic symbol's art that placing through its 9-slice guides could make more than +/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the +/// canvas draws nothing of it. At the art's natural size the same art is picked. +#[test] +fn symbol_art_past_the_object_limit_picks_nothing() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + // Drawn in six cells, each with its own copy of the mask's 3,240,000 squares. + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default())); + let scaled = d.alloc_id(); + let natural = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap(); + d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit"); + let opt = HitOptions::default(); + let n = d.node(scaled).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square"); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path"); + assert!(symbol_part_outline(&d, n, &[0]).is_none()); + assert!(symbol_part_outline(&d, n, &[1]).is_none()); + let n = d.node(natural).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0])); + assert!(symbol_part_outline(&d, n, &[0]).is_some()); +} + +/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it +/// included, so an object in it is picked and outlined there even when placing that object through +/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. +#[test] +fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]); + group.set_clips(true); + let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default())); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap(); + let m = d.symbol_slice_map("Corner", xf).unwrap(); + assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit"); + assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn"); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1])); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap()); + assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}"); +} + +/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes, +/// which the canvas does not draw: a click over an original shape whose face the group erased +/// picks nothing, and a click over the face it shows picks the face. +#[test] +fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); + let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + sources.name = Some(crate::shaper::SOURCES.into()); + sources.opacity = 0.0; + let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]); + group.extras_mut().shaper = Some(Box::default()); + let root = Node::group(d.alloc_id(), vec![Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face"); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bff8e40f3..8eeb1786d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -109,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument}; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; -pub use selection::{AnchorRef, SegmentRef, Selection}; +pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index f03402791..643f81c36 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -54,6 +54,20 @@ pub struct Selection { /// the engine then sets the document's from it. Editing state, never saved. #[serde(skip)] pub new_art: Arc>, + /// An object of a dynamic symbol instance's art, selected with Direct Selection: the instance + /// is the one selected object, and solid fill and stroke changes go to its own appearance for + /// that object ([`crate::symbol_overrides`]). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symbol_part: Option, +} + +/// See [`Selection::symbol_part`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolPart { + /// The symbol instance. + pub instance: NodeId, + /// The object in the symbol's art, as [`crate::symbol_overrides::part`] names it. + pub path: Vec, } impl Selection { @@ -79,6 +93,13 @@ impl Selection { self.slices.clear(); self.guides.clear(); self.key_guide = None; + self.symbol_part = None; + } + /// Select only instance `instance`, with the object at `path` of its symbol's art (see + /// [`Selection::symbol_part`]). + pub fn set_symbol_part(&mut self, instance: NodeId, path: Vec) { + self.set([instance]); + self.symbol_part = Some(SymbolPart { instance, path }); } /// Is there a key for Align: an object or a guide? pub fn has_key(&self) -> bool { @@ -93,6 +114,7 @@ impl Selection { pub fn add(&mut self, id: NodeId) { self.target = None; self.slices.clear(); + self.symbol_part = None; if !self.objects.contains(&id) { self.objects.push(id); } @@ -100,6 +122,7 @@ impl Selection { pub fn remove(&mut self, id: NodeId) { self.target = None; self.slices.clear(); + self.symbol_part = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); self.segments.remove(&id); @@ -128,6 +151,7 @@ impl Selection { /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a /// Shift-click on a guide). pub fn toggle_guide(&mut self, i: usize) { + self.symbol_part = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); if self.key_guide == Some(i) { @@ -207,6 +231,27 @@ impl Selection { if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) { self.key_guide = None; } + // A selected part needs its instance selected alone, with no guides, its symbol dynamic + // and the object still in the symbol's art as a path, compound path or type, where neither + // it nor a group or layer around it is hidden, locked or a template. + let editable_part = |art: &crate::Node, path: &[u32]| { + crate::symbol_overrides::part(art, path).is_some_and(crate::symbol_overrides::takes_part_paint) + && (0..=path.len()) + .all(|k| crate::symbol_overrides::part(art, &path[..k]).is_some_and(|n| n.visible && !n.locked && !n.is_template())) + }; + let part_kept = self.symbol_part.as_ref().is_some_and(|sp| { + self.objects == [sp.instance] + && self.guides.is_empty() + && found.get(&sp.instance).and_then(|p| doc.node_at(p)).is_some_and(|n| match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => { + doc.symbols.iter().find(|s| s.name == *symbol).is_some_and(|s| s.dynamic && editable_part(&s.art, &sp.path)) + } + _ => false, + }) + }); + if !part_kept { + self.symbol_part = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay @@ -238,6 +283,9 @@ impl Selection { } self.target = None; self.slices.clear(); + if self.symbol_part.as_ref().is_some_and(|sp| gone.contains(&sp.instance)) { + self.symbol_part = None; + } self.objects.retain(|o| !gone.contains(o)); self.anchors.retain(|id, _| !gone.contains(id)); self.segments.retain(|id, _| !gone.contains(id)); diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index 5665f1140..7882fef73 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -41,6 +41,82 @@ fn guides_are_selected_on_their_own() { assert_eq!(s.guides, vec![0]); } +/// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers +/// around it in the symbol's art are visible, unlocked and not a template. An art edit that hides +/// or locks it, or one around it, or makes a layer around it a template, drops the pick. +#[test] +fn a_picked_symbol_part_is_dropped_once_it_cannot_be_edited() { + let rect = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let leaf = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art()); + let group = Node::group(d.alloc_id(), vec![Arc::new(leaf)]); + let layer = Node::layer(d.alloc_id(), "Inner", crate::LayerColor::Preset(2)); + let root = Node::group(d.alloc_id(), vec![Arc::new(group), Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Parts".into(), art: Arc::new(root.clone()) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Parts".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + // The layer in the art holds a path too. + let in_layer = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art()); + let mut art = root; + if let Some(NodeKind::Layer { children, .. }) = art.children_mut().and_then(|c| c.get_mut(1)).map(|c| &mut Arc::make_mut(c).kind) { + children.push(Arc::new(in_layer)); + } + d.symbols[0].art = Arc::new(art.clone()); + let picked = |d: &Document, path: &[u32]| { + let mut s = Selection::default(); + s.set_symbol_part(inst, path.to_vec()); + s.prune(d); + s.symbol_part.is_some() + }; + assert!(picked(&d, &[0, 0]) && picked(&d, &[1, 0]), "editable parts stay picked"); + assert!(!picked(&d, &[0]) && !picked(&d, &[1]) && !picked(&d, &[]), "a group or layer does not stay picked"); + let edited = |change: &dyn Fn(&mut Node)| { + let mut d = d.clone(); + let mut art = art.clone(); + change(&mut art); + d.symbols[0].art = Arc::new(art); + d + }; + let child = |art: &mut Node, path: &[usize]| -> Node { + let mut n = &*art; + for &i in path { + n = &n.children().unwrap()[i]; + } + n.clone() + }; + let set = |art: &mut Node, path: &[usize], n: Node| { + let mut at = art; + for &i in path { + at = Arc::make_mut(&mut at.children_mut().unwrap()[i]); + } + *at = n; + }; + let hidden = edited(&|art| { + let mut n = child(art, &[0, 0]); + n.visible = false; + set(art, &[0, 0], n); + }); + assert!(!picked(&hidden, &[0, 0]), "the part is hidden"); + let locked_group = edited(&|art| { + let mut n = child(art, &[0]); + n.locked = true; + set(art, &[0], n); + }); + assert!(!picked(&locked_group, &[0, 0]), "the group around it is locked"); + assert!(picked(&locked_group, &[1, 0]), "a part outside the locked group stays"); + let template = edited(&|art| { + let mut n = child(art, &[1]); + if let NodeKind::Layer { template, .. } = &mut n.kind { + *template = true; + } + set(art, &[1], n); + }); + assert!(!picked(&template, &[1, 0]), "the layer around it is a template"); + let hidden_root = edited(&|art| art.visible = false); + assert!(!picked(&hidden_root, &[0, 0]), "the art is hidden"); +} + #[test] fn guides_show_and_lock_with_their_layer() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 5caada836..5aa6147fa 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -176,7 +176,7 @@ impl Document { pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { let mut tree = |n: &Node| { walk_with_masks(n, &mut |m| { - node_paints(m).for_each(&mut *f); + node_paints(m).chain(override_paints(m)).for_each(&mut *f); if let NodeKind::Mesh(mesh) = &m.kind { mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); } @@ -275,10 +275,11 @@ fn map_style_paint(attrs: &mut serde_json::Map, key: } /// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs -/// and gradient stops (with their link), then mesh points (unlinked), in paint order. +/// and gradient stops (with their link), a dynamic symbol instance's own paints +/// ([`override_paints`]), then mesh points (unlinked), in paint order. pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { n.walk(&mut |m| { - for p in node_paints(m) { + for p in node_paints(m).chain(override_paints(m)) { match p { Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()), Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())), @@ -315,6 +316,12 @@ pub fn node_paints(n: &Node) -> impl Iterator { n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref()) } +/// The paints a dynamic symbol instance keeps for objects of its art +/// ([`crate::symbol_overrides`]), which [`node_paints`] leaves out. +pub fn override_paints(n: &Node) -> impl Iterator { + n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten() +} + /// Mutable [`node_paints`], in the same order. pub fn node_paints_mut(n: &mut Node) -> impl Iterator { let runs = match &mut n.kind { @@ -353,6 +360,20 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { } out = Some(m); } + // A dynamic symbol instance's own paints for objects of its art (`symbol_overrides`). + if !n.extras().symbol_overrides.is_empty() { + let mut overrides = n.extras().symbol_overrides.clone(); + let mut k = 0; + for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() { + if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) { + k += 1; + } + } + if k > 0 { + out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides; + count += k; + } + } if let Some(ch) = n.children() { for (i, c) in ch.iter().enumerate() { if let Some((new, k)) = map_node(c, f) { diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index acc682872..995445f57 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -451,6 +451,18 @@ impl NineSliceMap { self.most_at(art, &boxes, 0) } + /// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one + /// piece, with everything in it: the map of the cell its box lies in, or of the cell at the + /// origin when it has no box. `None` when its box crosses a guide. + pub fn one_cell(&self, n: &Node, doc: &Document) -> Option { + let boxes = doc.symbol_boxes().quick(); + let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + return Some(self.cell_at(Point::ZERO)); + }; + let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); + (cols[0] == cols[1] && rows[0] == rows[1]).then(|| self.cell_xf(cols[0], rows[0])) + } + /// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`. pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 { Self { clips: false, ..*self }.most_placed(art, doc) diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs index b4e3a726f..33f0fd769 100644 --- a/crates/doc/src/symbol_overrides.rs +++ b/crates/doc/src/symbol_overrides.rs @@ -49,6 +49,10 @@ impl SymbolOverride { r.style.fill = p.clone(); } if let Some(p) = &self.stroke { + // A painted stroke on characters without a weight gets 1 pt, as a path's does. + if !p.is_none() && r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; + } r.style.stroke = p.clone(); } } @@ -57,6 +61,14 @@ impl SymbolOverride { n.appearance.set_fill(p.clone()); } if let Some(p) = &self.stroke { + // A stroke without paint has no weight; painting it gives it 1 pt. + if !p.is_none() + && let Some(s) = n.appearance.stroke_mut() + && s.paint.is_none() + && s.width == 0.0 + { + s.width = 1.0; + } n.appearance.set_stroke(p.clone()); } } @@ -69,6 +81,13 @@ impl SymbolOverride { } } +/// Can Direct Selection select `n`, an object of a dynamic symbol's art, and change its fill and +/// stroke for one instance? Paths, compound paths and type can; groups, images and every other +/// object cannot. +pub fn takes_part_paint(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) +} + /// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file /// of one endless path stays small once read. fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { @@ -180,6 +199,27 @@ mod tests { assert_eq!(kept.len(), 1); } + #[test] + fn a_stroke_on_type_without_a_weight_gets_one_point() { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let style = crate::CharStyle { stroke: Paint::None, stroke_width: 0.0, ..crate::CharStyle::default() }; + let text = Node::new(NodeId(2), NodeKind::Text(Box::new(crate::TextObject::point(vectorcraft_geom::Point::ZERO, "Hi", style)))); + let mut a = Node::group(NodeId(1), vec![Arc::new(text)]); + apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]); + let Some(NodeKind::Text(t)) = part(&a, &[0]).map(|n| &n.kind) else { panic!("type") }; + assert!(t.runs.iter().all(|r| r.style.stroke == red && r.style.stroke_width == 1.0), "the stroke draws at 1 pt"); + + // A path whose stroke has neither paint nor weight gets 1 pt too. + let mut ap = Appearance::default_art(); + if let Some(s) = ap.stroke_mut() { + (s.paint, s.width) = (Paint::None, 0.0); + } + let path = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap); + let mut a = Node::group(NodeId(1), vec![Arc::new(path)]); + apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]); + assert_eq!(part(&a, &[0]).unwrap().appearance.stroke_width(), 1.0); + } + #[test] fn a_path_deeper_than_the_cap_does_not_read() { let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a26c75da2..c88f47c79 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -590,11 +590,16 @@ pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result Result> { match named_ids(p)? { Some(ids) => Ok(ids), - None => Ok(s.doc()?.selection.objects.clone()), + None => { + edit::refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + } } } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 30e48d4fc..6e369f67b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -1136,6 +1136,42 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + // An object of a dynamic symbol instance's art selected with Direct Selection takes the clicked + // object's solid fill and stroke, as the Eyedropper Options allow, for this instance only. A + // gradient or pattern sampled for it, a source that is not a path, compound path or type (a + // symbol instance's own appearance is empty), and options that pick up neither color are + // refused, and the paint defaults stay as they are. A path of a compound path stands for the + // compound path, which holds the paint. + if !reverse + && !append + && !defaults_only + && p.get("ids").is_none() + && let Some((id, path)) = super::brushsym::selected_part(s)? + { + let d = &s.doc()?.doc; + let src = Arc::new(d.node(super::path::compound_or_self(d, clicked)).ok_or(EngineError::NoNode(clicked))?.clone()); + let (fill, stroke) = (super::paint::proxy_paint(&src, false, None), super::paint::proxy_paint(&src, true, None)); + let a = attrs.appearance; + if !vectorcraft_doc::symbol_overrides::takes_part_paint(&src) + || !(a.fill.color || a.stroke.color) + || (a.fill.color && !super::paint::part_takes(&fill)) + || (a.stroke.color && !super::paint::part_takes(&stroke)) + { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, "Eyedropper", |o| { + if a.fill.color { + o.fill = Some(fill); + } + if a.stroke.color { + o.stroke = Some(stroke); + } + })?; + // The clicked object becomes the Eyedropper's sample, and new art takes its paint. + Picked::of(&src).to_defaults(s, &attrs.appearance); + s.eyedropper_sample = Some(src); + return Ok(json!({ "ids": [id.0] })); + } let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8cf3a2195..5dcb82136 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -36,7 +36,8 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +pub(crate) use dynamic::{edit_part, part_only, selected_part}; +use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, @@ -266,6 +267,15 @@ pub fn specs() -> Vec { has_selection, symbol_instance_set_appearance ), + cmd!( + "symbol.instance.selectPart", + "Select Symbol Instance Part", + [], + None, + "{id, path: [i…] (as symbol.instance.setAppearance)} select one path, compound path or type object of a dynamic symbol instance's art, as a Direct Selection click on it does: the instance is selected alone, and until the selection changes paint.setFill and paint.setStroke with a solid color or none, paint.invert and paint.complement on a solid color (without ids, id or item; type whose characters differ in color is refused) and appearance.copyFrom with a solid fill and stroke (the Eyedropper, without ids, reverse or append; options that pick up neither color are refused) change that object's fill or stroke for this instance only; gradients and patterns are refused, and so are the commands that change the selected objects without ids (transparency.set, the gradient commands, object.group, edit.cut and edit.clear among them); edit.copy copies the instance whole, and selection exports, artboard.fitToSelection, object.liquify, object.isolate, library.add and select.save use the instance (docs/mcp.md); selectPart itself is refused for a static symbol, a path naming no object, a group, image or other kind of object, a hidden, locked or template object or one inside such a group, and a hidden or locked instance → {id, path}", + has_doc, + symbol_instance_select_part + ), cmd!( "symbol.instance.reset", "Reset Symbol Instance", diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index baa721c45..6197e57be 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -61,6 +61,62 @@ fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc Result)>> { + Ok(s.doc()?.selection.symbol_part.as_ref().map(|sp| (sp.instance, sp.path.clone()))) +} + +/// Change instance `id`'s own appearance for the object at `path` of its art by `f`, as one undo +/// step named `label`; nothing is recorded when the change leaves it as it was. Replies how many +/// objects the instance changes. +pub(crate) fn edit_part(s: &mut Session, id: NodeId, path: &[u32], label: &str, f: impl FnOnce(&mut SymbolOverride)) -> Result { + let list = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.extras().symbol_overrides.clone(); + let old = list.iter().find(|o| o.path == path).cloned().unwrap_or_else(|| SymbolOverride { path: path.to_vec(), ..Default::default() }); + let mut new = old.clone(); + f(&mut new); + if new == old { + return Ok(list.len()); + } + s.edit(label, |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + }) +} + +/// The refusal of anything but a solid color fill or stroke change while an object of a symbol +/// instance's art is selected ([`selected_part`]), and of selecting any other object of the art. +pub(crate) fn part_only() -> EngineError { + EngineError::Other("Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change".into()) +} + +pub(super) fn symbol_instance_select_part(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.selectPart"; + if p.get("id").is_none() { + return Err(bad(C, "missing id")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + // Direct Selection passes over hidden, locked and template objects, and over everything in a + // group or layer that is: the instance in the document, and the object in the symbol's art. + let editable = |n: &Node| n.visible && !n.locked && !n.is_template(); + if !s.doc()?.doc.is_editable(id) || !(0..=path.len()).all(|k| symbol_overrides::part(&art, &path[..k]).is_some_and(editable)) { + return Err(bad(C, "the object is hidden or locked")); + } + if !symbol_overrides::part(&art, &path).is_some_and(symbol_overrides::takes_part_paint) { + return Err(part_only()); + } + let shown = json!(path); + s.select(|_, sel| sel.set_symbol_part(id, path))?; + Ok(json!({ "id": id.0, "path": shown })) +} + /// The `path` param: child indices from the art's root, naming an object of `art`. fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { let path: Option> = @@ -117,16 +173,7 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu let d = &s.doc()?.doc; let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; - let count = s.edit("Symbol Instance Appearance", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let mut e = n.extras_mut(); - let list = &mut e.symbol_overrides; - list.retain(|o| o.path != path); - if !new.is_empty() { - list.push(new); - } - Ok(list.len()) - })?; + let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?; Ok(json!({ "id": id.0, "overrides": count })) } diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 69bfcb66e..60353921e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -888,6 +888,8 @@ pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; let alt = switch_or(p, "alt", false, C)?; + // Refused while an object of a dynamic instance's art is selected: the instance stays as it is. + crate::cmd::edit::refuse_symbol_part(s)?; let st = s.doc()?; let color = match color_param(p, "color", C)? { Some(c) => crate::cmd::paint::new_color(s, p, c), diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index d54238925..bcaa59426 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; -use super::edit::{duplicate_in, outermost, selected_roots}; +use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, @@ -730,8 +730,13 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) { return Err(bad(C, "pins must be finite")); } - let ids = - if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + let selected = || { + refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + }; + let ids = if rest { puppet_ids(p, selected)? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); let angles: Vec> = match p.get("angles") { None | Some(Value::Null) => vec![None; pins.len()], diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 21bd4f9bc..8c7de8bdc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected", has_selection_guides_or_artboard_tool, cut ), @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -220,7 +220,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)", + "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected", has_selection_or_guides, clear ), @@ -829,16 +829,38 @@ fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate:: } /// Selected top-level objects in paint order, dropping any whose ancestor is also selected. +/// +/// Refused with [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art +/// is selected ([`super::brushsym::selected_part`]): only the paint commands change that object, +/// and only its solid fill and stroke, so a command that gathers the selection here leaves the +/// instance as it is. Copy copies the instance whole ([`copy_cut_targets`]). pub(crate) fn selected_roots(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) } +/// [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art is selected: +/// for the commands that read the selected objects without [`selected_roots`]. A command that +/// changes nothing in the document ([`Session::reading`]) reads the instance instead. +pub(crate) fn refuse_symbol_part(s: &Session) -> Result<()> { + if !s.reading && s.doc()?.selection.symbol_part.is_some() { Err(super::brushsym::part_only()) } else { Ok(()) } +} + +/// [`refuse_symbol_part`] as a command's `enabled` check: the command is dimmed, and refused with +/// [`super::brushsym::part_only`]'s message, while an object of a dynamic symbol instance's art is +/// selected. +pub(crate) fn no_symbol_part(s: &Session) -> std::result::Result<(), String> { + if !s.reading && s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { Err(super::brushsym::part_only().to_string()) } else { Ok(()) } +} + /// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a /// path of a compound path selected on its own (Group Selection, or in the isolated compound path) /// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the -/// rest still go to the compound path, which holds the paint. +/// rest still go to the compound path, which holds the paint. Refused while an object of a symbol +/// instance's art is selected, as [`selected_roots`] is: it moves with its instance only. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_, _| true)) } @@ -847,8 +869,10 @@ pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { /// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the /// objects held by another selected object are dropped. A selected path of a compound path stands /// for its compound unless the compound is `root` or `members` is set (as in -/// [`selected_roots_to_transform`]). +/// [`selected_roots_to_transform`]). Refused while an object of a symbol instance's art is +/// selected, as [`selected_roots`] is. pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(); Ok(roots_within(&st.doc, ids, |compound, _| members || compound == root)) @@ -925,6 +949,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); } + // With an object of a dynamic symbol instance's art selected, the instance is copied. let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; // Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay @@ -965,6 +990,7 @@ fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); } + refuse_symbol_part(s)?; // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { copy(s, p)?; @@ -1038,6 +1064,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { fn clear(s: &mut Session, p: &Value) -> Result { let guides = switch_or(p, "guides", false, "edit.clear")?; let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None }; + // An object of a symbol instance's art can't be removed from the instance alone. + if explicit.is_none() && s.doc()?.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } // Direct-selected anchors or segments: remove those, and any objects selected whole with them. if explicit.is_none() && (!s.doc()?.selection.anchors.is_empty() || !s.doc()?.selection.segments.is_empty()) { return super::path::delete_anchors(s, p); diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 13564614b..0869ad850 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -214,10 +214,11 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom:: } /// The selected objects (their top-most selected ancestors) that are artwork: those on template -/// layers are guides and are left out. +/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected, +/// the instance is exported whole. pub(crate) fn selected_artwork(s: &Session) -> Result> { - let ids = edit::selected_roots(s)?; let st = s.doc()?; + let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)); let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 21b5245ae..0f72b066a 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -92,8 +92,12 @@ impl Session { /// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in /// document coordinates, and the length its spreads are fractions of there (see - /// [`Node::proxy_freeform`]). + /// [`Node::proxy_freeform`]). `None` while an object of a symbol instance is selected + /// ([`vectorcraft_doc::Selection::symbol_part`]): its points are in the symbol's coordinates. pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> { + if self.active()?.selection.symbol_part.is_some() { + return None; + } let (n, item) = self.proxy_source(stroke)?; n.proxy_freeform(stroke, item) } diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 2b0e214cc..ec0778364 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -354,8 +354,10 @@ fn close(a: f64, b: f64) -> bool { } /// The members a Global Edit started on the one selected object would take, with `o`: that -/// object first, then the others in paint order. +/// object first, then the others in paint order. Refused while an object of a dynamic symbol +/// instance's art is selected. fn matches(s: &Session, o: &Options) -> Result> { + super::edit::refuse_symbol_part(s)?; let st = s.doc()?; let d = &st.doc; let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) }; @@ -423,6 +425,7 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { if st.global_edit.is_some() { return Ok(()); } + super::edit::no_symbol_part(s)?; let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 47989a29c..c73a23d6c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Select Gradient Stop", [], None, - "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}", + "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on; refused while an object of a symbol instance's art is selected → {index}", always, select_stop ), @@ -59,6 +59,10 @@ pub(crate) fn active_gradient(s: &Session) -> Option { fn select_stop(s: &mut Session, p: &Value) -> Result { const C: &str = "gradient.selectStop"; + // No document is needed for the default paint's gradient, so this does not use `refuse_symbol_part`. + if s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { + return Err(super::brushsym::part_only()); + } let index = match p.get("index") { None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")), Some(Value::Null) => None, diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 1ea621c63..c33be78d5 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -58,12 +58,16 @@ pub(super) fn create(s: &mut Session, p: &Value) -> Result { } /// The graph `id` names (or the graph containing it), else the selected graph (or the graph -/// containing the selection). +/// containing the selection). Refused while an object of a dynamic symbol instance's art is +/// selected. pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { let st = s.doc()?; let start = match named_id(p)? { Some(id) => vec![id], - None => st.selection.objects.clone(), + None => { + crate::cmd::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } }; for id in start { let mut cur = Some(id); diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index b19206c39..9841d2abb 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -76,8 +76,9 @@ pub fn specs() -> Vec { // ---------- predicates ---------- /// The Intertwine groups of the selection: selected ones, and those around a selected object. +/// None while an object of a dynamic symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { let group = st.doc.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| st.doc.node(*a).is_some_and(is_intertwine)); @@ -92,6 +93,7 @@ fn selected_groups(s: &Session) -> Vec { fn has_intertwine(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Intertwine group".into()) } else { Ok(()) } } @@ -378,11 +380,14 @@ fn arrange(s: &mut Session, p: &Value) -> Result { } id } - None => match selected_groups(s).as_slice() { - [g] => *g, - [] => return Err(bad(C, "select an Intertwine group")), - _ => return Err(bad(C, "select one Intertwine group")), - }, + None => { + super::edit::refuse_symbol_part(s)?; + match selected_groups(s).as_slice() { + [g] => *g, + [] => return Err(bad(C, "select an Intertwine group")), + _ => return Err(bad(C, "select one Intertwine group")), + } + } }; let at = match p.get("at") { Some(_) => Some(point_param(p, "at").filter(|q| q.x.is_finite() && q.y.is_finite()).ok_or_else(|| bad(C, "at must be [x, y]"))?), diff --git a/crates/engine/src/cmd/live/common.rs b/crates/engine/src/cmd/live/common.rs index 226a57a0d..80c9d714a 100644 --- a/crates/engine/src/cmd/live/common.rs +++ b/crates/engine/src/cmd/live/common.rs @@ -33,9 +33,10 @@ pub(super) fn roots_param(s: &Session, p: &Value) -> Result> { selected_roots(s) } -/// Selected nodes of a kind (the selection itself or an ancestor). +/// Selected nodes of a kind (the selection itself or an ancestor). None while an object of a +/// dynamic symbol instance's art is selected. pub(super) fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -64,16 +65,19 @@ pub(super) fn is_mesh(n: &Node) -> bool { pub(super) fn has_blend(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) } } pub(super) fn has_envelope(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) } } pub(super) fn has_mesh(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 01721471c..f2d322a39 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}", - can_edit_mask, + can_enter, enter ), cmd!( @@ -79,6 +79,13 @@ pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> { mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into())) } +/// Edit Opacity Mask: mask editing can be entered ([`can_edit_mask`]), and outside it no object of a +/// dynamic symbol instance's art is selected, since its instance's mask is not edited from it. +fn can_enter(s: &Session) -> std::result::Result<(), String> { + can_edit_mask(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode /// are not entered while an opacity mask is edited. pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> { @@ -117,12 +124,17 @@ fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.mask_edit.is_some() { return Err(bad(C, "already editing an opacity mask")); } - let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let given = object_param(p, "id", C)?; + if given.is_none() { + super::edit::refuse_symbol_part(s)?; + } + let id = given.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; let layer = start(s, id)?; Ok(json!({ "layer": layer.0 })) } -/// The first selected object with an opacity mask. +/// The first selected object with an opacity mask. Callers refuse first while an object of a +/// dynamic symbol instance's art is selected ([`super::edit::refuse_symbol_part`]). fn first_masked(st: &crate::DocState) -> Option { st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some())) } @@ -145,6 +157,9 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { st.revision += 1; return Ok(json!({ "on": false, "id": id.map(|i| i.0) })); } + if given.is_none() && editing.is_none() { + super::edit::refuse_symbol_part(s)?; + } let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; match editing { Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index b400f637a..061f4fb9d 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -242,6 +242,11 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; check_switches(p, &["anchors", "releaseOutside"], "object.transform")?; let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; + // An object selected in a symbol instance's art stays where the symbol puts it: the selection + // is left as it is, and the reply's warning goes to the status bar. + if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() { + return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() })); + } let (ids, anchors) = match ids_param(p, "ids") { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index e2bd0957d..93e2bd929 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -82,9 +82,10 @@ fn groups_of(d: &Document, ids: &[NodeId]) -> Vec { out } -/// The Objects on Path groups of the selection ([`groups_of`]). +/// The Objects on Path groups of the selection ([`groups_of`]). None while an object of a dynamic +/// symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() + s.active().filter(|st| st.selection.symbol_part.is_none()).map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() } /// The groups a command acts on: those of `ids` when given, otherwise those of the selection. @@ -102,6 +103,7 @@ fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result> { fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 7089770e4..b8b0138c5 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} put the mask art back above each masked object (default: the selection, or the object whose mask is being edited, which leaves mask editing)", - has_doc, + can_change_mask, release ), cmd!( @@ -43,25 +43,22 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} keep the mask but stop applying it", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Disable Opacity Mask", &json!({ "disabled": true })) ), - cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( - s, - p, - "Enable Opacity Mask", - &json!({ "disabled": false }) - )), + cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| { + set_flags(s, p, "Enable Opacity Mask", &json!({ "disabled": false })) + }), cmd!( "transparency.unlinkOpacityMask", "Unlink Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?} the object moves without its mask", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Unlink Opacity Mask", &json!({ "linked": false })) ), - cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( + cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| set_flags( s, p, "Link Opacity Mask", @@ -73,7 +70,7 @@ pub fn specs() -> Vec { [], None, "{id?|ids?, clip?, invert?, disabled?, linked?} change the opacity masks of `ids`, the selection, or the object whose mask is being edited", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Opacity Mask Options", p) ), cmd!( @@ -152,9 +149,21 @@ pub(crate) fn percent(o: f64) -> f32 { (o / 100.0).clamp(0.0, 1.0) as f32 } -/// The [`transparency_targets`] that have an opacity mask. +/// Refuses a command that would change [`transparency_targets`] when they are the selection, without +/// `id` or `ids` and outside mask editing, while an object of a dynamic symbol instance's art is +/// selected: the instance's transparency and mask change only when the instance is selected. +pub(crate) fn refuse_symbol_part_unnamed(s: &Session, p: &Value) -> Result<()> { + if p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_none() { + super::edit::refuse_symbol_part(s)?; + } + Ok(()) +} + +/// The [`transparency_targets`] that have an opacity mask, refused as +/// [`refuse_symbol_part_unnamed`] says. fn masked_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; + refuse_symbol_part_unnamed(s, p)?; let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect(); if ids.is_empty() { return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into())); @@ -162,6 +171,13 @@ fn masked_targets(s: &Session, p: &Value) -> Result> { Ok(ids) } +/// The commands that change an existing opacity mask: a document is open, and outside mask editing +/// no object of a dynamic symbol instance's art is selected ([`masked_targets`]). +fn can_change_mask(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty /// mask and takes into mask editing, is where mask editing can be entered /// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index bbb5f35d3..53a592492 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,5 +1,7 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. +use std::borrow::Cow; + use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; @@ -19,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -28,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -86,7 +88,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -95,7 +97,7 @@ pub fn proxy_specs() -> Vec { "Complement", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model()) ), @@ -172,7 +174,7 @@ impl Session { /// new art. pub fn proxy_paints(&self) -> (Paint, Paint) { let show = |stroke: bool| match self.proxy_source(stroke) { - Some((n, item)) => proxy_paint(n, stroke, item), + Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), }; @@ -180,17 +182,22 @@ impl Session { } /// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item - /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. - pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option)> { + /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. With + /// an object of a dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that + /// object as the instance draws it. + pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(Cow<'_, Node>, Option)> { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return Some((Cow::Owned(part), None)); + } let first = st.doc.node(*st.selection.subjects().first()?)?; let item = self.appearance_item(); if first.appearance.item_of_kind(item, !stroke).is_some() { - return Some((first, item)); + return Some((Cow::Borrowed(first), item)); } let mut nodes = vec![]; painted(first, true, &mut nodes); - nodes.first().map(|n| (*n, None)) + nodes.first().map(|n| (Cow::Borrowed(*n), None)) } pub(crate) fn remember_paint_now(&mut self, p: &Paint) { @@ -257,6 +264,9 @@ impl DocState { if self.selection.is_empty() { return (false, false); } + if self.selection.symbol_part.is_some() { + return (false, false); + } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); let (mut selected, mut nodes) = (vec![], vec![]); self.doc.walk(|n| { @@ -275,6 +285,18 @@ impl DocState { }; (differ(false), differ(true)) } + + /// The object of a dynamic symbol instance's art selected with Direct Selection + /// ([`vectorcraft_doc::Selection::symbol_part`]), with the instance's own appearance changes + /// applied, in the symbol's coordinates. `None` without one. + pub(crate) fn selected_symbol_part(&self) -> Option { + let sp = self.selection.symbol_part.as_ref()?; + let inst = self.doc.node(sp.instance)?; + let NodeKind::SymbolInstance { symbol, .. } = &inst.kind else { return None }; + let sym = self.doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst); + vectorcraft_doc::symbol_overrides::part(&art, &sp.path).cloned() + } } impl Session { @@ -465,6 +487,26 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) s.remember_paint(&paint); return Ok(json!({"objects": ids.len()})); } + // An object of a dynamic symbol instance's art selected with Direct Selection: a solid color or + // none changes that object for this instance only, and anything else is refused. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + if !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, if fill { "Fill Color" } else { "Stroke Color" }, |o| { + if fill { + o.fill = Some(paint.clone()); + } else { + o.stroke = Some(paint.clone()); + } + })?; + paint_new_art(s, &paint, fill); + if focus { + s.fill_active = fill; + } + s.remember_paint(&paint); + return Ok(json!({"objects": 1})); + } // Resolve the requested targets before mutating defaults or the active proxy. let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; @@ -553,15 +595,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }, )?; // Rejected edits leave the next shape's appearance and active proxy untouched. - // New art takes an unplaced gradient so that it fits the new object's own bounds. - if fill { - s.paint.fill = unplaced(&paint); - } else { - if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { - s.paint.stroke_width = 1.0; - } - s.paint.stroke = unplaced(&paint); - } + paint_new_art(s, &paint, fill); if focus { s.fill_active = fill; } @@ -569,6 +603,39 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) Ok(json!({"objects": ids.iter().collect::>().len()})) } +/// The instance whose object is selected with Direct Selection, and that object's path +/// ([`super::brushsym::selected_part`]), when `p` names no `ids`, `id` or `item`: the paint commands +/// then change that object. A bad `target` or `aligned` is refused as it is for any other selection. +fn selected_part_target(s: &Session, p: &Value, cmd: &str) -> Result)>> { + if p.get("ids").is_some() || p.get("id").is_some() || p.get("item").is_some() { + return Ok(None); + } + let part = super::brushsym::selected_part(s)?; + if part.is_some() { + item_target(s, p, cmd)?; + } + Ok(part) +} + +/// Can an object of a dynamic symbol instance's art selected with Direct Selection take `paint`? A +/// solid color or none can; a gradient or pattern cannot. +pub(crate) fn part_takes(paint: &Paint) -> bool { + matches!(paint, Paint::Solid { .. } | Paint::None) +} + +/// Make `paint` the fill or stroke of new art. New art takes an unplaced gradient so that it fits +/// the new object's own bounds, and a painted stroke on art that had none gets the 1 pt default. +fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) { + if fill { + s.paint.fill = unplaced(paint); + } else { + if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { + s.paint.stroke_width = 1.0; + } + s.paint.stroke = unplaced(paint); + } +} + /// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets /// would each fit to their own bounds can span them: one with its own vector (`start`/`end`), /// a freeform one or a gradient swatch already has its placement. @@ -824,6 +891,32 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { /// active item of the proxy's kind is recoloured in each selected object's own stack instead. fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let stroke = stroke_param(s, p, cmd)?; + // An object of a dynamic symbol instance's art selected with Direct Selection: the solid color + // it shows is recolored for this instance only. A gradient or pattern is refused, and so is type + // whose characters differ in that paint, since one change holds one paint. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + let part = s.doc()?.selected_symbol_part(); + let mut paint = part.as_ref().map_or(Paint::None, |n| proxy_paint(n, stroke, None)); + let runs_differ = match part.as_ref().map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.runs.iter().any(|r| !same_in_proxy(if stroke { &r.style.stroke } else { &r.style.fill }, &paint)), + _ => false, + }; + if runs_differ || !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + let changed = super::colorcmds::map_paint(&mut paint, f); + if changed { + super::brushsym::edit_part(s, id, &path, label, |o| { + if stroke { + o.stroke = Some(paint.clone()); + } else { + o.fill = Some(paint.clone()); + } + })?; + s.remember_paint(&paint); + } + return Ok(json!({ "changed": changed as usize })); + } let ids = match super::given_objects(s, p, cmd)? { Some(ids) => ids, None => super::edit::selected_roots(s)?, @@ -863,6 +956,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( fn transparency(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.set"; + super::opacitymask::refuse_symbol_part_unnamed(s, p)?; let ids = super::opacitymask::transparency_targets(s, p)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e0de6bd4a..4aeeed95c 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Move Anchors", [], None, - "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)", + "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected); refused while an object of a symbol instance's art is selected", has_selection, move_anchors ), @@ -226,6 +226,8 @@ fn close(s: &mut Session, p: &Value) -> Result { pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?); check_switches(p, &["patterns"], "path.moveAnchors")?; + // An object selected in a symbol instance's art stays where the symbol puts it. + super::edit::refuse_symbol_part(s)?; // Objects selected whole move as objects, and their pattern tiles move with them when // `patterns` (default: General › Transform Pattern Tiles) is on. let whole = s.doc()?.selection.whole(); @@ -341,7 +343,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { } /// The compound path enclosing `id`, or `id` itself if it is not inside a compound path. -pub(super) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { +pub(crate) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id) } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 614a37d21..1e7f88d2b 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -189,8 +189,10 @@ fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> { has_doc(s) } +/// The repeats of the selection: selected ones, and those around a selected object. None while +/// an object of a dynamic symbol instance's art is selected. fn selected_repeats(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -207,6 +209,7 @@ fn selected_repeats(s: &Session) -> Vec { fn has_repeat(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_repeats(s).is_empty() { Err("select a repeat".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 0de33a22c..930559db3 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -534,7 +534,7 @@ impl Session { if st.doc.node(old) != read.job.replaced.as_ref() { return Err(bad(PLACE, "replace: the object to replace was changed or deleted")); } - if st.selection.objects != [old] { + if st.selection.objects != [old] || st.selection.symbol_part.is_some() { self.execute("select.set", &json!({ "ids": [old.0] }))?; } } @@ -565,6 +565,10 @@ fn prepare(s: &Session, p: &Value) -> Result { if corners.iter().flatten().any(|c| !(c.x.abs() <= MAX_COORD && c.y.abs() <= MAX_COORD)) { return Err(bad(PLACE, "at/rect lie outside the canvas")); } + // An object selected in a symbol instance's art can't be swapped, and its instance stays. + if replace { + super::edit::refuse_symbol_part(s)?; + } let st = s.doc()?; let old = match (replace, &st.selection.objects[..]) { (false, _) => None, diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..dd091fb71 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -139,7 +139,15 @@ impl FilterRun { let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; - let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, + // as `selected_roots` refuses it. + let selected = match ids_param(p, "ids") { + Some(ids) => ids, + None => { + super::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } + }; // Without `doc.read` a filter is given no objects: it can only add new ones. let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index f4996f4ad..00b87c649 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; use vectorcraft_color::{Color, Paint, Swatch}; -use vectorcraft_doc::swatches::{node_colors, node_paints}; +use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::colorcmds::{Recolor, Scope}; @@ -99,7 +99,7 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> read(n, only, &mut nodes); } for m in nodes { - for p in node_paints(m) { + for p in node_paints(m).chain(override_paints(m)) { match p { Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()), Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())), @@ -522,6 +522,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { }; let row_swatches: Vec> = rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + super::edit::refuse_symbol_part(s)?; let ids = s.doc()?.selection.objects.clone(); let group = text_param(p, "group", C)?; if ids.is_empty() && group.is_none() { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 4937ff200..c9df27002 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -343,10 +343,15 @@ impl Session { /// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the /// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else /// the first selected object's (a group's first painted object's) top stroke, type's first - /// run's character stroke; with nothing selected, the next object drawn's - /// ([`Session::new_art_stroke`]). None without a document or the selection's stroke. + /// run's character stroke; with an object of a dynamic symbol instance selected + /// ([`DocState::selected_symbol_part`]), that object's as the instance draws it; with nothing + /// selected, the next object drawn's ([`Session::new_art_stroke`]). None without a document or + /// the selection's stroke. pub fn shown_stroke(&self) -> Option { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return node_stroke(&part); + } let Some(first) = st.selection.objects.first() else { return Some(self.new_art_stroke()) }; let first = st.doc.node(*first)?; if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) { @@ -360,13 +365,19 @@ impl Session { impl DocState { /// Which Stroke panel values the selected objects' strokes (type: every run's) don't share, - /// and whether Align Stroke applies to them (no open path among them). One walk of the - /// selection, so callers that ask every frame cache it by revision. + /// and whether Align Stroke applies to them (no open path among them). With an object of a + /// dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that object alone is + /// read. One walk of the selection, so callers that ask every frame cache it by revision. pub fn stroke_mixed(&self) -> StrokeMixed { + let part = self.selected_symbol_part(); let mut nodes = vec![]; - for id in &self.selection.objects { - if let Some(n) = self.doc.node(*id) { - painted(n, true, &mut nodes); + if let Some(part) = &part { + painted(part, true, &mut nodes); + } else { + for id in &self.selection.objects { + if let Some(n) = self.doc.node(*id) { + painted(n, true, &mut nodes); + } } } let mut strokes = vec![]; diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 2864f574b..7b0cad576 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -156,6 +156,9 @@ fn add(s: &mut Session, p: &Value) -> Result { let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?); if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)?; + } else if apply { + // A selection that can't take a style is refused before any style is added. + super::appearance::appearance_targets(s, &json!({}))?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 618ef2ee3..de262cf2b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -78,6 +78,10 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { (name, xf, ids.first().copied()) } None => { + // Refused while an object of a dynamic instance's art is selected, unless `name` is given. + if str_param(p, "name").is_none() && st.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } let picked = if str_param(p, "name").is_some() { vec![] } else { selected_instances(st) }; let symbols: Vec<(String, Affine)> = picked.iter().filter_map(|id| instance_of(&st.doc, *id)).collect(); match symbols.first() { diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index a115498e1..2224ddccc 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -73,6 +73,11 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { } } +/// Journaled commands that write a file and change nothing in the document: with an object of a +/// dynamic symbol instance's art selected, they read the instance as queries do +/// ([`crate::Session::execute`]). +pub(crate) const JOURNALED_READS: &[&str] = &["css.export"]; + /// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with /// its checks), then running as on the selection: the result and the selection afterwards are the /// menu command's (#2130). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 399a4ed11..79aa06891 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -164,6 +164,10 @@ pub struct Session { /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest /// history step takes the colors the batch finally left. pub(crate) batch_paint_docs: Vec, + /// While a command that changes nothing in the document runs (a query, or one of + /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol + /// instance's art. + pub(crate) reading: bool, /// While [`Session::place_read`] runs `file.place`: the file read for it, which the command /// places instead of reading the file; `None` otherwise. pub(crate) place_hand_off: Option, diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1eaf15b88..27976ff51 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,4 +1,4 @@ -use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; +use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; use super::doc_state::DocState; use super::error::{EngineError, Result}; use super::history::NamedStep; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ec9fdbac1..d9ef5740b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -81,6 +81,7 @@ impl Session { batch_stash: None, batch_steps: None, batch_paint_docs: Vec::new(), + reading: false, place_hand_off: None, queue_hand_off: None, place_queue_roots: None, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index da285eb3d..e3fae1030 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -340,7 +340,7 @@ impl Session { preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0), ); st.keep_interaction(it); - if preview.0 == "object.transform" { + if preview.0 == "object.transform" && changed { let m = cmd::matrix_param(&preview.1, "matrix"); if let Some(m) = m { // The options are the last preview's params with the values it noted, without the diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 21822583d..71f90e7e4 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,5 +1,6 @@ use super::{ - CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only, + CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, + strip_replay_only, }; impl Session { @@ -35,6 +36,13 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id)); + let r = self.execute_spec(spec, id, params); + self.reading = outer; + r + } + + fn execute_spec(&mut self, spec: &'static CommandSpec, id: &str, params: &Value) -> Result { // A key the command's params description doesn't name is a parameter error (#1442), before // anything runs, for the commands other commands run too. A `command.batch` or an // `action.batch` checks its steps before the first one runs, and each step is checked again diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 1c46d5302..9ec00cd24 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -1,10 +1,14 @@ //! Dynamic symbols (#4938): an instance changes the appearance of objects in its symbol's art and -//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. +//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. Direct +//! Selection picks one path, compound path or type object of an instance's art, and a solid color +//! Fill or Stroke changes it. use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; +use crate::tooling::{UiRequest, ViewInfo}; use crate::*; fn session() -> Session { @@ -46,6 +50,9 @@ fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { const RED: [u8; 4] = [255, 0, 0, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255]; +/// The refusal of anything but a solid fill or stroke on a picked object says so. +const REFUSED: &str = "solid color fill and stroke"; + #[test] fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { let mut s = session(); @@ -58,6 +65,10 @@ fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { assert_eq!(pixel(&s, 80, 30), RED, "the changed object"); assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); assert_eq!(overrides(&s, other), 0, "other instances keep the symbol's look"); + // The same value again records nothing. + let undo = undo_steps(&s); + assert_eq!(s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap()["overrides"], 1); + assert_eq!(undo_steps(&s), undo, "no undo step"); // A later change to the same object merges with the first; null takes one back. s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "opacity": 50})).unwrap(); assert_eq!(node(&s, inst).extras().symbol_overrides[0].opacity, Some(0.5)); @@ -176,6 +187,666 @@ fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() { assert_eq!(overrides(&s, other), 1, "the instance keeps its changes"); } +/// Press the Direct Selection tool at (`x`, `y`), drag to `to` and release, with `m` held: what the +/// drag asked of the UI. +fn direct(s: &mut Session, (x, y): (f64, f64), to: (f64, f64), m: Mods) -> Vec { + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(m), v).unwrap(); + let mut out = vec![]; + if to != (x, y) { + out = s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap(); + out +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The fills of the symbol's own art. +fn symbol_fills(s: &Session) -> Vec { + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + art.children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect() +} + +#[test] +fn direct_selection_paints_one_object_of_one_dynamic_instance() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [inst]); + assert_eq!(sel.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] })); + let undo = undo_steps(&s); + // The Swatches panel, the Color panel and the Control bar set the fill with paint.setFill. + assert_eq!(s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap()["objects"], 1); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(pixel(&s, 80, 30), RED, "the picked object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"], "the symbol's art is unchanged"); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#ff0000".into()), "new art takes the fill"); + // The same paint again records nothing. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(undo_steps(&s), undo + 1); + // Stroke goes to the same object. + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(s.paint.stroke.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the stroke"); + assert!(s.paint.stroke_width > 0.0, "a painted stroke has a weight"); + let o = node(&s, inst).extras().symbol_overrides[0].clone(); + assert_eq!(o.path, vec![1]); + assert!(o.fill.is_some() && o.stroke.is_some()); + // The Eyedropper's click on another object gives the selected object its paint. + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap()["ids"], json!([inst.0])); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255], "the eyedropper's paint"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"]); + for _ in 0..7 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(overrides(&s, inst), 0, "undo takes each change back"); + assert_eq!(pixel(&s, 80, 30), BLUE); +} + +#[test] +fn a_drag_of_a_picked_object_moves_nothing_and_says_why() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let (before, undo) = (node(&s, inst).kind.clone(), undo_steps(&s)); + let ui = direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("symbol instance"))), "{ui:?}"); + assert_eq!(node(&s, inst).kind, before, "the instance stays put"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert!(s.execute("object.nudge", &json!({"dx": 1})).is_err(), "the arrow keys too"); + assert!(s.execute("object.rotate", &json!({"angle": 10})).is_err()); + // The Selection tool picks the instance whole again, and it moves. + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before); +} + +/// The Stroke panel and the Control bar show the stroke of the object picked in a dynamic instance. +#[test] +fn the_stroke_panel_shows_a_picked_objects_stroke() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "color": "#00ff00"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2})).unwrap(); + s.execute("stroke.set", &json!({"ids": [b.0], "weight": 7})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(7.0), "the picked object's weight"); + assert!(!s.doc().unwrap().stroke_mixed().weight, "the instance's art has two weights, the object one"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(2.0)); +} + +#[test] +fn delete_and_cut_of_a_picked_object_remove_nothing() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["edit.cut", "edit.clear"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{cmd}: {e}"); + assert!(s.doc().unwrap().doc.node(inst).is_some(), "{cmd} keeps the instance"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); + // Named by id, the instance is removed. + s.execute("edit.clear", &json!({"ids": [inst.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); +} + +#[test] +fn commands_on_the_selection_leave_a_picked_objects_instance_alone() { + // A dynamic instance with an opacity mask, a plain square and the Desaturate plug-in, with an + // object of the instance picked. + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let desaturate = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); + let plugin = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(desaturate)})).unwrap(); + let mask = square(&mut s, 20.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [inst.0, mask.0]})).unwrap(); + assert!(node(&s, inst).extras().mask.is_some()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst, other, plugin["id"].clone()) + }; + let (mut s, inst, other, plugin) = setup(); + let doc = s.doc().unwrap().doc.clone(); + // Each command runs on its own copy of the setup, compared with that copy as it was, so every + // command that fails is reported. + let mut failures = Vec::new(); + for (cmd, p) in [ + ("object.puppetWarp", json!({"pins": [[80, 30]], "moved": [[90, 40]], "rest": true})), + ("recolor.apply", json!({"map": {"#0000ff": "#00ff00"}})), + ("plugin.run", json!({"id": plugin, "params": {"amount": 100}})), + ("object.group", json!({})), + ("object.ungroup", json!({})), + ("object.hide", json!({})), + ("object.lock", json!({})), + ("object.arrange.bringToFront", json!({})), + ("object.expand", json!({})), + ("object.rotate", json!({"angle": 45})), + ("symbol.instance.reset", json!({})), + ("symbol.instance.setAppearance", json!({"path": [1], "opacity": 50})), + ("symbol.adjust", json!({"tool": "shift", "points": [[80, 30], [95, 30]], "radius": 200})), + ("symbol.edit", json!({})), + ("select.globalEdit", json!({})), + ("object.blend.release", json!({})), + ("object.repeat.release", json!({})), + ("object.intertwine.release", json!({})), + ("object.objectsOnPath.expand", json!({})), + ("transparency.set", json!({"opacity": 50})), + ("transparency.set", json!({"blend": "multiply"})), + ("transparency.releaseOpacityMask", json!({})), + ("transparency.setOpacityMask", json!({"invert": true, "clip": false})), + ("transparency.disableOpacityMask", json!({})), + ("transparency.unlinkOpacityMask", json!({})), + ("transparency.editOpacityMask", json!({})), + ("transparency.viewOpacityMask", json!({"on": true})), + ("paint.editGradient", json!({"angle": 30})), + ("paint.setGradientGeom", json!({"start": [62, 30], "end": [98, 30]})), + ("paint.swap", json!({})), + ("stroke.set", json!({"weight": 3})), + ("path.moveAnchors", json!({"dx": 5, "dy": 5})), + ( + "file.place", + json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(b""), "replace": true}), + ), + ("object.intertwine.arrange", json!({"at": [80, 30]})), + ("transparency.editOpacityMask", json!({"id": null})), + ("transparency.viewOpacityMask", json!({"on": true, "id": null})), + ] { + let (mut t, ..) = setup(); + let (doc, undo) = (t.doc().unwrap().doc.clone(), undo_steps(&t)); + match t.execute(cmd, &p) { + Ok(r) => failures.push(format!("{cmd}: ran ({r})")), + Err(e) if !e.to_string().contains("symbol instance") => failures.push(format!("{cmd}: {e}")), + Err(_) => {} + } + if t.doc().unwrap().selection.symbol_part.is_none() { + failures.push(format!("{cmd}: the object is no longer picked")); + } + if t.doc().unwrap().doc != doc { + failures.push(format!("{cmd}: the document changed")); + } + if undo_steps(&t) != undo { + failures.push(format!("{cmd}: an undo step was added")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + // Copy copies the instance whole. + s.execute("edit.copy", &json!({})).unwrap(); + assert!(matches!(&s.clipboard.nodes[..], [n] if matches!(n.kind, NodeKind::SymbolInstance { .. }))); + // The picked object's paint still changes. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_some()); + // A command given ids acts on them. + s.execute("object.rotate", &json!({"ids": [other.0], "angle": 45})).unwrap(); + assert_ne!(node(&s, other).kind, doc.node(other).unwrap().kind, "the named object turns"); + s.execute("transparency.setOpacityMask", &json!({"id": inst.0, "invert": true})).unwrap(); + assert!(node(&s, inst).extras().mask.as_ref().is_some_and(|m| m.invert), "the named instance's mask changes"); +} + +#[test] +fn the_commands_without_a_check_for_a_picked_object_use_its_instance() { + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst) + }; + // Export Selection: the whole instance, x 10..100, and not the object at 60..100. + let (mut s, _) = setup(); + let r = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap(); + assert_eq!(r["bounds"], json!([10.0, 10.0, 90.0, 40.0])); + // Fit to Selection: the whole instance too. + let (mut s, _) = setup(); + s.execute("artboard.fitToSelection", &json!({})).unwrap(); + let r = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((r.x0, r.x1), (10.0, 100.0)); + // Liquify: the instance is its target, skipped and left as it is. + let (mut s, inst) = setup(); + let doc = s.doc().unwrap().doc.clone(); + let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[80, 30]], "diameter": 150, "intensity": 1})).unwrap(); + assert_eq!((&r["ids"], &r["skipped"]), (&json!([]), &json!([inst.0]))); + assert!(s.doc().unwrap().doc == doc); + // Isolate: the instance, with the selection cleared. + let (mut s, inst) = setup(); + s.execute("object.isolate", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(inst)); + assert!(s.doc().unwrap().selection.is_empty()); + // Add to Library: a copy of the whole instance, 90 pt wide, and not the object's paint. + let (mut s, _) = setup(); + let lib = s.execute("library.add", &json!({"kind": "graphic"})).unwrap()["library"].clone(); + let got = s.execute("library.get", &json!({"library": lib})).unwrap(); + assert_eq!(got["graphics"][0]["width"], 90.0); + assert_ne!(s.execute("library.add", &json!({"kind": "fillColor"})).unwrap()["name"], "#ff0000"); + // Save Selection: the instance, which Recall selects whole. + let (mut s, inst) = setup(); + s.execute("select.save", &json!({"name": "Saved"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.saved_selections[0].objects, vec![inst]); + s.execute("select.recall", &json!({"name": "Saved"})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((&sel.objects, &sel.symbol_part), (&vec![inst], &None)); +} + +#[test] +fn a_blend_around_a_picked_objects_instance_is_left_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [inst.0, other.0], "steps": 1})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let (doc, undo) = (s.doc().unwrap().doc.clone(), undo_steps(&s)); + let e = s.execute("object.blend.release", &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{e}"); + assert!(s.doc().unwrap().doc == doc, "the blend stays"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + // With the blend selected, it is released. + s.execute("select.set", &json!({"ids": [blend.0]})).unwrap(); + s.execute("object.blend.release", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(blend).is_none()); +} + +/// Dynamic symbol "Photo" of a gray 2 × 1 image and a blue square, made in place: its instance and +/// the image's path in the art. +fn image_symbol(s: &mut Session) -> (NodeId, Value) { + let mut png = Vec::new(); + let pixels = image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()); + pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let im = s + .edit("Place", |d, _| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf: vectorcraft_geom::Affine::translate((10.0, 10.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + Ok(id) + }) + .unwrap(); + let sq = square(s, 60.0); + let inst = id(&s.execute("symbol.new", &json!({"name": "Photo", "ids": [im.0, sq.0], "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + let i = art.children().unwrap().iter().position(|c| matches!(c.kind, NodeKind::Image(_))).unwrap(); + (inst, json!([i])) +} + +#[test] +fn only_paths_compound_paths_and_type_of_the_art_are_picked() { + let mut s = session(); + // A compound path, type, a blend and a static symbol's instance beside a path, in a group. + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + let text = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + let (c, d) = (square(&mut s, 60.0), square(&mut s, 150.0)); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [c.0, d.0], "steps": 1})).unwrap()); + let inner = pair(&mut s, "static"); + let plain = square(&mut s, 100.0); + let group = id(&s.execute("object.group", &json!({"ids": [plain.0]})).unwrap()); + let ids = [compound.0, text.0, blend.0, inner.0, group.0]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Mixed", "ids": ids, "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols.iter().find(|y| y.name == "Mixed").unwrap().art.clone(); + let at = |kind: &dyn Fn(&NodeKind) -> bool| art.children().unwrap().iter().position(|c| kind(&c.kind)).unwrap(); + let compound = at(&|k| matches!(k, NodeKind::Compound { .. })); + let text = at(&|k| matches!(k, NodeKind::Text(_))); + let blend = at(&|k| matches!(k, NodeKind::Blend { .. })); + let inner = at(&|k| matches!(k, NodeKind::SymbolInstance { .. })); + let group = at(&|k| matches!(k, NodeKind::Group { .. })); + for path in [json!([compound]), json!([text]), json!([group, 0])] { + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + } + assert_eq!(overrides(&s, inst), 3); + let undo = undo_steps(&s); + s.execute("select.none", &json!({})).unwrap(); + for path in [json!([]), json!([group]), json!([blend]), json!([inner])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + assert_eq!((overrides(&s, inst), undo_steps(&s)), (3, undo), "nothing changes"); +} + +/// A Direct Selection press on an image of the art is refused with the status message, and a drag +/// from it does not move what was selected. +#[test] +fn a_press_on_an_image_of_the_art_is_refused_and_moves_nothing() { + let mut s = session(); + let (inst, path) = image_symbol(&mut s); + let other = square(&mut s, 150.0); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let before = node(&s, other).kind.clone(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 60.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 60.0), v).unwrap(); + assert_eq!(node(&s, other).kind, before, "what was selected stays put"); + assert_eq!(s.doc().unwrap().selection.objects, [other], "and stays selected"); + assert_eq!(overrides(&s, inst), 0); +} + +/// A gradient or pattern for a picked object is refused and changes nothing, new art's paint +/// included. An object that shows a gradient takes a solid color. +#[test] +fn gradients_and_patterns_on_a_picked_object_are_refused() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("swatch.new", &json!({"name": "Ramp", "gradient": g})).unwrap(); + let tile = square(&mut s, 150.0); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + // An object to sample a gradient from. + let far = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [far.0], "gradient": g})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone()); + for (cmd, p) in [ + ("paint.setFill", json!({"gradient": g})), + ("paint.setStroke", json!({"gradient": g})), + ("paint.setFill", json!({"gradient": g, "span": "selection"})), + ("paint.setFill", json!({"swatch": "Ramp"})), + ("paint.setFill", json!({"swatch": "Dots"})), + ("appearance.copyFrom", json!({"source": far.0})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd} {p}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "new art keeps its paint"); + // An object whose art has a gradient takes a solid color; Invert, Complement and selecting one + // of its stops are refused. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": {"gradient": g}})).unwrap(); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + s.fill_active = true; + for index in [json!(1), Value::Null] { + let e = s.execute("gradient.selectStop", &json!({"index": index})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "gradient.selectStop {index}: {e}"); + } + assert_eq!(s.selected_stop(), None); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // The Eyedropper's options can leave the gradient out: the fill stays. + s.execute("appearance.copyFrom", &json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}}}})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // Options that pick up neither color leave nothing to change, so the click is refused. + let (undo, fill) = (undo_steps(&s), s.paint.fill.clone()); + let neither = json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}, "stroke": {"color": false}}}}); + let e = s.execute("appearance.copyFrom", &neither).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes"); +} + +#[test] +fn select_part_refuses_hidden_and_locked_objects_of_the_art() { + let mut s = session(); + let (a, b, c) = (square(&mut s, 10.0), square(&mut s, 60.0), square(&mut s, 110.0)); + let group = id(&s.execute("object.group", &json!({"ids": [b.0, c.0]})).unwrap()); + let inst = id(&s.execute("symbol.new", &json!({"name": "Three", "ids": [a.0, group.0], "type": "dynamic"})).unwrap()); + // The art's first square is hidden and its group locked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let children = art.children_mut().unwrap(); + std::sync::Arc::make_mut(&mut children[0]).visible = false; + std::sync::Arc::make_mut(&mut children[1]).locked = true; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + for path in [json!([0]), json!([1]), json!([1, 0])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + // With the art's group unlocked, a locked instance's objects are still not picked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + std::sync::Arc::make_mut(&mut art.children_mut().unwrap()[1]).locked = false; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("object.lock", &json!({"ids": [inst.0]})).unwrap(); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "locked instance: {e}"); + s.execute("object.unlockAll", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap(); +} + +#[test] +fn choosing_the_selection_tool_again_picks_the_instance_whole() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + let revision = s.doc().unwrap().revision; + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(s.doc().unwrap().revision, revision, "panels cached by revision read the instance"); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); +} + +#[test] +fn the_eyedropper_sample_taken_for_a_picked_object_is_kept() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + let target = square(&mut s, 150.0); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap(); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the sample's fill"); + // A reverse Eyedropper click with nothing selected applies that sample. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": target.0, "reverse": true})).unwrap()["ids"], json!([target.0])); + assert_eq!(node(&s, target).appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); +} + +#[test] +fn invert_and_complement_recolor_a_picked_object_for_its_instance_only() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let r = s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(symbol_part_fill(&s, inst), "#ffff00", "the picked object's own blue, inverted"); + assert_eq!(overrides(&s, inst), 1, "only the picked object changes"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert!(*s.doc().unwrap().doc.symbols[0].art == *art, "the symbol's art stays"); + assert_eq!(pixel(&s, 80, 30), [255, 255, 0, 255], "the changed object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + s.execute("paint.complement", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#0000ff", "the complement of yellow"); + assert_eq!(undo_steps(&s), undo + 2, "one undo step"); +} + +/// One change holds one paint, so Invert and Complement are refused for type whose characters +/// differ in color, and each character's color stays. A fill gives all of them one color. +#[test] +fn invert_and_complement_are_refused_for_picked_type_whose_characters_differ() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let NodeKind::Text(tx) = &mut art.kind else { panic!("type: {art:?}") }; + let mut second = tx.runs[0].clone(); + second.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + tx.runs.push(second); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#ff00ff", "one color inverts"); +} + +/// The color of the fill instance `inst`'s first change sets. +fn symbol_part_fill(s: &Session, inst: NodeId) -> String { + let o = node(s, inst).extras().symbol_overrides[0].clone(); + o.fill.as_ref().and_then(|p| p.color()).map(|c| c.to_hex()).unwrap_or_else(|| panic!("a solid fill: {o:?}")) +} + +#[test] +fn a_stroke_on_a_picked_type_object_without_a_weight_draws() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setStroke", &json!({"ids": [t.0], "none": true})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let part = s.doc().unwrap().selected_symbol_part().unwrap(); + let NodeKind::Text(tx) = &part.kind else { panic!("type: {part:?}") }; + assert!(tx.runs.iter().all(|r| r.style.stroke_width == 1.0 && !r.style.stroke.is_none()), "{:?}", tx.runs); +} + +/// The colors the Fill and Stroke proxies show, as `paint.proxies` replies them. +fn proxy_colors(s: &mut Session) -> (Option, Option) { + let r = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!((r["fillMixed"].clone(), r["strokeMixed"].clone()), (json!(false), json!(false)), "{r}"); + let (fill, stroke) = s.proxy_paints(); + assert_eq!((r["fill"].clone(), r["stroke"].clone()), (json!(fill), json!(stroke))); + (fill.color().map(|c| c.to_hex()), stroke.color().map(|c| c.to_hex())) +} + +#[test] +fn the_fill_and_stroke_proxies_show_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None), "the symbol's own paint"); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#ff0000".into()), Some("#00ff00".into())), "the object's own paint"); + // The other object of the same instance shows the symbol's paint. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None)); +} + +#[test] +fn selecting_a_guide_too_lets_go_of_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap()["index"].as_u64().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("guide.select", &json!({"indexes": [g], "toggle": true})).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.guides.len(), sel.symbol_part), (vec![inst], 1, None)); + // Paint now goes to the instance, not to the object. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(overrides(&s, inst), 0); + // Pruning a selection with a guide in it lets go of the object as well. + let mut sel = s.doc().unwrap().selection.clone(); + sel.symbol_part = Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] }); + sel.prune(&s.doc().unwrap().doc); + assert_eq!(sel.symbol_part, None); +} + +/// A dynamic symbol with 9-slice guides at 25%: a 100 × 100 frame at (50, 50), a 10 × 10 square +/// in its top-left corner and one in its center; its instance resized to 300 × 100. +fn sliced_panel() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let rect = + |s: &mut Session, x: f64, y: f64, w: f64| id(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()).0; + let ids = [rect(&mut s, 50.0, 50.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0)]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Panel", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + (s, inst) +} + +#[test] +fn direct_selection_picks_and_outlines_the_object_where_a_9_slice_instance_draws_it() { + let (mut s, inst) = sliced_panel(); + let part_box = |s: &Session, path: &[u32]| { + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), path).unwrap(); + vectorcraft_geom::kurbo::Shape::bounding_box(&o) + }; + let close = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6); + // The center cell (75..125 of the art) is drawn at 75..325, so the center square at 175..225; + // placed by the instance's transform alone it would be at 185..215. + assert!(close(part_box(&s, &[2]), Rect::new(175.0, 95.0, 225.0, 105.0)), "{:?}", part_box(&s, &[2])); + assert!(close(part_box(&s, &[1]), Rect::new(50.0, 50.0, 60.0, 60.0)), "the corner keeps its size: {:?}", part_box(&s, &[1])); + direct(&mut s, (177.0, 100.0), (177.0, 100.0), Mods::default()); + assert_eq!(s.doc().unwrap().selection.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![2] })); + direct(&mut s, (165.0, 100.0), (165.0, 100.0), Mods::default()); + let picked = s.doc().unwrap().selection.symbol_part.clone().map(|p| p.path); + assert_ne!(picked, Some(vec![2]), "where the square is not drawn"); +} + +/// An object that is drawn once in each 9-slice cell it reaches, rather than cut at the guides +/// (here area type), is outlined where its copies are drawn. +#[test] +fn the_outline_of_an_object_drawn_in_each_9_slice_cell_matches_what_is_drawn() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let frame = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()).0; + let area = json!({"width": 80, "height": 10}); + let text = id(&s.execute("text.create", &json!({"x": 60, "y": 95, "area": area, "size": 6, "text": "Hi"})).unwrap()).0; + let ids = [frame, text]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Bar", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), &[1]).unwrap(); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&o); + // The left copy is drawn at 60..75, the center one (placed whole it reaches 0..400) at 75..325 + // and the right one at 325..340. + let want = Rect::new(60.0, 95.0, 340.0, 105.0); + assert!([(b.x0, want.x0), (b.y0, want.y0), (b.x1, want.x1), (b.y1, want.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6), "{b:?}"); +} + +#[test] +fn direct_selection_picks_a_static_instance_whole_as_before() { + let mut s = session(); + let inst = pair(&mut s, "static"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.symbol_part), (vec![inst], None)); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).is_err(), "a static symbol"); + // A dynamic one: Alt picks the instance whole, and a path naming no object is refused. + s.execute("symbol.options", &json!({"name": "Pair", "type": "dynamic"})).unwrap(); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..Mods::default() }); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [2]})).is_err()); + assert!(s.execute("symbol.instance.selectPart", &json!({"path": [1]})).is_err(), "no id"); + // Making the symbol static again lets go of the picked object. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("symbol.options", &json!({"name": "Pair", "type": "static"})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); +} + #[test] fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before() { let mut s = session(); @@ -191,3 +862,201 @@ fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before assert_eq!(back.node(inst).unwrap().extras().symbol_overrides, doc.node(inst).unwrap().extras().symbol_overrides); assert_eq!(back.symbols, doc.symbols); } + +#[test] +fn a_swatch_edit_recolors_a_picked_objects_own_paints() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + let fill = s.doc().unwrap().selected_symbol_part().unwrap().appearance.fill_paint(); + assert_eq!(fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "{fill:?}"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +#[test] +fn a_swatch_or_color_only_a_picked_objects_paint_uses_counts_as_used() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].clone(); + assert!(unused(&mut s).as_array().unwrap().contains(&json!("Brand"))); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert!(!unused(&mut s).as_array().unwrap().contains(&json!("Brand")), "only the object's own paint uses it"); + let colors = s.execute("recolor.colors", &json!({"ids": [inst.0]})).unwrap(); + assert!(colors["colors"].as_array().unwrap().iter().any(|c| c["hex"] == "#ff0000" && c["swatch"] == "Brand"), "{colors}"); + // A swatch edit maps the same paint once. + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); +} + +/// The Eyedropper takes a picked object's paints only from a path, compound path or type. +#[test] +fn the_eyedropper_refuses_a_source_that_is_not_painted_art() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let (a, b) = (square(&mut s, 150.0), square(&mut s, 150.0)); + let group = id(&s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let (fill, stroke, own, undo) = (s.paint.fill.clone(), s.paint.stroke.clone(), node(&s, inst).extras().symbol_overrides.clone(), undo_steps(&s)); + s.eyedropper_sample = None; + for source in [inst, other, group] { + let e = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + } + // A click with the tool on an instance, the picked one included. + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("eyedropper", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 30.0), v).unwrap(); + assert_eq!(node(&s, inst).extras().symbol_overrides, own, "the object keeps its paints"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "the defaults for new art stay"); + assert!(s.eyedropper_sample.is_none(), "nothing was sampled"); + assert_eq!(pixel(&s, 80, 30), RED); +} + +/// A path of a compound path gives the paint of the compound path, which holds it. +#[test] +fn the_eyedropper_samples_a_compound_path_through_its_member() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let (a, b) = (square(&mut s, 120.0), square(&mut s, 170.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [compound.0], "color": "#00ff00"})).unwrap(); + let member = node(&s, compound).children().unwrap()[0].id; + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": member.0})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +/// A Cmd-drag lends the Selection tool, or with Alt Group Selection, which act on the whole instance. +#[test] +fn a_lent_selection_tool_moves_the_instance_of_a_picked_object() { + let cmd = Mods { cmd: true, ..Mods::default() }; + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + direct(&mut s, (80.0, 30.0), (100.0, 40.0), cmd); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); + + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..cmd }); + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "Group Selection selects the instance whole"); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); + // A plain Direct Selection click picks the object again once the tool is back. + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); +} + +/// The commands that change nothing in the document read the instance of a picked object, so the +/// Attributes and CSS Properties panels and the others keep showing it. +#[test] +fn read_only_commands_read_the_instance_of_a_picked_object() { + let mut failures = Vec::new(); + for spec in crate::cmd::command_specs().iter().filter(|c| !c.journal) { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + if let Err(e) = s.execute(spec.id, &json!({})) + && e.to_string().contains("symbol instance") + { + failures.push(format!("{}: {e}", spec.id)); + } + if s.doc().is_ok_and(|st| st.doc != doc) { + failures.push(format!("{}: the document changed", spec.id)); + } + // A command run after one of these is refused as before. + if s.doc().is_ok_and(|st| st.selection.symbol_part.is_some()) && s.execute("object.hide", &json!({})).is_ok() { + failures.push(format!("{}: the refusal stays off afterward", spec.id)); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("css.selection", &json!({})).unwrap(); + s.execute("attributes.info", &json!({})).unwrap(); +} + +/// Export CSS writes a file and changes nothing in the document, so it reads the instance of a +/// picked object as it reads an instance selected whole, and the next edit is refused as before. +#[test] +fn css_export_reads_the_instance_of_a_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let whole = s.execute("css.export", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let picked = s.execute("css.export", &json!({})).unwrap(); + assert_eq!(picked["rules"], 1); + assert_eq!(picked["data"], whole["data"]); + assert!(s.doc().unwrap().doc == doc, "the document changed"); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + assert!(s.execute("object.hide", &json!({})).is_err()); +} + +/// A drag that moved nothing leaves Transform Again repeating the last real transformation. +#[test] +fn a_refused_drag_of_a_picked_object_is_not_kept_for_transform_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(s.doc().unwrap().last_transform.is_none(), "{:?}", s.doc().unwrap().last_transform); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); +} + +/// A bad `target` or `aligned` on a paint command is refused with an object picked too. +#[test] +fn bad_paint_parameters_leave_a_picked_object_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for (cmd, p) in [ + ("paint.setFill", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.setStroke", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.invert", json!({"target": "bogus"})), + ("paint.complement", json!({"target": "bogus"})), + ("paint.setFill", json!({"color": "#ff0000", "aligned": "yes"})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("`target` must") || e.contains("aligned"), "{cmd} {p}: {e}"); + assert_eq!(overrides(&s, inst), 0, "{cmd} {p}"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); +} + +/// An object picked while Replace read its file: the instance selected when Place started is +/// selected again first, so a replay of the journal replaces it. +#[test] +fn a_replace_that_finishes_after_an_object_was_picked_selects_the_instance_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let svg = ""; + let p = json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes()), "replace": true}); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.place_read(read).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); + let last: Vec<_> = s.journal.iter().rev().take(2).rev().map(|(id, _)| id.clone()).collect(); + assert_eq!(last, ["select.set", "file.place"]); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c1c4df67d..fa389583f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -117,6 +117,8 @@ impl Session { // `select_tool` calls its [`Tool::chosen_again`] (the Blend tool finishes its blend). let again = self.lender.as_ref().unwrap_or(&self.tool).id() == id; self.switch_tool(id, view)?; + // `switch_tool` returns early for the tool already active. + self.select_whole_instance(id); if again { let acts = self.with_tool_cx(view, |t, cx| t.chosen_again(cx)); self.apply_actions(acts)?; @@ -147,10 +149,24 @@ impl Session { } if vectorcraft_tools::catalog::is_selection_tool(id) { self.last_selection_tool = Some(self.tool.id()); + self.select_whole_instance(id); } Ok(()) } + /// The selection tools other than Direct Selection pick whole objects: with one of them chosen, + /// an object selected in a symbol instance's art gives way to the instance. The revision + /// changes with it, so panels that cache by revision show the instance. + fn select_whole_instance(&mut self, id: &str) { + if id != "directSelection" + && vectorcraft_tools::catalog::is_selection_tool(id) + && let Some(st) = self.active_mut() + && st.selection.symbol_part.take().is_some() + { + st.revision += 1; + } + } + /// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is, /// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others. /// Cmd+Alt lends Group Selection while Direct Selection is active. Cmd still lends the last @@ -200,6 +216,8 @@ impl Session { // Fails only without a document, and then there is no typing to keep. let _ = crate::cmd::settle_interaction(self); } + // The Selection and Group Selection tools act on the instance whole. + self.select_whole_instance(id); let lent = self.make_tool(id); self.lender = Some(std::mem::replace(&mut self.tool, lent)); PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev } diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index d27a9f2a4..a2ffbbf87 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -41,6 +41,11 @@ //! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]). //! A press on a selected object's centre point (Attributes panel › Show Center) selects the object //! whole, and a drag from it moves the object. +//! A click on an instance of a dynamic symbol selects the object of the symbol's art under the +//! pointer (`symbol.instance.selectPart`), outlined: the solid color Fill, Stroke, Color, Swatches +//! and Eyedropper changes then go to that object for this instance only, and a drag moves nothing. +//! The engine refuses any object but a path, compound path or type. Alt and Shift pick the instance +//! whole, as for other objects. //! //! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors //! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else @@ -50,8 +55,8 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at}; -use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection}; +use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at, symbol_part_at, symbol_part_outline}; +use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection, symbol_overrides}; use vectorcraft_geom::{Anchor, PathData, PathSeg, Point, Rect, Vec2}; use crate::bbox::move_delta; @@ -667,6 +672,12 @@ fn next_group(cx: &ToolContext, ancestry: &[NodeId]) -> Option { .find(|id| !cx.selection.contains(*id)) } +/// Is an object of instance `id`'s art selected ([`vectorcraft_doc::Selection::symbol_part`]), +/// rather than the instance whole? +fn part_picked(cx: &ToolContext, id: NodeId) -> bool { + cx.selection.symbol_part.as_ref().is_some_and(|sp| sp.instance == id) +} + /// The segment of path `id` nearest to `p`, where the hit test found its stroke. fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { let pd = cx.doc.node(id)?.path_data()?; @@ -984,6 +995,11 @@ impl Tool for DirectSelectionTool { let mut out = self.spine_overlays(cx); out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id))); if !self.group { + if let Some(sp) = &cx.selection.symbol_part + && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline(cx.doc, n, &sp.path)) + { + out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false }); + } out.extend(frame_overlays(cx, &self.frame_anchors(cx))); let drag = if let State::Bracket(b) = &self.state { Some(b) } else { None }; out.extend(pathtype::overlays(cx, self.bracket_hover, drag)); @@ -1309,7 +1325,7 @@ impl DirectSelectionTool { if ev.mods.shift { return vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]; } - if cx.selection.partial(id).is_some() || cx.selection.segments.contains_key(&id) { + if cx.selection.partial(id).is_some() || cx.selection.segments.contains_key(&id) || part_picked(cx, id) { return vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; } vec![] @@ -1319,6 +1335,26 @@ impl DirectSelectionTool { h.ancestry.push(part); h.leaf = part; } + // On an instance of a dynamic symbol a click picks the object of its art under the pointer; + // a drag from it moves nothing (`object.transform` refuses while it is selected). + if !ev.mods.alt + && !ev.mods.shift + && let Some(n) = cx.doc.node(h.leaf) + && let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && let Some(sym) = cx.doc.symbols.iter().find(|s| s.name == *symbol && s.dynamic) + && let Some(path) = symbol_part_at(cx.doc, n, p, cx.selection_hit_options()) + { + self.frame.clear(); + // The engine refuses any other object with a status message, and the selection stays, + // so a drag from the press does not move it. + if symbol_overrides::part(&sym.art, &path).is_some_and(symbol_overrides::takes_part_paint) { + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; + } + if cx.selection.symbol_part.as_ref().is_some_and(|sp| sp.instance == h.leaf && sp.path == path) { + return vec![]; + } + return vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": h.leaf.0, "path": path}))]; + } // On a gradient mesh a click picks the point, the mesh line's two ends or the four points // around the patch under it (Shift toggles them), and selects the mesh; a drag moves the // picked points. @@ -1340,7 +1376,7 @@ impl DirectSelectionTool { // A segment pick keeps its endpoints available for movement without selecting // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks // the whole path (and Alt-drag copies it), as with Group Selection. - let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); + let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none() && !part_picked(cx, h.leaf); if !ev.mods.alt && matches!(h.kind, HitKind::Stroke | HitKind::Outline) && let Some(s) = segment_at(cx, h.leaf, p) @@ -1376,7 +1412,7 @@ impl DirectSelectionTool { return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; } self.frame.clear(); - if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { + if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() || part_picked(cx, h.leaf) { return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; } vec![] diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index a8507e693..42396e241 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -2147,3 +2147,165 @@ fn selected_path_handle_over_background_rect_is_draggable() { let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); assert_eq!(acts, vec![Action::Begin("Reshape".into())]); } + +/// On an instance of a dynamic symbol, a Direct Selection click picks the object of the symbol's +/// art under the pointer, which is then outlined; Alt and Shift, a static symbol and Group +/// Selection pick the instance whole, as before. +#[test] +fn a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |id, x: f64| { + Arc::new(Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default_art())) + }; + let art = Node::group(NodeId(0), vec![square(0, 0.0), square(0, 20.0)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Pair".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)) * vectorcraft_geom::Affine::scale(2.0); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Pair".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |d: &vectorcraft_doc::Document, sel: &Selection, group: bool, x: f64, y: f64, m: Mods| { + DirectSelectionTool::new(group).pointer(&cx(d, sel, &p), &PointerEvent::new(PointerKind::Down, x, y).with_mods(m)) + }; + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + // The second square is drawn at 340..360. + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods::default()), part(json!([1]))); + assert_eq!(down(&d, &none, false, 310.0, 310.0, Mods::default()), part(json!([0]))); + let whole = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods { alt: true, ..Mods::default() }), whole, "Alt picks the instance"); + let shift = down(&d, &none, false, 350.0, 310.0, Mods { shift: true, ..Mods::default() }); + assert_eq!(shift, vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]); + assert_eq!(down(&d, &none, true, 350.0, 310.0, Mods::default()), whole, "Group Selection picks the instance"); + // The part selected: a click on it again keeps it, and it is outlined. + let mut sel = Selection::default(); + sel.set_symbol_part(id, vec![1]); + assert_eq!(down(&d, &sel, false, 350.0, 310.0, Mods::default()), vec![]); + let outline = DirectSelectionTool::new(false).overlays(&cx(&d, &sel, &p)); + let drawn = + outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); + assert_eq!(drawn, Some(Rect::new(340.0, 300.0, 360.0, 320.0))); + // With the part selected, an Alt-click on the instance, or a press on its center point, picks + // the instance whole again. + assert_eq!(down(&d, &sel, false, 350.0, 310.0, Mods { alt: true, ..Mods::default() }), whole, "Alt with the part selected"); + d.node_mut(id).unwrap().edit_attrs(|a| a.show_center = Some(true)); + assert_eq!(down(&d, &sel, false, 330.0, 310.0, Mods::default()), whole, "the center point with the part selected"); + // A static symbol: the instance is picked whole. + d.symbols[0].dynamic = false; + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods::default()), whole); +} + +/// In a dynamic symbol's art, a Direct Selection press passes over a locked object: it picks the +/// unlocked object beneath it, and where only a locked object is under the pointer it picks the +/// instance whole. +#[test] +fn a_press_on_a_dynamic_instance_passes_over_locked_objects() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |x: f64, locked: bool| { + let mut n = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default_art()); + n.locked = locked; + Arc::new(n) + }; + // The unlocked square at 0..10 has a locked square over it; the square at 20..30 is locked. + let art = Node::group(NodeId(0), vec![square(0.0, false), square(0.0, true), square(20.0, true)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Locked".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Locked".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(down(305.0, 305.0), vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": [0]}))]); + assert_eq!(down(325.0, 305.0), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + // A locked clipping path still clips, but a press on its outline picks the art it clips. + let mut clip = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + clip.locked = true; + let art = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(-10.0, -10.0, 30.0, 30.0)), Appearance::default_art()); + let masked = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + let mut d = d.clone(); + d.symbols[0].art = Arc::new(masked); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(down(319.0, 310.0), vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": [1]}))]); +} + +/// In a dynamic symbol's art, a Direct Selection press passes over a guide path while guides are +/// hidden or locked and picks the square beneath it; with guides shown and unlocked it picks the +/// guide path. The same goes for a press on the edge of a guide used as a clipping path. +#[test] +fn a_press_on_a_dynamic_instance_passes_over_guides_while_they_cannot_be_picked() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |r: Rect, is_guide: bool| { + let mut n = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); + let NodeKind::Path { guide, .. } = &mut n.kind else { panic!("Node::path makes a path") }; + *guide = is_guide; + Arc::new(n) + }; + let small = Rect::new(0.0, 0.0, 10.0, 10.0); + let art = Node::group(NodeId(0), vec![square(small, false), square(small, true)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Guided".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Guided".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + let down = |d: &vectorcraft_doc::Document, guides: bool, x: f64, y: f64| { + let c = ToolContext { guides, ..cx(d, &none, &p) }; + DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)) + }; + assert_eq!(down(&d, false, 305.0, 305.0), part(json!([0])), "guides off: the square beneath"); + assert_eq!(down(&d, true, 305.0, 305.0), part(json!([1])), "guides on: the guide"); + // A guide gives a clip group no clip region, so nothing in the group is hit away from its edge, + // as outside symbols. With guides off a press on its edge picks the instance whole. + let clip = Rect::new(0.0, 0.0, 20.0, 20.0); + let masked = + Node::new(NodeId(0), NodeKind::Group { children: vec![square(clip, true), square(Rect::new(-10.0, -10.0, 30.0, 30.0), false)], clip: true }); + d.symbols[0].art = Arc::new(masked); + assert_eq!( + down(&d, false, 319.0, 310.0), + vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))], + "guides off: the clipping path's edge" + ); + assert_eq!(down(&d, true, 319.0, 310.0), part(json!([0])), "guides on: the clipping path's edge"); +} + +/// In a dynamic symbol's art, a Direct Selection press on the outline of a clip group's clipping +/// path picks the clipping path, also where the art it clips covers the path's edge; inside the +/// mask away from the edge the art is picked. +#[test] +fn a_press_on_a_clipping_path_in_a_dynamic_instance_picks_it() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let rect = |r: Rect| Arc::new(Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())); + let art = Node::new( + NodeId(0), + NodeKind::Group { children: vec![rect(Rect::new(0.0, 0.0, 20.0, 20.0)), rect(Rect::new(-10.0, -10.0, 30.0, 30.0))], clip: true }, + ); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Masked".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Masked".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + // The clipping path's right edge is at x = 320; the art covers it. + assert_eq!(down(321.0, 310.0), part(json!([0])), "just outside the mask, on its edge"); + assert_eq!(down(319.0, 310.0), part(json!([0])), "just inside the mask, on its edge"); + assert_eq!(down(310.0, 310.0), part(json!([1])), "inside the mask, the art"); + // Clipped art from 10 to 30 picked: its outline is cut to the clipping path's bounds (0 to 20), + // where it is picked. + let art = Node::new( + NodeId(0), + NodeKind::Group { children: vec![rect(Rect::new(0.0, 0.0, 20.0, 20.0)), rect(Rect::new(10.0, 10.0, 30.0, 30.0))], clip: true }, + ); + d.symbols[0].art = Arc::new(art); + let mut sel = Selection::default(); + sel.set_symbol_part(id, vec![1]); + let outline = DirectSelectionTool::new(false).overlays(&cx(&d, &sel, &p)); + let drawn = + outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); + assert_eq!(drawn, Some(Rect::new(310.0, 310.0, 320.0, 320.0))); +} diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 7a9b59e2d..d984504e1 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -444,6 +444,11 @@ impl GradientTool { let id = paint_owner(cx.doc, h.leaf); let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] }; let mut params = Self::geom_params(cx, p, p + v); + // Without `ids`, the engine refuses the gradient while an object of a dynamic symbol + // instance's art is selected. + if cx.selection.symbol_part.is_some() { + return vec![Action::Exec("paint.setGradientGeom".into(), params)]; + } params["ids"] = json!([id.0]); // With `ids` the engine edits the topmost fill or stroke unless an item is named. if let Some(i) = cx.doc.node(id).and_then(|n| n.appearance.item_of_kind(cx.appearance_item, cx.fill_active)) { diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs index 2542ec7c5..c6582152b 100644 --- a/crates/tools/src/xform/gradient/tests.rs +++ b/crates/tools/src/xform/gradient/tests.rs @@ -268,6 +268,31 @@ fn a_click_inside_the_art_applies_the_gradient_from_there() { assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty()); } +/// With an object of a dynamic symbol instance selected with Direct Selection, a click on the +/// instance sends the gradient without `ids`, which the engine refuses. +#[test] +fn a_click_on_a_dynamic_instance_with_a_picked_object_names_no_object() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, Symbol}; + let square = + Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let art = Node::group(NodeId(0), vec![Arc::new(square)]); + let (mut d, _) = doc_with_rect(); + d.symbols.push(Symbol { dynamic: true, name: "One".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "One".into(), xf: Affine::translate((300.0, 300.0)) })).unwrap(); + let mut s = Selection::default(); + s.set_symbol_part(id, vec![0]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(305.0, 305.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(305.0, 305.0))); + let (c, v) = exec(&a[0]); + assert_eq!(c, "paint.setGradientGeom"); + assert!(v.get("ids").is_none() && v.get("item").is_none(), "{v}"); +} + #[test] fn double_click_on_a_stop_opens_its_popover() { let (d, s) = graded(two()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 981975808..6cc8fb578 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6229,6 +6229,7 @@ breakText Glyphs Глифи Reset Symbol Instance Нулиране на екземпляра на символ @msg type must be static or dynamic, not {v} type трябва да е static или dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` е статичен символ: първо задайте типа на символа на „Динамичен“ +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change На път, съставен път или текст, избран в екземпляр на символ, могат да се променят само плътните цветове на запълването и контура @msg path must be a list of child indices path трябва да е списък с индекси на дъщерни обекти @msg no object at path {shown} in the symbol's art в графиката на символа няма обект на пътя {shown} @msg give fill, stroke, opacity and/or blend задайте fill, stroke, opacity и/или blend diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4fd69733d..39a73d96a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6278,6 +6278,7 @@ shape Open Otvoren Reset Symbol Instance Vrati instancu simbola na početno @msg type must be static or dynamic, not {v} type mora biti static ili dynamic, a ne {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statički simbol: prvo postavite vrstu simbola na Dinamički +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Putanji, složenoj putanji ili tekstu odabranom u instanci simbola mogu se mijenjati samo pune boje ispune i poteza @msg path must be a list of child indices path mora biti lista indeksa podređenih objekata @msg no object at path {shown} in the symbol's art nema objekta na putanji {shown} u grafici simbola @msg give fill, stroke, opacity and/or blend navedite fill, stroke, opacity i/ili blend diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f2a467fc5..3f61c46c3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6389,6 +6389,7 @@ breakText Glyphs Glifs Reset Symbol Instance Restableix la instància de símbol @msg type must be static or dynamic, not {v} type ha de ser static o dynamic, no {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` és un símbol estàtic: primer establiu el tipus de símbol a Dinàmic +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change D'un traçat, un traçat compost o un text seleccionat en una instància de símbol només es pot canviar el color sòlid de l'emplenament i el traç @msg path must be a list of child indices path ha de ser una llista d’índexs de fills @msg no object at path {shown} in the symbol's art no hi ha cap objecte al camí {shown} de l’art del símbol @msg give fill, stroke, opacity and/or blend indiqueu fill, stroke, opacity i/o blend diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 15cd33ed0..9291741e3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6406,6 +6406,7 @@ breakText Glyphs Glyfy Reset Symbol Instance Obnovit instanci symbolu @msg type must be static or dynamic, not {v} type musí být static nebo dynamic, ne {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statický symbol: nejprve nastavte jeho typ symbolu na Dynamický +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change U cesty, složené cesty nebo textu vybraného v instanci symbolu lze měnit jen plnou barvu výplně a tahu @msg path must be a list of child indices path musí být seznam indexů potomků @msg no object at path {shown} in the symbol's art v kresbě symbolu není na cestě {shown} žádný objekt @msg give fill, stroke, opacity and/or blend zadejte fill, stroke, opacity a/nebo blend diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 978a0971a..5f5d188ed 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6243,6 +6243,7 @@ breakText Glyphs Glyffer Reset Symbol Instance Nulstil symbolforekomst @msg type must be static or dynamic, not {v} type skal være static eller dynamic, ikke {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` er et statisk symbol: indstil først symboltypen til Dynamisk +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change For en kurve, sammensat kurve eller tekst, der er markeret i en symbolforekomst, kan kun ensfarvet fyld og streg ændres @msg path must be a list of child indices path skal være en liste over underordnede indekser @msg no object at path {shown} in the symbol's art intet objekt ved stien {shown} i symbolets grafik @msg give fill, stroke, opacity and/or blend angiv fill, stroke, opacity og/eller blend diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e61e26c81..e42790f99 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5992,6 +5992,7 @@ breakText Glyphs Glyphen Reset Symbol Instance Symbolinstanz zurücksetzen @msg type must be static or dynamic, not {v} type muss static oder dynamic sein, nicht {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` ist ein statisches Symbol: Stellen Sie zuerst den Symboltyp auf „Dynamisch“ ein +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Bei einem in einer Symbolinstanz ausgewählten Pfad, zusammengesetzten Pfad oder Text lässt sich nur die Vollfarbe von Fläche und Kontur ändern @msg path must be a list of child indices path muss eine Liste von Kindindizes sein @msg no object at path {shown} in the symbol's art kein Objekt am Pfad {shown} in der Grafik des Symbols @msg give fill, stroke, opacity and/or blend geben Sie fill, stroke, opacity und/oder blend an diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bad5543ba..ddc22b961 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6358,6 +6358,7 @@ breakText Glyphs Γλύφοι Reset Symbol Instance Επαναφορά στιγμιότυπου συμβόλου @msg type must be static or dynamic, not {v} το type πρέπει να είναι static ή dynamic, όχι {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first το `{name}` είναι στατικό σύμβολο: ορίστε πρώτα τον τύπο συμβόλου σε Δυναμικό +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Σε διαδρομή, σύνθετη διαδρομή ή κείμενο που έχει επιλεγεί σε στιγμιότυπο συμβόλου αλλάζει μόνο το συμπαγές χρώμα του γεμίσματος και της πινελιάς @msg path must be a list of child indices το path πρέπει να είναι λίστα δεικτών θυγατρικών αντικειμένων @msg no object at path {shown} in the symbol's art δεν υπάρχει αντικείμενο στη διαδρομή {shown} στο γραφικό του συμβόλου @msg give fill, stroke, opacity and/or blend δώστε fill, stroke, opacity ή/και blend diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 651886e05..531aaf18e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6348,6 +6348,7 @@ breakText Glyphs Glifos Reset Symbol Instance Restablecer instancia de símbolo @msg type must be static or dynamic, not {v} type debe ser static o dynamic, no {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` es un símbolo estático: primero establezca su tipo de símbolo en Dinámico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change De un trazado, trazado compuesto o texto seleccionado en una instancia de símbolo solo se puede cambiar el color sólido del relleno y del trazo @msg path must be a list of child indices path debe ser una lista de índices de hijos @msg no object at path {shown} in the symbol's art no hay ningún objeto en la ruta {shown} del arte del símbolo @msg give fill, stroke, opacity and/or blend indique fill, stroke, opacity y/o blend diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f79d04cea..6ae295376 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6436,6 +6436,7 @@ breakText Glyphs Merkit Reset Symbol Instance Palauta symboli-ilmentymä @msg type must be static or dynamic, not {v} type-arvon on oltava static tai dynamic, ei {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` on staattinen symboli: aseta ensin sen symbolin tyypiksi Dynaaminen +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Symboli-ilmentymästä valitusta polusta, yhdistelmäpolusta tai tekstistä voi muuttaa vain täytön ja viivan yhtenäistä väriä @msg path must be a list of child indices path-arvon on oltava luettelo alikohteiden indeksejä @msg no object at path {shown} in the symbol's art symbolin kuvituksessa ei ole objektia polussa {shown} @msg give fill, stroke, opacity and/or blend anna fill, stroke, opacity ja/tai blend diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b9c9c1165..fdad1035b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6401,6 +6401,7 @@ breakText Glyphs Glyphes Reset Symbol Instance Réinitialiser l'instance de symbole @msg type must be static or dynamic, not {v} type doit être static ou dynamic, et non {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` est un symbole statique : définissez d'abord son type de symbole sur Dynamique +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Seules les couleurs unies du fond et du contour d'un tracé, d'un tracé transparent ou d'un texte sélectionné dans une instance de symbole peuvent changer @msg path must be a list of child indices path doit être une liste d'indices d'enfants @msg no object at path {shown} in the symbol's art aucun objet au chemin {shown} dans l'illustration du symbole @msg give fill, stroke, opacity and/or blend indiquez fill, stroke, opacity et/ou blend diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9f6c6807f..efff1b054 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5473,6 +5473,7 @@ @msg `{name}` is a built-in workspace; choose another name `{name}` एक अंतर्निहित वर्कस्पेस है; एक और नाम चुनें @msg `{name}` is a process colour: tints apply to global and spot colours `{name}` एक प्रक्रिया रंग है: टिंट वैश्विक और स्पॉट रंगों पर लागू होते हैं @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` एक स्थिर सिंबल है: अपने सिंबल प्रकार को पहले गतिशील करने के लिए सेट करें +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change सिंबल इंस्टेंस में चुने गए पाथ, कंपाउंड पाथ या टाइप का केवल ठोस रंग वाला फ़िल और स्ट्रोक बदला जा सकता है @msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` `{base}` का एक टिंट है: `{base}` संपादित करें (केवल एक टिंट swatch केवल एक नया नाम लेता है) @msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` वर्किंग स्पेस है: पहले edit.colorSettings से दूसरा चुनें @msg `{name}` is built in and stays `{name}` बनाया गया है और रहता है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2de1183cc..3153fae2a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6390,6 +6390,7 @@ breakText Glyphs Glifák Reset Symbol Instance Szimbólumpéldány visszaállítása @msg type must be static or dynamic, not {v} a type értéke static vagy dynamic lehet, nem {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statikus szimbólum: előbb állítsa a szimbólum típusát Dinamikusra +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Egy szimbólumpéldányban kijelölt útvonalnak, összetett útvonalnak vagy szövegnek csak a kitöltés és a körvonal egyszínű színe módosítható @msg path must be a list of child indices a path gyermekindexek listája kell legyen @msg no object at path {shown} in the symbol's art a szimbólum grafikájában nincs objektum a(z) {shown} útvonalon @msg give fill, stroke, opacity and/or blend adja meg a fill, stroke, opacity és/vagy blend értékét diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bffe798c5..9ba667353 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6243,6 +6243,7 @@ breakText Glyphs Glif Reset Symbol Instance Atur ulang instans simbol @msg type must be static or dynamic, not {v} type harus static atau dynamic, bukan {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` adalah simbol statis: setel dulu jenis simbolnya ke Dinamis +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Hanya warna solid isian dan goresan jalur, jalur majemuk, atau teks yang dipilih dalam instans simbol yang dapat diubah @msg path must be a list of child indices path harus berupa daftar indeks anak @msg no object at path {shown} in the symbol's art tidak ada objek di jalur {shown} dalam karya seni simbol @msg give fill, stroke, opacity and/or blend berikan fill, stroke, opacity dan/atau blend diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 49f8130ae..bfef006c9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6026,6 +6026,7 @@ breakText Glyphs Glifi Reset Symbol Instance Reimposta istanza di simbolo @msg type must be static or dynamic, not {v} type deve essere static o dynamic, non {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` è un simbolo statico: imposta prima il tipo di simbolo su Dinamico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Di un tracciato, un tracciato composto o un testo selezionato in un'istanza di simbolo si può modificare solo il colore pieno di riempimento e traccia @msg path must be a list of child indices path deve essere un elenco di indici figlio @msg no object at path {shown} in the symbol's art nessun oggetto al percorso {shown} nella grafica del simbolo @msg give fill, stroke, opacity and/or blend specifica fill, stroke, opacity e/o blend diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 94b2e408a..07ce883e6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6091,6 +6091,7 @@ breakText Glyphs 字形 Reset Symbol Instance シンボルインスタンスをリセット @msg type must be static or dynamic, not {v} type は static または dynamic にしてください({v} は無効です) @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` はスタティックシンボルです。先にシンボルの種類をダイナミックに設定してください +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change シンボルインスタンス内で選択したパス、複合パス、テキストは、塗りと線の単色のみ変更できます @msg path must be a list of child indices path は子のインデックスのリストにしてください @msg no object at path {shown} in the symbol's art シンボルのアートのパス {shown} にオブジェクトがありません @msg give fill, stroke, opacity and/or blend fill、stroke、opacity、blend のいずれかを指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 77b99070f..5d049fb9e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6403,6 +6403,7 @@ breakText Glyphs გლიფები Reset Symbol Instance სიმბოლოს ეგზემპლარის ჩამოყრა @msg type must be static or dynamic, not {v} type უნდა იყოს static ან dynamic და არა {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` სტატიკური სიმბოლოა: ჯერ სიმბოლოს ტიპად დააყენეთ „დინამიკური“ +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change სიმბოლოს ეგზემპლარში მონიშნულ ტრაექტორიას, შედგენილ ტრაექტორიას ან ტექსტს მხოლოდ შევსებისა და კონტურის მთლიანი ფერი შეეცვლება @msg path must be a list of child indices path უნდა იყოს შვილობილი ინდექსების სია @msg no object at path {shown} in the symbol's art სიმბოლოს გრაფიკაში გზაზე {shown} ობიექტი არ არის @msg give fill, stroke, opacity and/or blend მიუთითეთ fill, stroke, opacity და/ან blend diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca570d3cc..5cf97c257 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5987,6 +5987,7 @@ breakText Glyphs 글리프 Reset Symbol Instance 심볼 인스턴스 재설정 @msg type must be static or dynamic, not {v} type은 static 또는 dynamic이어야 합니다({v}는 안 됨) @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}`은(는) 정적 심볼입니다. 먼저 심볼 유형을 동적으로 설정하세요 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 심볼 인스턴스에서 선택한 패스, 컴파운드 패스 또는 문자는 칠과 선의 단색만 변경할 수 있습니다 @msg path must be a list of child indices path는 자식 인덱스 목록이어야 합니다 @msg no object at path {shown} in the symbol's art 심볼 아트의 경로 {shown}에 개체가 없습니다 @msg give fill, stroke, opacity and/or blend fill, stroke, opacity 및/또는 blend를 지정하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bfbdef06c..5a16c754c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6289,6 +6289,7 @@ breakText Glyphs Glyphs Reset Symbol Instance Symboolinstantie opnieuw instellen @msg type must be static or dynamic, not {v} type moet static of dynamic zijn, niet {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` is een statisch symbool: stel eerst het symbooltype in op Dynamisch +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Van een pad, samengesteld pad of tekst die in een symboolinstantie is geselecteerd, kan alleen de effen kleur van vulling en lijn veranderen @msg path must be a list of child indices path moet een lijst met onderliggende indexen zijn @msg no object at path {shown} in the symbol's art geen object op pad {shown} in de illustratie van het symbool @msg give fill, stroke, opacity and/or blend geef fill, stroke, opacity en/of blend op diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 668a55ed7..e218ac53c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6261,6 +6261,7 @@ breakText Glyphs Glify Reset Symbol Instance Resetuj wystąpienie symbolu @msg type must be static or dynamic, not {v} type musi mieć wartość static lub dynamic, a nie {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` jest symbolem statycznym: najpierw ustaw jego typ symbolu na Dynamiczny +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change W ścieżce, ścieżce złożonej lub tekście zaznaczonym w wystąpieniu symbolu można zmienić tylko jednolity kolor wypełnienia i obrysu @msg path must be a list of child indices path musi być listą indeksów elementów podrzędnych @msg no object at path {shown} in the symbol's art brak obiektu w ścieżce {shown} w grafice symbolu @msg give fill, stroke, opacity and/or blend podaj fill, stroke, opacity i/lub blend diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 73cf2c8ee..af16149fe 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6324,6 +6324,7 @@ breakText Glyphs Glifos Reset Symbol Instance Redefinir instância de símbolo @msg type must be static or dynamic, not {v} type deve ser static ou dynamic, não {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` é um símbolo estático: defina primeiro o tipo de símbolo como Dinâmico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change De um caminho, caminho composto ou texto selecionado em uma instância de símbolo só é possível alterar a cor sólida do preenchimento e do traçado @msg path must be a list of child indices path deve ser uma lista de índices de filhos @msg no object at path {shown} in the symbol's art nenhum objeto no caminho {shown} na arte do símbolo @msg give fill, stroke, opacity and/or blend informe fill, stroke, opacity e/ou blend diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9b462f1b9..2ff6c5873 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6383,6 +6383,7 @@ breakText Glyphs Глифы Reset Symbol Instance Сбросить экземпляр символа @msg type must be static or dynamic, not {v} type должен быть static или dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статический символ: сначала задайте для него тип символа «Динамический» +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change У контура, составного контура или текста, выделенного в экземпляре символа, можно изменить только сплошной цвет заливки и обводки @msg path must be a list of child indices path должен быть списком индексов дочерних объектов @msg no object at path {shown} in the symbol's art в графике символа нет объекта по пути {shown} @msg give fill, stroke, opacity and/or blend укажите fill, stroke, opacity и/или blend diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 44dfd2563..ea0550be3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6458,6 +6458,7 @@ breakText Glyphs Glyfer Reset Symbol Instance Återställ symbolinstans @msg type must be static or dynamic, not {v} type måste vara static eller dynamic, inte {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` är en statisk symbol: ställ först in symboltypen på Dynamisk +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change För en bana, sammansatt bana eller text som är markerad i en symbolinstans kan bara enfärgad fyllning och linje ändras @msg path must be a list of child indices path måste vara en lista med underordnade index @msg no object at path {shown} in the symbol's art inget objekt vid sökvägen {shown} i symbolens bild @msg give fill, stroke, opacity and/or blend ange fill, stroke, opacity och/eller blend diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6ce1191c8..cf5eea934 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6363,6 +6363,7 @@ breakText Glyphs Glifler Reset Symbol Instance Sembol Örneğini Sıfırla @msg type must be static or dynamic, not {v} type static veya dynamic olmalıdır, {v} değil @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statik bir semboldür: önce Sembol Türünü Dinamik olarak ayarlayın +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Bir sembol örneğinde seçilen yolun, bileşik yolun veya yazının yalnızca dolgu ve konturunun düz rengi değiştirilebilir @msg path must be a list of child indices path alt öğe dizinlerinden oluşan bir liste olmalıdır @msg no object at path {shown} in the symbol's art sembolün çiziminde {shown} yolunda nesne yok @msg give fill, stroke, opacity and/or blend fill, stroke, opacity ve/veya blend verin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 81d168096..b8d3c44c0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6006,6 +6006,7 @@ breakText Glyphs Гліфи Reset Symbol Instance Скинути екземпляр символу @msg type must be static or dynamic, not {v} type має бути static або dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статичний символ: спершу встановіть для нього тип символу «Динамічний» +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change У контуру, складеного контуру або тексту, виділеного в екземплярі символу, можна змінити лише суцільний колір заливки й обведення @msg path must be a list of child indices path має бути списком індексів дочірніх об’єктів @msg no object at path {shown} in the symbol's art у графіці символу немає об’єкта за шляхом {shown} @msg give fill, stroke, opacity and/or blend вкажіть fill, stroke, opacity та/або blend diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3b1169439..7a736a1b4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6007,6 +6007,7 @@ breakText Glyphs Glyph Reset Symbol Instance Đặt lại đối tượng ký hiệu @msg type must be static or dynamic, not {v} type phải là static hoặc dynamic, không phải {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` là ký hiệu tĩnh: trước tiên hãy đặt Kiểu ký hiệu thành Động +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Chỉ có thể thay đổi màu đặc của màu tô và nét của đường dẫn, đường dẫn phức hoặc chữ được chọn trong một đối tượng ký hiệu @msg path must be a list of child indices path phải là danh sách chỉ số con @msg no object at path {shown} in the symbol's art không có đối tượng tại đường dẫn {shown} trong hình của ký hiệu @msg give fill, stroke, opacity and/or blend hãy cung cấp fill, stroke, opacity và/hoặc blend diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9fa2f182..347f1a47a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6247,6 +6247,7 @@ breakText Glyphs 字形 Reset Symbol Instance 重置符号实例 @msg type must be static or dynamic, not {v} type 必须是 static 或 dynamic,而不是 {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是静态符号:请先将其符号类型设为动态 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 在符号实例中选中的路径、复合路径或文字只能更改填色和描边的纯色 @msg path must be a list of child indices path 必须是子项索引列表 @msg no object at path {shown} in the symbol's art 符号图稿中路径 {shown} 处没有对象 @msg give fill, stroke, opacity and/or blend 请提供 fill、stroke、opacity 和/或 blend diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6a34a843a..663a94c06 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6044,6 +6044,7 @@ breakText Glyphs 字符 Reset Symbol Instance 重設符號實體 @msg type must be static or dynamic, not {v} type 必須是 static 或 dynamic,而不是 {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是靜態符號:請先將其符號類型設為動態 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 在符號實體中選取的路徑、複合路徑或文字只能變更填色和筆畫的純色 @msg path must be a list of child indices path 必須是子項索引清單 @msg no object at path {shown} in the symbol's art 符號圖稿中路徑 {shown} 處沒有物件 @msg give fill, stroke, opacity and/or blend 請提供 fill、stroke、opacity 和/或 blend diff --git a/docs/gaps.md b/docs/gaps.md index a73d82c73..8369b2573 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -624,9 +624,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall as a `.vectorcraft` library into the user library folder (listed under User Defined) or a file (`brush.libraries`, `brush.addFromLibrary`, `brush.saveLibrary`, `brush.loadLibrary`). - **Missing:** the Art brush's Overlap; more built-in brush, symbol and graphic-style libraries (original, - generated in code, never Adobe's); dynamic symbols' on-canvas editing (Direct Selection of an object inside a - dynamic instance, with the Fill/Stroke controls and Appearance panel then setting its change) and their `.ai` - read and write; Global Edit of groups holding type or + generated in code, never Adobe's); dynamic symbols' on-canvas editing beyond a solid fill and stroke on a path, + compound path or type object inside a dynamic instance (Direct Selection selects it, and the Fill/Stroke controls set + its change) and their `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). - **Estimate:** 14–24 h. diff --git a/docs/mcp.md b/docs/mcp.md index 284a5e887..3e43b8e60 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1790,7 +1790,35 @@ already walks Image Trace. Short agent entry points: root; `[1, 0]` is the first object of the art's second member when that member is a group). `fill` and `stroke` take a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`), `opacity` is 0..100 and `blend` a blend mode name; `null` gives one back to the symbol. It is refused for a static symbol and for - a path that names no object. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties + a path that names no object. On the canvas (VectorCraft's own rule), a Direct Selection click on an instance of a + dynamic symbol selects the path, compound path or type object of its art under the pointer and outlines it + (`symbol.instance.selectPart {id, path}`; Alt or Shift selects the instance whole). While it is selected, + `paint.setFill` and `paint.setStroke` with a solid color or none, `paint.invert` and `paint.complement` on a solid + color, all without `ids`, `id` or `item`, and the Eyedropper's `appearance.copyFrom` with a solid fill and stroke + change that object's fill or stroke for this instance only, each as one undo step. A painted stroke on type without a + weight gets 1 pt. A gradient or pattern for the object is refused with a status message, and so are `paint.invert` and + `paint.complement` on type whose characters differ in color, Eyedropper options that pick up neither color, an + Eyedropper source that is not a path, compound path or type (a symbol instance or a group, for which the paint defaults + stay as they are too), and a click + or `symbol.instance.selectPart` on any other object of the art (a group, image, blend or nested instance among them). + The commands that change the selected objects without `ids` are refused the same way and change nothing, among them + opacity and blending mode, moving and transforming, `path.moveAnchors`, `file.place` with `replace`, Puppet Warp, Delete + and Cut, Group, Hide, Lock, Arrange, Expand, + Recolor Artwork, plug-in filters, effects, the gradient commands, `gradient.selectStop`, the Appearance panel's rows and + `symbol.instance.setAppearance`. A Cmd-drag that lends the Selection or Group Selection tool selects the instance whole + first and moves it. `swatch.unused` and `recolor.colors` count the fills and strokes an instance keeps for objects of its + art. Commands that change nothing in the document, such as `attributes.info`, `css.selection`, `css.export`, + `effect.list` and `object.expand.info`, read the instance. These commands do not check for a selected object and use the instance: + `edit.copy` copies the instance whole; `document.exportSelection`, `document.export` with `selectionOnly` and + selection-mode `document.exportForScreens` export it whole; `artboard.fitToSelection` fits the artboard to the whole instance; + `object.liquify` without `ids` takes the instance as its target and, since it reshapes paths only, leaves it as it is + and lists it in `skipped` when the brush reaches it; `object.isolate` without `id` isolates the instance and clears the + selection; `library.add` without `ids` adds a copy of the whole instance as a `graphic`, and its fill and stroke kinds + read the instance rather than the selected object; `select.save` saves the instance, and `select.recall` then selects it + whole. Direct Selection passes over hidden, locked and template objects of the art, and + `symbol.instance.selectPart` refuses a path to one or to an object inside one, and an instance that is hidden or + locked or sits in a hidden or locked group or layer. Choosing another selection tool selects the instance whole again. + `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c388ee2fd..49b266520 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -721,6 +721,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a ``, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. - Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`. + - **Dynamic symbol instances on the canvas** ([Symbols, archived copy](https://web.archive.org/web/20240206075757/https://helpx.adobe.com/illustrator/using/symbols.html)): each instance of a dynamic symbol can have an appearance of its own while it shares the symbol's shape. The rest of this entry is VectorCraft's own rules. A Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of the symbol's art under the pointer and outlines it (`symbol.instance.selectPart`); the Swatches and Color panels, the Control bar, the Eyedropper and the other fill and stroke controls then change that object's solid color fill or stroke for this instance only, each as one undo step, and the symbol's art and its other instances stay as they were. Alt or Shift picks the instance whole. A gradient or pattern for the object, an Eyedropper source that is not a path, compound path or type, a click on any other object of the art (a group, image, blend or nested instance) and the commands that change the selected objects (opacity, moving, Delete, Group, Hide, Lock, Arrange, Expand, the gradient commands, the Appearance panel's rows and others) are refused with a status message. Copy, Export Selection, Fit to Selection, Liquify, Isolate, Add to Library and Save Selection use the whole instance (`docs/mcp.md`), and another selection tool selects the instance whole again. Tests: `crates/engine/src/tests/dynamic_symbols.rs`, `direct::tests::a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer`. - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. @@ -1754,7 +1755,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b7b2250c5..fca9b2354 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -385,7 +385,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | | Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | -| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; on-canvas Direct Selection of a part inside an instance (with the Fill/Stroke controls then setting its change) remains | +| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains |