diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index c94bc7742..e025c1adf 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -133,11 +133,52 @@ fn to_attrs(v: &T) -> Map { m } -/// Captured character styles set Auto leading explicitly. A missing style attribute instead -/// leaves the text's existing leading alone, unlike a run's omitted Auto value. -fn char_attrs(v: &CharStyle) -> Map { +/// Every character attribute of `v` as a JSON map, without the style name: those the run's JSON +/// leaves out at their defaults (Auto leading and kerning, no OpenType features, Roman baseline +/// alignment…) are written out. A style captured from text sets them all; an attribute missing +/// from a style instead leaves the text's own value alone. +pub fn char_attrs(v: &CharStyle) -> Map { let mut m = to_attrs(v); - m.insert("leading".into(), json!(v.leading)); + for (k, x) in [ + ("font_version", json!(v.font_version)), + ("leading", json!(v.leading)), + ("kerning", json!(v.kerning)), + ("features", json!(v.features)), + ("overprint_fill", json!(v.overprint_fill)), + ("overprint_stroke", json!(v.overprint_stroke)), + ("stroke_cap", json!(v.stroke_cap)), + ("stroke_join", json!(v.stroke_join)), + ("stroke_miter_limit", json!(v.stroke_miter_limit)), + ("stroke_dash", json!(v.stroke_dash)), + ("stroke_align", json!(v.stroke_align)), + ("position", json!(v.position)), + ("small_caps", json!(v.small_caps)), + ("charAlign", json!(v.char_align)), + ("proportionalMetrics", json!(v.proportional_metrics)), + ("digits", json!(v.digits)), + ] { + m.insert(k.into(), x); + } + m +} + +/// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's +/// JSON leaves out at their defaults (the Hyphenation options, the Every-line Composer, no tab +/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters. +pub fn para_attrs(v: &ParaStyle) -> Map { + let mut m = to_attrs(v); + for (k, x) in [ + ("hyphenation", json!(v.hyphenation)), + ("composer", json!(v.composer)), + ("tabs", json!(v.tabs)), + ("mojikumi", json!(v.mojikumi)), + ("direction", json!(v.direction)), + ("leading_model", json!(v.leading_model)), + ("burasagari", json!(v.burasagari)), + ("kinsoku", json!(v.kinsoku)), + ] { + m.insert(k.into(), x); + } m } @@ -187,13 +228,19 @@ fn with_attrs(base: &T, attrs: &Map(cur: &T, old: &Map, new: &Map) -> T { - let Some(mut m) = to_map(cur) else { return cur.clone() }; - // Runs omit Auto leading from JSON; it still matches a style's explicit null. - if old.get("leading") == Some(&Value::Null) { - m.entry("leading").or_insert(Value::Null); +/// value (or not set by it) take the new value; overrides stay. `attrs` lists every attribute of +/// `cur` ([`char_attrs`], [`para_attrs`]), so one at its default matches a style that sets the +/// default. Unchanged if the style doesn't round-trip through JSON. +fn restyle( + cur: &T, + attrs: fn(&T) -> Map, + old: &Map, + new: &Map, +) -> T { + let Some(full) = to_map(cur) else { return cur.clone() }; + let mut m = attrs(cur); + if let Some(name) = full.get("style_name") { + m.insert("style_name".into(), name.clone()); } for (k, v) in new { if old.get(k).is_none_or(|o| m.get(k) == Some(o)) { @@ -246,7 +293,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map { for r in &mut t.runs { if r.style.style_name == target { - r.style = restyle(&r.style, old, new); + r.style = restyle(&r.style, char_attrs, old, new); changed = true; } } @@ -254,7 +301,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map { for pa in t.para_styles_mut() { if pa.style_name == target { - *pa = restyle(pa, old, new); + *pa = restyle(pa, para_attrs, old, new); changed = true; } } @@ -286,7 +333,7 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result to_attrs(t.para_at(t.paragraphs_in(at, at).start)), + Kind::Para => para_attrs(t.para_at(t.paragraphs_in(at, at).start)), })) } @@ -314,7 +361,7 @@ fn attrs_param(p: &Value, kind: Kind, verb: &str) -> Result char_attrs(&with_attrs(&CharStyle::default(), m, &c)?), - Kind::Para => to_attrs(&with_attrs(&ParaStyle::default(), m, &c)?), + Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), m, &c)?), }; Ok(Some(m.keys().filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone()))).collect())) } @@ -708,6 +755,78 @@ mod tests { } } + /// The attribute names a style of type `T` can set, without the style name. + fn settable() -> std::collections::BTreeSet { + attr_names::().into_iter().filter(|&n| n != "style_name").map(String::from).collect() + } + + /// New and Redefine capture every paragraph attribute. A paragraph style redefined from a + /// paragraph at the defaults gives its paragraphs the defaults back where they had the old + /// definition's values, and `setAttrs` with a default value does too; local overrides, and + /// attributes `setAttrs` leaves out, stay. + #[test] + fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { + use vectorcraft_doc::{Composer, HyphenationOptions}; + let (mut s, source) = session_with_text("Source"); + let [linked, local] = + ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); + let ids = [source, linked, local]; + let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para_at(0).clone(), + _ => panic!("not text"), + }; + let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit); + let composers = |s: &Session| ids.map(|id| para(s, id).composer); + let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::>(); + s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap(); + s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3])); + assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default()); + assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body"))); + assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); + s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); + assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); + // Style options: a default value reaches the users too; an attribute left out stays as it is. + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3])); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap(); + assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3])); + assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"})); + } + + /// The same for character styles: Auto kerning and the Roman baseline alignment, which the + /// run's JSON leaves out, are captured and reach the style's users. + #[test] + fn char_style_redefined_to_the_defaults_updates_its_text() { + use vectorcraft_doc::CharAlign; + let (mut s, source) = session_with_text("Source"); + let [linked, local] = + ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); + let ids = [source, linked, local]; + let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning); + let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align); + let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::>(); + s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap(); + s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); + assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); + s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); + assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3])); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap(); + assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3])); + assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"})); + } + #[test] fn auto_leading_new_distinguishes_unset_attributes() { let (mut s, id) = session_with_text("Title"); diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 50d5fe877..4c08a3469 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -6,6 +6,7 @@ use egui::{Sense, Ui, vec2}; use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::textstyles; use super::character::{end_typing, text_editing, text_style}; use super::{pstate, selection_len, set_pstate}; @@ -54,21 +55,14 @@ fn styles(app: &mut VectorcraftApp, kind: Kind) -> Vec<(String, Map Option<(Option, Map)> { let (c, p) = text_style(app)?; - let v = match kind { - Kind::Char => serde_json::to_value(&c), - Kind::Para => serde_json::to_value(&p), - } - .ok()?; - let mut m = v.as_object()?.clone(); - // A character style can explicitly set Auto, which the run's JSON leaves out. - if kind == Kind::Char { - m.entry("leading").or_insert(Value::Null); - } - let name = m.remove("style_name").and_then(|v| v.as_str().map(str::to_string)); - Some((name, m)) + Some(match kind { + Kind::Char => (c.style_name.clone(), textstyles::char_attrs(&c)), + Kind::Para => (p.style_name.clone(), textstyles::para_attrs(&p)), + }) } /// Parameters targeting the selection: the Type tool's selected characters (paragraph styles: the @@ -243,6 +237,29 @@ mod tests { assert!(shown.lines().any(|line| line == "Automatic"), "no override after clearing: {shown}"); } + /// Styles made from new type hold its Auto alignment and its attributes at their defaults; + /// the type they were made from shows no override until one of those attributes changes. + #[test] + fn styles_made_from_type_mark_only_real_overrides() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + for kind in [Kind::Char, Kind::Para] { + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Plain"})).unwrap(); + app.run(&format!("{}.apply", kind.prefix()), json!({"name": "Plain"})).unwrap(); + } + for (change, label) in [(None, "Plain"), (Some(json!({"kerning": 40, "composer": "singleLine"})), "Plain+")] { + if let Some(p) = change { + app.run("text.setFormat", p).unwrap(); + } + for kind in [Kind::Char, Kind::Para] { + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == label), "expected {label} in the {} styles: {shown}", kind.what()); + } + } + } + fn capture_app() -> (VectorcraftApp, NodeId, egui::Context) { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e3a51b08..0ddafd6ac 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -394,6 +394,7 @@ since 2026-10-01. - **Artboard tab-switch follow-up:** changing the active document through its tab aligns the Artboard tool with the displayed board before Properties Delete; clicking the already-active tab preserves multiselection and Move/Scale Artwork options. Three headless `chrome::tests::artboard_document_tab_*` regressions cover actual tab A → B → A clicks followed by Properties Delete, surviving boards and guides, preserved artwork/symbols and Undo/Redo; controls cover same-tab multiselection, Move/Scale Artwork options and other tools. Historical focused validation on `dbd2497b` recorded one expected RED failure with two passing controls, then all three GREEN tests passed. All three also passed in historical full CI on `a0671777`. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. - **Artboard clean active-tab close (2026-10-15):** with the Artboard tool active, closing a clean active tab restores the surviving document's displayed board before Properties Delete. Nine targeted checks passed: three existing tab regressions, one clean close/Delete regression and five existing unsaved-document controls. The close regression checks the correct surviving board, preserved artwork/symbols and object selection, affected guides, one Delete edit, saved state and Undo/Redo. Three reviewed original full-app headless captures show the active tab before closing, the surviving document's shown board after closing, and the correct survivor after Properties Delete, using the actual tab × and Delete controls. - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). +- **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides and attributes the new definition leaves out stay. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands.