From 0437d987fbf02e10e1f6a4e66a59564e6a875900 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 15:09:55 +0500 Subject: [PATCH 01/14] =?UTF-8?q?#4936:=20Edit=20=E2=80=BA=20Edit=20Custom?= =?UTF-8?q?=20Dictionary=E2=80=A6=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom dictionary's words in a list, with an Entry field and Add, Delete and Change, applied on Done, as the public Check and correct spelling page describes it. The menu item moves from the Spelling submenu to the Edit menu, where the page puts it. --- crates/engine/src/cmd/spelling.rs | 6 + crates/ui-egui/src/dialogs.rs | 2 + .../ui-egui/src/dialogs/custom_dictionary.rs | 308 ++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/menus/invoke.rs | 7 + crates/ui-egui/src/menus/tree.rs | 10 +- docs/gaps.md | 5 +- docs/mcp.md | 4 + docs/roadmap.md | 9 +- docs/target-app-parity.md | 17 +- docs/type-parity.md | 5 +- 37 files changed, 407 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/custom_dictionary.rs diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 8ae2a49c2..464c25ed2 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -223,6 +223,12 @@ fn suggest(_s: &mut Session, p: &Value) -> Result { Ok(json!({ "suggestions": suggestions })) } +/// `w` as the custom dictionary keeps it (trimmed, lower case), or None when it isn't one word of at +/// most `craft_proof::MAX_CUSTOM_WORD_CHARS` characters: what `text.spelling.addWord` takes. +pub fn custom_word(w: &str) -> Option { + craft_proof::custom_word(w) +} + /// `p`'s `key` as a custom-dictionary word (trimmed, lower case), or an error naming what it must be. fn word_param(p: &Value, key: &str, command: &str) -> Result { let w = str_param(p, key).ok_or_else(|| bad(command, format!("missing `{key}`")))?; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fd0373b72..38ccc908d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,6 +26,7 @@ mod command; pub mod composite_fonts; pub mod confirm; pub mod corners; +pub mod custom_dictionary; pub mod dimension; mod document_setup; mod duotone; @@ -432,6 +433,7 @@ registry! { NewBrush: [new_brush::KIND] => new_brush::SPEC, EditSelection: [edit_selection::KIND] => edit_selection::SPEC, EditViews: [edit_views::KIND] => edit_views::SPEC, + EditCustomDictionary: [custom_dictionary::KIND] => custom_dictionary::SPEC, PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, LayerOptions: [layer_options::KIND] => layer_options::SPEC, LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/custom_dictionary.rs b/crates/ui-egui/src/dialogs/custom_dictionary.rs new file mode 100644 index 000000000..ef72573ea --- /dev/null +++ b/crates/ui-egui/src/dialogs/custom_dictionary.rs @@ -0,0 +1,308 @@ +//! Edit › Edit Custom Dictionary…: the custom dictionary's words in a list, with an Entry field and +//! Add, Delete and Change, as the public +//! [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html) +//! page describes it: enter a word and Add it, select a word and Delete it, or select a word, edit it +//! in the Entry field and Change it. Nothing changes until Done, which deletes and adds the words +//! that differ (`text.spelling.removeWord`, `text.spelling.addWord`); Cancel leaves the dictionary as +//! it was. +//! +//! Fields: `orig` (the words when the dialog opened), `words` (as edited, sorted), `entry` (the +//! Entry field) and `selected` (the word picked, or null). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::spelling::custom_word; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Edit Custom Dictionary. +pub const KIND: &str = "editCustomDictionary"; + +/// The command its menu item runs: with no params, it opens this dialog. +pub const COMMAND: &str = "text.spelling.dictionary"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Edit Custom Dictionary").into(), body, confirm, ok: Some("Done"), min_width: 420.0, ..DialogSpec::FORM }; + +/// Open Edit Custom Dictionary on the custom dictionary's words. +pub fn open(app: &mut VectorcraftApp) -> Result { + let words = app.run(COMMAND, json!({})).map_err(|e| e.to_string())?.get("words").cloned().unwrap_or_else(|| json!([])); + app.ui.dialog = Some(Dialog::new(KIND, json!({ "orig": words, "words": words, "entry": "", "selected": null }))); + Ok(Value::Null) +} + +fn list(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default() +} + +/// The word picked in the list, while it is still there. +fn selected(d: &Dialog, words: &[String]) -> Option { + d.fields.get("selected").and_then(Value::as_str).filter(|w| words.iter().any(|x| x == w)).map(String::from) +} + +/// What the buttons can do now: the Entry field as a dictionary word that isn't in the list yet +/// (for Add and Change), and the word picked (for Delete and Change). +fn actions(d: &Dialog) -> (Option, Option) { + let words = list(d, "words"); + let entry = custom_word(&d.str("entry")).filter(|w| !words.contains(w)); + (entry, selected(d, &words)) +} + +fn set_words(d: &mut Dialog, mut words: Vec, selected: Option<&str>) { + words.sort(); + words.dedup(); + d.fields.insert("words".into(), json!(words)); + d.fields.insert("selected".into(), json!(selected)); +} + +/// Add: the Entry field's word joins the list, picked. +fn add(d: &mut Dialog) { + let (Some(w), _) = actions(d) else { return }; + let mut words = list(d, "words"); + words.push(w.clone()); + set_words(d, words, Some(&w)); +} + +/// Delete: the word picked leaves the list; the one after it (or before, for the last) is picked. +fn delete(d: &mut Dialog) { + let (_, Some(w)) = actions(d) else { return }; + let mut words = list(d, "words"); + let Some(i) = words.iter().position(|x| *x == w) else { return }; + words.remove(i); + let next = words.get(i).or_else(|| words.last()).cloned(); + if let Some(n) = &next { + d.fields.insert("entry".into(), json!(n)); + } + set_words(d, words, next.as_deref()); +} + +/// Change: the word picked becomes the Entry field's word. +fn change(d: &mut Dialog) { + let (Some(to), Some(w)) = actions(d) else { return }; + let mut words: Vec = list(d, "words").into_iter().filter(|x| *x != w).collect(); + words.push(to.clone()); + set_words(d, words, Some(&to)); +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let words = list(d, "words"); + let current = selected(d, &words); + let (entry, picked) = actions(d); + let mut pick = None; + let (mut added, mut deleted, mut changed) = (false, false, false); + widgets::dim_label(ui, tl!("Entry:")); + form::text(ui, d, "entry", 380.0); + ui.add_space(8.0); + ui.horizontal_top(|ui| { + widgets::list_box(ui, |ui| { + ui.set_width(260.0); + let row = ui.spacing().interact_size.y; + egui::ScrollArea::vertical().id_salt("custom-dictionary").min_scrolled_height(200.0).max_height(200.0).show_rows( + ui, + row, + words.len(), + |ui, range| { + ui.set_width(ui.available_width()); + for w in words.get(range).unwrap_or_default() { + if ui.selectable_label(current.as_ref() == Some(w), w.as_str()).clicked() { + pick = Some(w.clone()); + } + } + }, + ); + }); + ui.add_space(16.0); + ui.vertical(|ui| { + added = ui.add_enabled_ui(entry.is_some(), |ui| widgets::flat_button(ui, tl!("Add"), 90.0).clicked()).inner; + ui.add_space(6.0); + deleted = ui.add_enabled_ui(picked.is_some(), |ui| widgets::flat_button(ui, tl!("Delete"), 90.0).clicked()).inner; + ui.add_space(6.0); + changed = ui.add_enabled_ui(entry.is_some() && picked.is_some(), |ui| widgets::flat_button(ui, tl!("Change"), 90.0).clicked()).inner; + }); + }); + // Picking a word puts it in the Entry field, to edit for Change. + if let Some(w) = pick { + d.fields.insert("entry".into(), json!(w)); + d.fields.insert("selected".into(), json!(w)); + } + if added { + add(d); + } else if deleted { + delete(d); + } else if changed { + change(d); + } + false +} + +/// Done: deletes the words no longer in the list, then adds the new ones. A word that can't be +/// saved keeps the dialog open with the error, its list as edited. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (orig, words) = (list(d, "orig"), list(d, "words")); + // The dictionary as it is now: another window may have deleted a word already. + let now = app.run(COMMAND, json!({})).map_err(|e| e.to_string())?.get("words").cloned().unwrap_or_default(); + let kept = |w: &String| now.as_array().is_some_and(|a| a.contains(&json!(w))); + for w in orig.iter().filter(|w| !words.contains(w) && kept(w)) { + app.run("text.spelling.removeWord", json!({ "word": w })).map_err(|e| e.to_string())?; + } + for w in words.iter().filter(|w| !orig.contains(w)) { + app.run("text.spelling.addWord", json!({ "word": w })).map_err(|e| e.to_string())?; + } + app.ui.dialog = None; + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + use serde_json::{Value, json}; + + use crate::VectorcraftApp; + + // The custom dictionary is the whole process's: each test here uses words no other test does. + const A: &str = "zqvdicta"; + const B: &str = "zqvdictb"; + const M: &str = "zqvdictm"; + const X: &str = "zqvdictx"; + const Y: &str = "zqvdicty"; + + fn app() -> VectorcraftApp { + VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()) + } + + fn has(app: &mut VectorcraftApp, w: &str) -> bool { + app.run("text.spelling.dictionary", json!({})).unwrap()["words"].as_array().unwrap().contains(&json!(w)) + } + + fn forget(app: &mut VectorcraftApp, words: &[&str]) { + for w in words { + let _ = app.run("text.spelling.removeWord", json!({ "word": w })); + } + } + + fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + /// The dialog's list, of `mine` (other tests may have their words in the dictionary meanwhile). + fn words(app: &VectorcraftApp, mine: &[&str]) -> Vec { + super::list(app.ui.dialog.as_ref().unwrap(), "words").into_iter().filter(|w| mine.contains(&w.as_str())).collect() + } + + /// One frame of the dialog on an 800 × 600 window → the text painted, with its rect. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + // A window takes a sizing frame and fades in: each frame is a quarter second on. + let time = ctx.input(|i| i.time) + 0.25; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + } + + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + } + + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) -> Vec<(String, Rect)> { + frame(app, ctx, vec![]); + let texts = frame(app, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at)]); + frame(app, ctx, vec![press(true)]); + frame(app, ctx, vec![press(false)]); + frame(app, ctx, vec![]) + } + + /// Edit › Edit Custom Dictionary… (beside Spelling, as the public page has it) opens the + /// dialog on the custom dictionary's words. + #[test] + fn the_edit_menu_opens_it_on_the_custom_dictionary() { + let mut app = app(); + forget(&mut app, &[M]); + app.run("text.spelling.addWord", json!({ "word": M })).unwrap(); + let e = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Edit Custom Dictionary…").expect("in the Edit menu"); + assert_eq!((e.path.as_slice(), e.command.as_deref(), e.enabled), (&["Edit".to_string()][..], Some(super::COMMAND), true)); + crate::menus::invoke(&mut app, super::COMMAND, json!({})); + let d = app.ui.dialog.as_ref().expect("Edit Custom Dictionary opens"); + assert_eq!(d.kind, super::KIND); + assert_eq!(words(&app, &[M]), [M]); + forget(&mut app, &[M]); + } + + /// Add, Delete and Change edit the list; nothing changes in the dictionary until Done, which + /// applies them; Cancel leaves it as it was. + #[test] + fn add_delete_and_change_apply_on_done() { + let mut app = app(); + forget(&mut app, &[A, B, "zqvdictc"]); + app.run("text.spelling.addWord", json!({ "word": A })).unwrap(); + super::open(&mut app).unwrap(); + let ctx = context(); + // Add: the Entry field's word (kept in lower case) joins the list. + set(&mut app, "entry", json!(" ZqvDictB ")); + click(&mut app, &ctx, "Add"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), [A, B]); + assert!(!has(&mut app, B), "not until Done"); + // Change: pick A, edit it in the Entry field. + set(&mut app, "selected", json!(A)); + set(&mut app, "entry", json!("zqvdictc")); + click(&mut app, &ctx, "Change"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), [B, "zqvdictc"]); + // Delete the word picked. + set(&mut app, "selected", json!(B)); + click(&mut app, &ctx, "Delete"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), ["zqvdictc"]); + // Cancel: the dictionary is as it was. + crate::dialogs::cancel(&mut app); + assert!(has(&mut app, A) && !has(&mut app, B) && !has(&mut app, "zqvdictc")); + // Again, then Done. + super::open(&mut app).unwrap(); + set(&mut app, "selected", json!(A)); + set(&mut app, "entry", json!("zqvdictc")); + click(&mut app, &ctx, "Change"); + set(&mut app, "entry", json!(B)); + click(&mut app, &ctx, "Add"); + click(&mut app, &ctx, "Done"); + assert!(app.ui.dialog.is_none(), "Done closes it"); + assert!(!has(&mut app, A) && has(&mut app, B) && has(&mut app, "zqvdictc")); + forget(&mut app, &[A, B, "zqvdictc"]); + } + + /// Add and Change need one word that isn't in the list yet; Delete and Change need a word + /// picked. A button that can't act does nothing. + #[test] + fn the_buttons_act_only_on_a_word_they_can_take() { + let mut app = app(); + forget(&mut app, &[X, Y]); + app.run("text.spelling.addWord", json!({ "word": X })).unwrap(); + super::open(&mut app).unwrap(); + let ctx = context(); + for entry in ["", "two words", X, &"z".repeat(65)] { + set(&mut app, "entry", json!(entry)); + assert_eq!(super::actions(app.ui.dialog.as_ref().unwrap()).0, None, "{entry:?}"); + click(&mut app, &ctx, "Add"); + assert_eq!(words(&app, &[X, Y]), [X], "{entry:?} isn't added"); + } + // Nothing picked: Delete and Change do nothing. + set(&mut app, "entry", json!(Y)); + click(&mut app, &ctx, "Delete"); + click(&mut app, &ctx, "Change"); + assert_eq!(words(&app, &[X, Y]), [X]); + crate::dialogs::cancel(&mut app); + forget(&mut app, &[X]); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e1121be66..ea7cb7037 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6440,3 +6440,5 @@ breakText Glyphs Глифи # Align to Pixel Grid Align to Pixel Grid Подравняване към пикселната мрежа @msg side is left or right, not `{s}` side е left или right, а не `{s}` + Edit Custom Dictionary Редактиране на потребителски речник + Entry: Запис: diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d4749aba4..fc89a3d78 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6491,3 +6491,5 @@ shape Open Otvoren # Align to Pixel Grid Align to Pixel Grid Poravnaj s mrežom piksela @msg side is left or right, not `{s}` side je left ili right, a ne `{s}` + Edit Custom Dictionary Uredi prilagođeni rječnik + Entry: Unos: diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 91169aaa4..cc1845ac9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6675,3 +6675,5 @@ breakText Glyphs Glifs Ignore All Ignora-ho tot Add to Dictionary Afegeix al diccionari No Suggestions No hi ha suggeriments + Edit Custom Dictionary Edita el diccionari personalitzat + Entry: Entrada: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 91d86612c..67e4e0f29 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6694,3 +6694,5 @@ breakText Glyphs Glyfy Ignore All Ignorovat vše Add to Dictionary Přidat do slovníku No Suggestions Žádné návrhy + Edit Custom Dictionary Upravit vlastní slovník + Entry: Položka: diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6348e68a4..a0541cc63 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6454,3 +6454,5 @@ breakText Glyphs Glyffer # Align to Pixel Grid Align to Pixel Grid Juster efter pixelgitter @msg side is left or right, not `{s}` side er left eller right, ikke `{s}` + Edit Custom Dictionary Redigér egen ordliste + Entry: Opslag: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0ffe6eeb9..8c90460e1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6281,3 +6281,5 @@ breakText Glyphs Glyphen Ignore All Alle ignorieren Add to Dictionary Zum Wörterbuch hinzufügen No Suggestions Keine Vorschläge + Edit Custom Dictionary Benutzerwörterbuch bearbeiten + Entry: Eintrag: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6dc7a40ea..be38e9531 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6646,3 +6646,5 @@ breakText Glyphs Γλύφοι Ignore All Παράβλεψη όλων Add to Dictionary Προσθήκη στο λεξικό No Suggestions Καμία πρόταση + Edit Custom Dictionary Επεξεργασία προσαρμοσμένου λεξικού + Entry: Λήμμα: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0f3947277..06b41b90d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6637,3 +6637,5 @@ breakText Glyphs Glifos Ignore All Omitir todo Add to Dictionary Añadir al diccionario No Suggestions No hay sugerencias + Edit Custom Dictionary Editar diccionario personalizado + Entry: Entrada: diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e2ab48874..74c1dea15 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6729,3 +6729,5 @@ breakText Glyphs Merkit Ignore All Ohita kaikki Add to Dictionary Lisää sanastoon No Suggestions Ei ehdotuksia + Edit Custom Dictionary Muokkaa omaa sanastoa + Entry: Merkintä: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c2c333b6b..275e10c7b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6689,3 +6689,5 @@ breakText Glyphs Glyphes Ignore All Tout ignorer Add to Dictionary Ajouter au dictionnaire No Suggestions Aucune suggestion + Edit Custom Dictionary Modifier le dictionnaire personnalisé + Entry: Entrée : diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index d39ab857f..3fc0e8d3d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8135,3 +8135,5 @@ theme Appearance अपीयरेंस Ignore All सभी को अनदेखा करें Add to Dictionary शब्दकोश में जोड़ें No Suggestions कोई सुझाव नहीं + Edit Custom Dictionary कस्टम शब्दकोश संपादित करें + Entry: प्रविष्टि: diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2b70f11db..2d016c433 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6678,3 +6678,5 @@ breakText Glyphs Glifák Ignore All Az összes kihagyása Add to Dictionary Hozzáadás a szótárhoz No Suggestions Nincs javaslat + Edit Custom Dictionary Egyéni szótár szerkesztése + Entry: Bejegyzés: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ed0ce6d4e..cdaffd788 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6454,3 +6454,5 @@ breakText Glyphs Glif # Align to Pixel Grid Align to Pixel Grid Sejajarkan ke grid piksel @msg side is left or right, not `{s}` side adalah left atau right, bukan `{s}` + Edit Custom Dictionary Edit kamus kustom + Entry: Entri: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 975800f1f..d2784a4ae 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6315,3 +6315,5 @@ breakText Glyphs Glifi Ignore All Ignora tutto Add to Dictionary Aggiungi al dizionario No Suggestions Nessun suggerimento + Edit Custom Dictionary Modifica dizionario personalizzato + Entry: Voce: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4e0df1a47..fa863ec79 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6380,3 +6380,5 @@ breakText Glyphs 字形 Ignore All すべて無視 Add to Dictionary 辞書に追加 No Suggestions 候補なし + Edit Custom Dictionary カスタム辞書を編集 + Entry: エントリ: diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0ff9696f7..287b26e70 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6755,3 +6755,5 @@ font-script Japanese იაპონური Ignore All ყველას იგნორირება Add to Dictionary ლექსიკონში დამატება No Suggestions შემოთავაზებები არ არის + Edit Custom Dictionary მომხმარებლის ლექსიკონის რედაქტირება + Entry: ჩანაწერი: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b68b4b308..135100f99 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6276,3 +6276,5 @@ breakText Glyphs 글리프 Ignore All 모두 무시 Add to Dictionary 사전에 추가 No Suggestions 제안 없음 + Edit Custom Dictionary 사용자 사전 편집 + Entry: 항목: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 005972780..904c0863a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6505,3 +6505,5 @@ breakText Glyphs Glyphs # Align to Pixel Grid Align to Pixel Grid Uitlijnen op pixelraster @msg side is left or right, not `{s}` side is left of right, niet `{s}` + Edit Custom Dictionary Aangepast woordenboek bewerken + Entry: Item: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index feb3d8308..5a71e3e1d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6550,3 +6550,5 @@ breakText Glyphs Glify Ignore All Ignoruj wszystko Add to Dictionary Dodaj do słownika No Suggestions Brak sugestii + Edit Custom Dictionary Edytuj słownik użytkownika + Entry: Wpis: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e0e83aaec..2fea8a249 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6767,3 +6767,5 @@ swatch Yellow Green Verde-amarelado Ignore All Ignorar Tudo Add to Dictionary Adicionar ao Dicionário No Suggestions Nenhuma Sugestão + Edit Custom Dictionary Editar Dicionário Personalizado + Entry: Entrada: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7ccd9c605..9a755766e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6783,3 +6783,5 @@ shape Open Открытый Ignore All Пропустить все Add to Dictionary Добавить в словарь No Suggestions Нет вариантов + Edit Custom Dictionary Редактировать пользовательский словарь + Entry: Запись: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a732ff6c0..5d4d51713 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8179,3 +8179,5 @@ digits Hindi Hindi Ignore All Ignorera alla Add to Dictionary Lägg till i ordlistan No Suggestions Inga förslag + Edit Custom Dictionary Redigera egen ordlista + Entry: Post: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dad8615f5..c0f2c92ef 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6650,3 +6650,5 @@ breakText Glyphs Glifler Ignore All Tümünü Yoksay Add to Dictionary Sözlüğe Ekle No Suggestions Öneri Yok + Edit Custom Dictionary Özel Sözlüğü Düzenle + Entry: Giriş: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3fcc681b5..7dd65d9f8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6295,3 +6295,5 @@ breakText Glyphs Гліфи Ignore All Пропустити все Add to Dictionary Додати до словника No Suggestions Немає варіантів + Edit Custom Dictionary Редагувати словник користувача + Entry: Запис: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5151b4a57..eeab12613 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6296,3 +6296,5 @@ breakText Glyphs Glyph Ignore All Bỏ qua tất cả Add to Dictionary Thêm vào từ điển No Suggestions Không có gợi ý + Edit Custom Dictionary Chỉnh sửa từ điển tùy chỉnh + Entry: Mục nhập: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e527dbb1f..3658297fa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6532,3 +6532,5 @@ breakText Glyphs 字形 Align to Pixel Grid 对齐像素网格 @msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格 @msg side is left or right, not `{s}` side 应为 left 或 right,而不是 `{s}` + Edit Custom Dictionary 编辑自定义词典 + Entry: 条目: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 16e6898bf..c97eef30a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6330,3 +6330,5 @@ breakText Glyphs 字符 Align to Pixel Grid 對齊像素格點 @msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點 @msg side is left or right, not `{s}` side 應為 left 或 right,而不是 `{s}` + Edit Custom Dictionary 編輯自訂字典 + Entry: 項目: diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 7b3adaffe..c37a8268c 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -287,6 +287,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { } return true; } + // Edit Custom Dictionary…: the custom dictionary's words in a list, to add, delete or change. + if id == crate::dialogs::custom_dictionary::COMMAND && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::custom_dictionary::open(app) { + app.status(e); + } + return true; + } // Edit Views…: the saved views in a list, to rename or delete. if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { if let Err(e) = crate::dialogs::edit_views::open(app) { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index ebea83e90..97651d074 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -1,6 +1,6 @@ use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS}; use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items}; -use super::{Item, Sep, c, cp, json, sub, todo, todos}; +use super::{Item, Sep, c, cp, json, sub, todos}; /// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see /// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`). @@ -97,10 +97,10 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { Sep, cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})), cp("Find Next", "edit.findNext", json!({"find": ""})), - sub( - "Spelling", - vec![c("Auto Spell Check", "edit.autoSpellCheck"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")], - ), + sub("Spelling", vec![c("Auto Spell Check", "edit.autoSpellCheck"), todos("Check Spelling…", "Cmd+I")]), + // Edit › Edit Custom Dictionary…, beside Spelling, on the public Check and correct + // spelling page. + c("Edit Custom Dictionary…", crate::dialogs::custom_dictionary::COMMAND), Sep, sub( "Edit Colors", diff --git a/docs/gaps.md b/docs/gaps.md index 0b910710f..1d2b4c933 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -449,7 +449,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Missing:** CJK vertical composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and - `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Check Spelling and Edit Custom Dictionary dialogs remain), + `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Edit Custom Dictionary dialog; the Check Spelling dialog remains), Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). - **Impact:** professional typography and Japanese/Chinese publishing. @@ -854,6 +854,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index a9b1f1cd8..b2437347d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6115,6 +6115,10 @@ preferences, one word per line; without preferences (`VECTORCRAFT_NO_PREFS`, the closes. `text.spelling.ignoreAll {word}` is Ignore All: the active document's checks skip the word, in any case, while the document is open. It is not saved and not an undo step. +In the app, Edit › Edit Custom Dictionary… (`text.spelling.dictionary` with no params, from the menus) opens a dialog of +the words, with an Entry field and Add, Delete and Change; Done runs `text.spelling.removeWord` and +`text.spelling.addWord` for the words that differ, and Cancel changes nothing. + ```json {"name":"run_command","arguments":{"command":"text.spelling.addWord","params":{"word":"VectorCraft"}}} {"name":"run_command","arguments":{"command":"text.spelling.ignoreAll","params":{"word":"Zqvwkx"}}} diff --git a/docs/roadmap.md b/docs/roadmap.md index 115ba760d..2fed7e653 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-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; 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-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; 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) > Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). @@ -139,9 +139,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | 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), 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 | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); 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 | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | @@ -192,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c76d781c8..38ccd4567 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-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; 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; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; 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; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -213,15 +213,15 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -482,10 +482,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | @@ -1914,6 +1914,8 @@ The right-click followed (2026-11-10), as the same page's "Correct spelling erro The page's other way to turn Auto Spell Check on followed (2026-11-10): right-click the text and choose Spelling › Auto Spell Check (#4936). A right-click on type (which selects it) has a Spelling submenu with Auto Spell Check, which runs `edit.autoSpellCheck` as Edit › Spelling does. The context menu draws no check marks, its toggles saying what they do (Show Rulers, Hide Guides), but a submenu that holds only toggles shows them, so Auto Spell Check is checked while it is on; other art has no Spelling submenu (`right_click_on_type_turns_auto_spell_check_on_and_off`). +Edit Custom Dictionary followed (2026-11-10), as the same page's "Edit the custom dictionary" describes it: Edit › Edit Custom Dictionary…, then enter a word in the Entry field and Add it, select a word and Delete it, or select a word, edit it in the Entry field and Change it, then Done (#4936). The dialog lists the custom dictionary's words (`text.spelling.dictionary`); picking one puts it in the Entry field. Add and Change take one word that isn't listed yet, kept in lower case as the dictionary keeps it, and Delete and Change need a word picked. Nothing changes until Done, which deletes and adds the words that differ (`text.spelling.removeWord`, `text.spelling.addWord`); Cancel leaves the dictionary as it was (`ui-egui/src/dialogs/custom_dictionary.rs`). The Check Spelling dialog remains. + ## Control bar: Paragraph section while type is selected (2026-11-06) With type selected, the Control bar shows a Paragraph: link and the Align left, Align center and Align right buttons after its Character controls, as the public documentation has the Control panel format paragraphs while the Type tool is in use, with a way to the Paragraph panel ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html)), and its alignment buttons align text as the Paragraph panel's do ([Align text](https://helpx.adobe.com/illustrator/using/align-text.html)). A button aligns the selected type (`text.setStyle {justify}`, one undo step) and the shared alignment shows selected; the link opens the Paragraph panel in a popover under it, as Character: opens the Character panel. Keeping to the three alignments that don't justify is our choice, for the bar's room; the popover has all seven. Short of room the bar leaves controls out from its right end: the inline X/Y/W/H fields, the Distribute and Align buttons, area type's vertical alignment, then these buttons and last the link, keeping room for the Transform link; on a 1440 pt bar with one point type object, the buttons show (pinned by a headless test, #5372). @@ -1930,6 +1932,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Type advanced: the Edit Custom Dictionary dialog, #4936; evidence added; the Check Spelling dialog remains; scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 8f3c8e7c2..54b009723 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-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **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 @@ -47,7 +47,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | -| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. The Check Spelling and Edit Custom Dictionary dialogs remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click). | +| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. Edit › Edit Custom Dictionary… lists the custom dictionary's words, with an Entry field and Add, Delete and Change, applied on Done (Cancel leaves the dictionary as it was). The Check Spelling dialog remains. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click), `crates/ui-egui/src/dialogs/custom_dictionary.rs` (the dialog). | | 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. 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 | @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. | | 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on type has Spelling › Auto Spell Check, checked while it is on; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-09 | minor | Auto Spell Check (#4936): Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, without type with effects; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain. Scores and estimates unchanged. | From 4a954a6a843152c5a8ad4b9036c3cc6e7f1ffc00 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 21:48:25 +0500 Subject: [PATCH 02/14] Edit Custom Dictionary: Done saves its deletes and adds together, all or none (part of #4936) Done ran text.spelling.removeWord and text.spelling.addWord one word at a time, each saving the file, so a save that failed after a delete was saved left that word deleted even after Cancel. text.spelling.editDictionary {remove, add} makes every change, saves once and takes all of them back when the save fails; Done runs it once. --- crates/engine/src/cmd/spelling.rs | 65 +++++++++++++++++++ crates/engine/src/tests/spelling.rs | 35 ++++++++++ .../ui-egui/src/dialogs/custom_dictionary.rs | 50 ++++++++++---- docs/mcp.md | 5 +- 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 464c25ed2..641f6a4fe 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -85,6 +85,15 @@ pub fn specs() -> Vec { always, change_word ), + cmd!( + query "text.spelling.editDictionary", + "Edit Custom Dictionary", + [], + None, + "{remove?: [word…], add?: [word…] (each one word, at most 64 characters; at most 10000 of each)} deletes the words of remove that are there and adds those of add, then saves once: all of it, or none of it when the save fails (Edit Custom Dictionary's Done) → {removed: [word…], added: [word…]}", + always, + edit_dictionary + ), cmd!( query "text.spelling.ignoreAll", "Ignore All", @@ -348,6 +357,62 @@ fn change_word(s: &mut Session, p: &Value) -> Result { Ok(json!({ "word": to })) } +/// Most words `text.spelling.editDictionary` takes in each of its lists. +const MAX_EDIT_WORDS: usize = 10_000; + +/// `p`'s `key`, a list of custom-dictionary words (none when it is missing), deduplicated. +fn word_list(p: &Value, key: &str, command: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(BTreeSet::new()) }; + let words = v.as_array().ok_or_else(|| bad(command, format!("`{key}` must be a list of words")))?; + if words.len() > MAX_EDIT_WORDS { + return Err(bad(command, format!("`{key}` holds more than {MAX_EDIT_WORDS} words"))); + } + words + .iter() + .map(|w| { + w.as_str().and_then(craft_proof::custom_word).ok_or_else(|| { + bad(command, format!("`{key}` must hold words of one word, at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS)) + }) + }) + .collect() +} + +/// `text.spelling.editDictionary`: Edit Custom Dictionary's Done. Every delete and add is made, +/// then saved once; when the save fails, all of them are taken back, so a failed Done leaves the +/// dictionary as it was rather than half changed. +fn edit_dictionary(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.spelling.editDictionary"; + let (remove, add) = (word_list(p, "remove", C)?, word_list(p, "add", C)?); + let before: BTreeSet = craft_proof::user_dictionary().into_iter().collect(); + // A word in both lists stays; a word to delete that another window deleted already is skipped. + let removed: Vec = remove.iter().filter(|w| !add.contains(*w) && before.contains(*w)).cloned().collect(); + let added: Vec = add.iter().filter(|w| !before.contains(*w)).cloned().collect(); + if removed.is_empty() && added.is_empty() { + return Ok(json!({ "removed": removed, "added": added })); + } + for w in &removed { + craft_proof::remove_word(w); + } + for w in &added { + craft_proof::add_word(w); + } + let saved = save_dictionary(s, &removed, C); + dictionary_changed(); + saved.inspect_err(|_| { + for w in &added { + craft_proof::remove_word(w); + } + for w in &removed { + craft_proof::add_word(w); + } + // Words the save took in from the file stay, as for one word. + if !added.is_empty() { + retake_file_words(s); + } + })?; + Ok(json!({ "removed": removed, "added": added })) +} + /// `text.spelling.ignoreAll`: Ignore All, for the active document. fn ignore_all(s: &mut Session, p: &Value) -> Result { const C: &str = "text.spelling.ignoreAll"; diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs index eca38d8e6..889458ac5 100644 --- a/crates/engine/src/tests/spelling.rs +++ b/crates/engine/src/tests/spelling.rs @@ -260,6 +260,41 @@ fn a_change_that_cant_be_saved_is_taken_back() { run(&mut s, "text.spelling.removeWord", json!({"word": "wkqzvx"})); } +/// Edit Custom Dictionary's Done: its deletes and adds are saved together, or, when the save +/// fails, none of them is kept (a delete saved before an add that failed would stay otherwise). +#[test] +fn a_dictionary_edit_is_saved_whole_or_not_at_all() { + let _one_at_a_time = dictionary_lock(); + const EDIT: &str = "text.spelling.editDictionary"; + let dir = vectorcraft_testkit::temp_dir("spelling-edit"); + let file = dir.join("Custom Dictionary.txt"); + std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap(); + let mut s = session(); + s.attach_custom_dictionary(file.clone()); + // A word already gone and a word already there are skipped; added words are kept in lower case. + let r = run(&mut s, EDIT, json!({"remove": ["pqzvka", "pqzvkz"], "add": ["PqzvkC", "pqzvkb"]})); + assert_eq!(r, json!({"removed": ["pqzvka"], "added": ["pqzvkc"]})); + let saved = std::fs::read_to_string(&file).unwrap(); + let lines: Vec<&str> = saved.lines().collect(); + assert!(lines.contains(&"pqzvkb") && lines.contains(&"pqzvkc") && !lines.contains(&"pqzvka"), "{saved}"); + // A save that fails takes back every delete and add. + let folder = dir.join("folder"); + std::fs::create_dir_all(&folder).unwrap(); + let mut failing = session(); + failing.attach_custom_dictionary(folder); + assert!(failing.execute(EDIT, &json!({"remove": ["pqzvkb", "pqzvkc"], "add": ["pqzvkd"]})).is_err()); + assert!(in_dictionary(&mut s, "pqzvkb") && in_dictionary(&mut s, "pqzvkc"), "the deletes are taken back"); + assert!(!in_dictionary(&mut s, "pqzvkd"), "the add is taken back"); + // A list that isn't one of words is refused before anything changes. + let many: Vec = (0..10_001).map(|i| format!("pqzv{i}")).collect(); + for p in [json!({"remove": "pqzvkb"}), json!({"add": ["a b"]}), json!({"add": [5]}), json!({"remove": ["pqzvkb"], "add": many})] { + assert!(s.execute(EDIT, &p).is_err(), "{EDIT} refuses a bad list"); + assert!(in_dictionary(&mut s, "pqzvkb"), "{EDIT} changed nothing"); + } + run(&mut s, EDIT, json!({"remove": ["pqzvkb", "pqzvkc"]})); + assert!(!in_dictionary(&mut s, "pqzvkb") && !in_dictionary(&mut s, "pqzvkc")); +} + #[cfg(unix)] #[test] fn a_word_another_window_saved_stays_when_this_save_fails() { diff --git a/crates/ui-egui/src/dialogs/custom_dictionary.rs b/crates/ui-egui/src/dialogs/custom_dictionary.rs index ef72573ea..8a61f7095 100644 --- a/crates/ui-egui/src/dialogs/custom_dictionary.rs +++ b/crates/ui-egui/src/dialogs/custom_dictionary.rs @@ -3,7 +3,7 @@ //! [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html) //! page describes it: enter a word and Add it, select a word and Delete it, or select a word, edit it //! in the Entry field and Change it. Nothing changes until Done, which deletes and adds the words -//! that differ (`text.spelling.removeWord`, `text.spelling.addWord`); Cancel leaves the dictionary as +//! that differ in one `text.spelling.editDictionary`, all or none; Cancel leaves the dictionary as //! it was. //! //! Fields: `orig` (the words when the dialog opened), `words` (as edited, sorted), `entry` (the @@ -22,6 +22,9 @@ pub const KIND: &str = "editCustomDictionary"; /// The command its menu item runs: with no params, it opens this dialog. pub const COMMAND: &str = "text.spelling.dictionary"; +/// The command Done runs: every delete and add, saved once. +const EDIT: &str = "text.spelling.editDictionary"; + pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Edit Custom Dictionary").into(), body, confirm, ok: Some("Done"), min_width: 420.0, ..DialogSpec::FORM }; @@ -136,19 +139,14 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } -/// Done: deletes the words no longer in the list, then adds the new ones. A word that can't be -/// saved keeps the dialog open with the error, its list as edited. +/// Done: deletes the words no longer in the list and adds the new ones in one +/// `text.spelling.editDictionary`, saved once. When it can't be saved, none of it is kept: the +/// dialog stays open with the error, its list as edited, and Cancel leaves the dictionary as it was. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (orig, words) = (list(d, "orig"), list(d, "words")); - // The dictionary as it is now: another window may have deleted a word already. - let now = app.run(COMMAND, json!({})).map_err(|e| e.to_string())?.get("words").cloned().unwrap_or_default(); - let kept = |w: &String| now.as_array().is_some_and(|a| a.contains(&json!(w))); - for w in orig.iter().filter(|w| !words.contains(w) && kept(w)) { - app.run("text.spelling.removeWord", json!({ "word": w })).map_err(|e| e.to_string())?; - } - for w in words.iter().filter(|w| !orig.contains(w)) { - app.run("text.spelling.addWord", json!({ "word": w })).map_err(|e| e.to_string())?; - } + let remove: Vec<&String> = orig.iter().filter(|w| !words.contains(w)).collect(); + let add: Vec<&String> = words.iter().filter(|w| !orig.contains(w)).collect(); + app.run(EDIT, json!({ "remove": remove, "add": add })).map_err(|e| e.to_string())?; app.ui.dialog = None; Ok(Value::Null) } @@ -282,6 +280,34 @@ mod tests { forget(&mut app, &[A, B, "zqvdictc"]); } + /// Done that can't be saved keeps none of the edits: the dialog stays open with the error and + /// Cancel leaves the dictionary as it was. + #[test] + fn a_done_that_cant_be_saved_changes_nothing() { + const P: &str = "zqvdictp"; + const Q: &str = "zqvdictq"; + let mut app = app(); + forget(&mut app, &[P, Q]); + app.run("text.spelling.addWord", json!({ "word": P })).unwrap(); + let folder = vectorcraft_testkit::temp_dir("custom-dictionary-done").join("Custom Dictionary.txt"); + std::fs::create_dir_all(&folder).unwrap(); + let mut failing = self::app(); + failing.session.attach_custom_dictionary(folder); + super::open(&mut failing).unwrap(); + let ctx = context(); + set(&mut failing, "selected", json!(P)); + click(&mut failing, &ctx, "Delete"); + set(&mut failing, "entry", json!(Q)); + click(&mut failing, &ctx, "Add"); + assert_eq!(words(&failing, &[P, Q]), [Q]); + click(&mut failing, &ctx, "Done"); + assert!(failing.ui.dialog.is_some(), "the dialog stays open with the error"); + assert!(has(&mut app, P) && !has(&mut app, Q), "neither the delete nor the add is kept"); + crate::dialogs::cancel(&mut failing); + assert!(has(&mut app, P) && !has(&mut app, Q)); + forget(&mut app, &[P, Q]); + } + /// Add and Change need one word that isn't in the list yet; Delete and Change need a word /// picked. A button that can't act does nothing. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 7571b017f..67ce12721 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6135,8 +6135,9 @@ closes. `text.spelling.ignoreAll {word}` is Ignore All: the active document's ch any case, while the document is open. It is not saved and not an undo step. In the app, Edit › Edit Custom Dictionary… (`text.spelling.dictionary` with no params, from the menus) opens a dialog of -the words, with an Entry field and Add, Delete and Change; Done runs `text.spelling.removeWord` and -`text.spelling.addWord` for the words that differ, and Cancel changes nothing. +the words, with an Entry field and Add, Delete and Change; Done runs `text.spelling.editDictionary {remove, add}` for +the words that differ, which deletes and adds them and saves once, all or none (a save that fails keeps none of them), +and Cancel changes nothing. ```json {"name":"run_command","arguments":{"command":"text.spelling.addWord","params":{"word":"VectorCraft"}}} From 5080a8eced487d7b4e6030c199349bdac516a620 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 22:13:23 +0500 Subject: [PATCH 03/14] text.spelling.editDictionary: say each list entry must be a single word (part of #4936) --- crates/engine/src/cmd/spelling.rs | 6 +++--- crates/engine/src/tests/spelling.rs | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 641f6a4fe..35d1a1277 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -370,9 +370,9 @@ fn word_list(p: &Value, key: &str, command: &str) -> Result> { words .iter() .map(|w| { - w.as_str().and_then(craft_proof::custom_word).ok_or_else(|| { - bad(command, format!("`{key}` must hold words of one word, at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS)) - }) + w.as_str() + .and_then(craft_proof::custom_word) + .ok_or_else(|| bad(command, format!("`{key}` must hold single words of at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS))) }) .collect() } diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs index 889458ac5..16d7f0199 100644 --- a/crates/engine/src/tests/spelling.rs +++ b/crates/engine/src/tests/spelling.rs @@ -291,6 +291,8 @@ fn a_dictionary_edit_is_saved_whole_or_not_at_all() { assert!(s.execute(EDIT, &p).is_err(), "{EDIT} refuses a bad list"); assert!(in_dictionary(&mut s, "pqzvkb"), "{EDIT} changed nothing"); } + let e = s.execute(EDIT, &json!({"add": ["a b"]})).unwrap_err().to_string(); + assert!(e.contains("must hold single words of at most 64 characters"), "{e}"); run(&mut s, EDIT, json!({"remove": ["pqzvkb", "pqzvkc"]})); assert!(!in_dictionary(&mut s, "pqzvkb") && !in_dictionary(&mut s, "pqzvkc")); } From 6484e72dfff19ebd7a691b09a796c6d90e68576f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:14:22 -0800 Subject: [PATCH 04/14] 3D: Extrude & Bevel and Inflate build detailed shapes they refused as too complex Each Extrude & Bevel and Inflate solid now takes up to 65,536 outline points (was 2,048) and 163,840 faces (was 32,768), VectorCraft's own limits, as Revolve's. Before any point is sampled, the outline's lines and curves are counted with each curve's tolerance pieces, and the faces the walls, bevels and caps (Extrude) or the sides (Inflate) need. When they do not fit, every curve takes fewer pieces, down to 4; a shape that does not fit at 4 is refused with the existing surface error before any face is built. The subpath count is capped at the point limit too. The error texts are unchanged. A closed path of 2,000 curves now extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings. Docs: docs/three-d.md, docs/mcp.md, docs/target-app-parity.md. --- crates/three-d/src/lib.rs | 2 + crates/three-d/src/solid.rs | 5 +- crates/three-d/src/solid/build.rs | 32 +++-- crates/three-d/src/solid/inflate.rs | 22 +++- crates/three-d/src/solid/rings.rs | 95 ++++++++++++--- crates/three-d/src/solid/tests.rs | 41 ++++--- crates/three-d/src/solid/tests_limits.rs | 141 +++++++++++++++++++++++ docs/mcp.md | 4 + docs/target-app-parity.md | 1 + docs/three-d.md | 16 ++- 10 files changed, 308 insertions(+), 51 deletions(-) create mode 100644 crates/three-d/src/solid/tests_limits.rs diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index fa22c10fb..5606cb884 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -25,6 +25,8 @@ const MAX_PROFILE_POINTS: usize = 16_384; const MAX_FACES: usize = 32768; /// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art. const MAX_REVOLVE_FACES: usize = 163_840; +/// Most faces Extrude & Bevel and Inflate build for one solid, as many as Revolve's. +const MAX_SOLID_FACES: usize = MAX_REVOLVE_FACES; /// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. const CURVE_SAMPLES: usize = 16; const MIN_CURVE_SAMPLES: usize = 4; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 4d6e78188..f4e11270e 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -11,7 +11,7 @@ use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Vec2}; -use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale}; +use crate::{MAX_FACES, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_scale}; mod build; mod inflate; @@ -319,3 +319,6 @@ mod tests; #[cfg(test)] mod tests_inflate_shading; + +#[cfg(test)] +mod tests_limits; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 9948c4796..d03773ccd 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,5 +1,7 @@ -use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings}; -use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; +use super::rings::{ + CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, solid_rings, +}; +use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; /// Turns 3D points into document points and shades faces for one view. pub(super) struct Camera { @@ -87,8 +89,8 @@ impl Builder<'_> { } if let Some(mesh) = &mut self.mesh { mesh.push((contours, n.unit())); - if mesh.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); + if mesh.len() > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } return Ok(()); } @@ -135,8 +137,8 @@ impl Builder<'_> { #[cfg(test)] view, }); - if self.facets.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); + if self.facets.len() > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } Ok(()) } @@ -407,15 +409,25 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio if o.depth <= 0.0 { return plane_faces(path, rule, bounds, view, mesh); } - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let steps = steps(&o); + // One face for each edge of the outline in each band between two steps, and the two caps: + // counted before any point is sampled, and again on the rings, which can have more points + // once contours that meet are merged. + let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 }); + let size = bounds.width().max(bounds.height()); + let most = curve_pieces(path, size, bands, caps)?; + let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; + let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); + } let camera = Camera::new(view, bounds, o.depth); let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; - let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; // Every band of a twisted outline, bevels included, is cut as its walls' first slice is (with - // bevels and no walls, as its shallowest band is), within the facet limit. + // bevels and no walls, as its shallowest band is), within [`MAX_FACES`] facets. let depth_of = |s: &[Step]| s.get(1).zip(s.first()).map_or(0.0, |(b, a)| (b.t - a.t).abs()); let wall = steps .windows(2) @@ -465,7 +477,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul /// [`plane`], or with `mesh` its face before projection instead of its facet. pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { check_bounds(bounds)?; - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 51f9af911..b3de81e61 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,7 +1,12 @@ use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; -use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings}; -use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; +use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; +use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; + +/// The slices the sides are cut into: more to follow a twist or taper. +fn slices(o: &Inflate) -> usize { + if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 } +} /// A shape to inflate: its rings and the membrane under them on the grid of the bounds. struct Puff { @@ -18,11 +23,14 @@ struct Puff { /// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + // The sides take one face for each edge of the outline in each slice; the grid's faces are + // counted as they are built. + let size = bounds.width().max(bounds.height()); + let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?; + let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; if rings.is_empty() { return Ok(None); } - let size = bounds.width().max(bounds.height()); if size <= 0.0 { return Ok(None); } @@ -312,7 +320,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } if o.depth > 0.0 { // The sides, cut into slices that follow a twist or taper. - let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; + let slices = slices(&o); let at = |ring: &[Point], k: usize| -> Vec { let t = k as f64 / slices as f64; let turn = Turn::new(bounds, o.twist, o.taper, t); @@ -320,6 +328,10 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo }; let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); + let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); + } for (ring, pieces) in rings.iter().zip(&pieces) { for k in 0..slices { b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 81e67cc03..02fb6f406 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -1,8 +1,7 @@ -use super::{FillRule, ParamCurve, PathData, Point, Rect}; +use super::{FillRule, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect}; -/// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel -/// bands and distance tests. -pub(super) const MAX_SOLID_POINTS: usize = 2048; +/// Outline points Extrude & Bevel and Inflate sample at most, and contours they take at most. +pub(super) const MAX_SOLID_POINTS: usize = 65536; /// Outline points Rotate samples at most: it only turns the outline, so an outlined paragraph fits. pub(super) const MAX_PLANE_POINTS: usize = 65536; @@ -10,13 +9,76 @@ pub(super) const MAX_PLANE_POINTS: usize = 65536; /// Pieces a twisted wall's strip is cut into along one edge at most. pub(super) const MAX_PIECES: usize = 64; +/// Pieces a curve takes at most, where its tolerance asks for them. +pub(super) const CURVE_PIECES: u32 = 32; + +/// The fewest pieces a curve of a solid over its budget is held to: a curve whose tolerance asks +/// for fewer takes fewer. +pub(super) const MIN_CURVE_PIECES: u32 = 4; + +const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect"; +pub(super) const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape"; + /// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from -/// its chords), 1 to 32. +/// its chords), 1 to [`CURVE_PIECES`]. fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { let d = |a: Point, b: Point, c: Point| (a.to_vec2() - 2.0 * b.to_vec2() + c.to_vec2()).hypot(); let bend = d(c.p0, c.p1, c.p2).max(d(c.p1, c.p2, c.p3)); let n = (0.75 * bend / tolerance).sqrt().ceil(); - if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } + if n.is_finite() { (n as u32).clamp(1, CURVE_PIECES) } else { CURVE_PIECES } +} + +/// The most pieces each curve of a solid's outline takes, counted on `path` before any point is +/// sampled: [`CURVE_PIECES`] when the outline fits within [`MAX_SOLID_POINTS`] points and +/// [`MAX_SOLID_FACES`] faces, else the largest number down to [`MIN_CURVE_PIECES`] that fits, the +/// same for every curve. A curve whose tolerance (see [`sample`]) asks for fewer pieces takes +/// fewer. The faces are counted as `per_edge` for each edge of the outline and `fixed` more. +/// +/// An error when the outline takes more than [`MAX_SOLID_POINTS`] points, or contours, with +/// every curve held to [`MIN_CURVE_PIECES`], and when its faces do not fit with that many. +pub(super) fn curve_pieces(path: &PathData, size: f64, per_edge: usize, fixed: usize) -> Result { + let tolerance = (size / 2000.0).max(0.05); + // How many curves ask for each number of pieces. + let mut curves = [0usize; CURVE_PIECES as usize + 1]; + let (mut lines, mut segments, mut open) = (0usize, 0usize, 0usize); + for (contours, sp) in path.subpaths.iter().enumerate() { + if contours >= MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + // A subpath of one anchor draws nothing, as in [`sample`]. + if sp.anchors.len() < 2 { + continue; + } + if sp.anchors.iter().any(|a| [a.p, a.h_in, a.h_out].iter().any(|q| !q.x.is_finite() || !q.y.is_finite())) { + return Err("The shape contains invalid coordinates".into()); + } + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; + // Every segment takes a point at least. + segments = segments.saturating_add(count); + if segments > MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + open += usize::from(!sp.closed); + for i in 0..count { + if sp.segment_is_line(i) { + lines += 1; + } else if let Some(n) = curves.get_mut(pieces(sp.segment(i), tolerance) as usize) { + *n += 1; + } + } + } + let points = |most: u32| -> usize { + curves.iter().enumerate().fold(lines, |sum, (asked, n)| sum.saturating_add(n.saturating_mul(asked.min(most as usize)))) + }; + if points(MIN_CURVE_PIECES) > MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + // An open contour is closed by one more edge. + let faces = |most: u32| points(most).saturating_add(open).saturating_mul(per_edge).saturating_add(fixed); + (MIN_CURVE_PIECES..=CURVE_PIECES) + .rev() + .find(|most| points(*most) <= MAX_SOLID_POINTS && faces(*most) <= MAX_SOLID_FACES) + .ok_or_else(|| TOO_COMPLEX_SURFACE.into()) } /// Contours thinner than this on average (twice their area over their perimeter) are dropped when @@ -37,14 +99,15 @@ pub(super) const DEBRIS: f64 = 1e-6; /// are. A profile the boolean refuses is built from its contours as drawn, as it was before /// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice /// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built. -pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { - let sampled = sample(path, size, limit)?; +/// Each curve takes at most `most` pieces, on the profile as drawn and on the merged outline. +pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result>, String> { + let sampled = sample(path, size, limit, most)?; let closed = closed_straight(path); if !meets(&sampled, &closed) { return orient(sampled, rule); } match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { - Ok(merged) => sample(&merged, size, limit).map(wound), + Ok(merged) => sample(&merged, size, limit, most).map(wound), Err(_) => orient(sampled, rule), } } @@ -197,12 +260,12 @@ fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool { /// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would /// if nesting alone decided (#1724). pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { - orient(sample(path, size, limit)?, rule) + orient(sample(path, size, limit, CURVE_PIECES)?, rule) } /// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as -/// its subpath does. -pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result>, String> { +/// its subpath does, each curve in at most `most` pieces. +pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Result>, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; @@ -210,7 +273,7 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result= 2) { let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; if segments > limit { - return Err("The shape is too complex for a 3D effect".into()); + return Err(TOO_COMPLEX_SHAPE.into()); } let mut ring: Vec = vec![]; if let Some(first) = sp.anchors.first() { @@ -218,10 +281,10 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result limit { - return Err("The shape is too complex for a 3D effect".into()); + return Err(TOO_COMPLEX_SHAPE.into()); } for k in 1..=samples { ring.push(curve.eval(f64::from(k) / f64::from(samples))); @@ -252,7 +315,7 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S // Which rings hold which: each ring against the others, its edges walked only where its // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair // whose bounds miss the point costs a step too: many small rings are a square of pairs). - let too_complex = || String::from("The shape is too complex for a 3D effect"); + let too_complex = || String::from(TOO_COMPLEX_SHAPE); let mut work = 0usize; let mut within = |out: &[Vec], bounds: &[Rect], i: usize| -> Result, String> { let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 46d178287..385800623 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -277,11 +277,14 @@ fn malformed_and_degenerate_input_is_bounded() { // Too many anchors: refused, not truncated. let mut many = BezPath::new(); many.move_to((0.0, 0.0)); - for i in 0..3000 { + for i in 0..70_000 { many.line_to((f64::from(i), f64::from(i % 7))); } many.close_path(); - assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err()); + assert_eq!( + extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap_err(), + "The shape is too complex for a 3D effect" + ); } #[test] @@ -374,8 +377,7 @@ fn bumps(count: usize) -> (PathData, Rect) { } /// Curves are followed within a tolerance rather than by sixteen points each: small curves take a -/// few points, so shapes of many curves (outlined type) fit; Rotate, which only turns the outline, -/// takes far more. +/// few points, so shapes of many curves (outlined type) fit, in a solid and in Rotate. #[test] fn small_curves_take_few_points_and_rotate_takes_large_outlines() { // 300 curves were 4,800 points at sixteen each, over the old limit of 1,024. @@ -383,10 +385,13 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() { assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok()); assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok()); - // 4,000 curves: too many for a solid, not for Rotate. + // 4,000 curves fit a solid and Rotate alike; 70,000 fit neither. let (p, r) = bumps(4000); - assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex")); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1); + let (p, r) = bumps(70_000); + assert_eq!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect"); + assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap_err(), "The shape is too complex for a 3D effect"); // Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt // here (1/2000 of its 100 pt size is less). let mut c = BezPath::new(); @@ -1019,7 +1024,7 @@ fn flat_far_and_too_large_merges() { let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect(); let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false); let with_line = PathData::new(squares.iter().cloned().chain([line]).collect()); - assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line))); + assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX, rings::CURVE_PIECES).unwrap(), &rings::closed_straight(&with_line))); assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6)); // Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the // test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas. @@ -1049,9 +1054,8 @@ fn flat_far_and_too_large_merges() { let b = wide.bounds().unwrap(); let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap(); assert_eq!(joined.iter().map(|f| f.contours.len()).sum::(), 2, "the squares joined, the lobe apart"); - // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced: - // past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so - // they can't be built as they are), and Rotate's 65,536 takes it. + // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced, + // within the 65,536 points Extrude & Bevel, Inflate and Rotate take. let mut bars = vec![]; for i in 0..24 { let x = i as f64 * 10.0; @@ -1061,9 +1065,18 @@ fn flat_far_and_too_large_merges() { let grid = PathData::new(bars); let b = grid.bounds().unwrap(); for rule in [FillRule::NonZero, FillRule::EvenOdd] { - assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); - assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); - assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate"); + // With a limit of 2,048 points the grid fits as drawn and its merged outline does not: its + // contours as drawn cross, so they can't be built as they are. + assert!(rings::sample(&grid, 240.0, 2048, rings::CURVE_PIECES).is_ok()); + assert_eq!( + rings::solid_rings(&grid, rule, 240.0, 2048, rings::CURVE_PIECES), + Err("The shape is too complex for a 3D effect".into()), + "{rule:?}" + ); + let expected = vectorcraft_pathops::area(&grid, rule); + assert!(close(area(&extrude(&grid, rule, b, Extrude::default(), front()).unwrap()), expected, 1e-6), "{rule:?}: Extrude & Bevel"); + assert!(!inflate(&grid, rule, b, Inflate::default(), front()).unwrap().is_empty(), "{rule:?}: Inflate"); + assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), expected, 1e-6), "{rule:?}: Rotate"); } } @@ -1076,7 +1089,7 @@ fn meets_within_twice_the_boolean_tolerance() { let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) }; - let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p)); + let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX, rings::CURVE_PIECES).unwrap(), &rings::closed_straight(p)); // Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on // the page (greater y) than the first's: snapped onto the first's heights, they are joined. let d = 0.99e-6; diff --git a/crates/three-d/src/solid/tests_limits.rs b/crates/three-d/src/solid/tests_limits.rs new file mode 100644 index 000000000..350b8dd95 --- /dev/null +++ b/crates/three-d/src/solid/tests_limits.rs @@ -0,0 +1,141 @@ +use vectorcraft_geom::{Anchor, SubPath}; + +use super::*; + +const SHAPE: &str = "The shape is too complex for a 3D effect"; +const SURFACE: &str = "The 3D surface is too complex; simplify the shape"; + +/// A closed ring of `count` curved petals around a circle of `radius`: each curve leaves its anchor +/// outward, `lift` points out, and comes back to the next anchor, so a larger lift asks for more +/// pieces per curve. +fn petals(count: usize, radius: f64, lift: f64) -> PathData { + let chord = std::f64::consts::TAU * radius / count as f64; + let anchors = (0..count) + .map(|i| { + let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos(); + let p = Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s); + let (out, along) = (Vec2::new(c, s) * lift, Vec2::new(-s, c) * (chord / 3.0)); + Anchor::with_handles(p, p + out - along, p + out + along) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// A closed polygon of `count` straight edges around a circle. +fn polygon(count: usize, radius: f64) -> PathData { + let anchors = (0..count) + .map(|i| { + let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos(); + Anchor::corner(Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// Turned so that the walls and bevels show. +fn turned() -> View { + View { rotation_x: -18.0, rotation_y: -26.0, rotation_z: 8.0, ..View::default() } +} + +fn size(path: &PathData) -> f64 { + let b = path.bounds().unwrap(); + b.width().max(b.height()) +} + +/// How many points the outline samples to, each curve in at most `most` pieces. +fn points(path: &PathData, most: u32) -> usize { + rings::sample(path, size(path), usize::MAX, most).unwrap().iter().map(Vec::len).sum() +} + +fn built(facets: &[Facet]) -> bool { + !facets.is_empty() + && facets.len() <= MAX_SOLID_FACES + && facets.iter().flat_map(|f| f.contours.iter().flatten()).all(|p| p.x.is_finite() && p.y.is_finite()) +} + +#[test] +fn two_thousand_curves_extrude_with_a_bevel_and_inflate() { + let path = petals(2000, 500.0, 4.0); + let b = path.bounds().unwrap(); + // Every curve takes several pieces: far more points than 2,048, all of them kept. + let sampled = points(&path, rings::CURVE_PIECES); + assert!(sampled >= 3 * 2000, "{sampled}"); + assert_eq!(rings::curve_pieces(&path, size(&path), 2, 2), Ok(rings::CURVE_PIECES)); + let bevel = Extrude { bevel: Some(Bevel::default()), ..Extrude::default() }; + let extruded = extrude(&path, FillRule::NonZero, b, bevel, turned()).unwrap(); + assert!(built(&extruded) && extruded.len() > sampled, "{}", extruded.len()); + let inflated = inflate(&path, FillRule::NonZero, b, Inflate::default(), turned()).unwrap(); + assert!(built(&inflated), "{}", inflated.len()); +} + +#[test] +fn an_outline_at_the_point_limit_is_taken_and_one_past_it_is_refused_before_sampling() { + let at = polygon(MAX_SOLID_POINTS, 20_000.0); + assert_eq!(rings::curve_pieces(&at, size(&at), 1, 2), Ok(rings::CURVE_PIECES)); + assert!(built(&extrude(&at, FillRule::NonZero, at.bounds().unwrap(), Extrude::default(), turned()).unwrap())); + let past = polygon(MAX_SOLID_POINTS + 1, 20_000.0); + assert_eq!(rings::curve_pieces(&past, size(&past), 1, 2), Err(SHAPE.to_string())); + assert_eq!(extrude(&past, FillRule::NonZero, past.bounds().unwrap(), Extrude::default(), turned()), Err(SHAPE.to_string())); + assert_eq!(inflate(&past, FillRule::NonZero, past.bounds().unwrap(), Inflate::default(), turned()), Err(SHAPE.to_string())); +} + +#[test] +fn faces_at_the_limit_are_taken_and_one_past_it_is_refused_before_sampling() { + // 40,960 straight edges in four bands are exactly the face limit. + let path = polygon(MAX_SOLID_FACES / 4, 20_000.0); + assert_eq!(rings::curve_pieces(&path, size(&path), 4, 0), Ok(rings::CURVE_PIECES)); + assert_eq!(rings::curve_pieces(&path, size(&path), 4, 1), Err(SURFACE.to_string())); + // A rounded bevel gives 20,000 edges nine bands each, past the limit. + let path = polygon(20_000, 20_000.0); + let rounded = Extrude { bevel: Some(Bevel { shape: BevelShape::Rounded, ..Bevel::default() }), ..Extrude::default() }; + assert_eq!(extrude(&path, FillRule::NonZero, path.bounds().unwrap(), rounded, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn curves_over_the_budget_take_fewer_pieces_down_to_four() { + let path = petals(2000, 500.0, 20.0); + let s = size(&path); + assert_eq!(points(&path, rings::CURVE_PIECES), 6 * 2000, "six pieces a curve within the tolerance"); + // 16 faces an edge: 12,000 points are too many, 10,000 at five pieces a curve fit. + assert_eq!(rings::curve_pieces(&path, s, 16, 0), Ok(5)); + assert_eq!(points(&path, 5), 5 * 2000); + // At four pieces a curve, 21 faces an edge are still too many. + assert_eq!(rings::curve_pieces(&path, s, 21, 0), Err(SURFACE.to_string())); + // Twisted 96°, the walls are 16 slices: built with five pieces a curve. + let b = path.bounds().unwrap(); + let twisted = extrude(&path, FillRule::NonZero, b, Extrude { twist: 96.0, hidden: true, ..Extrude::default() }, turned()).unwrap(); + assert!(built(&twisted) && twisted.len() > MAX_FACES, "{}", twisted.len()); + // Twisted 360°, 60 slices are too many even at four pieces a curve. + assert_eq!(extrude(&path, FillRule::NonZero, b, Extrude { twist: 360.0, ..Extrude::default() }, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn five_and_ten_thousand_curves_build_at_the_defaults() { + let five = petals(5000, 1250.0, 2.0); + assert!(built(&inflate(&five, FillRule::NonZero, five.bounds().unwrap(), Inflate::default(), turned()).unwrap())); + assert!(built(&extrude(&five, FillRule::NonZero, five.bounds().unwrap(), Extrude::default(), turned()).unwrap())); + let ten = petals(10_000, 2500.0, 4.0); + let b = ten.bounds().unwrap(); + assert!(points(&ten, rings::CURVE_PIECES) > 10_000); + assert!(built(&extrude(&ten, FillRule::NonZero, b, Extrude::default(), turned()).unwrap())); + let deep = Extrude { twist: 360.0, bevel: Some(Bevel { shape: BevelShape::Rounded, repeat: 4, ..Bevel::default() }), ..Extrude::default() }; + assert_eq!(extrude(&ten, FillRule::NonZero, b, deep, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn many_empty_contours_and_invalid_handles_are_refused_before_sampling() { + // Contours of one anchor and of none draw nothing, yet each is counted. + let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(1.0, i as f64))] }, false); + let square = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true); + let at = PathData::new((0..MAX_SOLID_POINTS - 1).map(lone).chain([square.clone()]).collect()); + assert_eq!(rings::curve_pieces(&at, 10.0, 1, 2), Ok(rings::CURVE_PIECES)); + let past = PathData::new((0..MAX_SOLID_POINTS).map(lone).chain([square.clone()]).collect()); + assert_eq!(rings::curve_pieces(&past, 10.0, 1, 2), Err(SHAPE.to_string())); + let b = Rect::new(0.0, 0.0, 10.0, 10.0); + assert_eq!(extrude(&past, FillRule::NonZero, b, Extrude::default(), turned()), Err(SHAPE.to_string())); + let mut bad = square; + bad.anchors[1].h_out = Point::new(f64::NAN, 0.0); + let bad = PathData::new(vec![bad]); + assert_eq!(rings::curve_pieces(&bad, 10.0, 1, 2), Err("The shape contains invalid coordinates".to_string())); + assert_eq!(inflate(&bad, FillRule::NonZero, b, Inflate::default(), turned()), Err("The shape contains invalid coordinates".to_string())); +} diff --git a/docs/mcp.md b/docs/mcp.md index a80d25f0b..89466afb7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2578,6 +2578,10 @@ solid, false open behind), `volume` (0..100%, default 50), `bothSides`. All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front position -21.88/-23.4/9.06), `perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets, the scope and what differs from the public documentation. +`threeD.extrude` and `threeD.inflate` take up to 65,536 outline points and build up to 163,840 faces for each solid +(VectorCraft's own limits); with more faces than that, each curve takes fewer pieces, down to 4. A shape past the +points returns "The shape is too complex for a 3D effect", and one whose faces do not fit at 4 pieces a curve returns +"The 3D surface is too complex; simplify the shape". `threeD.rotate` takes up to 65,536 points. ## Extrude & Bevel (Classic) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a8bd43ad5..19ee2abea 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1058,6 +1058,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). + - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). diff --git a/docs/three-d.md b/docs/three-d.md index 0e9901061..e9c8c5e32 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -105,7 +105,8 @@ The rest is our reading: - The solids turn, twist and taper about the center of the bounds of all their shapes, Inflate's grid covers those bounds, and with Perspective one eye sees every solid. A stroke too narrow to hold a point of Inflate's grid stays flat. -- A stroke whose outline takes more than 2,048 points is left out, and the fill's solid still draws. +- A stroke whose outline takes more than 65,536 points, or whose solid takes more than 163,840 faces, is left out, + and the fill's solid still draws. ## Groups @@ -235,10 +236,15 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about one axis ([revolve.md](revolve.md)). One 3D effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points - and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is - counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, - when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace - every hole between them); past it, the shape is too complex for the effect. + and a large one up to 32. Extrude & Bevel and Inflate take up to 65,536 outline points and 163,840 faces for each + solid (a stroke's outline is counted on its own), and Rotate (which only turns the outline) takes 65,536 points. + These limits are VectorCraft's own values. Before any point is sampled, Extrude & Bevel counts the faces its walls, + bevels and caps need, and Inflate counts the faces of its sides; when they do not fit, every curve of the shape + takes fewer pieces, down to 4 (a curve that needs fewer takes fewer). A shape that does not fit at 4 pieces a + curve is refused because the 3D surface is too complex. Inflate counts the faces of its front and back as it + builds them, against the same 163,840. The point limit is counted on the shape as drawn and, when its contours + meet, again on the merged outline, which can have more points (bars crossing in a grid trace every hole between + them); past it, the shape is too complex for the effect. - **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. From 64fb4d45547f7eae9c09d141600791d14f93b765 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Tue, 10 Nov 2026 19:14:48 +0100 Subject: [PATCH 05/14] =?UTF-8?q?Fix=20#6419:=20Edit=20=E2=80=BA=20Assign?= =?UTF-8?q?=20Profile=E2=80=A6=20and=20Color=20Settings=E2=80=A6=20scroll?= =?UTF-8?q?=20to=20the=20Color=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands show the Separations Preview panel (#4542), whose Color Settings section, with the Assign row, comes after the plates and Proof Setup. A floated panel fits its content only up to 320 points, so the section opened out of sight and the panel looked like the wrong one. The commands now leave a one-shot flag that scrolls the section into view, without animation, the next time the panel draws. Window › Separations Preview and edits made in the panel leave the scroll alone. --- crates/ui-egui/src/menus/run.rs | 8 ++- crates/ui-egui/src/panels/separations.rs | 66 ++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 6 +++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 92186e9f3..98d4da989 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -206,9 +206,13 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - crate::panel_docking::reveal_panel(app, "separations").and_then(|_| app.session.execute(id, p).map_err(|e| e.to_string())) + crate::panel_docking::reveal_panel(app, "separations").and_then(|_| { + app.ui.color_settings_scroll = true; + app.session.execute(id, p).map_err(|e| e.to_string()) + }) } // Outline or Preview is a setting of the active document's view. "view.outline" => match app.view_mut() { diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 38cfa604e..108943e46 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -84,6 +84,21 @@ fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, ext } fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { + let top = ui.cursor().top(); + settings_rows(app, ui); + // Edit › Color Settings… / Assign Profile… opened the panel for this section: scroll it into + // view once, as the plates and Proof Setup above it fill a short window (#6419). At once, not + // animated: the panel has just opened. egui moves the scroll area on a later frame, so ask for + // one rather than wait for the pointer to move. + if app.ui.color_settings_scroll && !ui.is_sizing_pass() { + let rect = egui::Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=ui.min_rect().bottom().max(top)); + ui.scroll_to_rect_animation(rect, Some(egui::Align::TOP), egui::style::ScrollAnimation::none()); + ui.ctx().request_repaint(); + app.ui.color_settings_scroll = false; + } +} + +fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { let st = cms::active_settings(); widgets::section_header(ui, tl!("Color Settings")); ui.horizontal(|ui| { @@ -215,4 +230,55 @@ mod tests { assert!(panel.iter().any(|(id, _)| id == "tritanopia")); assert!(panel.iter().all(|(id, _)| cms::ProofTarget::parse(id).is_some_and(|t| t.id() == *id))); } + + /// #6419: Edit › Assign Profile… and Color Settings… show the panel scrolled to its Color + /// Settings section, which a window shorter than the panel (a floated panel stops at 320 + /// points) otherwise shows only after scrolling past the plates and Proof Setup. Once: a + /// scroll away afterwards stays. + #[test] + fn color_settings_and_assign_profile_scroll_the_color_settings_into_view() { + const VIEW: f32 = 120.0; + fn find(s: &egui::Shape, text: &str) -> Option { + match s { + egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos.y), + egui::Shape::Vec(v) => v.iter().find_map(|s| find(s, text)), + _ => None, + } + } + // One frame of the panel in a scroll area VIEW points high, scrolled to `offset` when + // given: where the section's Black Point Compensation box shows in the view. + fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, offset: Option) -> f32 { + let mut top = 0.0; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + top = ui.cursor().top(); + let mut area = egui::ScrollArea::vertical().id_salt("separations").max_height(VIEW); + if let Some(y) = offset { + area = area.vertical_scroll_offset(y); + } + area.show(ui, |ui| show(app, ui)); + }); + out.textures_delta.clear(); + out.shapes.iter().find_map(|c| find(&c.shape, "Use Black Point Compensation")).unwrap() - top + } + let visible = |y: f32| (0.0..VIEW).contains(&y); + for id in ["edit.assignProfile", "edit.colorSettings"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let ctx = egui::Context::default(); + let before = frame(&ctx, &mut app, None); + assert!(!visible(before), "the section starts below the view: {before}"); + app.run(id, json!({})).unwrap(); + frame(&ctx, &mut app, None); + assert!(!app.ui.color_settings_scroll); + // egui moves a scroll area on the frame after the request and paints it moved on the + // one after that; both ask for a repaint. + assert!(ctx.has_requested_repaint()); + frame(&ctx, &mut app, None); + assert!(ctx.has_requested_repaint()); + let shown = frame(&ctx, &mut app, None); + assert!(visible(shown), "{id} scrolls the section into the view: {shown}"); + frame(&ctx, &mut app, Some(0.0)); + assert_eq!(frame(&ctx, &mut app, None), before, "scrolled back to the top, it stays there"); + } + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ce03daca5..dfe85768c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -392,6 +392,11 @@ pub struct UiState { /// That popup's first text field is still to take the keyboard. #[serde(skip)] pub transient_focus: bool, + /// The Separations Preview panel is still to scroll its Color Settings section into view: + /// Edit › Color Settings… and Assign Profile… show the panel for it, and the section sits below + /// the plates and Proof Setup, out of sight in a short window (#6419). + #[serde(skip)] + pub color_settings_scroll: 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, @@ -1009,6 +1014,7 @@ impl Default for UiState { open_panel: None, transient_panel: None, transient_focus: false, + color_settings_scroll: false, find_font_in: String::new(), control_bar: false, control_bar_bottom: false, From af0d01308f6285843e1ee05e8f2c30293fb8a2af Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:39:48 -0800 Subject: [PATCH 06/14] Scripts: a framed run keeps the file-access roots it started under A script.run that a control request started in the app's frames was advanced by later frames, which run unconfined, so its vc.run file commands could read and write outside the caller's automation roots. The job now keeps the file-access scope of its script.run and runs each slice in it, as background opening and the folder walker do. --- crates/engine/src/script/mod.rs | 23 ++++++++++------ crates/engine/src/tests/script_run.rs | 38 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 35e93c1ad..97b5f1889 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -502,6 +502,8 @@ struct Job { /// The documents open when the run began, by [`DocState::uid`]. start_uids: Vec, name: String, + /// The file-access scope `script.run` came in, which every slice runs in. + scope: crate::file_access::ScopeHandle, } /// The `Data` for a JSON value, within `limits`. When it does not fit, the error is the place in @@ -986,7 +988,8 @@ impl Session { self.script_report = Some(report); return Err(EngineError::Other(message)); } - let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name }; + let scope = crate::file_access::ScopeHandle::capture(); + let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name, scope }; let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); JOBS.with(|jobs| jobs.borrow_mut().insert(id, job)); self.script_key = Some(JobKey(id)); @@ -1170,20 +1173,24 @@ fn failure_text(report: &Value) -> String { } /// A run's slice: the working copy in, the real session lent to the host functions, one measured -/// slice run, and everything back. A panic in the interpreter is the outer `Err`. +/// slice run in the file-access scope the run began in, and everything back. A panic in the +/// interpreter is the outer `Err`. fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result, String> { let depth = std::mem::replace(&mut s.depth, 0); job.world.swap(s); s.script_in_run = true; let cell = job.host.session.clone(); std::mem::swap(s, &mut *cell.borrow_mut()); + let scope = job.scope.clone(); let realm = &mut job.realm; - let r = guard::catch_panic(|| { - #[cfg(test)] - if PANIC_IN_SLICE.with(Cell::get) { - panic!("a slice failed"); - } - realm.advance(SLICE_FUEL) + let r = scope.run(|| { + guard::catch_panic(|| { + #[cfg(test)] + if PANIC_IN_SLICE.with(Cell::get) { + panic!("a slice failed"); + } + realm.advance(SLICE_FUEL) + }) }); std::mem::swap(s, &mut *cell.borrow_mut()); s.script_in_run = false; diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 5480ba499..eba254116 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -862,6 +862,44 @@ fn a_file_saved_before_a_failure_stays_written_and_is_named() { let _ = std::fs::remove_file(&path); } +/// A run in the app's frames keeps the file-access scope its `script.run` came in, whatever scope +/// the frames that advance it run in. +#[test] +fn a_framed_run_keeps_the_file_access_scope_it_started_in() { + let dir = std::env::temp_dir().join(format!("vc-script-scope-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (allowed, outside) = (dir.join("allowed"), dir.join("outside")); + std::fs::create_dir_all(&allowed).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&allowed], &[&allowed]).unwrap().unwrap(); + let export = |target: &std::path::Path| { + let p = target.to_string_lossy().into_owned(); + format!("try {{ vc.run('document.export', {{path: {p:?}, format: 'svg'}}); vc.log('ok'); }} catch (e) {{ vc.log(e.name); }}") + }; + let finish = |s: &mut Session| { + loop { + if let Some(r) = s.script_pump() { + return r; + } + } + }; + let mut s = two_docs(); + s.script_frames = true; + let confined_target = outside.join("confined.svg"); + let started = crate::file_access::confine(Some(&roots), || run(&mut s, &export(&confined_target))).unwrap(); + assert_eq!(started["running"], true); + let r = crate::file_access::unconfined(|| finish(&mut s)).unwrap(); + assert_eq!(r["log"], json!(["Error"]), "a confined caller's run stays confined: {r}"); + assert!(!confined_target.exists()); + let user_target = outside.join("user.svg"); + let started = crate::file_access::unconfined(|| run(&mut s, &export(&user_target))).unwrap(); + assert_eq!(started["running"], true); + let r = crate::file_access::confine(Some(&roots), || finish(&mut s)).unwrap(); + assert_eq!(r["log"], json!(["ok"]), "a run the person started is not confined by a later caller: {r}"); + assert!(user_target.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_committed_run_exports_what_it_drew() { let path = std::env::temp_dir().join(format!("vc-script-export-{}.svg", std::process::id())); From a900653c9c1b4f2e34f52cf5df7232e4a9c70425 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:42:12 -0800 Subject: [PATCH 07/14] 3D: Inflate limits the work of filling its grid Inflate's grid clipped every ring of the outline in each cell the outline crosses and tested every edge for each cell's center, so its work grew with the outline's points times the cells: a comb of 4,000 teeth (16,002 points) took 1.1 to 1.3 s and one of 16,000 teeth 8.8 to 9.1 s. Inflate now counts that work (the edge index's entries, the edges its queries look at and the ring points its cells clip) and refuses the shape with "The 3D surface is too complex; simplify the shape" once it passes 268,435,456, VectorCraft's own value (about 0.65 s). The index takes at most 16,777,216 entries (64 MiB). Each row's inside test reads the sorted crossings of its line, and a cell clips only the rings whose bounds meet it, so a 65,536-point circle inflates in 0.66 s instead of 1.0 to 1.2 s. The effects test of a stroke past the limits uses 40,000 corners, so its outline passes the 65,536 points a solid takes. Docs: docs/three-d.md, docs/mcp.md. --- crates/effects/src/solids/tests.rs | 10 +- crates/three-d/src/solid.rs | 4 +- crates/three-d/src/solid/inflate.rs | 53 ++++++++- crates/three-d/src/solid/membrane.rs | 132 ++++++++++++++++++----- crates/three-d/src/solid/tests.rs | 8 +- crates/three-d/src/solid/tests_limits.rs | 56 ++++++++++ docs/mcp.md | 4 +- docs/three-d.md | 4 + 8 files changed, 228 insertions(+), 43 deletions(-) diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 29bbdb3bd..475868d6f 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -205,12 +205,12 @@ fn a_stroked_open_path_builds_a_ribbon() { /// still draws. #[test] fn a_stroke_past_the_limits_is_left_out() { - // 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline. - let points: Vec = (0..1500) + // 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..40_000) .map(|k| { - let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos(); - let r = if k % 2 == 0 { 100.0 } else { 96.0 }; - Point::new(200.0 + r * c, 200.0 + r * s) + let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos(); + let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 }; + Point::new(20_000.0 + r * c, 20_000.0 + r * s) }) .collect(); let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index f4e11270e..339b148a5 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -23,11 +23,13 @@ use build::{Camera, cuts, edge_distance, newell, section}; pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; #[cfg(test)] +use inflate::GRID_WORK; +#[cfg(test)] pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] -use membrane::{Edges, Membrane, cell_triangles}; +use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; use rings::signed_area; #[cfg(test)] use rings::{MAX_SOLID_POINTS, rings}; diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index b3de81e61..20242697f 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,5 +1,5 @@ use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; -use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; +use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material}; use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; @@ -43,8 +43,11 @@ fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result = const { std::cell::Cell::new(0) }; + /// The grid work of the last solid this thread inflated in full, for the tests. + pub(super) static GRID_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the @@ -151,14 +156,41 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo // builds the topology of every outline ring near its cell, so a self-crossing outline cost its // crossings again in every cell it spans. Past it, cells take their clipped pieces. let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS); - // The shape's part of a rectangle, as rings. + // Every ring's bounds, and the crossings of the last few rows the cells' centres lie on (a + // row of cells has at most seven, at its three levels). + let ring_bounds: Vec> = + rings.iter().map(|r| Some(r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p)))).collect(); + let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let rows: std::cell::RefCell)>> = std::cell::RefCell::new(vec![]); + let filled_at = |p: Point| -> bool { + let mut rows = rows.borrow_mut(); + if let Some((_, xs)) = rows.iter().find(|(y, _)| *y == p.y.to_bits()) { + return filled(xs, p.x); + } + let xs = if edges.charge(points) { crossings(&rings, p.y) } else { vec![] }; + let inside = filled(&xs, p.x); + if rows.len() >= 8 { + rows.remove(0); + } + rows.push((p.y.to_bits(), xs)); + inside + }; + // The shape's part of a rectangle, as rings: only the rings whose bounds meet it are clipped, + // each point counted as work. let cut = |r: Rect| -> Vec> { // A cell no edge crosses is wholly inside the shape or wholly outside it. if !edges.touch(r) { let corners = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; - return if filled(&rings, r.center()) { vec![corners] } else { vec![] }; + return if filled_at(r.center()) { vec![corners] } else { vec![] }; } - let pieces: Vec> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect(); + let pieces: Vec> = rings + .iter() + .zip(&ring_bounds) + .filter(|(_, b)| b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)) + .filter(|(ring, _)| edges.charge(ring.len())) + .map(|(ring, _)| clip(ring, r)) + .filter(|c| !c.is_empty()) + .collect(); if pieces.iter().map(|p| signed_area(p)).sum::() <= 1e-9 * r.area() { return vec![]; } @@ -193,6 +225,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } }) .collect(); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } let level = |i: isize, j: isize| -> u32 { if i < 0 || j < 0 || i as usize >= nx || j as usize >= ny { 0 } else { levels[j as usize * nx + i as usize] } }; for j in 0..ny { @@ -209,6 +244,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo for sx in (bx0..bx1).step_by(step) { let r = Rect::from_points(at(sx, sy), at(sx + step, sy + step)); let pieces = cut(r); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } if pieces.is_empty() { continue; } @@ -338,5 +376,10 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } } } + #[cfg(test)] + GRID_WORK.with(|w| w.set(edges.work())); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } Ok(b.finish()) } diff --git a/crates/three-d/src/solid/membrane.rs b/crates/three-d/src/solid/membrane.rs index 02d258531..e8a855e29 100644 --- a/crates/three-d/src/solid/membrane.rs +++ b/crates/three-d/src/solid/membrane.rs @@ -1,9 +1,35 @@ +use std::cell::Cell; + use super::build::segment_distance; -use super::rings::{MAX_PLANE_POINTS, contains, left, rings, signed_area}; +use super::rings::{MAX_PLANE_POINTS, TOO_COMPLEX_SURFACE, left, rings, signed_area}; use super::{FillRule, PathData, Point, Rect, Vec2}; +/// Most entries the edge index of one inflated shape holds: one for each bucket an edge's bounds +/// cover (64 MiB). +pub(super) const MAX_EDGE_ENTRIES: usize = 1 << 24; + +/// Most work the grid of one inflated shape takes: the index's entries, the edges its queries +/// look at, the crossings of the rows it fills and the ring points its cells clip, together. +pub(super) const MAX_GRID_WORK: usize = 1 << 28; + +#[cfg(test)] +thread_local! { + /// The grid work the next edge indexes of this thread may take, for the tests. + pub(super) static GRID_BUDGET: Cell = const { Cell::new(MAX_GRID_WORK) }; +} + +/// The grid work a new edge index may take. +fn grid_budget() -> usize { + #[cfg(test)] + return GRID_BUDGET.with(Cell::get); + #[cfg(not(test))] + MAX_GRID_WORK +} + /// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is -/// found among the buckets around it rather than among all the edges. +/// found among the buckets around it rather than among all the edges. It also counts the work +/// done with it (see [`MAX_GRID_WORK`]): once that is spent, its buckets read as empty, and the +/// caller refuses the shape. pub(super) struct Edges { edges: Vec<(Point, Point)>, normals: Vec<(Vec2, Vec2)>, @@ -11,10 +37,14 @@ pub(super) struct Edges { size: f64, n: usize, buckets: Vec>, + work: Cell, + budget: usize, } impl Edges { - pub(super) fn new(rings: &[Vec]) -> Self { + /// The index of `rings`' edges; an error when it would hold more than [`MAX_EDGE_ENTRIES`] + /// entries, counted before any is filed. + pub(super) fn new(rings: &[Vec]) -> Result { const N: usize = 64; let edges: Vec<(Point, Point)> = rings.iter().flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| (*a, *b))).collect(); @@ -37,16 +67,48 @@ impl Edges { let size = (bounds.width().max(bounds.height()) / N as f64).max(1e-9); let mut buckets = vec![vec![]; N * N]; let index = |v: f64, o: f64| (((v - o) / size).floor().max(0.0) as usize).min(N - 1); - for (k, (a, b)) in edges.iter().enumerate() { - let (i0, i1) = (index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)); - let (j0, j1) = (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0)); + let spans = |(a, b): &(Point, Point)| { + ((index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)), (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0))) + }; + let entries = edges.iter().map(spans).fold(0usize, |sum, ((i0, i1), (j0, j1))| sum.saturating_add((i1 - i0 + 1) * (j1 - j0 + 1))); + if entries > MAX_EDGE_ENTRIES { + return Err(TOO_COMPLEX_SURFACE.into()); + } + for (k, e) in edges.iter().enumerate() { + let ((i0, i1), (j0, j1)) = spans(e); for j in j0..=j1 { for i in i0..=i1 { buckets[j * N + i].push(k as u32); } } } - Self { edges, normals, origin: bounds.origin(), size, n: N, buckets } + Ok(Self { edges, normals, origin: bounds.origin(), size, n: N, buckets, work: Cell::new(entries), budget: grid_budget() }) + } + + /// Has the work done with the index gone past its budget? + pub(super) fn spent(&self) -> bool { + self.work.get() > self.budget + } + + /// The work done with the index so far. + #[cfg(test)] + pub(super) fn work(&self) -> usize { + self.work.get() + } + + /// Count `amount` more work; `false` once the budget is spent, when the work is not to be done. + pub(super) fn charge(&self, amount: usize) -> bool { + if self.spent() { + return false; + } + self.work.set(self.work.get().saturating_add(amount)); + !self.spent() + } + + /// The edges filed in bucket (`i`, `j`), counted as work; none once the budget is spent. + fn bucket(&self, i: isize, j: isize) -> &[u32] { + let bucket = self.buckets.get((j * self.n as isize + i) as usize).map_or(&[][..], Vec::as_slice); + if self.charge(bucket.len()) { bucket } else { &[] } } /// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis. @@ -58,15 +120,11 @@ impl Edges { /// looked at. pub(super) fn near(&self, p: Point, tolerance: f64) -> bool { self.touch(Rect::new(p.x - tolerance, p.y - tolerance, p.x + tolerance, p.y + tolerance)) && { - let n = self.n as isize; let (i0, i1) = (self.cell(p.x - tolerance, self.origin.x), self.cell(p.x + tolerance, self.origin.x)); let (j0, j1) = (self.cell(p.y - tolerance, self.origin.y), self.cell(p.y + tolerance, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] - .iter() - .filter_map(|k| self.edges.get(*k as usize)) - .any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) + self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)).any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) }) }) } @@ -75,10 +133,9 @@ impl Edges { /// Call `f` with each edge whose bounding box overlaps `r` (an edge in several of the /// buckets `r` covers comes more than once). fn each(&self, r: Rect, mut f: impl FnMut(Point, Point)) { - let n = self.n as isize; for j in self.cell(r.y0, self.origin.y)..=self.cell(r.y1, self.origin.y) { for i in self.cell(r.x0, self.origin.x)..=self.cell(r.x1, self.origin.x) { - for (a, b) in self.buckets[(j * n + i) as usize].iter().filter_map(|k| self.edges.get(*k as usize)) { + for (a, b) in self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)) { if a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 { f(*a, *b); } @@ -89,12 +146,11 @@ impl Edges { /// Does any edge pass through `r` (its bounding box, at least, overlapping it)? pub(super) fn touch(&self, r: Rect) -> bool { - let n = self.n as isize; let (i0, i1) = (self.cell(r.x0, self.origin.x), self.cell(r.x1, self.origin.x)); let (j0, j1) = (self.cell(r.y0, self.origin.y), self.cell(r.y1, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] + self.bucket(i, j) .iter() .filter_map(|k| self.edges.get(*k as usize)) .any(|(a, b)| a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0) @@ -122,12 +178,15 @@ impl Edges { let mut best = f64::INFINITY; let mut nearest = (p, Vec2::ZERO); for r in 0..n { + if self.spent() { + break; + } for j in (cj - r)..=(cj + r) { for i in (ci - r)..=(ci + r) { if (j - cj).abs() != r && (i - ci).abs() != r || i < 0 || j < 0 || i >= n || j >= n { continue; } - for k in &self.buckets[(j * n + i) as usize] { + for k in self.bucket(i, j) { if let Some((a, b)) = self.edges.get(*k as usize) { let d = *b - *a; let t = if d.hypot2() < 1e-18 { 0.0 } else { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) }; @@ -152,9 +211,24 @@ impl Edges { } } -/// Is `p` filled by `rings` (nonzero winding, as they are oriented)? -pub(super) fn filled(rings: &[Vec], p: Point) -> bool { - rings.iter().filter(|r| contains(r, p)).count() % 2 == 1 +/// Where the edges of `rings` cross the line through `y`, in order: the points of the line +/// filled by `rings` (nonzero winding, as they are oriented) are those with an odd number of +/// crossings to their right ([`filled`]). An edge crosses where one end is above the line and +/// the other is not, as [`super::rings::contains`] counts it. +pub(super) fn crossings(rings: &[Vec], y: f64) -> Vec { + let mut xs: Vec = rings + .iter() + .flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len())) + .filter(|(a, b)| (a.y > y) != (b.y > y)) + .map(|(a, b)| a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)) + .collect(); + xs.sort_by(f64::total_cmp); + xs +} + +/// Is `x` filled on the line whose [`crossings`] are `xs`? +pub(super) fn filled(xs: &[f64], x: f64) -> bool { + (xs.len() - xs.partition_point(|v| *v <= x)) % 2 == 1 } /// `ring` cut to the rectangle `cell` (Sutherland–Hodgman: the rectangle is convex, the ring @@ -203,13 +277,17 @@ impl Membrane { pub(super) fn solve(rings: &[Vec], edges: &Edges, origin: Point, step: f64, nx: usize, ny: usize) -> Self { let node = |i: usize, j: usize| Point::new(origin.x + i as f64 * step, origin.y + j as f64 * step); let idx = |i: usize, j: usize| j * nx + i; - // A node on the outline itself is held at zero with the outside. - let inside: Vec = (0..nx * ny) - .map(|k| { - let p = node(k % nx, k / nx); - filled(rings, p) && !edges.near(p, 1e-6 * step) - }) - .collect(); + // A node on the outline itself is held at zero with the outside. Each row of nodes is + // tested against the crossings of its line. + let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let mut inside = Vec::with_capacity(nx * ny); + for j in 0..ny { + let xs = if edges.charge(points) { crossings(rings, node(0, j).y) } else { vec![] }; + inside.extend((0..nx).map(|i| { + let p = node(i, j); + filled(&xs, p.x) && !edges.near(p, 1e-6 * step) + })); + } // The nearest crossing of the outline from `p` along (dx, dy), within one step. let crossing = |p: Point, dx: f64, dy: f64| -> f64 { let mut best = step; diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 385800623..f3efe8455 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -324,11 +324,11 @@ fn the_inflate_membrane_matches_known_solutions() { c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01)); let (p, b) = shape(&c); let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(); - let disc = Membrane::solve(&r, &Edges::new(&r), b.origin(), b.width() / 48.0, 49, 49); + let disc = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), b.width() / 48.0, 49, 49); assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak); let (p, b) = shape(&square(0.0, 0.0, 100.0)); let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(); - let sq = Membrane::solve(&r, &Edges::new(&r), b.origin(), 100.0 / 48.0, 49, 49); + let sq = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), 100.0 / 48.0, 49, 49); assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak); // Smooth across the middle: no crease where a distance-to-the-edge height would have one // (the diagonals). The second difference along a diagonal stays small and even. @@ -426,7 +426,7 @@ fn twisted_pieces_stay_within_the_facet_limit() { fn the_edge_index_finds_the_nearest_edge() { let (p, r) = bumps(200); let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap(); - let edges = Edges::new(&rings); + let edges = Edges::new(&rings).unwrap(); for k in 0..400 { let t = f64::from(k); let q = Point::new(-300.0 + (t * 37.0) % 1400.0, -50.0 + (t * 13.0) % 130.0); @@ -442,7 +442,7 @@ fn the_edge_index_looks_along_the_side_from_outside() { let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); // Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid). let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))]; - let edges = Edges::new(&rings); + let edges = Edges::new(&rings).unwrap(); let q = Point::new(-10.0, 0.5); let (fast, slow) = (edges.distance(q), edge_distance(&rings, q)); assert!((fast - slow).abs() < 1e-9, "{fast} against {slow}"); diff --git a/crates/three-d/src/solid/tests_limits.rs b/crates/three-d/src/solid/tests_limits.rs index 350b8dd95..bd7009623 100644 --- a/crates/three-d/src/solid/tests_limits.rs +++ b/crates/three-d/src/solid/tests_limits.rs @@ -139,3 +139,59 @@ fn many_empty_contours_and_invalid_handles_are_refused_before_sampling() { assert_eq!(rings::curve_pieces(&bad, 10.0, 1, 2), Err("The shape contains invalid coordinates".to_string())); assert_eq!(inflate(&bad, FillRule::NonZero, b, Inflate::default(), turned()), Err("The shape contains invalid coordinates".to_string())); } + +/// A comb of `teeth` teeth, 100 wide and 100 tall, on a strip along its foot: its outline +/// crosses every cell of the grid Inflate fills. +fn comb(teeth: usize) -> PathData { + let w = 100.0 / teeth as f64; + let mut points: Vec = (0..teeth) + .flat_map(|k| { + let x = k as f64 * w; + [Point::new(x, 0.0), Point::new(x, 100.0), Point::new(x + w / 2.0, 100.0), Point::new(x + w / 2.0, 0.0)] + }) + .collect(); + points.extend([Point::new(100.0, -1.0), Point::new(0.0, -1.0)]); + PathData::new(vec![SubPath::polyline(&points, true)]) +} + +/// Inflate `path` with the grid work its edge index may take set to `budget`. +fn inflated_within(path: &PathData, budget: usize) -> Result, String> { + GRID_BUDGET.with(|b| b.set(budget)); + let out = inflate(path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()); + GRID_BUDGET.with(|b| b.set(MAX_GRID_WORK)); + out +} + +#[test] +fn inflate_at_its_grid_work_budget_builds_and_one_past_it_is_refused() { + let path = comb(200); + assert!(built(&inflated_within(&path, MAX_GRID_WORK).unwrap())); + let work = GRID_WORK.with(std::cell::Cell::get); + assert!(work > 0); + assert!(built(&inflated_within(&path, work).unwrap())); + assert_eq!(GRID_WORK.with(std::cell::Cell::get), work, "the same work at its budget"); + assert_eq!(inflated_within(&path, work - 1), Err(SURFACE.to_string())); +} + +#[test] +fn the_edge_index_at_its_entry_limit_is_built_and_one_past_it_is_refused() { + // A diagonal across the whole grid of 64 by 64 buckets is filed in every bucket, each way. + let diagonal = vec![Point::new(0.0, 0.0), Point::new(64.0, 64.0)]; + assert_eq!(MAX_EDGE_ENTRIES, 2048 * 2 * 64 * 64); + let mut rings = vec![diagonal; 2048]; + assert!(Edges::new(&rings).is_ok_and(|e| e.work() == MAX_EDGE_ENTRIES)); + // A small triangle in one bucket: three entries more. + rings.push(vec![Point::new(0.0, 0.0), Point::new(0.5, 0.0), Point::new(0.0, 0.5)]); + assert_eq!(Edges::new(&rings).err(), Some(SURFACE.to_string())); +} + +#[test] +fn a_comb_that_crosses_every_cell_is_refused_once_its_grid_work_is_spent() { + // Two thousand curves take a small share of the budget. + let curves = petals(2000, 500.0, 4.0); + assert!(built(&inflate(&curves, FillRule::NonZero, curves.bounds().unwrap(), Inflate::default(), turned()).unwrap())); + assert!(GRID_WORK.with(std::cell::Cell::get) < MAX_GRID_WORK / 16); + // 16,002 points, each clipped in every cell its ring meets. + let path = comb(4000); + assert_eq!(inflate(&path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()), Err(SURFACE.to_string())); +} diff --git a/docs/mcp.md b/docs/mcp.md index 89466afb7..9f9b99a4a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2581,7 +2581,9 @@ the scope and what differs from the public documentation. `threeD.extrude` and `threeD.inflate` take up to 65,536 outline points and build up to 163,840 faces for each solid (VectorCraft's own limits); with more faces than that, each curve takes fewer pieces, down to 4. A shape past the points returns "The shape is too complex for a 3D effect", and one whose faces do not fit at 4 pieces a curve returns -"The 3D surface is too complex; simplify the shape". `threeD.rotate` takes up to 65,536 points. +"The 3D surface is too complex; simplify the shape". `threeD.inflate` also returns the surface error when its outline +has so many points across so many of its grid's cells that filling the grid would pass its work limit. `threeD.rotate` +takes up to 65,536 points. ## Extrude & Bevel (Classic) diff --git a/docs/three-d.md b/docs/three-d.md index e9c8c5e32..e541a078a 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -245,6 +245,10 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. builds them, against the same 163,840. The point limit is counted on the shape as drawn and, when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace every hole between them); past it, the shape is too complex for the effect. +- Inflate also limits the work of filling its grid, which grows with the outline's points times the grid cells its + outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth + across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a + circle builds. - **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. From 2dd63e72409771bfe4d4288ef83af358a1eff28f Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Tue, 10 Nov 2026 19:55:53 +0100 Subject: [PATCH 08/14] Fix #6419: document the Separations Preview panel's Color Settings section functions --- crates/ui-egui/src/panels/separations.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 108943e46..04709b42c 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -83,6 +83,8 @@ fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, ext widgets::dropdown_names(ui, id, super::label_or_name(current, extra == Some(current)), &refs, 210.0).and_then(|i| names.get(i).cloned()) } +/// The Color Settings section (Edit › Color Settings… / Assign Profile…), scrolled into view once +/// when one of those commands opened the panel ([`crate::state::UiState::color_settings_scroll`]). fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { let top = ui.cursor().top(); settings_rows(app, ui); @@ -98,6 +100,8 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, Load +/// Profile… and Gamut Check, and the profile the active document is assigned. fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { let st = cms::active_settings(); widgets::section_header(ui, tl!("Color Settings")); From 332e8fe15cc65cc749568a2b2a25053702f119c3 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Tue, 10 Nov 2026 19:57:24 +0100 Subject: [PATCH 09/14] #6429: a value typed in a Stroke popup sticks when a click outside closes it The Properties panel's Stroke link and the Appearance panel's Stroke: row open the Stroke panel as a popup. A click outside closed it without drawing it again, so the dash field (or any other field in it) never saw itself lose the keyboard, and what was typed went back. A popup closing while one of its fields has the keyboard now draws once more first: the field lets go of the keyboard to the click and commits, as it does in a panel. The Appearance popup closes on the press, before it is a click, so for that last frame a press is what lets go of the keyboard. Escape still drops what was typed. --- crates/ui-egui/src/panels/appearance.rs | 20 ++++++++++++- crates/ui-egui/src/panels/tests_appearance.rs | 28 +++++++++++++++++++ crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/src/widgets/dropdowns.rs | 14 +++++++++- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index ca1f3c28f..4e39c75b7 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -385,9 +385,23 @@ pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Re let area_id = egui::Id::unique("ap-row-popup"); let shown = ctx.memory(|m| m.area_rect(area_id)); // A press outside it (not one that closes a dropdown the popup opened) or Escape puts it away. + // With a field in it that has the keyboard, a press draws it once more first, so the field lets + // go of the keyboard to that press and commits what was typed, as in a panel (#6429). Escape + // drops what was typed. let pressed_outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| shown.is_some_and(|r| !r.contains(p)))); - if pressed_outside && !egui::Popup::is_any_open(ctx) || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + let mut last = false; + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { return close(app); + } else if pressed_outside && !egui::Popup::is_any_open(ctx) { + if !widgets::field_focused_in(ctx, egui::LayerId::new(egui::Order::Foreground, area_id)) { + return close(app); + } + last = true; + } + // Fields let go of the keyboard on a click, which this press isn't yet. + let surrender = ctx.options(|o| o.input_options.surrender_focus_on); + if last { + ctx.options_mut(|o| o.input_options.surrender_focus_on = egui::SurrenderFocusOn::Presses); } let screen = ctx.content_rect(); let left = panel.left() - POPUP_GAP - POPUP_W; @@ -415,6 +429,10 @@ pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Re } }); }); + if last { + ctx.options_mut(|o| o.input_options.surrender_focus_on = surrender); + close(app); + } } /// A row's name that edits the row when clicked ([`link`]), with the tooltip "Click to edit". diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 0431d6dec..2cf78847a 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -1994,3 +1994,31 @@ fn the_drop_buttons_light_up_while_a_row_is_dragged() { assert_eq!(drop_buttons_on(&ctx, &mut app, vec![]), [false, false], "dark again once it is released"); assert_eq!(stack_items(&app).len(), 2, "released elsewhere: nothing removed"); } + +/// #6429: a dash length typed into the Stroke panel that the Appearance panel's "Stroke:" row or +/// the Properties panel's Stroke link opens sticks when a click outside closes the popup, as it +/// does when Enter ends the edit. The popup used to close without drawing again, so the field +/// never saw itself lose the keyboard and the value went back. +#[test] +fn a_dash_typed_in_a_stroke_popup_sticks_when_a_click_outside_closes_it() { + type Panel = fn(&mut VectorcraftApp, &mut egui::Ui); + let hosts: [(&str, Panel); 2] = [("Stroke:", appearance::show), ("Stroke", super::properties::show)]; + for (link, show) in hosts { + let mut app = app_with_rect(); + run(&mut app, "stroke.set", json!({"dash": [10.0]})); + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, link).center(), show); + // The popup's first dash field: a click gives it the keyboard with its text selected. + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "10").center(), show); + frame_events(&ctx, &mut app, vec![egui::Event::Text("4".into())], show); + let dash = |app: &VectorcraftApp| current_stroke(app).and_then(|s| s.dash).map(|d| d.pattern); + assert_eq!(dash(&app), Some(vec![10.0]), "{link}: nothing set while typing"); + click(&ctx, &mut app, egui::pos2(5.0, 3000.0), show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(dash(&app).and_then(|p| p.first().copied()), Some(4.0), "{link}: the typed dash stuck: {:?}", dash(&app)); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(!texts.iter().any(|(t, _)| t == "Dashed Line"), "{link}: the popup closed"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 56bac9808..be2965ef3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -47,7 +47,7 @@ pub use drag::{ use dropdowns::combo_button_id; #[cfg(test)] pub(crate) use dropdowns::menu_floor; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, popover_is_open}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, field_focused_in, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 1046e56a7..d5d914b7b 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -234,8 +234,13 @@ pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) - && click_pos.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground)); was_open && !clicked_elsewhere }; + // Closing with a field in it that has the keyboard: it draws once more, so the field lets go of + // the keyboard to the click and commits what was typed, as in a panel (#6429). + let last = was_open && !open && field_focused_in(ctx, egui::LayerId::new(egui::Order::Foreground, id)); + let mut shown = open || last; let inner = - egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner); + egui::Popup::menu(resp).id(id).open_bool(&mut shown).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner); + open = shown && !last; ctx.data_mut(|d| { if open { d.insert_temp(id, frame); @@ -246,6 +251,13 @@ pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) - inner } +/// Whether a field in the popup on `layer` has the keyboard. A popup closing with one draws once +/// more before it goes: the field lets go of the keyboard to the click away and commits what was +/// typed, as it does in a panel. A popup that closed without drawing again dropped it (#6429). +pub(crate) fn field_focused_in(ctx: &egui::Context, layer: egui::LayerId) -> bool { + ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| r.layer_id == layer) +} + /// Whether the popover for this anchor was open in the latest completed frame. pub(crate) fn popover_is_open(ctx: &egui::Context, anchor: egui::Id) -> bool { let frame = ctx.cumulative_frame_nr(); From a5d09c13f01aa7858063778a45bd54bab373cb2a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:57:34 -0800 Subject: [PATCH 10/14] Docs: with glyph bounds, the font size follows the vertical scale docs/mcp.md and docs/target-app-parity.md said that a width or height typed with Measure Type by Glyph Bounds on changes the font size by the scale. The font size follows the vertical scale only: a width typed alone, out of proportion, changes the horizontal scale and keeps the font size, and a height typed alone changes the horizontal scale the other way. A new test checks both. --- crates/engine/src/tests/typeglyphbounds.rs | 24 ++++++++++++++++++++++ docs/mcp.md | 5 +++-- docs/target-app-parity.md | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests/typeglyphbounds.rs b/crates/engine/src/tests/typeglyphbounds.rs index 9fd8e400b..0ebd61738 100644 --- a/crates/engine/src/tests/typeglyphbounds.rs +++ b/crates/engine/src/tests/typeglyphbounds.rs @@ -127,6 +127,30 @@ fn set_bounds_moves_and_sizes_the_ink_box() { assert!((ink(&s, t).height() - before.height()).abs() < 1e-6, "{:?}", ink(&s, t)); } +/// The font size the panels show follows the vertical scale: a typed width alone changes the +/// horizontal scale and keeps the font size, and a typed height alone changes the font size and +/// keeps the width, so the horizontal scale changes the other way. +#[test] +fn a_typed_width_changes_the_horizontal_scale_and_a_typed_height_the_font_size() { + let shown = |s: &Session, t: NodeId| { + let o = text(s, t); + let st = o.effective_char_style(&o.runs[0].style).unwrap(); + (st.size, st.h_scale) + }; + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let before = ink(&s, t); + run(&mut s, "object.setBounds", json!({"width": before.width() * 2.0})); + let (size, h_scale) = shown(&s, t); + assert!((size - 100.0).abs() < 1e-6 && (h_scale - 200.0).abs() < 1e-6, "{size} pt at {h_scale}%"); + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + run(&mut s, "object.setBounds", json!({"height": before.height() * 2.0})); + let (size, h_scale) = shown(&s, t); + assert!((size - 200.0).abs() < 1e-6 && (h_scale - 50.0).abs() < 1e-6, "{size} pt at {h_scale}%"); + assert!((ink(&s, t).width() - before.width()).abs() < 1e-6, "{:?}", ink(&s, t)); +} + #[test] fn set_bounds_with_the_current_values_records_no_undo_step() { let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); diff --git a/docs/mcp.md b/docs/mcp.md index a80d25f0b..ba11596b2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4083,8 +4083,9 @@ outlines and its underline and strikethrough bars. This applies to each point ty default. A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its -font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt -font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is +font size changes by the vertical scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a +72 pt font. A width typed alone, out of proportion, changes the horizontal scale and keeps the font size, and a height +typed alone keeps the width, so the horizontal scale changes the other way. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that draws nothing (no text, or only spaces without underline or strikethrough), area type, type on a path and type inside a selected group are measured as before. Arrange in Grid and Group Overlapping Objects measure visual bounds as diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a8bd43ad5..89690084e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1070,7 +1070,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - A butt-capped curve whose first or last handle sits on its end point also stops at that end: a straight 50 pt curve with a 2 pt stroke and its first handle on its start point measures 50 pt tall, not 51 pt (doc `reach::tests::a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end`). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). + - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size; the font size follows the vertical scale, so a W typed alone, out of proportion, changes the horizontal scale instead. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. The Lights list, Add Light and Delete Light manage one to ten independent colored lights; ambient is shared and visibility remains approximate ([revolve.md](revolve.md)). From 9dc15b8f1fa450cec9f43f19d4c6eb9471bc700c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Mon, 9 Nov 2026 20:36:16 +0000 Subject: [PATCH 11/14] G5: built-in Game Trails brush library Add Speed Line, Dash Trail, Burst Ray and Zigzag as original art brushes so motion and impact strokes are one library click away. --- crates/engine/src/cmd/brushlib.rs | 42 ++++++++++++++++++- crates/engine/src/tests/brushlib.rs | 16 ++++++- crates/ui-egui/src/i18n/bg.tsv | 5 +++ crates/ui-egui/src/i18n/bs.tsv | 5 +++ crates/ui-egui/src/i18n/ca.tsv | 5 +++ crates/ui-egui/src/i18n/cs.tsv | 5 +++ crates/ui-egui/src/i18n/da.tsv | 5 +++ crates/ui-egui/src/i18n/de.tsv | 5 +++ crates/ui-egui/src/i18n/el.tsv | 5 +++ crates/ui-egui/src/i18n/es.tsv | 5 +++ crates/ui-egui/src/i18n/fi.tsv | 5 +++ crates/ui-egui/src/i18n/fr.tsv | 5 +++ crates/ui-egui/src/i18n/hi.tsv | 5 +++ crates/ui-egui/src/i18n/hu.tsv | 5 +++ crates/ui-egui/src/i18n/id.tsv | 5 +++ crates/ui-egui/src/i18n/it.tsv | 5 +++ crates/ui-egui/src/i18n/ja.tsv | 5 +++ crates/ui-egui/src/i18n/ka.tsv | 5 +++ crates/ui-egui/src/i18n/ko.tsv | 5 +++ crates/ui-egui/src/i18n/nl.tsv | 5 +++ crates/ui-egui/src/i18n/pl.tsv | 5 +++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +++ crates/ui-egui/src/i18n/ru.tsv | 5 +++ crates/ui-egui/src/i18n/sv.tsv | 5 +++ crates/ui-egui/src/i18n/tr.tsv | 5 +++ crates/ui-egui/src/i18n/uk.tsv | 5 +++ crates/ui-egui/src/i18n/vi.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 +++ .../src/panels/tests_brush_libraries.rs | 2 +- docs/gaps.md | 12 +++--- docs/mcp.md | 8 ++-- docs/target-app-parity.md | 11 ++--- 33 files changed, 207 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 8f36e16f6..a5ff58ddf 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -165,13 +165,13 @@ pub struct BuiltinBrushLibrary { make: fn() -> BrushLibrary, } -/// The built-in brush libraries, in menu order: original brushes generated in code, one library -/// per kind of brush. +/// The built-in brush libraries, in menu order: original brushes generated in code. pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, + BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, ]; /// Built-in library `id`, made once. @@ -378,6 +378,44 @@ fn scatter_shapes() -> BrushLibrary { built("Scatter Shapes", doc, brushes) } +/// Game Trails: art brushes for motion lines and impact streaks (original game HUD / VFX art). +fn game_trails() -> BrushLibrary { + let speed = { + let n = 32; + let half = |x: f64| 2.2 * (1.0 - x / 100.0).powf(1.2).max(0.08); + let xs = |i: usize| 100.0 * i as f64 / n as f64; + let mut pts: Vec = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect(); + pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x)))); + PathData::single(SubPath::polyline(&pts, true)) + }; + let dash = group(vec![ + black(shapes::rounded_rectangle(Rect::new(0.0, -1.0, 14.0, 1.0), 1.0)), + black(shapes::rounded_rectangle(Rect::new(22.0, -1.0, 36.0, 1.0), 1.0)), + black(shapes::rounded_rectangle(Rect::new(44.0, -1.0, 58.0, 1.0), 1.0)), + black(shapes::rounded_rectangle(Rect::new(66.0, -1.0, 80.0, 1.0), 1.0)), + black(shapes::rounded_rectangle(Rect::new(88.0, -1.0, 100.0, 1.0), 1.0)), + ]); + let burst = { + // A wedge that flares from a tip at x=0 to a wide end at x=100. + let tip = Point::new(0.0, 0.0); + let top = Point::new(100.0, -6.0); + let bot = Point::new(100.0, 6.0); + PathData::single(SubPath::polyline(&[tip, top, bot], true)) + }; + let zigzag = band(100.0, 1.8, 40, |x| { + let t = (x / 12.5).floor(); + let local = (x / 12.5) - t; + if (t as i32) % 2 == 0 { -3.0 + 6.0 * local } else { 3.0 - 6.0 * local } + }); + let brushes = vec![ + art("Speed Line", black(speed), Colorization::Tints), + art("Dash Trail", dash, Colorization::Tints), + art("Burst Ray", black(burst), Colorization::Tints), + art("Zigzag", black(zigzag), Colorization::Tints), + ]; + built("Game Trails", Document::new(100.0, 100.0), brushes) +} + // ---------- commands ---------- fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index f5ac96d50..e98593bfc 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -112,8 +112,20 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ["Notched Ribbon", "Double Line", "Wave", "Teardrop", "Striped Ribbon"] ); assert_eq!(libs[0]["count"].as_u64(), Some(5)); + let trails = run(&mut s, "brush.library", json!({"library": "game-trails"})); + assert_eq!( + trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), + ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] + ); + assert_eq!(libs[4]["count"].as_u64(), Some(4)); // One kind of brush per library, every brush drawing something. - for (id, kind) in [("art-ribbons", "art"), ("calligraphic-nibs", "calligraphic"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter")] { + for (id, kind) in [ + ("art-ribbons", "art"), + ("calligraphic-nibs", "calligraphic"), + ("pattern-borders", "pattern"), + ("scatter-shapes", "scatter"), + ("game-trails", "art"), + ] { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); for b in &lib.brushes { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index aec59eee3..bfb2804c9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5904,6 +5904,11 @@ axis Y Axis Y ос User Brush Library 9 Потребителска библиотека с четки 9 User Brush Library 10 Потребителска библиотека с четки 10 Art Ribbons Арт ленти + Zigzag Зигзаг + Burst Ray Лъч на удар + Dash Trail Пунктирна следа + Speed Line Линия на скорост + Game Trails Игрови следи Calligraphic Nibs Калиграфски писци Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index bc1d51c6d..12a1714be 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5954,6 +5954,11 @@ axis Y Axis Y osa User Brush Library 9 Korisnička biblioteka četkica 9 User Brush Library 10 Korisnička biblioteka četkica 10 Art Ribbons Umjetničke trake + Zigzag Cik-cak + Burst Ray Zraka udara + Dash Trail Isprekidani trag + Speed Line Linija brzine + Game Trails Tragovi igre Calligraphic Nibs Kaligrafski vrhovi Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1a8c56a02..d84e3a76c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -362,6 +362,11 @@ antiAlias Sharp Nítid Art Brush Options Opcions del pinzell artístic Art Optimized Optimitzat per a l’art Art Ribbons Cintes d’art + Zigzag Zigzag + Burst Ray Raig d’impacte + Dash Trail Estela discontínua + Speed Line Línia de velocitat + Game Trails Esteles de joc Art using a deleted global swatch keeps its colour. L’art que utilitza una mostra global suprimida conserva el seu color. Artboard Tauler Artboard Options Opcions del tauler diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 88d92db1f..3d6b0d2d9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6118,6 +6118,11 @@ axis Y Axis osa Y User Brush Library 9 Uživatelská knihovna štětců 9 User Brush Library 10 Uživatelská knihovna štětců 10 Art Ribbons Obrazové stuhy + Zigzag Cikcak + Burst Ray Paprsek nárazu + Dash Trail Čárkovaná stopa + Speed Line Rychlostní čára + Game Trails Herní stopy Calligraphic Nibs Kaligrafická pera Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 0f952c968..7c06c069f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5919,6 +5919,11 @@ axis Y Axis Y-akse User Brush Library 9 Brugerpenselbibliotek 9 User Brush Library 10 Brugerpenselbibliotek 10 Art Ribbons Kunstbånd + Zigzag Zigzag + Burst Ray Burststråle + Dash Trail Streget spor + Speed Line Hastighedslinje + Game Trails Spilspor Calligraphic Nibs Kalligrafiske penne Pattern Borders Mønsterborter Scatter Shapes Spredte former diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c4b96d84c..bc6c36435 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5701,6 +5701,11 @@ axis Y Axis Y-Achse User Brush Library 9 Benutzer-Pinsel-Bibliothek 9 User Brush Library 10 Benutzer-Pinsel-Bibliothek 10 Art Ribbons Bildbänder + Zigzag Zickzack + Burst Ray Stoßstrahl + Dash Trail Strichelspur + Speed Line Geschwindigkeitslinie + Game Trails Spielspuren Calligraphic Nibs Kalligrafie-Federn Pattern Borders Musterbordüren Scatter Shapes Streuformen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8c7962347..76c6099eb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6069,6 +6069,11 @@ axis Y Axis Άξονας Y User Brush Library 9 Βιβλιοθήκη πινέλων χρήστη 9 User Brush Library 10 Βιβλιοθήκη πινέλων χρήστη 10 Art Ribbons Γραφικές κορδέλες + Zigzag Ζιγκζάγκ + Burst Ray Ακτίνα κρούσης + Dash Trail Διακεκομμένο ίχνος + Speed Line Γραμμή ταχύτητας + Game Trails Ίχνη παιχνιδιού Calligraphic Nibs Καλλιγραφικές πένες Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 05e052332..4e5cf58ef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6060,6 +6060,11 @@ axis Y Axis Eje Y User Brush Library 9 Biblioteca de pinceles del usuario 9 User Brush Library 10 Biblioteca de pinceles del usuario 10 Art Ribbons Cintas de ilustración + Zigzag Zigzag + Burst Ray Rayo de impacto + Dash Trail Estela discontinua + Speed Line Línea de velocidad + Game Trails Estelas de juego Calligraphic Nibs Plumillas caligráficas Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d4d8b3bf8..d16ef8286 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6148,6 +6148,11 @@ axis Y Axis Y-akseli User Brush Library 9 Käyttäjän sivellinkirjasto 9 User Brush Library 10 Käyttäjän sivellinkirjasto 10 Art Ribbons Kuvitusnauhat + Zigzag Siksak + Burst Ray Iskusäde + Dash Trail Katkoviiva + Speed Line Nopeusviiva + Game Trails Pelijäljet Calligraphic Nibs Kalligrafiakärjet Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f926cf50f..8613ae5ea 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6113,6 +6113,11 @@ axis Y Axis Axe Y User Brush Library 9 Bibliothèque de formes utilisateur 9 User Brush Library 10 Bibliothèque de formes utilisateur 10 Art Ribbons Rubans d'illustration + Zigzag Zigzag + Burst Ray Rayon d’impact + Dash Trail Traînée tiretée + Speed Line Ligne de vitesse + Game Trails Traînées de jeu Calligraphic Nibs Plumes calligraphiques Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index f129d3482..22d882a03 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -421,6 +421,11 @@ antiAlias Sharp शार्प Art Brush Options आर्ट ब्रश ऑप्शन्स Art Optimized आर्टवर्क के लिए अनुकूलित Art Ribbons आर्ट रिबन + Zigzag ज़िगज़ैग + Burst Ray बर्स्ट रे + Dash Trail डैश ट्रेल + Speed Line स्पीड लाइन + Game Trails गेम ट्रेल्स Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index beb49b1ae..46f4669cc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6101,6 +6101,11 @@ axis Y Axis Y tengely User Brush Library 9 Felhasználói ecsetkönyvtár 9 User Brush Library 10 Felhasználói ecsetkönyvtár 10 Art Ribbons Grafikus szalagok + Zigzag Cikkcakk + Burst Ray Ütéssugár + Dash Trail Szaggatott nyom + Speed Line Sebességvonal + Game Trails Játéknyomok Calligraphic Nibs Kalligrafikus tollhegyek Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7bf4d166c..737f45124 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5919,6 +5919,11 @@ axis Y Axis Sumbu Y User Brush Library 9 Pustaka kuas pengguna 9 User Brush Library 10 Pustaka kuas pengguna 10 Art Ribbons Pita seni + Zigzag Zigzag + Burst Ray Sinar ledakan + Dash Trail Jejak putus-putus + Speed Line Garis kecepatan + Game Trails Jejak game Calligraphic Nibs Pena kaligrafi Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7cb3619cf..d5420b3a9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5735,6 +5735,11 @@ axis Y Axis Asse Y User Brush Library 9 Libreria pennelli utente 9 User Brush Library 10 Libreria pennelli utente 10 Art Ribbons Nastri grafici + Zigzag Zigzag + Burst Ray Raggio d’impatto + Dash Trail Scia tratteggiata + Speed Line Linea di velocità + Game Trails Scia di gioco Calligraphic Nibs Pennini calligrafici Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7e2ee3e17..28787e6fb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5800,6 +5800,11 @@ axis Y Axis Y 軸 User Brush Library 9 ユーザーブラシライブラリ 9 User Brush Library 10 ユーザーブラシライブラリ 10 Art Ribbons アートリボン + Zigzag ジグザグ + Burst Ray バーストレイ + Dash Trail ダッシュトレイル + Speed Line スピードライン + Game Trails ゲームトレイル Calligraphic Nibs カリグラフィペン先 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 512429c1c..f7ddd3329 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6041,6 +6041,11 @@ axis Y Axis Y ღერძი User Brush Library 9 მომხმარებლის ფუნჯების ბიბლიოთეკა 9 User Brush Library 10 მომხმარებლის ფუნჯების ბიბლიოთეკა 10 Art Ribbons ნახატის ლენტები + Zigzag ზიგზაგი + Burst Ray დარტყმის სხივი + Dash Trail წყვეტილი კვალი + Speed Line სიჩქარის ხაზი + Game Trails თამაშის კვალი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 349ecfd74..36201c465 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5696,6 +5696,11 @@ axis Y Axis Y축 User Brush Library 9 사용자 브러시 라이브러리 9 User Brush Library 10 사용자 브러시 라이브러리 10 Art Ribbons 아트 리본 + Zigzag 지그재그 + Burst Ray 버스트 레이 + Dash Trail 대시 트레일 + Speed Line 스피드 라인 + Game Trails 게임 트레일 Calligraphic Nibs 서예 펜촉 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 48d9108ed..4d2d1b707 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5970,6 +5970,11 @@ axis Y Axis Y-as User Brush Library 9 Penseelbibliotheek van gebruiker 9 User Brush Library 10 Penseelbibliotheek van gebruiker 10 Art Ribbons Illustratielinten + Zigzag Zigzag + Burst Ray Inslagstraal + Dash Trail Streepspoor + Speed Line Snelheidslijn + Game Trails Spelsporen Calligraphic Nibs Kalligrafische penpunten Pattern Borders Patroonranden Scatter Shapes Strooivormen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1f018c3e8..c4a5d4f33 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5972,6 +5972,11 @@ axis Y Axis Oś Y User Brush Library 9 Biblioteka pędzli użytkownika 9 User Brush Library 10 Biblioteka pędzli użytkownika 10 Art Ribbons Wstęgi artystyczne + Zigzag Zygzak + Burst Ray Promień uderzenia + Dash Trail Ślad kreskowany + Speed Line Linia prędkości + Game Trails Ślady gry Calligraphic Nibs Stalówki kaligraficzne Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 661dfd79d..5139495ef 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6033,6 +6033,11 @@ axis Y Axis Eixo Y User Brush Library 9 Biblioteca de Pincéis do Usuário 9 User Brush Library 10 Biblioteca de Pincéis do Usuário 10 Art Ribbons Fitas de Arte + Zigzag Zigue-zague + Burst Ray Raio de impacto + Dash Trail Rastro tracejado + Speed Line Linha de velocidade + Game Trails Rastros de jogo Calligraphic Nibs Penas Caligráficas Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5532f5df5..803159a1d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5729,6 +5729,11 @@ axis Y Axis Ось Y User Brush Library 9 Пользовательская библиотека кистей 9 User Brush Library 10 Пользовательская библиотека кистей 10 Art Ribbons Художественные ленты + Zigzag Зигзаг + Burst Ray Ударный луч + Dash Trail Пунктирный след + Speed Line Линия скорости + Game Trails Игровые следы Calligraphic Nibs Каллиграфические перья Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a43367d53..8dd68ee2e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6141,6 +6141,11 @@ axis Y Axis Y-axel User Brush Library 9 Användarbibliotek för penslar 9 User Brush Library 10 Användarbibliotek för penslar 10 Art Ribbons Konstband + Zigzag Sicksack + Burst Ray Stötstråle + Dash Trail Streckat spår + Speed Line Hastighetslinje + Game Trails Spår från spel Calligraphic Nibs Kalligrafiska pennspetsar Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 75806752b..3479fd156 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6074,6 +6074,11 @@ axis Y Axis Y ekseni User Brush Library 9 Kullanıcı Fırça Kitaplığı 9 User Brush Library 10 Kullanıcı Fırça Kitaplığı 10 Art Ribbons Sanat Şeritleri + Zigzag Zigzag + Burst Ray Darbe ışını + Dash Trail Kesik iz + Speed Line Hız çizgisi + Game Trails Oyun izleri Calligraphic Nibs Kaligrafi Uçları Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 42fb8fd69..b96c347dc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5715,6 +5715,11 @@ axis Y Axis Вісь Y User Brush Library 9 Користувацька бібліотека пензлів 9 User Brush Library 10 Користувацька бібліотека пензлів 10 Art Ribbons Художні стрічки + Zigzag Зигзаг + Burst Ray Ударний промінь + Dash Trail Пунктирний слід + Speed Line Лінія швидкості + Game Trails Ігрові сліди Calligraphic Nibs Каліграфічні пера Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d9dc7ad9c..ab43035c2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5716,6 +5716,11 @@ axis Y Axis Trục Y User Brush Library 9 Thư viện cọ người dùng 9 User Brush Library 10 Thư viện cọ người dùng 10 Art Ribbons Dải băng nghệ thuật + Zigzag Zigzag + Burst Ray Tia va chạm + Dash Trail Vệt đứt đoạn + Speed Line Đường tốc độ + Game Trails Vệt game Calligraphic Nibs Ngòi bút thư pháp Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6117097bc..b5105e92e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5956,6 +5956,11 @@ axis Y Axis Y 轴 User Brush Library 9 用户画笔库 9 User Brush Library 10 用户画笔库 10 Art Ribbons 艺术丝带 + Zigzag 锯齿 + Burst Ray 冲击射线 + Dash Trail 虚线轨迹 + Speed Line 速度线 + Game Trails 游戏轨迹 Calligraphic Nibs 书法笔尖 Pattern Borders 图案边框 Scatter Shapes 散布形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b1e7416a9..d853aa88c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5753,6 +5753,11 @@ axis Y Axis Y 軸 User Brush Library 9 使用者筆刷資料庫 9 User Brush Library 10 使用者筆刷資料庫 10 Art Ribbons 藝術緞帶 + Zigzag 鋸齒 + Burst Ray 衝擊射線 + Dash Trail 虛線軌跡 + Speed Line 速度線 + Game Trails 遊戲軌跡 Calligraphic Nibs 書法筆尖 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 09ed75af7..22f863fbd 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -127,7 +127,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Brush Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.brushLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("art-ribbons"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes")]); + assert_eq!(opened, [json!("art-ribbons"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails")]); let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(enabled("window.brushLibrary.other"), Some(true)); assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes"); diff --git a/docs/gaps.md b/docs/gaps.md index 3e1e28fa9..43fa83db3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -631,8 +631,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (four original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a + its own (five original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter + Shapes and Game Trails; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and dragging one onto the Brushes panel adds it; Save Brushes / Save Brush Library writes the document's brushes @@ -648,11 +649,11 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi graphic style libraries (Window › Graphic Style Libraries; seven original libraries generated in code, including Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries (and further graphic-style libraries beyond the seven shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the seven shipped, and further symbol libraries beyond Basic Shapes, UI Frames and Map Markers; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). -- **Estimate:** 9–19 h. +- **Estimate:** 8–18 h. ## G3 3D and Materials @@ -857,6 +858,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. | +| 2026-11-09 | minor | G5: built-in Game Trails brush library (Speed Line, Dash Trail, Burst Ray, Zigzag); Missing trimmed; estimate 11–21 → 10–20 h. | | 2026-11-09 | minor | G4 type: Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, #4936; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | | 2026-11-09 | minor | G5: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); Missing trimmed; estimate 12–22 → 11–21 h. | | 2026-11-09 | minor | G5: Direct Selection of a dynamic instance part takes opacity, blend, gradient and pattern fill/stroke; Art brush Overlap and Pattern auto corners documented Done; estimate 15–25 → 12–22 h. | diff --git a/docs/mcp.md b/docs/mcp.md index 67ce12721..ac54ece46 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2839,10 +2839,10 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: four generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and -the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default -ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and +Brush libraries are read-only sets of brushes: five generated in code (Art Ribbons, Calligraphic Nibs, Pattern +Borders, Scatter Shapes, Game Trails), the `.vectorcraft` files in the user library folder of the desktop app (User +Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has +the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). `brush.addFromLibrary {library, name? | names?}` copies brushes into the document's Brushes as one undo step (no names: the whole library), with the resources their art uses: patterns, symbols, swatches (`swatchConflict` as for diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fcc540c91..79085b7a5 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-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; 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; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; 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; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -217,7 +217,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 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); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 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) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (five generated libraries including Game Trails, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 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) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -486,7 +486,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (four generated in code are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (five generated in code including Game Trails are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -538,7 +538,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~36% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have three (Basic Shapes, UI Frames with default 9-slice, Map Markers), graphic style libraries seven (including Game HUD) and brush libraries four (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~38% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have three (Basic Shapes, UI Frames with default 9-slice, Map Markers), graphic style libraries seven (including Game HUD) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -797,7 +797,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol. A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. @@ -1949,6 +1949,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | | 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports (evidence in Feature revisions). Scores and estimates unchanged. | +| 2026-11-09 | minor | Bundled content: built-in Game Trails brush library (Speed Line, Dash Trail, Burst Ray, Zigzag); brush libraries evidence updated; ~32% → ~34%; more built-in brush libraries remain. | | 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. | | 2026-11-09 | minor | Bundled content: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); symbol libraries evidence updated; ~30% → ~32%; more built-in symbol libraries remain. | | 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. | From b86497b1e4d160a09b114488790734801f29ab1f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 11:06:32 -0800 Subject: [PATCH 12/14] JavaScript: identifiers take every Unicode ID_Start and ID_Continue character, and no others is_id_start now accepts the Other_ID_Start characters (U+1885, U+1886, U+2118, U+212E, U+309B, U+309C), and is_id_part the Other_ID_Continue characters (U+00B7, U+0387, U+1369 to U+1371, U+19DA, U+30FB, U+FF65), as Unicode's ID_Start and ID_Continue properties and the ECMAScript Names and Keywords clause define them. Letters, marks, digits and connectors that are Pattern_Syntax are refused, which replaces the U+2E2F special case. Escaped identifier characters go through the same check. A supplementary letter right after a number is now the "identifier starts immediately after numeric literal" error. Letter categories still come from Unicode 16.0, so characters assigned in later versions are not identifier characters yet. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/js/src/chars.rs | 92 +++++++++++++++++++++++++++++++++------ crates/js/src/lexer.rs | 2 +- crates/js/src/tests.rs | 68 +++++++++++++++++++++++++++++ docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + 5 files changed, 152 insertions(+), 15 deletions(-) diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs index eb61fe763..986fa2b66 100644 --- a/crates/js/src/chars.rs +++ b/crates/js/src/chars.rs @@ -1,5 +1,8 @@ //! Character classes of ECMA-262 5.1 section 7: white space, line terminators and the characters -//! identifiers are made of. +//! identifiers are made of. Identifiers follow the Unicode `ID_Start` and `ID_Continue` rule of +//! later editions. + +use std::cmp::Ordering; use unicode_general_category::{GeneralCategory as G, get_general_category}; @@ -14,35 +17,98 @@ pub fn is_whitespace(c: u32) -> bool { matches!(c, 0x09 | 0x0B | 0x0C | 0x20 | 0xA0 | 0xFEFF | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000) } -/// A character an identifier may start with: a letter (categories Lu, Ll, Lt, Lm, Lo and Nl), -/// `$` or `_` (section 7.6). `\u` escapes are decoded before this check. +/// A character an identifier may start with: a Unicode `ID_Start` character, `$` or `_` +/// (the Names and Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, Lm, Lo and Nl) +/// and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode Standard Annex +/// #31). The categories come from Unicode 16.0. `\u` escapes are decoded before this check. pub fn is_id_start(c: u32) -> bool { match c { 0x24 | 0x5F => true, 0x41..=0x5A | 0x61..=0x7A => true, 0..=0x7F => false, - // U+2E2F VERTICAL TILDE is a letter (Lm) that Unicode leaves out of identifiers. - 0x2E2F => false, - _ => category(c).is_some_and(|g| { - matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber) - }), + _ if is_other_id_start(c) => true, + _ => { + category(c).is_some_and(|g| { + matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber) + }) && !is_pattern_syntax(c) + } } } -/// A character an identifier may hold after its first: those it may start with, decimal digits -/// (Nd), combining marks (Mn, Mc), connector punctuation (Pc), and the zero width non-joiner and -/// joiner. +/// A character an identifier may hold after its first: a Unicode `ID_Continue` character, `$`, +/// `_`, or the zero width non-joiner and joiner. `ID_Continue` adds to `ID_Start` the decimal +/// digits (Nd), combining marks (Mn, Mc), connector punctuation (Pc) and `Other_ID_Continue`, +/// less `Pattern_Syntax` and `Pattern_White_Space`. pub fn is_id_part(c: u32) -> bool { match c { 0x30..=0x39 => true, 0x200C | 0x200D => true, - 0x2E2F => false, _ if is_id_start(c) => true, 0..=0x7F => false, - _ => category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)), + _ if is_other_id_continue(c) => true, + _ => { + category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)) + && !is_pattern_syntax(c) + } } } +/// `Other_ID_Start` (Unicode PropList): characters kept in `ID_Start` though their category is +/// not a letter. +fn is_other_id_start(c: u32) -> bool { + matches!(c, 0x1885 | 0x1886 | 0x2118 | 0x212E | 0x309B | 0x309C) +} + +/// `Other_ID_Continue` (Unicode PropList) outside ASCII, less the zero width non-joiner and joiner, +/// which [`is_id_part`] takes on their own. +fn is_other_id_continue(c: u32) -> bool { + matches!(c, 0xB7 | 0x387 | 0x1369..=0x1371 | 0x19DA | 0x30FB | 0xFF65) +} + +/// Non-ASCII `Pattern_Syntax` ranges (Unicode PropList). Unicode does not change this set, and no +/// identifier character belongs to it. `Pattern_White_Space` outside ASCII (U+0085, U+200E, U+200F, +/// U+2028 and U+2029) holds no letter, mark, digit or connector, so the category checks leave it out. +const PATTERN_SYNTAX: [(u32, u32); 23] = [ + (0x00A1, 0x00A7), + (0x00A9, 0x00A9), + (0x00AB, 0x00AC), + (0x00AE, 0x00AE), + (0x00B0, 0x00B1), + (0x00B6, 0x00B6), + (0x00BB, 0x00BB), + (0x00BF, 0x00BF), + (0x00D7, 0x00D7), + (0x00F7, 0x00F7), + (0x2010, 0x2027), + (0x2030, 0x203E), + (0x2041, 0x2053), + (0x2055, 0x205E), + (0x2190, 0x245F), + (0x2500, 0x2775), + (0x2794, 0x2BFF), + (0x2E00, 0x2E7F), + (0x3001, 0x3003), + (0x3008, 0x3020), + (0x3030, 0x3030), + (0xFD3E, 0xFD3F), + (0xFE45, 0xFE46), +]; + +/// Whether a non-ASCII code point is `Pattern_Syntax`. +fn is_pattern_syntax(c: u32) -> bool { + PATTERN_SYNTAX + .binary_search_by(|&(lo, hi)| { + if hi < c { + Ordering::Less + } else if lo > c { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .is_ok() +} + /// The Unicode general category of a code point; `None` for a lone surrogate. fn category(c: u32) -> Option { char::from_u32(c).map(get_general_category) diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs index 8b82570a7..9a6ce8766 100644 --- a/crates/js/src/lexer.rs +++ b/crates/js/src/lexer.rs @@ -341,7 +341,7 @@ impl<'s> Lexer<'s> { } else { self.decimal() }; - if self.peek().is_some_and(|c| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) { + if self.code_point(self.pos).is_some_and(|(c, _)| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) { return Err(self.error_at(self.pos, "identifier starts immediately after numeric literal")); } let mut token = self.token(TokenKind::Number(value), start, line, column, false); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index c1cd09547..dab28cec0 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -401,6 +401,74 @@ fn identifier_characters_follow_their_unicode_categories() { assert!(parse("var a\\u00B2 = 1;", &Options::default()).is_err()); } +#[test] +fn identifiers_take_the_other_id_start_and_continue_characters() { + // Other_ID_Start: not letters by category, but ID_Start, so they start and continue a name. + for c in [0x1885, 0x1886, 0x2118, 0x212E, 0x309B, 0x309C] { + let ch = char::from_u32(c).unwrap_or_default(); + for src in [format!("var {ch} = 1;"), format!("var a{ch} = 1;"), format!("var \\u{c:04X}\\u{{{c:X}}} = 1;")] { + let p = ok(&src); + let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!("{src:?}") }; + assert!(decls[0].name.name.contains(ch), "{src:?}"); + } + } + // Other_ID_Continue: may follow the first character, but cannot start a name. + for c in [0xB7, 0x387, 0x1369, 0x1371, 0x19DA, 0x30FB, 0xFF65] { + let ch = char::from_u32(c).unwrap_or_default(); + for src in [format!("var a{ch} = 1;"), format!("var a\\u{c:04X} = 1;"), format!("var a\\u{{{c:X}}} = 1;")] { + ok(&src); + } + assert!(err(&format!("var {ch} = 1;")).message.contains("unexpected character"), "{c:X}"); + assert!(err(&format!("var \\u{c:04X} = 1;")).message.contains("identifier escape"), "{c:X}"); + assert!(err(&format!("var \\u{{{c:X}}} = 1;")).message.contains("identifier escape"), "{c:X}"); + } +} + +#[test] +fn identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes() { + // U+2E2F is a letter by category but Pattern_Syntax; U+2010, U+00B6 and U+2190 are + // punctuation and symbols in Pattern_Syntax. + for c in [0x2E2F, 0x2010, 0xB6, 0x2190, 0x3001] { + let ch = char::from_u32(c).unwrap_or_default(); + assert!(err(&format!("var {ch} = 1;")).message.contains("unexpected character"), "{c:X}"); + assert!(err(&format!("var a{ch} = 1;")).message.contains("unexpected character"), "{c:X}"); + assert!(err(&format!("var a\\u{c:04X} = 1;")).message.contains("identifier escape"), "{c:X}"); + assert!(err(&format!("var \\u{{{c:X}}} = 1;")).message.contains("identifier escape"), "{c:X}"); + } + for bad in [r"var \uD800 = 1;", r"var a\uDC00 = 1;", r"var a\u{D800} = 1;", r"var a\u{110000} = 1;", r"var a\u{} = 1;"] { + assert!(parse(bad, &Options::default()).is_err(), "{bad}"); + } + // A supplementary letter is one identifier character, at the start and after a number. + let p = ok("var \u{10000}\u{1D7CE} = 1;"); + let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!() }; + assert_eq!(decls[0].name.name, "\u{10000}\u{1D7CE}"); + assert!(err("1\u{10000};").message.contains("immediately after numeric literal")); +} + +#[test] +fn no_pattern_syntax_or_white_space_character_is_an_identifier_character() { + use crate::chars::{is_id_part, is_id_start}; + // Pattern_Syntax and Pattern_White_Space as Unicode's PropList lists them; Unicode does not + // change either set. `$` is Pattern_Syntax but an identifier character in scripts. + let pattern = "0009..000D 0020 0021..002F 003A..0040 005B..005E 0060 007B..007E 0085 00A1..00A7 00A9 00AB..00AC 00AE \ + 00B0..00B1 00B6 00BB 00BF 00D7 00F7 200E..200F 2010..2027 2028..2029 2030..203E 2041..2053 2055..205E \ + 2190..245F 2500..2775 2794..2BFF 2E00..2E7F 3001..3003 3008..3020 3030 FD3E..FD3F FE45..FE46"; + let mut checked = 0; + for range in pattern.split_whitespace() { + let (lo, hi) = range.split_once("..").unwrap_or((range, range)); + let (Ok(lo), Ok(hi)) = (u32::from_str_radix(lo, 16), u32::from_str_radix(hi, 16)) else { panic!("{range}") }; + for c in (lo..=hi).filter(|&c| c != 0x24) { + assert!(!is_id_start(c) && !is_id_part(c), "U+{c:04X}"); + checked += 1; + } + } + assert_eq!(checked, 2760 + 11 - 1); + for c in 0xD800..=0xDFFF { + assert!(!is_id_start(c) && !is_id_part(c), "U+{c:04X}"); + } + assert!(!is_id_start(0x110000) && !is_id_part(0x110000)); +} + #[test] fn comments_and_line_terminators() { let p = ok("a /* one\ntwo */ b"); diff --git a/docs/mcp.md b/docs/mcp.md index a80d25f0b..2188af67e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1525,7 +1525,9 @@ File › Scripts › Other Script… (Cmd+F12) picks a `.js` or `.jsx` file and https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html describes that menu item. Pass exactly one of `path` and `source`. A `path` must end in `.js` or `.jsx`, in any case. A `.jsxbin` file or text that starts with `@JSXBIN@` is compiled and ends with an error, as does text that is -not UTF-8. The language is ES5.1. The script reaches the app through one global object: +not UTF-8. The language is ES5.1, except that identifiers take the characters Unicode's `ID_Start` and +`ID_Continue` properties allow, as later editions define them, and may use `\u{...}` escapes. The script reaches the +app through one global object: - `vc.run(id, params)` runs a command and returns its result. A command that fails throws an error the script can catch. The documents and the session state the command changed, preferences included, go back, and any file it diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index deb9d7fe1..17e1344ef 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -727,6 +727,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. + - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$`, `_`, and after the first character the zero width non-joiner and joiner, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. From 40368e77e0d691f64d0c2ae87611de67ce2d2cc9 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Tue, 10 Nov 2026 20:15:44 +0100 Subject: [PATCH 13/14] #6429: the Appearance popup's last frame puts surrender_focus_on back however it ends A drop guard restores egui's app-wide option, so a panic in the popup's panel (caught by the app's guard) no longer leaves presses ending every field's edit. --- crates/ui-egui/src/panels/appearance.rs | 28 +++++++++++++++---- crates/ui-egui/src/panels/tests_appearance.rs | 2 ++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 4e39c75b7..32963ce68 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -398,11 +398,9 @@ pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Re } last = true; } - // Fields let go of the keyboard on a click, which this press isn't yet. - let surrender = ctx.options(|o| o.input_options.surrender_focus_on); - if last { - ctx.options_mut(|o| o.input_options.surrender_focus_on = egui::SurrenderFocusOn::Presses); - } + // Fields let go of the keyboard on a click, which this press isn't yet. The app-wide option + // goes back however this function ends, a panic in the panel included. + let _surrender = last.then(|| SurrenderOnPress::set(ctx)); let screen = ctx.content_rect(); let left = panel.left() - POPUP_GAP - POPUP_W; let x = if left >= screen.left() + POPUP_GAP { left } else { (panel.right() + POPUP_GAP).min(screen.right() - POPUP_W - POPUP_GAP) }; @@ -430,11 +428,29 @@ pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Re }); }); if last { - ctx.options_mut(|o| o.input_options.surrender_focus_on = surrender); close(app); } } +/// While it lives, a press anywhere makes a field let go of the keyboard, as a click does +/// otherwise; dropped, egui's previous `surrender_focus_on` is back ([`row_popup`]'s last frame). +struct SurrenderOnPress(egui::Context, egui::SurrenderFocusOn); + +impl SurrenderOnPress { + fn set(ctx: &egui::Context) -> Self { + let before = ctx.options(|o| o.input_options.surrender_focus_on); + ctx.options_mut(|o| o.input_options.surrender_focus_on = egui::SurrenderFocusOn::Presses); + Self(ctx.clone(), before) + } +} + +impl Drop for SurrenderOnPress { + fn drop(&mut self) { + let before = self.1; + self.0.options_mut(|o| o.input_options.surrender_focus_on = before); + } +} + /// A row's name that edits the row when clicked ([`link`]), with the tooltip "Click to edit". fn name_link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug, s: &str) -> egui::Response { link(ui, pos, id, s).on_hover_text(tl!("Click to edit")) diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 2cf78847a..3d486dfe2 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -2016,6 +2016,8 @@ fn a_dash_typed_in_a_stroke_popup_sticks_when_a_click_outside_closes_it() { let dash = |app: &VectorcraftApp| current_stroke(app).and_then(|s| s.dash).map(|d| d.pattern); assert_eq!(dash(&app), Some(vec![10.0]), "{link}: nothing set while typing"); click(&ctx, &mut app, egui::pos2(5.0, 3000.0), show); + let surrender = ctx.options(|o| o.input_options.surrender_focus_on); + assert_eq!(surrender, egui::SurrenderFocusOn::Clicks, "{link}: fields let go of the keyboard on a click again"); frame_events(&ctx, &mut app, vec![], show); assert_eq!(dash(&app).and_then(|p| p.first().copied()), Some(4.0), "{link}: the typed dash stuck: {:?}", dash(&app)); let texts = frame_events(&ctx, &mut app, vec![], show); From 7d01ebf2fdb8c42f3d54d89ea20aaa5f87524733 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 11:19:34 -0800 Subject: [PATCH 14/14] JavaScript: check identifier characters against every Unicode 16 code point - A new test walks every code point from U+0000 to U+10FFFF, surrogates left out, and compares the start and part checks with Unicode Standard Annex #31's general-category definition of ID_Start and ID_Continue, written out in the test. The test also checks the set sizes against the totals in Unicode 16.0's DerivedCoreProperties (141,269 and 144,541). - The Other_ID_Continue test covers every code point of the property, U+136A to U+1370 and the zero width non-joiner and joiner included. - The zero width non-joiner and joiner are part of the Other_ID_Continue list in chars.rs instead of a separate case. --- crates/js/src/chars.rs | 15 +++++---- crates/js/src/tests.rs | 64 +++++++++++++++++++++++++++++++++------ docs/target-app-parity.md | 2 +- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs index 986fa2b66..a7b2c1374 100644 --- a/crates/js/src/chars.rs +++ b/crates/js/src/chars.rs @@ -35,14 +35,13 @@ pub fn is_id_start(c: u32) -> bool { } } -/// A character an identifier may hold after its first: a Unicode `ID_Continue` character, `$`, -/// `_`, or the zero width non-joiner and joiner. `ID_Continue` adds to `ID_Start` the decimal -/// digits (Nd), combining marks (Mn, Mc), connector punctuation (Pc) and `Other_ID_Continue`, -/// less `Pattern_Syntax` and `Pattern_White_Space`. +/// A character an identifier may hold after its first: a Unicode `ID_Continue` character or `$`. +/// `ID_Continue` adds to `ID_Start` the decimal digits (Nd), combining marks (Mn, Mc), connector +/// punctuation (Pc, which holds `_`) and `Other_ID_Continue` (which holds the zero width +/// non-joiner and joiner), less `Pattern_Syntax` and `Pattern_White_Space`. pub fn is_id_part(c: u32) -> bool { match c { 0x30..=0x39 => true, - 0x200C | 0x200D => true, _ if is_id_start(c) => true, 0..=0x7F => false, _ if is_other_id_continue(c) => true, @@ -59,10 +58,10 @@ fn is_other_id_start(c: u32) -> bool { matches!(c, 0x1885 | 0x1886 | 0x2118 | 0x212E | 0x309B | 0x309C) } -/// `Other_ID_Continue` (Unicode PropList) outside ASCII, less the zero width non-joiner and joiner, -/// which [`is_id_part`] takes on their own. +/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their +/// category is not a letter, mark, digit or connector. fn is_other_id_continue(c: u32) -> bool { - matches!(c, 0xB7 | 0x387 | 0x1369..=0x1371 | 0x19DA | 0x30FB | 0xFF65) + matches!(c, 0xB7 | 0x387 | 0x1369..=0x1371 | 0x19DA | 0x200C | 0x200D | 0x30FB | 0xFF65) } /// Non-ASCII `Pattern_Syntax` ranges (Unicode PropList). Unicode does not change this set, and no diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index dab28cec0..8c24b5d0b 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -413,7 +413,7 @@ fn identifiers_take_the_other_id_start_and_continue_characters() { } } // Other_ID_Continue: may follow the first character, but cannot start a name. - for c in [0xB7, 0x387, 0x1369, 0x1371, 0x19DA, 0x30FB, 0xFF65] { + for c in [0xB7, 0x387].into_iter().chain(0x1369..=0x1371).chain([0x19DA, 0x200C, 0x200D, 0x30FB, 0xFF65]) { let ch = char::from_u32(c).unwrap_or_default(); for src in [format!("var a{ch} = 1;"), format!("var a\\u{c:04X} = 1;"), format!("var a\\u{{{c:X}}} = 1;")] { ok(&src); @@ -445,19 +445,29 @@ fn identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes() { assert!(err("1\u{10000};").message.contains("immediately after numeric literal")); } -#[test] -fn no_pattern_syntax_or_white_space_character_is_an_identifier_character() { - use crate::chars::{is_id_part, is_id_start}; - // Pattern_Syntax and Pattern_White_Space as Unicode's PropList lists them; Unicode does not - // change either set. `$` is Pattern_Syntax but an identifier character in scripts. +/// The code point ranges of `Pattern_Syntax` and `Pattern_White_Space` as Unicode's PropList lists +/// them; Unicode does not change either set. +fn pattern_syntax_and_white_space() -> Vec> { let pattern = "0009..000D 0020 0021..002F 003A..0040 005B..005E 0060 007B..007E 0085 00A1..00A7 00A9 00AB..00AC 00AE \ 00B0..00B1 00B6 00BB 00BF 00D7 00F7 200E..200F 2010..2027 2028..2029 2030..203E 2041..2053 2055..205E \ 2190..245F 2500..2775 2794..2BFF 2E00..2E7F 3001..3003 3008..3020 3030 FD3E..FD3F FE45..FE46"; + pattern + .split_whitespace() + .map(|range| { + let (lo, hi) = range.split_once("..").unwrap_or((range, range)); + let (Ok(lo), Ok(hi)) = (u32::from_str_radix(lo, 16), u32::from_str_radix(hi, 16)) else { panic!("{range}") }; + lo..=hi + }) + .collect() +} + +#[test] +fn no_pattern_syntax_or_white_space_character_is_an_identifier_character() { + use crate::chars::{is_id_part, is_id_start}; + // `$` is Pattern_Syntax but an identifier character in scripts. let mut checked = 0; - for range in pattern.split_whitespace() { - let (lo, hi) = range.split_once("..").unwrap_or((range, range)); - let (Ok(lo), Ok(hi)) = (u32::from_str_radix(lo, 16), u32::from_str_radix(hi, 16)) else { panic!("{range}") }; - for c in (lo..=hi).filter(|&c| c != 0x24) { + for range in pattern_syntax_and_white_space() { + for c in range.filter(|&c| c != 0x24) { assert!(!is_id_start(c) && !is_id_part(c), "U+{c:04X}"); checked += 1; } @@ -469,6 +479,40 @@ fn no_pattern_syntax_or_white_space_character_is_an_identifier_character() { assert!(!is_id_start(0x110000) && !is_id_part(0x110000)); } +#[test] +fn identifier_characters_are_unicode_16_id_start_and_id_continue() { + use crate::chars::{is_id_part, is_id_start}; + use unicode_general_category::{GeneralCategory as G, get_general_category}; + // Unicode Standard Annex #31: ID_Start is the categories Lu, Ll, Lt, Lm, Lo and Nl plus + // Other_ID_Start; ID_Continue adds Mn, Mc, Nd, Pc and Other_ID_Continue; both leave out + // Pattern_Syntax and Pattern_White_Space. Other_ID_Start and Other_ID_Continue as PropList 16.0 + // lists them. + let other_start = [0x1885..=0x1886, 0x2118..=0x2118, 0x212E..=0x212E, 0x309B..=0x309C]; + let other_continue = [0xB7..=0xB7, 0x387..=0x387, 0x1369..=0x1371, 0x19DA..=0x19DA, 0x200C..=0x200D, 0x30FB..=0x30FB, 0xFF65..=0xFF65]; + let mut excluded = vec![false; 0x11_0000]; + for range in pattern_syntax_and_white_space() { + for c in range { + excluded[c as usize] = true; + } + } + let (mut id_starts, mut id_continues) = (0, 0); + for c in (0..=0x10_FFFF_u32).filter(|c| !(0xD800..=0xDFFF).contains(c)) { + let Some(ch) = char::from_u32(c) else { panic!("U+{c:04X}") }; + let g = get_general_category(ch); + let letter = matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber); + let id_start = (letter || other_start.iter().any(|r| r.contains(&c))) && !excluded[c as usize]; + let continuing = matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation); + let id_continue = (id_start || continuing || other_continue.iter().any(|r| r.contains(&c))) && !excluded[c as usize]; + id_starts += usize::from(id_start); + id_continues += usize::from(id_continue); + // Scripts add `$` and `_` at the start and `$` later (ECMA-262, Names and Keywords). + assert_eq!(is_id_start(c), id_start || c == 0x24 || c == 0x5F, "start U+{c:04X}"); + assert_eq!(is_id_part(c), id_continue || c == 0x24, "part U+{c:04X}"); + } + // The totals of ID_Start and ID_Continue in DerivedCoreProperties 16.0. + assert_eq!((id_starts, id_continues), (141_269, 144_541)); +} + #[test] fn comments_and_line_terminators() { let p = ok("a /* one\ntwo */ b"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index be1f71c13..91da1a136 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -727,7 +727,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$`, `_`, and after the first character the zero width non-joiner and joiner, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). + - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_16_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`.