From e77d15c70d7927477f325e01d08accb63b00809e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:30 +0700 Subject: [PATCH] #1001: named text styles take leading, kerning, mojikumi and other attributes left out at their defaults Character and paragraph styles checked a new attribute against the keys its base style serializes, and the JSON leaves out Auto leading and kerning, no mojikumi and other defaults. So charStyle.new / setAttrs refused leading or kerning, paraStyle.new refused mojikumi, and a style captured from text with explicit leading failed to apply to Auto-leading text, to duplicate or to come back out of a library. The check now also accepts every field name the style type deserializes (read from serde's derive), still refusing unknown keys and the file-only justify_auto. The test creates and edits styles with kerning, leading, small caps, mojikumi and direction, applies a captured leading style to fresh text with and without Clear Overrides, duplicates it, uses it from a library, and checks `nope` and `justify_auto` are still refused. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/engine/src/cmd/textstyles.rs | 70 ++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 166186be..2ddc66cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2) Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 549e95fd..a46e9859 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -133,14 +133,44 @@ fn to_attrs(v: &T) -> Map { m } +/// Every attribute name `T` reads, including those its JSON leaves out at their defaults (Auto +/// leading and kerning, no mojikumi…): the field names serde's derive hands `deserialize_struct`. +/// Not `justify_auto`, which [`to_map`] turns into `"justify": "Auto"`. +fn attr_names() -> Vec<&'static str> { + use serde::de::{Error as _, Visitor, value::Error}; + + struct Names<'a>(&'a mut &'static [&'static str]); + impl<'de> serde::Deserializer<'de> for Names<'_> { + type Error = Error; + fn deserialize_any>(self, _: V) -> std::result::Result { + Err(Error::custom("not a struct")) + } + fn deserialize_struct>(self, _: &'static str, fields: &'static [&'static str], _: V) -> std::result::Result { + *self.0 = fields; + Err(Error::custom("field names only")) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf option unit + unit_struct newtype_struct seq tuple tuple_struct map enum identifier ignored_any + } + } + + let mut fields: &'static [&'static str] = &[]; + // Always an error: the probe only records the names. + let _ = T::deserialize(Names(&mut fields)); + fields.iter().copied().filter(|&f| f != "justify_auto").collect() +} + /// `base` with `attrs` written over it (unknown or ill-typed attributes are an error). fn with_attrs(base: &T, attrs: &Map, cmd: &str) -> Result { let mut m = to_attrs(base); if let Some(full) = to_map(base) { m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null)); } + let names = attr_names::(); for (k, v) in attrs { - if !m.contains_key(k) { + // Not only the keys `base` writes: those at their defaults (Auto leading…) are left out. + if !m.contains_key(k) && !names.contains(&k.as_str()) { return Err(bad(cmd, format!("unknown attribute `{k}`"))); } m.insert(k.clone(), v.clone()); @@ -575,6 +605,44 @@ mod tests { assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); } + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no + /// mojikumi) are still attributes a style can set, and a style captured with one applies to + /// text at the default, is duplicated and comes back out of a library. + #[test] + fn styles_take_attributes_left_out_at_their_defaults() { + let (mut s, id) = session_with_text("A"); + s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap(); + s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap(); + for bad in [json!({"nope": 1}), json!({"justify_auto": true})] { + assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}"); + } + // Captured from text with explicit leading, applied to fresh Auto-leading text. + s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap(); + let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, None); + for clear in [false, true] { + s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0)); + } + let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0); + s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap(); + let st = runs(&s, fresh)[0].style.clone(); + assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0))); + s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; + assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft))); + // A library keeps the captured attributes and makes the style again where it's used. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap(); + let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap(); + assert_eq!(runs(&s, other)[0].style.leading, Some(30.0)); + } + #[test] fn paragraph_styles_and_survive_save() { let (mut s, id) = session_with_text("Body");