From b1f52d1369eca4f6f03d96d43fed9642fa2a4174 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:23:46 +0700 Subject: [PATCH 01/49] Select Text by Font: limit it to artboards or layers (#5529) Squashed from #5578 onto main. --- crates/engine/src/cmd/fonts.rs | 149 ++++++++++++++++++++++++++- crates/engine/tests/command_sweep.rs | 4 + crates/ui-egui/src/find_font.rs | 104 +++++++++++++++++-- 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/state.rs | 4 + docs/roadmap.md | 1 + docs/target-app-parity.md | 2 + docs/type-parity.md | 3 +- 33 files changed, 280 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index f648a00a3..ba91f87fa 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -10,8 +10,9 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; use vectorcraft_geom::Rect; +use super::select::on_artboard; use super::typecmd::refresh_bounds; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param}; pub fn specs() -> Vec { vec![ @@ -38,7 +39,7 @@ pub fn specs() -> Vec { "Select Text by Font", [], None, - "{family, style?} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}", + "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", has_doc, select_font ), @@ -713,6 +714,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { let style = str_param(p, "style").map(str::to_string); let ids = scope(s, false, None)?; let d = &s.doc()?.doc; + let within = FontScope::read(d, p)?; // Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group // or layer, or on a locked artboard. let editable = d.editable(); @@ -758,11 +760,65 @@ fn select_font(s: &mut Session, p: &Value) -> Result { }); } drop(editable); + let symbols = d.symbol_boxes(); + hits.retain(|id| within.holds(d, &symbols, *id)); + drop(symbols); let n = hits.len(); s.select(|_, sel| sel.set(hits))?; Ok(json!({ "count": n })) } +/// Where Select Text by Font looks: its `scope`. +enum FontScope { + Document, + /// The artboards' rectangles. + Artboards(Vec), + /// The layers and sublayers, with what they hold. + Layers(BTreeSet), +} + +impl FontScope { + fn read(d: &Document, p: &Value) -> Result { + const C: &str = "select.font"; + let expected = || bad(C, "`scope` must be \"document\", {artboards: [index, …]} or {layers: [id, …]}"); + let scope = match p.get("scope") { + None | Some(Value::Null) => return Ok(Self::Document), + Some(Value::String(v)) if v == "document" => return Ok(Self::Document), + Some(Value::Object(m)) if m.len() == 1 => m, + Some(_) => return Err(expected()), + }; + let list = |key: &str| -> Result>> { + match scope.get(key) { + None => Ok(None), + Some(Value::Array(a)) if !a.is_empty() => Ok(Some(a)), + Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))), + } + }; + if let Some(a) = list("artboards")? { + let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect); + return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::>().map(Self::Artboards); + } + if let Some(a) = list("layers")? { + let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer)); + return a + .iter() + .map(|v| layer(v).ok_or_else(|| bad(C, format!("{v} names no layer or sublayer")))) + .collect::>() + .map(Self::Layers); + } + Err(expected()) + } + + /// Is object `id` in the scope. + fn holds(&self, d: &Document, symbols: &vectorcraft_doc::SymbolBoxes, id: NodeId) -> bool { + match self { + Self::Document => true, + Self::Artboards(rects) => d.node(id).is_some_and(|n| rects.iter().any(|r| on_artboard(n, symbols, *r))), + Self::Layers(layers) => d.ancestry(id).is_some_and(|a| a.iter().any(|i| layers.contains(i))), + } + } +} + fn font_list(_: &mut Session, p: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); match str_param(p, "family") { @@ -911,6 +967,95 @@ mod tests { assert!(s.doc().unwrap().selection.objects.is_empty()); } + /// Select Text by Font with an artboard scope takes the text on the artboards, as All on + /// Active Artboard does: overlapping one, or centered on it, a straddling one included. + #[test] + fn select_font_in_artboards() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 600, "y": 0, "width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, x: i32| { + NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap()) + }; + let first = text(&mut s, 10); + let straddling = text(&mut s, 180); + let second = text(&mut s, 350); + let third = text(&mut s, 650); + let _off = text(&mut s, 1000); + let select = |s: &mut Session, scope: Value| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": scope})).unwrap(); + s.doc().unwrap().selection.objects.clone() + }; + assert_eq!(select(&mut s, json!({"artboards": [0]})), [first, straddling]); + assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second, third]); + assert_eq!(select(&mut s, json!("document")).len(), 5); + // The same objects All on Active Artboard takes. + s.execute("select.allOnArtboard", &json!({"artboard": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [first, straddling]); + // Hidden text stays out. + s.execute("select.set", &json!({"ids": [third.0]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second]); + for bad in [json!({"artboards": [3]}), json!({"artboards": []}), json!({"artboards": ["x"]}), json!({"pages": [0]}), json!("all"), json!(7)] { + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": bad})).is_err(), "{bad}"); + } + } + + /// Select Text by Font with a layer scope takes the text in the layers, their sublayers + /// included, and still leaves out hidden and locked text. + #[test] + fn select_font_in_layers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, y: i32| { + NodeId(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap()) + }; + let first_layer = s.doc().unwrap().doc.layers[0].id; + let a = text(&mut s, 20); + let second_layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let b = text(&mut s, 40); + let sub = s.execute("layer.newSublayer", &json!({"parent": second_layer})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": sub})).unwrap(); + let in_sub = text(&mut s, 60); + let locked = text(&mut s, 80); + s.execute("select.set", &json!({"ids": [locked.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + let select = |s: &mut Session, layers: Value| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": layers}})).unwrap(); + let mut v = s.doc().unwrap().selection.objects.clone(); + v.sort(); + v + }; + let d = &s.doc().unwrap().doc; + assert_eq!(d.ancestry(in_sub).unwrap()[1], NodeId(sub), "the text is in the sublayer"); + assert_eq!(select(&mut s, json!([first_layer.0])), [a]); + assert_eq!(select(&mut s, json!([second_layer])), [b, in_sub]); + assert_eq!(select(&mut s, json!([sub])), [in_sub]); + assert_eq!(select(&mut s, json!([first_layer.0, sub])), [a, in_sub]); + // Not a layer: an error, the selection as it was. + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [a.0]}})).is_err()); + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [u64::MAX]}})).is_err()); + assert_eq!(s.doc().unwrap().selection.objects.len(), 2); + } + + /// A symbol instance whose art uses the font is selected when the instance is in scope. + #[test] + fn select_font_scope_takes_symbol_instances_in_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0]; + let select = |s: &mut Session, i: u64| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [i]}})).unwrap()["count"].clone() + }; + assert_eq!(select(&mut s, 0), 1); + assert_eq!(s.doc().unwrap().selection.objects, [instance]); + assert_eq!(select(&mut s, 1), 0); + } + /// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked /// group or layer, and says so. Text on a visible, unlocked template layer is changed. #[test] diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index aca3d27a8..863a574cd 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -230,6 +230,10 @@ fn structured_junk() { ("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})), ("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})), ("select.add", json!({"ids": [[1], {"a": 1}]})), + ("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [0, u64::MAX, -1, "x", null, 1.5]}})), + ("select.font", json!({"family": "Source Sans 3", "scope": {"layers": [0, u64::MAX, -1, [1], {"a": 1}]}})), + ("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [], "layers": [1]}})), + ("select.font", json!({"family": "Source Sans 3", "scope": 7})), ("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})), ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})), ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000], [0, 0]]}, {"id": u64::MAX}], "mode": "toggle"})), diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index e73d78736..d63905e48 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -1,7 +1,8 @@ //! Type → Find Font…: the fonts the document uses (missing ones marked), Find (select the text //! using a font), Change / Change All to another font from the list Replace With Font From shows //! (the document's fonts, the recent fonts or every installed font), and Find in Folder… (the -//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`). +//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`). Find In limits Find +//! to the active artboard or the selected layers. use egui::Ui; use serde_json::{Value, json}; @@ -23,9 +24,10 @@ pub fn open_on_missing(app: &mut VectorcraftApp) { open_at(app, first.unwrap_or(0)); } -/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, and -/// System's font ([`system_family`]) to change to. +/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, +/// System's font ([`system_family`]) to change to, and the last Find In choice (Document at first). fn open_at(app: &mut VectorcraftApp, selected: usize) { + let find_in = FIND_IN.iter().find(|f| **f == app.ui.find_font_in).copied().unwrap_or("document"); let used = fonts(app); let source = "system"; let rows = replacement_rows(app, source, &used); @@ -33,7 +35,7 @@ fn open_at(app: &mut VectorcraftApp, selected: usize) { let style = rows.iter().find(|r| r.family.eq_ignore_ascii_case(&family)).map(|r| r.style.clone()).unwrap_or_default(); app.ui.dialog = Some(Dialog::new( "findFont", - json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows) }), + json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows), "findIn": find_in }), )); } @@ -210,6 +212,42 @@ fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value] d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default())); } +/// Find In's choices (the `findIn` field): the whole document, the active artboard, or the layers +/// highlighted in the Layers panel (else the current layer). +const FIND_IN: [&str; 3] = ["document", "artboard", "layers"]; +const FIND_IN_LABELS: [&str; 3] = ["Document", "Artboard", "Layers"]; + +/// The `scope` Find passes `select.font` for Find In choice `find_in`. A document without +/// artboards, or without a layer to look in, is looked through whole. +fn find_scope(app: &VectorcraftApp, find_in: &str) -> Value { + let Some(st) = app.session.active() else { return json!("document") }; + let n = st.doc.artboards.len(); + match find_in { + "artboard" if n > 0 => json!({ "artboards": [crate::panels::artboards::selected(app, n)] }), + "layers" => { + let is_layer = |id: &vectorcraft_doc::NodeId| st.doc.node(*id).is_some_and(vectorcraft_doc::Node::is_layer); + let mut layers: Vec = st.highlighted_rows().into_iter().filter(is_layer).map(|id| id.0).collect(); + if layers.is_empty() { + layers.extend(st.current_layer().map(|id| id.0)); + } + if layers.is_empty() { json!("document") } else { json!({ "layers": layers }) } + } + _ => json!("document"), + } +} + +/// Run the dialog's button `button` (find, change or changeAll) on the document font `from`. +fn run_button(app: &mut VectorcraftApp, d: &Dialog, button: &str, from: &Value) -> Result { + match button { + "find" => { + let scope = find_scope(app, &d.str("findIn")); + app.run("select.font", json!({ "family": from["family"], "style": from["style"], "scope": scope })) + } + "change" => change(app, d, from, false), + _ => change(app, d, from, true), + } +} + fn fonts(app: &mut VectorcraftApp) -> Vec { app.session.execute("text.fonts", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default() } @@ -342,6 +380,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } ui.add_space(14.0); + ui.horizontal(|ui| { + widgets::field_label(ui, tl!("Find In")); + let at = FIND_IN.iter().position(|s| *s == d.str("findIn")).unwrap_or(0); + if let Some(i) = widgets::dropdown_named(ui, "ff-find-in", Some(tl!("Find In")), FIND_IN_LABELS[at], &FIND_IN_LABELS, 120.0) { + d.fields.insert("findIn".into(), json!(FIND_IN[i])); + } + }); + ui.add_space(6.0); // An empty list (Recent before any font was applied) leaves nothing to change to. let replace = sel < list.len() && can_replace(&d, families.as_deref()); ui.horizontal(|ui| { @@ -362,14 +408,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); }); + app.ui.find_font_in = d.str("findIn"); let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize; if let (Some(a), Some(from)) = (act, list.get(sel)) { - let r = match a { - "find" => app.run("select.font", json!({ "family": from["family"], "style": from["style"] })), - "change" => change(app, &d, from, false), - _ => change(app, &d, from, true), - }; - match r { + match run_button(app, &d, a, from) { // The rows are a snapshot: the document's fonts and the recent fonts have moved on. Ok(_) if a != "find" => { let used = fonts(app); @@ -433,6 +475,48 @@ mod tests { assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3"); } + /// Find In passes Find the active artboard, or the selected layers (else the current + /// layer), as `select.font`'s scope. + #[test] + fn find_in_limits_find_to_the_artboard_or_the_layers() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 200, "y": 0, "width": 100, "height": 100})).unwrap(); + let text = |app: &mut VectorcraftApp, x: i32| { + app.session.execute("text.create", &json!({"x": x, "y": 40, "text": "Hi", "font": "Inter"})).unwrap()["id"].as_u64().unwrap() + }; + let first = text(&mut app, 10); + let layer = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let second = text(&mut app, 210); + let third = text(&mut app, 20); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Choose Find In on the open dialog, draw a frame, then press Find. + let find = |app: &mut VectorcraftApp, find_in: &str| { + open(app); + app.ui.dialog.as_mut().unwrap().fields.insert("findIn".into(), json!(find_in)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.clone().unwrap(); + let from = fonts(app).into_iter().find(|f| f["family"] == "Inter").unwrap(); + run_button(app, &d, "find", &from).unwrap(); + let mut ids: Vec = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect(); + ids.sort_unstable(); + ids + }; + open(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "document"); + assert_eq!(find(&mut app, "document"), [first, second, third]); + // Without a view, the active artboard is the first. + assert_eq!(find(&mut app, "artboard"), [first, third]); + assert_eq!(find_scope(&app, "layers"), json!({ "layers": [layer] })); + assert_eq!(find(&mut app, "layers"), [second, third]); + // Closing and opening the dialog again keeps the last choice. + app.ui.dialog = None; + open(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "layers"); + } + /// Change All leaves locked text in its font, and the status bar says so. #[test] fn change_all_clears_the_skip_warning_after_unlocking_text() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d758e8db3..d9536d7a0 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2699,6 +2699,7 @@ digits Malayalam Малаялам Reset Transformation Нулиране на трансформацията Replace With Font Замяна с шрифт Replace With Font From Замяна с шрифт от + Find In Търсене в System Система Replaces the saved preset of that name. Заменя записаната предварителна настройка с това име. Replace… Замяна… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 44d90cfba..ce486cb67 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4000,6 +4000,7 @@ effect Regular Obično Find in Folder… Pronađi u folderu… Replace With Font Zamijeni fontom Replace With Font From Zamijeni fontom iz + Find In Traži u System Sistem — substituted by {style} — zamijenjeno stilom {style} — {n} characters from another font — {n} znakova iz drugog fonta diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index c87981c47..b2b88879c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3406,6 +3406,7 @@ digits Malayalam Malaiàlam Reset Transformation Restableix la transformació Replace With Font Reemplaça per un tipus de lletra Replace With Font From Reemplaça per un tipus de lletra de + Find In Cerca a System Sistema Replace “{name}”, the file this document was opened from? Voleu reemplaçar “{name}”, el fitxer amb què es va obrir aquest document? Replaces the saved preset of that name. Reemplaça el preajust desat amb aquest nom. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2b051382b..089cc5e33 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4050,6 +4050,7 @@ effect Regular Pravidelný Find in Folder… Najít ve složce… Replace With Font Nahradit písmem Replace With Font From Nahradit písmem z + Find In Hledat v System Systém — substituted by {style} — nahrazeno řezem {style} — {n} characters from another font — znaky z jiného písma: {n} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 22c0d19bd..c8c6db18b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3766,6 +3766,7 @@ effect Regular Regular Find in Folder… Find i mappe… Replace With Font Erstat med skrifttype Replace With Font From Erstat med skrifttype fra + Find In Søg i System System — substituted by {style} — erstattet af {style} — {n} characters from another font — {n} tegn fra en anden skrifttype diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0bfb960b8..ea1f60441 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2731,6 +2731,7 @@ digits Malayalam Malayalam Reset Transformation Transformation zurücksetzen Replace With Font Durch Schrift ersetzen Replace With Font From Ersetzende Schrift aus + Find In Suchen in System System Replaces the saved preset of that name. Ersetzt die gespeicherte Vorgabe dieses Namens. Replace… Ersetzen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6c22f4976..a24e6fb51 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3341,6 +3341,7 @@ digits Malayalam Μαλαγιάλαμ Reset Transformation Επαναφορά μετασχηματισμού Replace With Font Αντικατάσταση με γραμματοσειρά Replace With Font From Αντικατάσταση με γραμματοσειρά από + Find In Αναζήτηση σε System Σύστημα Replaces the saved preset of that name. Αντικαθιστά την αποθηκευμένη προεπιλογή με αυτό το όνομα. Require a password to open the document Απαίτηση κωδικού για το άνοιγμα εγγράφου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0ba8a2bfc..b20bbb6b7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2774,6 +2774,7 @@ digits Malayalam Malayálam Reset Transformation Restablecer transformación Replace With Font Reemplazar con fuente Replace With Font From Reemplazar con fuente de + Find In Buscar en System Sistema Replaces the saved preset of that name. Reemplaza el preajuste guardado con ese nombre. Replace… Reemplazar… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 91ad598b9..8e1c377f9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3969,6 +3969,7 @@ effect Regular Normaali Find in Folder… Etsi kansiosta… Replace With Font Korvaa fontilla Replace With Font From Korvaavan fontin lähde + Find In Etsi kohteesta System Järjestelmä — substituted by {style} — korvattu tyylillä {style} — {n} characters from another font — {n} merkkiä toisesta fontista diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 04462dfef..a424b7e26 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2766,6 +2766,7 @@ digits Malayalam Malayalam Reset Transformation Réinitialiser la transformation Replace With Font Remplacer par la police Replace With Font From Remplacer par une police de + Find In Rechercher dans System Système Replaces the saved preset of that name. Remplace le paramètre prédéfini enregistré sous ce nom. Replace… Remplacer… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dede4b5d..49cd602d7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3306,6 +3306,7 @@ mark-style Japanese Japán Reset Transformation Átalakítás visszaállítása Replace With Font Helyettesítő betűtípus Replace With Font From Helyettesítő betűtípus forrása + Find In Keresés helye System Rendszer Replace “{name}”, the file this document was opened from? Felülírod ezt a fájlt, amelyből a dokumentumot megnyitottad: „{name}”? Replaces the saved preset of that name. Felülírja az azonos nevű mentett előbeállítást. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dfb8ef46e..c02f16b52 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3766,6 +3766,7 @@ effect Regular Reguler Find in Folder… Temukan di folder… Replace With Font Ganti dengan font Replace With Font From Ganti dengan font dari + Find In Cari di System Sistem — substituted by {style} — digantikan oleh {style} — {n} characters from another font — {n} karakter dari font lain diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1e124363f..efd781f71 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2807,6 +2807,7 @@ digits Malayalam Malayalam Reset Transformation Reimposta trasformazione Replace With Font Sostituisci con font Replace With Font From Sostituisci con font da + Find In Cerca in System Sistema Replaces the saved preset of that name. Sostituisce il preset salvato con quel nome. Replace… Sostituisci… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c4040d3c5..eea083135 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2793,6 +2793,7 @@ mark-style Japanese 日本式 Reset Transformation 変形をリセット Replace With Font 置換するフォント Replace With Font From 置換するフォントの一覧 + Find In 検索範囲 System システム Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 別のファイルに置き換えるか、無視してください。無視した場合、画像にはドキュメントに保存されたプレビューが表示され続けます。 Replaces the saved preset of that name. 同じ名前の保存済みプリセットを置き換えます。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c7b2cb935..af65f71b7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3559,6 +3559,7 @@ effect Regular ჩვეულებრივი Find in Folder… საქაღალდეში ძებნა… Replace With Font ჩანაცვლება შრიფტით Replace With Font From ჩანაცვლების შრიფტის წყარო + Find In ძებნის არე System სისტემა — substituted by {style} — ჩანაცვლებულია: {style} — {n} characters from another font — სხვა შრიფტის სიმბოლოები: {n} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4098a1134..6246149a2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2782,6 +2782,7 @@ mark-style Japanese 일본식 Reset Transformation 변형 재설정 Replace With Font 바꿀 글꼴 Replace With Font From 바꿀 글꼴 목록 + Find In 찾을 위치 System 시스템 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 다른 파일로 바꾸거나 무시하세요. 무시하면 해당 이미지는 문서에 저장된 미리 보기로 계속 표시됩니다. Replaces the saved preset of that name. 같은 이름으로 저장된 사전 설정을 대체합니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b5affe48a..d3e8ee5a8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3780,6 +3780,7 @@ effect Regular Regelmatig Find in Folder… Zoeken in map… Replace With Font Vervangen door lettertype Replace With Font From Vervangen door lettertype uit + Find In Zoeken in System Systeem — substituted by {style} — vervangen door {style} — {n} characters from another font — {n} tekens van een ander lettertype diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 82ef5d0f1..04545e4e5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3889,6 +3889,7 @@ effect Regular Normalny Find in Folder… Znajdź w folderze… Replace With Font Zastąp czcionką Replace With Font From Zastąp czcionką z + Find In Szukaj w System System — substituted by {style} — zastąpiona przez {style} — {n} characters from another font — znaki z innej czcionki: {n} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 20895d73b..382fc086c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2314,6 +2314,7 @@ digits Malayalam Malaiala Reset Transformation Redefinir Transformação Replace With Font Substituir Com Fonte Replace With Font From Substituir Com Fonte De + Find In Localizar em System Sistema Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Substitua por outro arquivo, ou ignore-o: suas imagens continuam mostrando a pré-visualização salva com o documento. Replaces the saved preset of that name. Substitui o predefinido salvo com esse nome. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1cb376142..f5980891e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2802,6 +2802,7 @@ digits Malayalam Малаялам Reset Transformation Сбросить трансформацию Replace With Font Заменить шрифтом Replace With Font From Заменить шрифтом из + Find In Искать в System Система Replaces the saved preset of that name. Заменяет сохранённый набор с таким именем. Replace… Заменить… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 55840c8bb..a0539f5cb 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2710,6 +2710,7 @@ digits Malayalam Malayalam Reset Transformation Återställ transformering Replace With Font Ersätt med typsnitt Replace With Font From Ersätt med typsnitt från + Find In Sök i System System Replaces the saved preset of that name. Ersätter den sparade förinställningen med samma namn. Replace… Ersätt… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a289160b5..4b605bb06 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2579,6 +2579,7 @@ digits Malayalam Malayalam Reset Transformation Dönüşümü Sıfırla Replace With Font Yazı Tipiyle Değiştir Replace With Font From Değiştirilecek Yazı Tipi Kaynağı + Find In Arama yeri System Sistem Replaces the saved preset of that name. Bu adın kayıtlı ön ayarını değiştirir. Replace… Değiştir… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c693f7ac0..78db714e4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2799,6 +2799,7 @@ digits Malayalam Малаялам Reset Transformation Скинути трансформацію Replace With Font Замінити шрифтом Replace With Font From Замінити шрифтом з + Find In Шукати в System Система Replaces the saved preset of that name. Замінює збережений набір із таким ім’ям. Replace… Замінити… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7c366fd07..f309c0acc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3074,6 +3074,7 @@ mark-style Japanese Kiểu Nhật Reset Transformation Đặt lại biến đổi Replace With Font Thay thế bằng phông chữ Replace With Font From Thay thế bằng phông chữ từ + Find In Tìm trong System Hệ thống Replace “{name}”, the file this document was opened from? Thay thế “{name}”, tệp mà tài liệu này được mở từ đó? Replaces the saved preset of that name. Thay thế thiết lập sẵn đã lưu có tên đó. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a040da3c0..30603ef86 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2677,6 +2677,7 @@ digits Malayalam 马拉雅拉姆数字 Reset Transformation 重置变换 Replace With Font 替换为字体 Replace With Font From 替换字体来源 + Find In 查找范围 System 系统 Replaces the saved preset of that name. 替换同名已存预设。 Replace… 替换… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1eee83b7f..3971235fa 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3680,6 +3680,7 @@ digits Malayalam 馬來亞拉姆數字 Reset Transformation 重設變形 Replace With Font 取代為字體 Replace With Font From 取代字體來源 + Find In 尋找範圍 System 系統 Replaces the saved preset of that name. 取代同名的已儲存預設集。 Replace… 取代… diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 72b5e56f1..2970fd8cc 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -370,6 +370,9 @@ pub struct UiState { /// That popup's first text field is still to take the keyboard. #[serde(skip)] pub transient_focus: bool, + /// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs. + #[serde(skip)] + pub find_font_in: String, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -974,6 +977,7 @@ impl Default for UiState { open_panel: None, transient_panel: None, transient_focus: false, + find_font_in: String::new(), control_bar: false, control_bar_bottom: false, toolbar: true, diff --git a/docs/roadmap.md b/docs/roadmap.md index 68b15aa3d..a5866a1c8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,6 +42,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | 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). | | 2026-11-05 | minor | Pencil and Paintbrush: a new stroke with Keep selected off leaves nothing selected, the earlier selection included, while Keep selected on selects only the new path (#5752); scores and estimates unchanged. | | 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); scores and estimates unchanged. | +| 2026-11-04 | minor | M7: Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); estimates unchanged. | | 2026-11-04 | minor | M7: Find Font's Replace With Font lists family and style rows with a search, and the list and the document's font list scroll on their own (#4972); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | M4: raster/PSD exports default to document resolution; CLI run/convert expose --ppi, matching the engine and MCP precedence. Scores and hours unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 447221238..af0f12e59 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -59,6 +59,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | PDF import: a DeviceN gradient of a spot ink over process plates keeps the ink and the plates, and PDF export writes it as a DeviceN shading again (#3235); scores unchanged | | 2026-11-05 | minor | Optical kerning moves each pair part of the way toward the font's own stem spacing, with a minimum gap and tighter limits, and leaves ideographs, kana, fullwidth forms and dashes on the font's spacing, so text is no longer squeezed (#5336). Scores and estimates unchanged. | | 2026-11-05 | minor | Gradient panel: Delete Stop sits at the right end of the ramp, so it shows whenever the ramp does instead of under the stop fields, where a short panel hid it (#5403); scores and estimates unchanged | +| 2026-11-04 | minor | Record Select Text by Font's artboard and layer scope (#5529, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | Raster/PSD defaults honor document resolution; CLI run/convert accept --ppi, matching MCP options.ppi precedence. Synthetic regressions added; scores and hours unchanged. | @@ -1020,6 +1021,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Manual kerning on inline graphics:** a manual kerning value on an inline graphic's character spaces it from the next character in reading order by the same distance as on a letter of its run's size, in left-to-right, right-to-left and vertical type; Auto and Optical kerning still apply no font pairs to it. This is VectorCraft's own rule. Test: `manual_kerning_on_an_inline_graphic_spaces_it_like_a_letter`. - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`. +- **Select Text by Font: scope (a VectorCraft addition, #5529):** `select.font` takes `scope`: `"document"` (the default, as before), `{artboards: [index, …]}` or `{layers: [id, …]}` (sublayers included). On an artboard means what All on Active Artboard takes (the bounds overlap it or the center is on it; one shared test, `select::on_artboard`); hidden and locked text stays out as before; a symbol instance counts where the instance is. Type › Find Font…'s Find In dropdown passes the active artboard or the selected layers (else the current layer). Tests: `crates/engine/src/cmd/fonts.rs`, `crates/ui-egui/src/find_font.rs`, `crates/engine/tests/command_sweep.rs`. - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the menu's saved font filters but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). diff --git a/docs/type-parity.md b/docs/type-parity.md index d40ec6f76..4b48fd84b 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -34,7 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | +| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | @@ -87,6 +87,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | | 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged | | 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. | +| 2026-11-04 | minor | Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | Typographer's Quotes: a lone typed `”` (U+201D) is set like a straight `"` where a quote opens (at the start, after a space), taking the document's opening quote, and stays `”` where one closes: the macOS Serbian Cyrillic layout has no straight quote and types `”` on Shift+2, which now gives „…” (#5247). `“ „` and the single quotes `‘ ’ ‚` stay as typed (that layout's apostrophe key types `’`, and `’90s` keeps its apostrophe); pasted text keeps its quotes; with the option off `”` stays as typed. Evidence: `docsetup::a_typed_closing_double_quote_opens_where_a_quote_opens`; scores unchanged | | 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. | | 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. | From d4f6fa07443d9edc5e41ea132820360d5ce975eb Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:23:56 +0700 Subject: [PATCH 02/49] EPS/.ai editing data: art-style strokes aligned inside or outside keep their alignment Squashed from #6010 onto main. --- crates/eps/src/import/ai/read/ops.rs | 4 +- crates/eps/src/import/ai/style.rs | 77 ++++++++++++++------ crates/eps/src/import/ai/tests.rs | 105 +++++++++++++++++++++++++++ docs/file-format-parity.md | 1 + 4 files changed, 163 insertions(+), 24 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 3d95c9485..c9bd31704 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1189,9 +1189,9 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P /// A style's stroke as an appearance item (`None` for a fill). fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option { - let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None }; + let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, align, brush } = item else { return None }; let mut layer = StrokeLayer::new(paint.clone(), line_width(*width)); - (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); + (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile, layer.align) = (*cap, *join, *miter, dash.clone(), profile.clone(), *align); layer.brush = brush.as_ref().map(|(name, _)| name.clone()); Some(AppearanceItem::Stroke(layer)) } diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 04ddf27c1..a23101e26 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -36,8 +36,15 @@ //! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for //! the object as entries (see [`super::brush`]). //! +//! A stroke aligned inside or outside its path (Stroke panel › Align Stroke, +//! https://helpx.adobe.com/illustrator/desktop/draw-and-paint/use-strokes/stroke-panel-options.html) +//! is its own basic filter too, `(Adobe Stroke Offset)`, with its stroke's style and +//! `StrokeOffsetInside` (1: inside, 0: outside, as its `DisplayString` names it) as entries. +//! Without that entry, or with it as anything but a `/Bool` 0 or 1, which side it paints isn't +//! known and the style isn't plain (its objects keep their drawn look). +//! //! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills -//! with a pattern or gradient too; strokes with a Calligraphic or Art brush too), each with its own +//! with a pattern or gradient too; strokes with a Calligraphic or Art brush or aligned inside or outside too), each with its own //! transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and //! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the @@ -47,7 +54,7 @@ use std::collections::HashMap; use serde_json::json; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile}; +use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, StrokeAlign, WidthProfile}; use vectorcraft_effects::is_pathfinder; use super::brush::PathBrush; @@ -78,6 +85,8 @@ const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl"; const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files +/// A stroke aligned inside or outside its path (Stroke panel › Align Stroke). +const STROKE_OFFSET: &str = "Adobe Stroke Offset"; // brand-ok: the filter's name in the files const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files const ART_BRUSH: &str = "Adobe ArtOnPath Brush Tool"; // brand-ok: the filter's and plug-in's name in the files @@ -211,6 +220,8 @@ pub(super) enum StyleItem { miter: f64, dash: Option, profile: Option, + /// Which side of the path it paints (centred, or inside or outside a closed path). + align: StrokeAlign, /// A brush: its name, and its settings for this object. brush: Option<(String, PathBrush)>, }, @@ -1211,29 +1222,49 @@ fn paint_item(f: &Filter) -> Result, ()> { (None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))), (None, None, None) => Err(()), }, - STROKE if !f.paints.0 => Ok(None), - // Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end - // and the pattern, fitted, between them. - STROKE if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => Err(()), - STROKE => match &f.stroke { - Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke { - paint: p.clone(), - width: f.width, - cap: f.cap, - join: f.join, - miter: f.miter, - dash: f.dash.clone(), - profile: width_profile(&f.arrays), - brush: None, - })), - _ => Err(()), - }, + STROKE => stroke_item(f, StrokeAlign::Center), CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()), + STROKE_OFFSET => offset_stroke(f), ART_BRUSH => art_stroke(f).map(Some).ok_or(()), _ => Err(()), } } +/// A stroke filter `f`'s stroke, painting on side `align` of its path: `Ok(None)` when it paints +/// nothing, `Err` when it paints in a way this doesn't read. +fn stroke_item(f: &Filter, align: StrokeAlign) -> Result, ()> { + if !f.paints.0 { + return Ok(None); + } + // Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end + // and the pattern, fitted, between them. + if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) { + return Err(()); + } + match &f.stroke { + Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke { + paint: p.clone(), + width: f.width, + cap: f.cap, + join: f.join, + miter: f.miter, + dash: f.dash.clone(), + profile: width_profile(&f.arrays), + align, + brush: None, + })), + _ => Err(()), + } +} + +/// A Stroke Offset filter `f`: a stroke whose own style is in its dictionary, aligned inside its +/// path when `StrokeOffsetInside` is 1 and outside when it is 0 (its `DisplayString` says which). +/// `Err` when that switch is missing or not a switch, or for a stroke [`paint_item`] doesn't read. +fn offset_stroke(f: &Filter) -> Result, ()> { + let inside = switch(f, "StrokeOffsetInside", Kind::Bool)?.ok_or(())?; + stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside }) +} + /// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a /// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle /// relative to the path isn't read. @@ -1261,6 +1292,7 @@ fn calligraphic_stroke(f: &Filter) -> Option { miter: f.miter, dash: None, profile: None, + align: StrokeAlign::Center, brush: Some((name.clone(), PathBrush::Calligraphic(super::brush::calligraphic(values, variation, codes)))), }) } @@ -1339,6 +1371,7 @@ fn art_stroke(f: &Filter) -> Option { miter: f.miter, dash: None, profile: None, + align: StrokeAlign::Center, brush: Some((name.clone(), PathBrush::Art)), }) } @@ -1383,7 +1416,7 @@ impl Builder<'_> { let c = &filters[k]; match c.name.as_str() { _ if c.compound => {} - FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) { + FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) { Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true, Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true, _ => {} @@ -1397,7 +1430,7 @@ impl Builder<'_> { let c = &filters[k]; match c.name.as_str() { _ if c.compound => self.plain = false, - FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => { + FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => { paints += 1; look.visible &= c.visible; match paint_item(c) { @@ -1518,7 +1551,7 @@ fn style(filters: &[Filter]) -> ArtStyle { let f = &filters[id]; match f.name.as_str() { STACK if f.compound => b.stack(id, &mut st), - FILL | STROKE | CALLIGRAPHIC | ART_BRUSH | CHAIN => { + FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH | CHAIN => { if let Some((item, look)) = b.item(id) { st.items.push(item); st.looks.push(look); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 5765fa6bf..c21bbee70 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5778,6 +5778,111 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// An art style named `name`: a blue fill under a red 2 pt stroke written as a Stroke Offset filter +/// whose `StrokeOffsetInside` entry is `inside` (`None`: left out). +fn offset_stroke_style(name: &str, inside: Option) -> String { + let switch = inside.map(|v| format!("{v} /Bool (StrokeOffsetInside) ,\n")).unwrap_or_default(); + offset_stroke_style_with(name, &switch, OFFSET_STROKE) +} + +/// The Stroke Offset stroke of [`offset_stroke_style`]: red, 2 pt, butt caps, miter joins, solid. +const OFFSET_STROKE: &str = "0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd"; + +/// An art style named `name`: a blue fill under a Stroke Offset filter whose dictionary holds +/// `entries` and the stroke style `stroke` (what follows `/StrokeStyle :`). +fn offset_stroke_style_with(name: &str, entries: &str, stroke: &str) -> String { + format!( + "/KnownStyle :\n({name}) /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\ + /CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Stroke Offset) 1 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n{entries}(Side) /String (DisplayString) ,\n\ + /StrokeStyle : {stroke}\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\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;\n" + ) +} + +#[test] +fn a_stroke_offset_filter_is_a_stroke_aligned_inside_or_outside() { + // GUARDS: a style's stroke aligned inside or outside (Stroke panel › Align Stroke) left the object as its drawn look. + use vectorcraft_doc::StrokeAlign; + for (inside, align) in [(Some(1), Some(StrokeAlign::Inside)), (Some(0), Some(StrokeAlign::Outside)), (None, None), (Some(2), None)] { + let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{}", offset_stroke_style("Edge", inside), layer(&styled_square("Edge")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let stroke = a[0].appearance.stroke().map(|s| (s.align, s.width, s.paint.color())); + match align { + Some(align) => { + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + assert_eq!(stroke, Some((align, 2.0, Some(Color::rgb(1.0, 0.0, 0.0))))); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + } + // Without a 0/1 switch, which side it paints isn't known: the drawn look stays. + None => assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{inside:?}: {:?}", r.warnings), + } + } +} + +/// A Stroke Offset stroke keeps the caps, joins, miter limit, dashes and width profile of its +/// style, as a Stroke Style Filter's does. +#[test] +fn a_stroke_offset_filter_keeps_its_strokes_style() { + use vectorcraft_doc::StrokeAlign; + let profile = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ + /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"; + let entries = format!("0 /Bool (StrokeOffsetInside) ,\n{profile}"); + let stroke = "0 R\n0 1 1 0 1 0 0 XA\n1 J 2 j 3 w 4 M [5 10 ]0 d\n0 XR\n1 0 Xd"; + let style = offset_stroke_style_with("Edge", &entries, stroke); + let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + let s = a[0].appearance.stroke().expect("a stroke"); + assert_eq!((s.align, s.width, s.cap, s.join, s.miter_limit), (StrokeAlign::Outside, 3.0, LineCap::Round, LineJoin::Bevel, 4.0)); + assert_eq!(s.dash.as_ref().map(|d| d.pattern.clone()), Some(vec![5.0, 10.0])); + assert_eq!(s.profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)])); +} + +/// A Stroke Offset stroke this doesn't read as a stroke keeps the object's drawn look: a switch +/// written as another kind, dashes aligned to corners and path ends, a gradient. +#[test] +fn a_stroke_offset_filter_with_a_stroke_this_does_not_read_keeps_the_drawn_look() { + let outside = "0 /Bool (StrokeOffsetInside) ,\n"; + let gradient = "0 R\nBb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd"; + let cases = [ + ("a switch written as a number", offset_stroke_style_with("Edge", "1 /Int (StrokeOffsetInside) ,\n", OFFSET_STROKE)), + ( + "dashes aligned to corners", + offset_stroke_style_with( + "Edge", + &format!("{outside}/Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"), + &OFFSET_STROKE.replace("[]0 d", "[5 10 ]0 d"), + ), + ), + ("a gradient", offset_stroke_style_with("Edge", outside, gradient)), + ]; + for (why, style) in cases { + let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge")))); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings); + } +} + +/// A listed `[Default]` style whose stroke is aligned outside comes in as the default graphic +/// style with that stroke. +#[test] +fn a_default_style_with_a_stroke_aligned_outside_comes_in() { + use vectorcraft_doc::StrokeAlign; + let r = + read_ok(&format!("{}{}", styles_and_list(&[offset_stroke_style("[Default]", Some(0))], &["[Default]"]), layer(&styled_square("[Default]")))); + assert_eq!(style_names(&r.doc), [("[Default]".to_string(), 1)]); + let stroke = r.doc.graphic_styles[0].appearance.stroke().map(|s| (s.align, s.width)); + assert_eq!(stroke, Some((StrokeAlign::Outside, 2.0))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + assert_eq!(a[0].extras().graphic_style, Some(1)); +} + #[test] fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() { // GUARDS: objects in a symbol defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 108d2b9bd..c68fdb4be 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -305,6 +305,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | From 500ff6ed4c192d3635e816889f20e655cb94c3da Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 03:31:58 +0100 Subject: [PATCH 03/49] .ai import: opacity mask art without its own paint takes the paint before its dictionary A dictionary's state starts afresh, with a black fill. Mask art is written going on from the paint before the mask's dictionary, which the writer leaves out when it doesn't change, so a mask rectangle with no colour of its own came in black and hid the white letters it masks. Mask art now starts from the paint (fill, stroke, line style, fill rule, overprint) before the dictionaries it is in; lock, visibility and transparency still start afresh. --- crates/eps/src/import/ai/read/ops.rs | 12 ++++++++++++ crates/eps/src/import/ai/tests.rs | 19 +++++++++++++++++++ docs/file-format-parity.md | 6 ++++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index c9bd31704..34f579bf7 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -49,6 +49,18 @@ impl<'a> Reader<'a> { return Ok(()); } self.end_path_quietly(); + // The art goes on from the paint before the dictionaries it is in, which `begin_obj` set + // aside: the writer leaves out paint that doesn't change, so a mask rectangle with no color + // of its own takes the white of the art before it, not a fresh state's black, which hides + // what it masks. Lock, visibility and transparency start afresh, as a dictionary's art does. + if let Some(f) = self.frames.iter().rev().take_while(|f| matches!(f.kind, Kind::Obj(_))).last() { + let before = &f.gs; + let gs = &mut self.gs; + (gs.fill, gs.stroke, gs.dash) = (before.fill.clone(), before.stroke.clone(), before.dash.clone()); + (gs.width, gs.cap, gs.join, gs.miter, gs.rule) = (before.width, before.cap, before.join, before.miter, before.rule); + (gs.overprint_fill, gs.overprint_stroke) = (before.overprint_fill, before.overprint_stroke); + (self.pending_unread_fill, self.pending_unread_stroke) = (f.pending_unread_fill, f.pending_unread_stroke); + } self.open(Kind::Mask, hidden) } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c21bbee70..17423b46b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7624,6 +7624,25 @@ fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() { } } +/// Mask art that sets no paint of its own paints with the paint before its dictionary: the writer +/// leaves out paint that doesn't change. In a user's file, white letters were masked by a rectangle +/// with no colour, which came in black and hid them. +#[test] +fn opacity_mask_art_without_its_own_paint_takes_the_paint_before_its_dictionary() { + let owner = format!("0 0 0 0 k\n0 1 0 0 K\n2 w\n{}B\n", rect_path(10.0, 20.0, 50.0, 60.0)); + let mask = format!( + "%_/ArtDictionary :\n%_/Mask :\n%_X=\n{}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n", + rect_path(10.0, 20.0, 30.0, 60.0).lines().map(|l| format!("%_{l}\n")).collect::() + "%_B\n" + ); + let r = read_ok(&layer(&format!("{owner}{mask}"))); + let a = art(&r.doc); + let m = a[0].extras().mask.as_ref().unwrap(); + assert_eq!(fill(&m.art), fill(&a[0])); + let stroke = |n: &Node| n.appearance.stroke().map(|s| (s.paint.clone(), s.width)); + assert_eq!(stroke(&m.art), stroke(&a[0])); + assert!(stroke(&m.art).is_some_and(|(_, w)| w == 2.0)); +} + /// An opacity mask's art of one shown object is that object; other art is read into a group. #[test] fn opacity_mask_art_of_one_object_is_that_object() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c68fdb4be..1e4828cca 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS and `.ai` editing data: opacity mask art without its own paint takes the paint before its dictionary; a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -50,7 +50,8 @@ private editing data. VectorCraft: A multi-layer mask (`Mb` … `Md` … `MB`) comes in as a clipping group in each layer it covers, each clipped by a copy of the mask's path. An opacity mask's art of one shown object, other than a guide or a clipping path, opens as that object in the - editing data of a `.ai`, EPS or PDF; other art opens in a group. + editing data of a `.ai`, EPS or PDF; other art opens in a group. Art in the mask that sets no paint of its + own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile open with different, unconverted RGB colours than before. @@ -305,6 +306,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | From 8f975aa0c5afba150fa8cfd3079d7094d67ccaf3 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 12:15:55 +0100 Subject: [PATCH 04/49] Fix #5960: a narrow part of a variable-width stroke paints over a wide part only where they overlap With a width profile, every slice of a stroke gradient (along or across) reached as far as the stroke is wide anywhere. Where the stroke crosses or runs close by itself, the later, narrower part painted a square of its colour over the wider part beside it, clipped only to the whole outline. Each rib now reaches as far as the stroke is wide there and on its way to the next points (each side of a profile stays between its widths at a span's points); for a dashed stroke, as wide as it gets within a cap's reach, since caps and dots reach along the path. --- crates/effects/src/stroke/gradient.rs | 101 ++++++++++++++++---- crates/effects/src/stroke/tests_gradient.rs | 49 ++++++++++ docs/target-app-parity.md | 3 +- 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 677eea308..629f5b6c9 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -34,6 +34,8 @@ const MESH_ROWS: usize = 16; const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. const PROFILE_STEP: f64 = 1.0 / 64.0; +/// The most points either way a rib looks along the path for how wide the stroke gets near it. +const WINDOW_POINTS: usize = 256; /// Flattening tolerance, cover margin and growth (see [`gradient_slices`]) of meshes and written /// slices (document points). const SLICE_TOL: f64 = 0.05; @@ -78,14 +80,26 @@ struct Lay<'a> { st: &'a StrokeLayer, /// Half the stroked width ([`aligned_width`]). hw: f64, - /// How far ribs reach either side of the path where nothing wider is near. - cover: f64, + /// How far ribs reach past the stroke's edge. margin: f64, /// How far a strip runs on past the start and end of an open subpath (caps and heads). ext: (f64, f64), } impl Lay<'_> { + /// How far the stroke reaches either side of the path at fraction `t` of a subpath: its wider + /// side, just before or just after a step there. + fn half(&self, t: f64) -> f64 { + let Some(p) = &self.st.profile else { return self.hw }; + let ((bl, br), (al, ar)) = p.around(t); + self.hw * bl.max(br).max(al).max(ar).max(0.0) + } + + /// How far a rib at fraction `t` reaches either side of the path to cover the stroke there. + fn cover(&self, t: f64) -> f64 { + self.half(t) + self.margin + } + /// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`: /// left; `None`: both, a centred stroke). fn band(&self, t: f64, side: Option) -> (f64, f64) { @@ -147,10 +161,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, if !(hw > 0.0 && hw.is_finite()) { return vec![]; } - let full = hw * st.profile_max(); let heads = (st.head_reach(false), st.head_reach(true)); - let cap = cap_extent(st.cap, 2.0 * full); - let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) }; + let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) }; + // Caps reach as far past the ends as the stroke is wide there. + let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t)); + lay.ext = (cap(0.0).max(heads.0) + margin, cap(1.0).max(heads.1) + margin); let subs = subpaths(bp); let count = subs.len(); let mut out = vec![]; @@ -165,7 +180,8 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, // A subpath of no length: a dot (round and projecting caps paint it). 1 => { let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1)); - vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)] + let r = lay.cover(0.0); + vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)] } _ => { let total = length(&pts, closed); @@ -215,9 +231,59 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); - // Ribs near an arrowhead reach across it. - let reach = |s: f64| { - let mut r = lay.cover; + // How far along the subpath each point is. + let mut along = Vec::with_capacity(n); + let mut s = 0.0; + for i in 0..n { + along.push(s); + s += pts[i].distance(pts[(i + 1) % n]); + } + // How far the ribs at point `i` reach: past the stroke there and on its way to the points + // either side, where it is no wider than at one of the two (each side of a width profile + // stays between its widths at the points of a span, and its points have points here). The + // caps and dots of a dashed stroke reach along the path as far as it is wide where they sit, + // so there the ribs cover the widest the stroke gets that far either way. Not as far as the + // stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part + // reaching that far would paint over a wide part beside it (#5960). The start of a closed + // subpath is also its end. Ribs near an arrowhead reach across it. + let cover = |i: usize| { + let t = along[i] / total; + if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(t) } + }; + let widest = lay.hw * lay.st.profile_max() + lay.margin; + let window = match lay.st.dash { + Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()), + _ => 0.0, + }; + let reach = |i: usize| { + let mut r = cover(i); + for forward in [false, true] { + // Out to the first point past the window (bounded: past that many, as wide as anywhere). + let (mut j, mut d, mut past) = (i, 0.0, false); + for _ in 0..WINDOW_POINTS { + let k = match (closed, forward) { + (true, true) => (j + 1) % n, + (true, false) => (j + n - 1) % n, + (false, true) if j + 1 < n => j + 1, + (false, false) if j > 0 => j - 1, + _ => { + past = true; + break; + } + }; + r = r.max(cover(k)); + d += pts[j].distance(pts[k]); + j = k; + if d > window { + past = true; + break; + } + } + if !past { + r = r.max(widest); + } + } + let s = along[i]; if heads.0 > 0.0 && s <= heads.0 { r = r.max(heads.0 + lay.margin); } @@ -227,29 +293,26 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, r }; let mut out = Vec::with_capacity(n + 4); - let mut s = 0.0; if !closed { let (d, b) = (dir[0], lay.band(0.0, side)); - out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b)); - out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b)); - s = pts[0].distance(pts[1]); + out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0), 0.0, b)); + out.push(Rib::square(pts[0], d, reach(0), 0.0, b)); } let (first, last) = if closed { (0, n) } else { (1, n - 1) }; for i in first..last { let v = i % n; - let t = s / total; - let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side)); + let t = along[v] / total; + let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side)); // A closed subpath starts after the fan of its first corner and ends with it. let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 }; out.extend(ribs.into_iter().skip(skip)); - s += pts[v].distance(pts[(v + 1) % n]); } if closed { - out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side))); + out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(0), 1.0, lay.band(1.0, side))); } else { let (d, b) = (dir[seg_count - 1], lay.band(1.0, side)); - out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b)); - out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b)); + out.push(Rib::square(pts[n - 1], d, reach(n - 1), 1.0, b)); + out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(n - 1), 1.0, b)); } out } diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index bf63b46b8..78fb8315e 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -237,3 +237,52 @@ fn slices_cover_an_arrowhead_on_a_long_straight_leg() { } } } + +/// Does the last slice holding `p` (the one filled last there) lay its gradient piece on a part of +/// the path running across the page (left or right), for a gradient in `mode`? Along, the piece +/// runs the way the path does; across, square to it. +fn painted_by_level_part(slices: &[Slice], p: Point, mode: StrokeGradientMode) -> Option { + slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| { + let d = s.to - s.from; + (d.x.abs() > d.y.abs()) == (mode == StrokeGradientMode::Along) + }) +} + +/// #5960: where a variable-width stroke crosses itself, the later pass paints over the earlier one +/// only where the two overlap. Its slices reach as far as the stroke is wide there, not as far as +/// it is wide anywhere, so a narrow later pass leaves the wide earlier one its own colour beside it. +#[test] +fn a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap() { + // Right along y = 50, round, then down through the first leg at x = 50, near the narrow end. + let crossing = poly(&[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0), (50.0, 0.0)], false); + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let mut st = stroke(10.0, mode); + st.profile = Some(WidthProfile::taper_end()); + let sl = slices(&crossing, &st); + // In the first leg (about 4 pt either side of it there) but 4 pt beside the second leg, + // which is less than 1 pt either side of itself there. + let level = painted_by_level_part(&sl, Point::new(54.0, 52.0), mode); + assert_eq!(level, Some(true), "{mode:?}: painted by the second leg"); + // Where the two overlap, the second leg is on top. + let level = painted_by_level_part(&sl, Point::new(50.5, 52.0), mode); + assert_eq!(level, Some(false), "{mode:?}: painted by the first leg"); + assert_covered(&crossing, &st); + } +} + +/// #5960: a narrow pass running close beside a wide one doesn't paint the wide one's edge. +#[test] +fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() { + // Right along y = 0, then back along y = 7: the way back is the narrow end of a taper. + let hairpin = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 7.0), (0.0, 7.0)], false); + let total = 207.0; + let mut st = stroke(10.0, StrokeGradientMode::Along); + st.profile = Some(WidthProfile::taper_end()); + let sl = slices(&hairpin, &st); + // Half way along the first leg, 3 pt off it (it is about 3.8 pt wide there either side); the + // way back is about 1.2 pt wide either side there, so it reaches no nearer than 5.8. + let p = Point::new(50.0, 3.0); + let t = param(&sl, p).expect("in a slice"); + assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total); + assert_covered(&hairpin, &st); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index af0f12e59..592465351 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 (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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Stroke gradients: where a variable-width stroke crosses or runs close by itself, a narrow part no longer paints over a wide part beside it, #5960; 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 @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 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 | Stroke gradients along or across a variable-width stroke: where the stroke crosses or runs close by itself, a narrow part paints over a wide part only where the two overlap, instead of in a square as wide as the stroke gets anywhere (#5960). Each slice reaches as far as the stroke is wide there (for a dashed stroke, wherever a cap or dot can reach). Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`. 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. | | 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. | From 7f7c4cf9ad3e94a671e73ef67037b5af58303bb3 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 04:08:22 +0100 Subject: [PATCH 05/49] .ai import: test every paint mask art takes, and that it stays in the mask The mask test now checks the whole fill and stroke (colour, overprint, width, cap, join, miter, dash) and the fill rule against the owner's, and that an object after the mask's dictionary paints with the owner's paint, not the paint the mask's art set. open_mask_object gets a doc comment. --- crates/eps/src/import/ai/read/ops.rs | 2 ++ crates/eps/src/import/ai/tests.rs | 48 ++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 34f579bf7..d9ddedc92 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -43,6 +43,8 @@ impl<'a> Reader<'a> { } } + /// `X=` in a `/Mask` dictionary: the opacity mask's art starts, read until its `X+` + /// ([`Self::end_mask`]). fn open_mask_object(&mut self, hidden: bool) -> Result<(), String> { if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { // `X=` also introduces legacy text frame art, read by `text_frames`. diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 17423b46b..0ae436f72 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7627,20 +7627,48 @@ fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() { /// Mask art that sets no paint of its own paints with the paint before its dictionary: the writer /// leaves out paint that doesn't change. In a user's file, white letters were masked by a rectangle /// with no colour, which came in black and hid them. +/// The unread pattern flags it also takes aren't covered here: an owner painted with a pattern the +/// file doesn't have is unread itself, which hides whether its mask art is. #[test] fn opacity_mask_art_without_its_own_paint_takes_the_paint_before_its_dictionary() { - let owner = format!("0 0 0 0 k\n0 1 0 0 K\n2 w\n{}B\n", rect_path(10.0, 20.0, 50.0, 60.0)); - let mask = format!( - "%_/ArtDictionary :\n%_/Mask :\n%_X=\n{}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n", - rect_path(10.0, 20.0, 30.0, 60.0).lines().map(|l| format!("%_{l}\n")).collect::() + "%_B\n" - ); - let r = read_ok(&layer(&format!("{owner}{mask}"))); + let paint = "0 0 0 0 k\n1 O\n0 1 0 0 K\n1 R\n2 w\n1 J\n1 j\n4 M\n[3 1] 0 d\n1 XR\n"; + let owner = format!("{paint}{}B\n", rect_path(10.0, 20.0, 50.0, 60.0)); + let hidden_rect = rect_path(10.0, 20.0, 30.0, 60.0).lines().map(|l| format!("%_{l}\n")).collect::(); + let mask = |own_paint: &str| { + format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{own_paint}{hidden_rect}%_B\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n") + }; + let fill_layer = |n: &Node| n.appearance.fill().cloned(); + let stroke_layer = |n: &Node| n.appearance.stroke().cloned(); + let rule = |n: &Node| match &n.kind { + NodeKind::Path { rule, .. } => Some(*rule), + _ => None, + }; + + let r = read_ok(&layer(&format!("{owner}{}", mask("")))); let a = art(&r.doc); let m = a[0].extras().mask.as_ref().unwrap(); - assert_eq!(fill(&m.art), fill(&a[0])); - let stroke = |n: &Node| n.appearance.stroke().map(|s| (s.paint.clone(), s.width)); - assert_eq!(stroke(&m.art), stroke(&a[0])); - assert!(stroke(&m.art).is_some_and(|(_, w)| w == 2.0)); + // The owner has the paint set, none of it the default, so equal layers mean it was taken. + let (f, s) = (fill_layer(&a[0]).unwrap(), stroke_layer(&a[0]).unwrap()); + assert!(f.overprint && s.overprint); + assert_eq!((s.width, s.cap, s.join, s.miter_limit), (2.0, LineCap::Round, LineJoin::Round, 4.0)); + assert_eq!(s.dash.as_ref().map(|d| (d.pattern.clone(), d.offset)), Some((vec![3.0, 1.0], 0.0))); + assert_eq!(rule(&a[0]), Some(vectorcraft_geom::FillRule::EvenOdd)); + assert_eq!(fill_layer(&m.art), Some(f)); + assert_eq!(stroke_layer(&m.art), Some(s)); + assert_eq!(rule(&m.art), rule(&a[0])); + + // Paint the mask's art sets for itself stays in the mask: an object after the dictionary that + // sets none paints with the owner's. + let own = "%_1 g\n%_0 O\n%_0 G\n%_0 R\n%_5 w\n%_0 J\n%_0 j\n%_10 M\n%_[] 0 d\n%_0 XR\n"; + let following = format!("{}B\n", rect_path(70.0, 20.0, 80.0, 30.0)); + let r = read_ok(&layer(&format!("{owner}{}{following}", mask(own)))); + let a = art(&r.doc); + assert_eq!(a.len(), 2); + let m = a[0].extras().mask.as_ref().unwrap(); + assert_ne!(stroke_layer(&m.art), stroke_layer(&a[0]), "the mask's art has its own paint"); + assert_eq!(fill_layer(&a[1]), fill_layer(&a[0])); + assert_eq!(stroke_layer(&a[1]), stroke_layer(&a[0])); + assert_eq!(rule(&a[1]), rule(&a[0])); } /// An opacity mask's art of one shown object is that object; other art is read into a group. From 82596bacc701010a0601b5251cc0521edd18922b Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 7 Nov 2026 03:00:57 +0400 Subject: [PATCH 06/49] i18n(ka): Georgian for 1 new string Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/i18n/ka.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index af65f71b7..bb6eeb41c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1330,6 +1330,7 @@ lighting Light სინათლე Arabic არაბული Hindi ჰინდი Farsi სპარსული +digits Hindi ჰინდი digits Devanagari დევანაგარი digits Bengali ბენგალური digits Gurmukhi გურმუხი From 6d18549edc67726e39aaf0bb2fdb8fbeade72b59 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 13:05:03 +0100 Subject: [PATCH 07/49] Fix #5960: evidence entry in the parity document --- docs/target-app-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 592465351..56b22bf95 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -597,6 +597,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). - **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode. - **Smooth widths between width points (#5714):** a variable-width stroke changes width along a smooth curve from one width point to the next, where it used to run in a straight line and turn a corner at each point. The public description is that the path "gradually gets wider or narrower from one point to the next" in "a flowing, continuous curve", and that stacked points make a sharp break ([Peachpit, Expressive Strokes](https://www.peachpit.com/articles/article.aspx?p=2979069)); the exact curve is not documented, so each side follows a monotone cubic (Fritsch–Carlson): no kink at a point, flat where a point is wider or narrower than both neighbours, never past the points either side, points on one line kept on it. The outline samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece). Presets with a middle point (Lens, Pinch, Teardrop, Wave) look softer; two-point tapers are unchanged. Tests: `appearance::profile::tests::{widths_run_smoothly_through_width_points, widths_never_pass_the_points_either_side, a_step_ends_the_curve_on_each_side, widest_covers_every_width_along_the_profile}`, `stroke::width::tests::{a_straight_run_follows_the_curve_between_width_points, samples_between_width_points_are_capped}`. +- **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap` and `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). From 8b6e358f0c89a4626a1c0fee2492030a7298f9d4 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:31:19 -0500 Subject: [PATCH 08/49] Alt + keypad codes: with NumLock off the keypad keys count as digits and don't move the caret Follow-up to #5939. With NumLock off the keypad sends Insert, End, arrows, Clear, Home and Page Up/Down; Windows still composes the code from them, but the app also moved the caret, so the character landed elsewhere. With Alt held, those keys without the extended-key flag now count as digits and are taken out of the frame; the separate navigation keys keep their meaning. --- apps/vectorcraft/src/windows_alt_codes.rs | 190 +++++++++++++++++----- docs/type-parity.md | 5 +- 2 files changed, 154 insertions(+), 41 deletions(-) diff --git a/apps/vectorcraft/src/windows_alt_codes.rs b/apps/vectorcraft/src/windows_alt_codes.rs index a4ef28dbb..69b5f1d6a 100644 --- a/apps/vectorcraft/src/windows_alt_codes.rs +++ b/apps/vectorcraft/src/windows_alt_codes.rs @@ -13,6 +13,13 @@ //! same undo step as the typing round it. The keypad digits themselves are taken out of the frame, //! so they don't run a shortcut bound to Alt and a digit. //! +//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right, +//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the +//! separate navigation keys by the extended-key flag, which only the separate keys set +//! (), +//! so with Alt held they count as digits and are taken out of the frame too, rather than moving +//! the caret before the character arrives. +//! //! egui reports a keypad digit as the same key as the digit above the letters, so the other //! platforms, whose systems have no such convention, can't tell them apart either. @@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102; const WM_SYSKEYDOWN: u32 = 0x0104; const WM_SYSKEYUP: u32 = 0x0105; const WM_SYSCHAR: u32 = 0x0106; -/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys -/// are Insert, End, … and type no code. +/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. const VK_NUMPAD0: usize = 0x60; const VK_NUMPAD9: usize = 0x69; +/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`, +/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`. +const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21]; +/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it +/// comes as the keypad's own key, the digit). +const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] = + [Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp]; +const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; +/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the +/// keypad (the keypad's own keys clear it). +const EXTENDED_KEY: isize = 1 << 24; /// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt. const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5]; @@ -51,23 +68,42 @@ struct AltCodes { high: Option, /// Characters the codes typed, for the next frame. typed: String, - /// Keypad digit presses and releases with Alt held since the last frame, which egui reports - /// as Alt and a digit: taken out of the frame. - keypad_events: usize, + /// The digits of the keypad presses and releases with Alt held since the last frame, which + /// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken + /// out of the frame. + keypad_events: Vec, +} + +/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and +/// `lparam` the message's flags. +fn keypad_digit(wparam: usize, lparam: isize) -> Option { + if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) { + return Some(wparam - VK_NUMPAD0); + } + if lparam & EXTENDED_KEY != 0 { + return None; + } + NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam) } impl AltCodes { - /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit). - fn message(&mut self, message: u32, wparam: usize) { - let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam); - match message { + /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit) + /// and `lparam` (its flags). + fn message(&mut self, message: u32, wparam: usize, lparam: isize) { + let keypad = match message { + WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam), + _ => None, + }; + if let Some(d) = keypad { // Windows sends a key pressed with Alt held as a system key. - WM_SYSKEYDOWN if keypad => { + if message == WM_SYSKEYDOWN { self.digits += 1; - self.keypad_events += 1; self.armed = false; } - WM_SYSKEYUP if keypad => self.keypad_events += 1, + self.keypad_events.push(d); + return; + } + match message { WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {} // Any other key ends the sequence and keeps its own meaning. WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(), @@ -99,14 +135,23 @@ impl AltCodes { /// out the keypad digits typed with Alt held. fn drain(&mut self, events: &mut Vec) { let mut keypad = std::mem::take(&mut self.keypad_events); - if keypad > 0 { + if !keypad.is_empty() { events.retain(|e| { - let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt); - if digit && keypad > 0 { - keypad -= 1; - return false; + let Event::Key { key, modifiers, .. } = e else { + return true; + }; + if !modifiers.alt { + return true; + } + // The first keypad key still to take out that egui reports as `key`. + let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key); + match keypad.iter().position(stands_for) { + Some(i) => { + keypad.remove(i); + false + } + None => true, } - true }); } if !self.typed.is_empty() { @@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) { if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) { CODES.with(|c| { if let Ok(mut c) = c.try_borrow_mut() { - c.message(msg.message, msg.wParam.0); + c.message(msg.message, msg.wParam.0, msg.lParam.0); } }); } @@ -152,14 +197,14 @@ mod tests { /// Alt held, keypad digits `code`, Alt released, and the character Windows then posts. fn alt_code(c: &mut AltCodes, code: &str, posted: u16) { - c.message(WM_SYSKEYDOWN, ALT); + c.message(WM_SYSKEYDOWN, ALT, 0); for d in code.bytes() { let vk = VK_NUMPAD0 + usize::from(d - b'0'); - c.message(WM_SYSKEYDOWN, vk); - c.message(WM_SYSKEYUP, vk); + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); } - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(posted)); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); } fn key(key: Key, alt: bool, pressed: bool) -> Event { @@ -186,11 +231,11 @@ mod tests { fn a_character_without_a_code_before_it_is_left_to_winit() { let mut c = AltCodes::default(); // A plain key press's character (winit types it), and Alt pressed and released alone. - c.message(WM_KEYDOWN, 0x41); - c.message(WM_CHAR, usize::from(b'a')); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(b'b')); + c.message(WM_KEYDOWN, 0x41, 0); + c.message(WM_CHAR, usize::from(b'a'), 0); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(b'b'), 0); let mut events = vec![]; c.drain(&mut events); assert!(events.is_empty(), "{events:?}"); @@ -199,12 +244,12 @@ mod tests { #[test] fn another_key_while_alt_is_held_cancels_the_code() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); // Alt+F: a shortcut, not a code. - c.message(WM_SYSKEYDOWN, 0x46); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0x41); + c.message(WM_SYSKEYDOWN, 0x46, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); let mut events = vec![]; c.drain(&mut events); assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}"); @@ -213,13 +258,80 @@ mod tests { #[test] fn a_code_above_the_basic_plane_arrives_as_two_halves() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0xD83D); - c.message(WM_CHAR, 0xDE00); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0xD83D, 0); + c.message(WM_CHAR, 0xDE00, 0); let mut events = vec![]; c.drain(&mut events); assert_eq!(events, vec![Event::Text("😀".into())]); } + + /// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the + /// extended-key bit), Alt released, and the character Windows then posts. + fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) { + c.message(WM_SYSKEYDOWN, ALT, 0); + for d in code.bytes() { + let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')]; + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); + } + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); + } + + #[test] + fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() { + let mut c = AltCodes::default(); + alt_code_numlock_off(&mut c, "0176", 0xB0); + // What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a + // release each, then a plain Home typed after Alt came up. + let mut events = vec![]; + for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] { + events.push(key(k, true, true)); + events.push(key(k, true, false)); + } + events.push(key(Key::Home, false, true)); + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]); + } + + #[test] + fn with_numlock_off_keypad_five_counts_too() { + let mut c = AltCodes::default(); + // Keypad 5 is Clear, which egui reports as the keypad's 5; 0151 uses every other kind. + alt_code_numlock_off(&mut c, "0151", 0x2014); + alt_code_numlock_off(&mut c, "0234", 0xEA); + let mut events = + vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)]; + c.drain(&mut events); + assert_eq!(events, vec![Event::Text("—ê".into())]); + } + + #[test] + fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() { + let mut c = AltCodes::default(); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0); + // Alt+Home on the separate Home key (extended): a shortcut, not a digit. + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); + let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)]; + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]); + } + + #[test] + fn numlock_off_keys_without_alt_are_left_alone() { + let mut c = AltCodes::default(); + c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0); + c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0); + let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)]; + c.drain(&mut events); + assert_eq!(events.len(), 2); + } } diff --git a/docs/type-parity.md b/docs/type-parity.md index 4b48fd84b..89820f910 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Typographer's Quotes sets a lone typed `”` as the opening quote where one opens, for the macOS Serbian Cyrillic layout, which has no straight quote, #5247; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Alt + keypad codes with NumLock off no longer move the caret, follow-up to #5939) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -52,7 +52,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | -| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut (`windows_alt_codes` tests; not yet tried on a Windows machine). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | +| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut; tried on Windows with NumLock on. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` tests; NumLock off not yet retried on a Windows machine). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | | Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. | | Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | @@ -82,6 +82,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Alt + numeric-keypad codes with NumLock off: the keypad keys arrive as Insert, End, arrows, Clear, Home and Page Up/Down, which Windows still composes into the code; with Alt held they now count as digits and are taken out of the frame instead of moving the caret, while the separate navigation keys (extended-key flag) keep their meaning (follow-up to #5939). 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 | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | From b2acf71764299f777c6cc08b01fa41f3b0d96719 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sun, 8 Nov 2026 09:40:09 +0100 Subject: [PATCH 09/49] Fix #5960: find how far gradient ribs reach in one pass along the path Each rib of a dashed stroke with a width profile looked up to 256 points either way along the path, asking the profile at every step, and reached as wide as the stroke gets anywhere past that. A sliding maximum over the points' precomputed cover now gives every rib's reach in time in proportion to the points, with no bound and no fallback to the widest width (review on #6093). --- crates/effects/src/stroke/gradient.rs | 103 ++++++++++++-------- crates/effects/src/stroke/tests_gradient.rs | 61 ++++++++++++ docs/target-app-parity.md | 4 +- 3 files changed, 125 insertions(+), 43 deletions(-) diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 629f5b6c9..1cb79e9c7 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -13,6 +13,8 @@ //! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh //! ([`gradient_meshes`]). +use std::collections::VecDeque; + use kurbo::{BezPath, Point, Shape, Vec2}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP}; @@ -34,8 +36,6 @@ const MESH_ROWS: usize = 16; const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. const PROFILE_STEP: f64 = 1.0 / 64.0; -/// The most points either way a rib looks along the path for how wide the stroke gets near it. -const WINDOW_POINTS: usize = 256; /// Flattening tolerance, cover margin and growth (see [`gradient_slices`]) of meshes and written /// slices (document points). const SLICE_TOL: f64 = 0.05; @@ -231,13 +231,16 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); - // How far along the subpath each point is. - let mut along = Vec::with_capacity(n); - let mut s = 0.0; - for i in 0..n { - along.push(s); - s += pts[i].distance(pts[(i + 1) % n]); - } + // How far each point is from the next, and along the subpath. + let seg: Vec = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect(); + let along: Vec = seg + .iter() + .scan(0.0, |s, l| { + let at = *s; + *s += l; + Some(at) + }) + .collect(); // How far the ribs at point `i` reach: past the stroke there and on its way to the points // either side, where it is no wider than at one of the two (each side of a width profile // stays between its widths at the points of a span, and its points have points here). The @@ -246,43 +249,18 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, // stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part // reaching that far would paint over a wide part beside it (#5960). The start of a closed // subpath is also its end. Ribs near an arrowhead reach across it. - let cover = |i: usize| { - let t = along[i] / total; - if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(t) } - }; - let widest = lay.hw * lay.st.profile_max() + lay.margin; + let cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect(); let window = match lay.st.dash { Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()), _ => 0.0, }; + let ahead = widest_ahead(&cover, &seg, closed, window); + // Behind a point is ahead of it on the subpath run backwards. + let back_seg: Vec = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect(); + let back_cover: Vec = cover.iter().rev().copied().collect(); + let behind: Vec = widest_ahead(&back_cover, &back_seg, closed, window).into_iter().rev().collect(); let reach = |i: usize| { - let mut r = cover(i); - for forward in [false, true] { - // Out to the first point past the window (bounded: past that many, as wide as anywhere). - let (mut j, mut d, mut past) = (i, 0.0, false); - for _ in 0..WINDOW_POINTS { - let k = match (closed, forward) { - (true, true) => (j + 1) % n, - (true, false) => (j + n - 1) % n, - (false, true) if j + 1 < n => j + 1, - (false, false) if j > 0 => j - 1, - _ => { - past = true; - break; - } - }; - r = r.max(cover(k)); - d += pts[j].distance(pts[k]); - j = k; - if d > window { - past = true; - break; - } - } - if !past { - r = r.max(widest); - } - } + let mut r = cover[i].max(ahead[i]).max(behind[i]); let s = along[i]; if heads.0 > 0.0 && s <= heads.0 { r = r.max(heads.0 + lay.margin); @@ -317,6 +295,49 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, out } +/// For each of the points of a subpath, how far the ribs of the points ahead of it reach +/// (`cover`), out to the first point more than `window` along (or the end of an open subpath, +/// or once round a closed one): `seg[i]` is how far point `i` is from the next (past the last, +/// the first when `closed`). Both ends of the window only move forward, so a queue of the +/// points that reach farther than every point after them gives each maximum, and a subpath +/// costs time in proportion to its points however many a window spans. +pub(super) fn widest_ahead(cover: &[f64], seg: &[f64], closed: bool, window: f64) -> Vec { + let n = cover.len().min(seg.len()); + if n == 0 { + return vec![]; + } + // A closed subpath's points ahead are numbered on round it once more. + let last = if closed { 2 * n - 1 } else { n - 1 }; + let mut pos = Vec::with_capacity(last + 1); + let mut s = 0.0; + for k in 0..=last { + pos.push(s); + s += seg[k % n]; + } + let mut out = Vec::with_capacity(n); + let mut queue: VecDeque = VecDeque::new(); + let mut next = 1; + for i in 0..n { + next = next.max(i + 1); + let stop = if closed { i + n } else { last }; + let mut past = next > i + 1 && pos[next - 1] - pos[i] > window; + while !past && next <= stop { + let r = cover[next % n]; + while queue.back().is_some_and(|&k| cover[k % n] <= r) { + queue.pop_back(); + } + queue.push_back(next); + past = pos[next] - pos[i] > window; + next += 1; + } + while queue.front().is_some_and(|&k| k <= i) { + queue.pop_front(); + } + out.push(queue.front().map_or(0.0, |&k| cover[k % n])); + } + out +} + /// A piece of a stroke whose gradient runs along or across it ([`gradient_slices`]). #[derive(Clone, Debug)] pub struct Slice { diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 78fb8315e..a0056d6b3 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -286,3 +286,64 @@ fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() { assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total); assert_covered(&hairpin, &st); } + +/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along +/// the path, however many points lie that far: not as far as it is wide anywhere when more than +/// 256 do. +#[test] +fn a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone() { + // The hairpin above, the way back in 0.01 pt steps: 500 points within a round cap's 5 pt. + let mut pts = vec![(0.0, 0.0), (100.0, 0.0)]; + pts.extend((0..=10_000).map(|i| (100.0 - i as f64 * 0.01, 7.0))); + let hairpin = poly(&pts, false); + let total = 207.0; + let mut st = stroke(10.0, StrokeGradientMode::Along); + st.profile = Some(WidthProfile::taper_end()); + st.cap = LineCap::Round; + st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() }); + let sl = slices(&hairpin, &st); + let p = Point::new(50.0, 3.0); + let t = param(&sl, p).expect("in a slice"); + assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total); +} + +/// The widest the ribs ahead reach is the widest over the points out to the first one more than +/// the window along, as a walk along the subpath finds it, open and closed. +#[test] +fn widest_ahead_is_the_widest_within_the_window() { + let mut seed = 0x9e37_79b9_u64; + let mut rnd = move || { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + for _ in 0..200 { + let n = 1 + (rnd() * 40.0) as usize; + let cover: Vec = (0..n).map(|_| rnd() * 10.0).collect(); + // Some points on top of each other. + let seg: Vec = (0..n).map(|_| if rnd() < 0.2 { 0.0 } else { rnd() * 3.0 }).collect(); + let window = [0.0, rnd() * 5.0, rnd() * 200.0][(rnd() * 3.0) as usize]; + for closed in [false, true] { + let got = super::gradient::widest_ahead(&cover, &seg, closed, window); + assert_eq!(got.len(), n); + for (i, &got) in got.iter().enumerate() { + let (mut j, mut d, mut want) = (i, 0.0, 0.0_f64); + for _ in 0..n { + let k = if closed { + (j + 1) % n + } else if j + 1 < n { + j + 1 + } else { + break; + }; + want = want.max(cover[k]); + d += seg[j]; + j = k; + if d > window { + break; + } + } + assert_eq!(got, want, "n {n}, closed {closed}, window {window}, point {i}"); + } + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 56b22bf95..4dc76f8dd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -34,7 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 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 | Stroke gradients along or across a variable-width stroke: where the stroke crosses or runs close by itself, a narrow part paints over a wide part only where the two overlap, instead of in a square as wide as the stroke gets anywhere (#5960). Each slice reaches as far as the stroke is wide there (for a dashed stroke, wherever a cap or dot can reach). Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`. Scores and estimates unchanged. | +| 2026-11-07 | minor | Stroke gradients along or across a variable-width stroke: where the stroke crosses or runs close by itself, a narrow part paints over a wide part only where the two overlap, instead of in a square as wide as the stroke gets anywhere (#5960). Each slice reaches as far as the stroke is wide there (for a dashed stroke, wherever a cap or dot can reach, found in one pass along the path however densely it is flattened). Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone`, `widest_ahead_is_the_widest_within_the_window`. 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. | | 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. | @@ -597,7 +597,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). - **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode. - **Smooth widths between width points (#5714):** a variable-width stroke changes width along a smooth curve from one width point to the next, where it used to run in a straight line and turn a corner at each point. The public description is that the path "gradually gets wider or narrower from one point to the next" in "a flowing, continuous curve", and that stacked points make a sharp break ([Peachpit, Expressive Strokes](https://www.peachpit.com/articles/article.aspx?p=2979069)); the exact curve is not documented, so each side follows a monotone cubic (Fritsch–Carlson): no kink at a point, flat where a point is wider or narrower than both neighbours, never past the points either side, points on one line kept on it. The outline samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece). Presets with a middle point (Lens, Pinch, Teardrop, Wave) look softer; two-point tapers are unchanged. Tests: `appearance::profile::tests::{widths_run_smoothly_through_width_points, widths_never_pass_the_points_either_side, a_step_ends_the_curve_on_each_side, widest_covers_every_width_along_the_profile}`, `stroke::width::tests::{a_straight_run_follows_the_curve_between_width_points, samples_between_width_points_are_capped}`. -- **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap` and `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). +- **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot, found in one pass along the path, so a densely flattened path costs no more per point), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone` and `widest_ahead_is_the_widest_within_the_window`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). From b73a880b3cb19efbc659a8af0bcc8836d452624b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:34:28 +0000 Subject: [PATCH 10/49] Cut PSD Unicode layer names on a character boundary Found while testing crates/render/src/encode/psd.rs:225 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/render/src/encode/psd.rs | 11 ++++++++++- crates/render/src/encode/tests_psd.rs | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index f03e73783..777276e69 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -222,7 +222,16 @@ impl Record { extra.push(ascii.len() as u8); extra.extend(ascii); extra.resize(extra.len().next_multiple_of(4), 0); - let units: Vec = self.name.encode_utf16().take(255).collect(); + // Cut before the character that would pass 255 units, never inside a surrogate pair. + let mut units: Vec = vec![]; + for c in self.name.chars() { + let mut buf = [0; 2]; + let encoded = c.encode_utf16(&mut buf); + if units.len() + encoded.len() > 255 { + break; + } + units.extend_from_slice(encoded); + } let mut luni = (units.len() as u32).to_be_bytes().to_vec(); luni.extend(units.iter().flat_map(|u| u.to_be_bytes())); block(&mut extra, b"luni", &luni)?; diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index 60f5aae05..e56278f1d 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -321,3 +321,12 @@ fn the_profile_and_the_size_limit() { let err = Renderer::new().export_region(&doc, doc.artboards[0].rect, RasterFormat::Psd, &o).unwrap_err(); assert!(err.contains(&MAX_SIDE.to_string()), "{err}"); } + +#[test] +fn long_unicode_name_is_cut_on_a_character_boundary() { + let mut b = DocBuilder::new(100.0, 60.0); + b.layer(&("a".repeat(254) + "😀")); + b.rect(Rect::new(0.0, 0.0, 5.0, 5.0), Color::rgb8(0, 0, 0), |_| {}); + let file = export(&b.build(), &opts(PsdOptions::default())); + assert_eq!(parse(&file).records.last().unwrap().name, "a".repeat(254)); +} From a571e242afd4a6aa55b07efcad3b34dd0e73ca86 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 14:14:23 +0100 Subject: [PATCH 11/49] Fix #5188: a Shift-click on a corner widget of a whole path picks its corner With Direct Selection, a Shift-click on a corner widget toggled its corner. With the path selected whole every corner already is, so the first Shift-click deselected the corner clicked and hid its widget, leaving the others. While every corner is selected, a Shift-click now picks that corner alone, as the first click of "click one widget, then Shift-click the others" does; with some corners picked it still toggles. --- crates/tools/src/corners.rs | 8 +++-- crates/tools/src/corners/tests.rs | 49 +++++++++++++++++++++++++++---- docs/target-app-parity.md | 4 ++- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index 5a7a49e1c..0629c53c8 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -257,7 +257,7 @@ pub struct CornerDrag { } /// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects -/// (Shift: toggles), as a click on the corner's anchor does. +/// (Shift: toggles, unless every corner is selected), as a click on the corner's anchor does. #[derive(Clone, Debug)] struct CornerClick { id: NodeId, @@ -282,7 +282,11 @@ impl CornerDrag { let path = widgets.paths.get(widget.0)?; let corner = path.widgets.get(widget.1)?.corner.index; let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?; - Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift }) + // While every corner is selected (the path selected whole), a Shift-click picks + // this corner alone, as the first click of "click one widget, then Shift-click the + // others" does: toggling it would deselect it and leave the rest (#5188). + let every = lc.all().is_subset(&lc.picked(cx.selection.partial(path.id))); + Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift && !every }) }) .flatten(); let outlines = widgets diff --git a/crates/tools/src/corners/tests.rs b/crates/tools/src/corners/tests.rs index a1fb0ee52..5cd783679 100644 --- a/crates/tools/src/corners/tests.rs +++ b/crates/tools/src/corners/tests.rs @@ -196,7 +196,9 @@ fn drag_follows_a_rotated_shape_and_a_click_does_nothing() { /// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the /// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift -/// toggles the corner. The Selection tool's click still does nothing. +/// toggles the corner, except while every corner is selected (#5188, see +/// `a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`). The Selection tool's click +/// still does nothing. #[test] fn a_click_on_a_widget_with_direct_selection_selects_its_corner() { let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); @@ -211,20 +213,57 @@ fn a_click_on_a_widget_with_direct_selection_selects_its_corner() { assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty()); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]); - let shift = Mods { shift: true, ..Mods::default() }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]); // With that corner selected, its widget alone shows, and a drag rounds it alone // (`direct_selected_corners_show_their_widgets_alone_and_round_alone`). let mut part = selected(id); part.anchors.insert(id, anchors.iter().copied().collect()); assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]); + // A Shift-click on it then deselects it. + let shift = Mods { shift: true, ..Mods::default() }; + let one = crate::testutil::cx(&d, &part, &p); + t.pointer(&one, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift)); + let a = t.pointer(&one, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]); let mut t = crate::create("selection"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty()); } +/// #5188: while every corner of a path is selected (the path selected whole), a Shift-click on a +/// widget picks that corner alone, as the first click of "click one widget, then Shift-click the +/// others" does, rather than deselecting it and leaving the others. Once some corners are picked, +/// Shift-clicks toggle. +#[test] +fn a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner() { + let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); + let p = paint(); + let lc = LiveCorners::of(d.node(id).unwrap()).unwrap(); + let anchors = |k: usize| -> Vec<(usize, usize)> { lc.anchors_of(&[k].into()).into_iter().collect() }; + let shift = Mods { shift: true, ..Mods::default() }; + let click = |s: &Selection, at: Point| { + let cx = crate::testutil::cx(&d, s, &p); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift)) + }; + let pick = |k: usize, mode: &str| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors(k), "mode": mode}))]; + // Selected whole: every widget shows; a Shift-click on one picks its corner alone. + let whole = selected(id); + let w = pts(&CornerWidgets::of(&d, &whole, 1.0, true).unwrap()); + assert_eq!(w.len(), 4); + assert_eq!(click(&whole, w[2]), pick(2, "set")); + // Every anchor direct-selected is every corner selected too. + let mut every = selected(id); + every.anchors.insert(id, (0..4).flat_map(anchors).collect()); + assert_eq!(click(&every, w[1]), pick(1, "set")); + // Two corners picked: a Shift-click on either widget deselects that corner. + let mut two = selected(id); + two.anchors.insert(id, [0, 2].into_iter().flat_map(anchors).collect()); + let shown = pts(&CornerWidgets::of(&d, &two, 1.0, true).unwrap()); + assert_eq!(shown, [w[0], w[2]]); + assert_eq!(click(&two, w[0]), pick(0, "toggle")); +} + #[test] fn direct_selected_corners_show_their_widgets_alone_and_round_alone() { let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4dc76f8dd..2cba66ea7 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 (Stroke gradients: where a variable-width stroke crosses or runs close by itself, a narrow part no longer paints over a wide part beside it, #5960; 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 (Live Corners: with every corner selected, a Shift-click on a corner widget picks that corner instead of deselecting it, #5188; 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 @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). Once some corners are picked, Shift-click still adds or removes one. Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | | 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 | @@ -558,6 +559,7 @@ since 2026-10-01. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. +- **Shift-click on corner widgets (#5188):** with Direct Selection, when every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, so the next drag rounds just it; once some corners are picked, Shift-click adds or removes a corner as on its anchor. Corner widgets are chosen by clicking one and Shift-clicking the others ([Logos By Nick, rounded corners in Illustrator](https://logosbynick.com/?p=15787)). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. - **TIFF in screen and asset export:** both format menus expose the existing TIFF encoder and Format Settings. Headless UI regressions choose TIFF and check scaled dimensions, artboard selection, filenames, encoding options, saved settings and collected-asset output. Public reference: [Export for Screens](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/export-for-screens.html), updated 2026-09-28, lists TIFF for both artboards and assets. Adobe staff [confirmed support in 30.2.1](https://illustrator.uservoice.com/forums/333657-illustrator-desktop-feature-requests/suggestions/49081034-add-tiff-format-into-export-for-screens-assets-d) on 2026-02-19; the older 2025 Collect assets guide omits TIFF. - **Export for Screens of the selected artboards:** while the Artboard tool is in use, File › Export › Export for Screens… (`file.exportForScreens`) opens with the artboards the tool has selected checked on its Artboards tab, and Export Artboard writes only those. Shift-clicks on the canvas, or Shift- or Ctrl/Cmd-clicks on the Artboards panel's rows, select several. The other fields show the settings the document last exported with. With another tool in use, the dialog takes its artboard choice from those settings too. Public reference: [Export selected artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/export-artboards.html). From e15965199f37b9f41285b97a39983d498c4ae8da Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 14:22:52 +0100 Subject: [PATCH 12/49] Fix #5188: the docs say how other corners are added after the first pick --- docs/target-app-parity.md | 3 ++- docs/ui-parity.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2cba66ea7..788e59c4c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). Once some corners are picked, Shift-click still adds or removes one. Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | | 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. | @@ -559,7 +560,7 @@ since 2026-10-01. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. -- **Shift-click on corner widgets (#5188):** with Direct Selection, when every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, so the next drag rounds just it; once some corners are picked, Shift-click adds or removes a corner as on its anchor. Corner widgets are chosen by clicking one and Shift-clicking the others ([Logos By Nick, rounded corners in Illustrator](https://logosbynick.com/?p=15787)). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. +- **Shift-click on corner widgets (#5188):** with Direct Selection, when every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, so the next drag rounds just it. The other corners' widgets then hide; a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Corner widgets are chosen by clicking one and Shift-clicking the others ([Logos By Nick, rounded corners in Illustrator](https://logosbynick.com/?p=15787)). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. - **TIFF in screen and asset export:** both format menus expose the existing TIFF encoder and Format Settings. Headless UI regressions choose TIFF and check scaled dimensions, artboard selection, filenames, encoding options, saved settings and collected-asset output. Public reference: [Export for Screens](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/export-for-screens.html), updated 2026-09-28, lists TIFF for both artboards and assets. Adobe staff [confirmed support in 30.2.1](https://illustrator.uservoice.com/forums/333657-illustrator-desktop-feature-requests/suggestions/49081034-add-tiff-format-into-export-for-screens-assets-d) on 2026-02-19; the older 2025 Collect assets guide omits TIFF. - **Export for Screens of the selected artboards:** while the Artboard tool is in use, File › Export › Export for Screens… (`file.exportForScreens`) opens with the artboards the tool has selected checked on its Artboards tab, and Export Artboard writes only those. Shift-clicks on the canvas, or Shift- or Ctrl/Cmd-clicks on the Artboards panel's rows, select several. The other fields show the settings the document last exported with. With another tool in use, the dialog takes its artboard choice from those settings too. Public reference: [Export selected artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/export-artboards.html). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 15bf3cdb4..bc19d68c7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # 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-07 · **Change:** minor (Live Corners: with every corner selected, a Shift-click on a corner widget picks that corner, #5188; 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 @@ -191,7 +191,7 @@ tools, never checked side by side) isn't captured row by row. | Alt with Direct Selection makes a marquee Group Selection's for the drag: it selects every path it touches, whole (Shift toggles them), and the Group Selection pointer stays until the release | done | #4890 | | Direct Selection: a click on a selected object's centre point (Attributes › Show Center) selects the object whole, and a drag from it moves the object in one undo step | done | #4891 | | Escape cancels a Direct Selection drag | done | #1803 (#1743) | -| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | +| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239), and with every corner selected a Shift-click on a widget picks that corner alone instead of deselecting it (#5188); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | | Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | | A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws (so a second shape drawn from the first one's centre moves the first: press elsewhere or deselect first). Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` | @@ -480,6 +480,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); 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 d8074562d1d22ff651cfd941b5677538807c3899 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:45:22 -0500 Subject: [PATCH 13/49] Alt + keypad codes: docs say the Windows path is unit-tested only; fix two comments The type parity row no longer says the NumLock-on path was tried on Windows: neither path has been run on a Windows machine yet. The comment on the NumLock-off egui keys says Clear comes as the top-row 5, and the test comment names the keys 0151 and 0234 actually use. --- apps/vectorcraft/src/windows_alt_codes.rs | 5 +++-- docs/type-parity.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/vectorcraft/src/windows_alt_codes.rs b/apps/vectorcraft/src/windows_alt_codes.rs index 69b5f1d6a..b370bf214 100644 --- a/apps/vectorcraft/src/windows_alt_codes.rs +++ b/apps/vectorcraft/src/windows_alt_codes.rs @@ -42,7 +42,7 @@ const VK_NUMPAD9: usize = 0x69; /// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`. const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21]; /// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it -/// comes as the keypad's own key, the digit). +/// comes as the top-row digit key, `Num5`). const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] = [Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp]; const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; @@ -300,7 +300,8 @@ mod tests { #[test] fn with_numlock_off_keypad_five_counts_too() { let mut c = AltCodes::default(); - // Keypad 5 is Clear, which egui reports as the keypad's 5; 0151 uses every other kind. + // Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear; + // 0234 uses Insert, Down, Page Down and Left. alt_code_numlock_off(&mut c, "0151", 0x2014); alt_code_numlock_off(&mut c, "0234", 0xEA); let mut events = diff --git a/docs/type-parity.md b/docs/type-parity.md index 89820f910..5675a804d 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -52,7 +52,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | -| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut; tried on Windows with NumLock on. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` tests; NumLock off not yet retried on a Windows machine). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | +| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | | Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. | | Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | From c2c92b7d491b57d25d5fc12ab32833d78f4282fc Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 14:34:06 +0100 Subject: [PATCH 14/49] Fix #5188: selected anchors that are no corner keep Shift-click adding --- crates/tools/src/corners.rs | 6 ++++-- crates/tools/src/corners/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index 0629c53c8..c60c6a748 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -284,8 +284,10 @@ impl CornerDrag { let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?; // While every corner is selected (the path selected whole), a Shift-click picks // this corner alone, as the first click of "click one widget, then Shift-click the - // others" does: toggling it would deselect it and leave the rest (#5188). - let every = lc.all().is_subset(&lc.picked(cx.selection.partial(path.id))); + // others" does: toggling it would deselect it and leave the rest (#5188). Selected + // anchors that are no corner show every widget too, but select no corner: there a + // Shift-click adds the corner. + let every = cx.selection.partial(path.id).is_none_or(|anchors| lc.all().is_subset(&lc.corners_of(anchors))); Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift && !every }) }) .flatten(); diff --git a/crates/tools/src/corners/tests.rs b/crates/tools/src/corners/tests.rs index 5cd783679..e555782d1 100644 --- a/crates/tools/src/corners/tests.rs +++ b/crates/tools/src/corners/tests.rs @@ -264,6 +264,29 @@ fn a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner() { assert_eq!(click(&two, w[0]), pick(0, "toggle")); } +/// #5188: an anchor selected that is no corner (here half way along a side) shows every widget +/// but selects no corner, so a Shift-click on a widget adds its corner to that selection. +#[test] +fn a_shift_click_on_a_widget_adds_to_anchors_that_are_no_corner() { + let corners = [(100.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| Point::new(x, y)); + let (d, id) = doc_with(PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true)), None); + let p = paint(); + let mut s = selected(id); + s.anchors.insert(id, [(0, 1)].into()); + let cx = cx(&d, &s, &p); + let widgets = CornerWidgets::for_tool(&cx, true).unwrap(); + assert_eq!(widgets.corners().len(), 4, "no corner selected: every widget shows"); + let at = pts(&widgets)[0]; + let lc = LiveCorners::of(d.node(id).unwrap()).unwrap(); + let corner = *lc.all().iter().next().unwrap(); + let anchors: Vec<(usize, usize)> = lc.anchors_of(&[corner].into()).into_iter().collect(); + let shift = Mods { shift: true, ..Mods::default() }; + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]); +} + #[test] fn direct_selected_corners_show_their_widgets_alone_and_round_alone() { let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); From 91a0ba4231a7a5760b273dc7b2a28f16af6c8345 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 16:57:19 +0100 Subject: [PATCH 15/49] Pen: Copy and Cut take the anchors a Cmd-lent Direct Selection picked (#6123) With the Pen or Curvature tool, Copy and Cut ignore the anchor selection, so that the last anchor the Pen leaves selected doesn't make Copy take just that anchor (#2122). Anchors picked with the Direct Selection that Cmd/Ctrl lends those tools were ignored too, and the whole path was copied. The session now remembers the selection the lent tool leaves when it gives the tool back. While that is still the selection, Copy and Cut take the picked anchors and segments, as with Direct Selection chosen. --- crates/engine/src/cmd/edit.rs | 4 ++- crates/engine/src/lib.rs | 4 +++ crates/engine/src/session/documents.rs | 2 ++ crates/engine/src/tests/draw2.rs | 38 ++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 1 + docs/ui-parity.md | 5 ++-- 6 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f6491d757..dfd50ae72 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -930,7 +930,9 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option>)>) -> i /// The direct-selection flag and resolved root nodes for Copy and Cut operations. fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { let st = s.doc()?; - let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature"); + // The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes + // the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123). + let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && s.lent_selection.as_ref() != Some(&st.selection); let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty()); let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))); let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dc19b9edb..2200b7990 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -62,6 +62,10 @@ pub struct Session { /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). pub(crate) lender: Option>, + /// The selection the lent tool's gesture left as it gave `lender` back: while it is still the + /// selection, the anchors in it are ones picked, not the Pen's last anchor, and Copy and Cut take + /// them with the Pen or Curvature tool back too (#6123). + pub(crate) lent_selection: Option, /// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd /// lends the other tools. None until one is chosen. pub(crate) last_selection_tool: Option<&'static str>, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ab258887f..cee5040f4 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -42,6 +42,7 @@ impl Session { journal: Journal::default(), tool: vectorcraft_tools::create("selection"), lender: None, + lent_selection: None, last_selection_tool: None, last_view: ViewInfo::default(), preview_memo: Default::default(), @@ -162,6 +163,7 @@ impl Session { } self.keep_tool_settings(); self.tool = self.make_tool(self.tool.id()); + self.lent_selection = None; self.tool_resets = self.tool_resets.wrapping_add(1); self.tool_set_since_reset = false; } diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 1af4ac40f..13cdefbdf 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2673,6 +2673,44 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() { assert_eq!(s.tool_id(), "rectangle"); } +/// #6123: anchors that Cmd (Ctrl) held with the Pen selects with the Direct Selection it lends are +/// what Copy and Cut take, as with Direct Selection chosen; the last anchor the Pen leaves selected +/// while drawing is not, Copy taking the whole path (#2122). +#[test] +fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { + let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() }); + let v = view(); + let mut s = session(); + s.select_tool("pen", v).unwrap(); + for x in [100.0, 200.0, 300.0, 400.0] { + gesture(&mut s, &[(x, 300.0)], none); + } + let id = s.doc().unwrap().selection.objects[0]; + let pasted = |s: &mut Session| { + let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone(); + let ids: Vec = ids.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect(); + ids.iter().map(|i| path(s, *i).subpaths[0].anchors.len()).collect::>() + }; + // A Cmd marquee around the middle two anchors: Copy takes them and the segment between. + gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd); + assert_eq!(s.tool_id(), "pen"); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [2]); + // Cut takes the segment between them out of the path, as with Direct Selection chosen. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + let left = path(&s, id).subpaths.iter().map(|sp| sp.anchors.len()).collect::>(); + assert_eq!((s.clipboard.nodes.len(), left), (1, vec![2, 2])); + // Drawing on: the last anchor the Pen leaves selected doesn't make Copy take that anchor. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + gesture(&mut s, &[(500.0, 300.0)], none); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [5]); +} + /// #4980: Cmd/Ctrl held with the Pen drags as Direct Selection: an anchor of the path being drawn /// moves on its own and the Pen goes on drawing that path from it; another path's straight segment /// moves both its ends and a curved one bends; each drag is one undo step and the pointer is the diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 8ecc738f2..9df450e1b 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -232,6 +232,7 @@ impl Session { let r = self.apply_actions(acts); self.keep_tool_settings(); self.tool = lender; + self.lent_selection = self.active().map(|d| d.selection.clone()); self.after_command(None); r.map(drop) } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bc19d68c7..30ed2ed66 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Live Corners: with every corner selected, a Shift-click on a corner widget picks that corner, #5188; 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-07 · **Change:** minor (Pen with Cmd/Ctrl held: Copy and Cut take the anchors the lent Direct Selection picked, #6123; 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 @@ -131,7 +131,7 @@ tools, never checked side by side) isn't captured row by row. | Click the first anchor to close; the drag shapes the closing curve | done | | | Click the last anchor to retract its outgoing handle; drag from it to pull a new one | done | | | Alt over a handle, anchor or segment acts as the Anchor Point tool | done | | -| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards; with Direct Selection (the default until a selection tool is chosen) a Cmd drag moves an anchor of the path being drawn, moves a straight segment or bends a curved one, one undo step each, and a Cmd-click away from art ends the path; with Selection the hidden bounding box of the path being drawn takes no press, so the drag moves the path (#4980) | +| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards; with Direct Selection (the default until a selection tool is chosen) a Cmd drag moves an anchor of the path being drawn, moves a straight segment or bends a curved one, one undo step each, and a Cmd-click away from art ends the path; with Selection the hidden bounding box of the path being drawn takes no press, so the drag moves the path (#4980); anchors or segments the lent Direct Selection picks are what Copy and Cut take, as with Direct Selection chosen, while Copy with the Pen's own last anchor selected takes the whole path (#6123) | | Click an open path's end to continue it; click another path's end to join | done | #776 | | Rubber Band preview | done | Preferences option | | Auto Add/Delete on a selected path; Shift or the preference disables it | done | | @@ -480,6 +480,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); 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 | From 748ac5cf6e5359881cc92052ecf5340f84e9560b Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 19:07:16 +0100 Subject: [PATCH 16/49] Pen: keep a lent pick across tool resets; a gesture that picks nothing keeps the Pen's anchor (#6123) The remembered selection is now tied to the document it was made in, by its uid, instead of being cleared on every tool reset: another view of the same document, or closing an inactive tab, kept the anchors picked but made Copy take the whole path. A lent gesture that leaves the selection as it found it (a Cmd+Shift marquee over nothing) is no longer recorded as a pick, so the Pen's last anchor stays its own and Copy takes the whole path. Also records #6123 in target-app-parity.md. --- crates/engine/src/cmd/edit.rs | 3 ++- crates/engine/src/lib.rs | 12 ++++++++---- crates/engine/src/session/documents.rs | 2 +- crates/engine/src/tests/draw2.rs | 16 +++++++++++++++- crates/engine/src/tooling.rs | 8 +++++++- docs/target-app-parity.md | 4 +++- 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dfd50ae72..19c64859e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -932,7 +932,8 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { let st = s.doc()?; // The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes // the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123). - let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && s.lent_selection.as_ref() != Some(&st.selection); + let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection); + let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked; let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty()); let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))); let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2200b7990..c9533158d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -62,10 +62,14 @@ pub struct Session { /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). pub(crate) lender: Option>, - /// The selection the lent tool's gesture left as it gave `lender` back: while it is still the - /// selection, the anchors in it are ones picked, not the Pen's last anchor, and Copy and Cut take - /// them with the Pen or Curvature tool back too (#6123). - pub(crate) lent_selection: Option, + /// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's + /// gesture found, so that one that leaves it as it was picks nothing. + pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>, + /// The selection a lent tool's gesture changed and left as it gave `lender` back, in the + /// document (its uid) it was made in: while it is still that document's selection, the anchors + /// in it are ones picked, not the Pen's last anchor, and Copy and Cut take them with the Pen or + /// Curvature tool back too (#6123). + pub(crate) lent_selection: Option<(u64, vectorcraft_doc::Selection)>, /// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd /// lends the other tools. None until one is chosen. pub(crate) last_selection_tool: Option<&'static str>, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index cee5040f4..d4859cdf1 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -42,6 +42,7 @@ impl Session { journal: Journal::default(), tool: vectorcraft_tools::create("selection"), lender: None, + selection_at_lend: None, lent_selection: None, last_selection_tool: None, last_view: ViewInfo::default(), @@ -163,7 +164,6 @@ impl Session { } self.keep_tool_settings(); self.tool = self.make_tool(self.tool.id()); - self.lent_selection = None; self.tool_resets = self.tool_resets.wrapping_add(1); self.tool_set_since_reset = false; } diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 13cdefbdf..e2c0be615 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2675,7 +2675,8 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() { /// #6123: anchors that Cmd (Ctrl) held with the Pen selects with the Direct Selection it lends are /// what Copy and Cut take, as with Direct Selection chosen; the last anchor the Pen leaves selected -/// while drawing is not, Copy taking the whole path (#2122). +/// while drawing is not, Copy taking the whole path (#2122), nor after a Cmd gesture that picks +/// nothing. #[test] fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() }); @@ -2709,6 +2710,19 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into()); s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(pasted(&mut s), [5]); + // A Cmd+Shift marquee over nothing picks nothing: the Pen's last anchor stays its own. + s.execute("edit.undo", &json!({})).unwrap(); + let shift_cmd = Mods { shift: true, ..cmd }; + gesture(&mut s, &[(600.0, 500.0), (700.0, 600.0)], shift_cmd); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [5]); + // The tool started afresh in the same document (another view of it) keeps a pick picked. + s.execute("edit.undo", &json!({})).unwrap(); + gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd); + s.reset_tool(); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [2]); } /// #4980: Cmd/Ctrl held with the Pen drags as Direct Selection: an anchor of the path being drawn diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9df450e1b..28b014ee5 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -219,6 +219,7 @@ impl Session { // The Selection and Group Selection tools act on the instance whole. self.select_whole_instance(id); let lent = self.make_tool(id); + self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone())); self.lender = Some(std::mem::replace(&mut self.tool, lent)); PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev } } @@ -232,7 +233,12 @@ impl Session { let r = self.apply_actions(acts); self.keep_tool_settings(); self.tool = lender; - self.lent_selection = self.active().map(|d| d.selection.clone()); + // A gesture that left the selection as it found it (a Shift marquee over nothing) picked + // nothing: the selection keeps what it was, the Pen's last anchor or an earlier pick. + let left = self.active().map(|d| (d.uid, d.selection.clone())); + if left != self.selection_at_lend.take() { + self.lent_selection = left; + } self.after_command(None); r.map(drop) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 788e59c4c..85a6a6f82 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 (Live Corners: with every corner selected, a Shift-click on a corner widget picks that corner instead of deselecting it, #5188; 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 (Pen with Cmd/Ctrl held: Copy and Cut take the anchors the lent Direct Selection picked, #6123; 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 @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). Once some corners are picked, Shift-click still adds or removes one. Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | | 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. | @@ -551,6 +552,7 @@ since 2026-10-01. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. +- **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From 8bb99ae455b7b85faca1016a7b357bfbc8c45b36 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 19:17:19 +0100 Subject: [PATCH 17/49] Docs: record #6123 in the Drawing tools area row and the M2 milestone row Scores and hours unchanged. --- docs/roadmap.md | 5 +++-- docs/target-app-parity.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index a5866a1c8..cf3690098 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 (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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (M2: Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked, #6123; 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 @@ -121,7 +121,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | @@ -182,6 +182,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); 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 85a6a6f82..6b2ac2a6c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -187,7 +187,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Area | Weight | Done | Main gaps | One agent (h) | Kind | |---|---:|---:|---|---:|---| | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | -| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged | +| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; 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 (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | From 194364304bec7b677a8d8a7118ecaaf6a865fb71 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:55:41 +0000 Subject: [PATCH 18/49] Strip CSS comments from style attributes and rule declarations Found while testing crates/svg/src/import/css.rs:20 (parse_decls / split_decls) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/import/css.rs | 14 ++++++++++---- crates/svg/src/import/css/tests.rs | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 3593a870b..fd864a547 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -18,7 +18,8 @@ pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>; /// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The /// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557). pub(super) fn parse_decls(s: &str) -> Vec<(String, String, bool)> { - split_decls(s) + let s = strip_comments(s); + split_decls(&s) .into_iter() .filter_map(|d| { let (k, v) = d.split_once(':')?; @@ -354,9 +355,8 @@ fn top_level_commas(list: &str) -> impl Iterator { }) } -/// Rules from every `