collective

Browse / github.com/storytold/deckcraft / patch

SmartArt: uniform text size, Font Color, no Flip; text and paragraph formatters take ids (part of #298)

SmartArt: uniform text size, Font Color, no Flip; text and paragraph formatters take ids (part of #298) Fixes to SmartArt editing and to the text formatters, found by agent runs building diagrams through the MCP server (#298: the CLI, MCP and control channel answer agents). SmartArt (matching PowerPoint): - Node text is sized alike: one automatic scale (the tightest shape's) applies to every node, and `smartArt.fontSize {size}` (or `format.size` with the graphic selected) sets one size for all. - Font Color with a SmartArt graphic selected colours all its nodes' text (kept across regeneration); before it answered ok and changed nothing. `shape.fill` on a picture is refused with a pointer to `pi

Status
open
Score
0.00
Adopters
0 other operators
Author
zberry (imported from pull request #1322, open)
Base
38d2ade2b77de39c0e06278ddfd44d1082ff80a8
Size
+785 −75 in 8 files
Review
pending
Shared
2026-10-28
Id
ab4c1bb7607266772822e8ef

How it went

No other operator has reported using it yet.

Diff

raw · apps/deckcraft-cli/tests/it/cli.rs crates/engine/src/cmd/format.rs crates/engine/src/cmd/shape.rs crates/engine/src/cmd/smartart.rs crates/engine/src/lib.rs crates/engine/src/tests.rs crates/model/src/lib.rs crates/pptx/src/read/shapes.rs

diff --git a/apps/deckcraft-cli/tests/it/cli.rs b/apps/deckcraft-cli/tests/it/cli.rs--- a/apps/deckcraft-cli/tests/it/cli.rs+++ b/apps/deckcraft-cli/tests/it/cli.rs@@ -98,7 +98,8 @@ Run it with: deckcraft-cli run --cmd 'shape.insert={…}'         "format.size: Font Size, in Home ▸ Font.  Parameters:-  size  pt+  size           pt+  ids, optional  [shape ids] (default: the selection) Acts on the selected text or shapes.  Run it with: deckcraft-cli run --cmd 'format.size={…}'@@ -133,7 +134,7 @@ fn describe_shows_alternative_sets_of_parameters() {  #[test] fn commands_lists_one_line_per_command() {-    assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n");+    assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt, ids?: [shape ids] (default: the selection)}\n");     assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n");     assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n"); }diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs--- a/crates/engine/src/cmd/format.rs+++ b/crates/engine/src/cmd/format.rs@@ -15,39 +15,103 @@ pub fn specs() -> Vec<CommandSpec> {     vec![         cmd!(noundo "font.add", "Add Font", [], None, "{data: base64 TTF/OTF/TTC} → {faces} added for this session", always, font_add),         cmd!(noundo "font.rescan", "Rescan Installed Fonts", ["Home", "Font"], None, "{} → {faces, families} (fonts installed since start-up)", always, font_rescan),-        cmd!("format.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{on?: bool}", has_text_or_shapes, bold),-        cmd!("format.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{on?: bool}", has_text_or_shapes, italic),+        cmd!(+            "format.bold",+            "Bold",+            ["Home", "Font"],+            Some("Cmd+B"),+            "{on?: bool, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| bold(s, p))+        ),+        cmd!(+            "format.italic",+            "Italic",+            ["Home", "Font"],+            Some("Cmd+I"),+            "{on?: bool, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| italic(s, p))+        ),         cmd!(             "format.underline",             "Underline",             ["Home", "Font"],             Some("Cmd+U"),-            "{on?: bool, style?: sng|dbl|heavy|dotted|dash|wavy}",+            "{on?: bool, style?: sng|dbl|heavy|dotted|dash|wavy, ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            underline+            |s, p| on_named(s, p, |s| underline(s, p))         ),         cmd!(             "format.strikethrough",             "Strikethrough",             ["Home", "Font"],             Some("Cmd+Shift+X"),-            "{on?: bool, double?: bool}",+            "{on?: bool, double?: bool, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| strike(s, p))+        ),+        cmd!(+            "format.superscript",+            "Superscript",+            ["Home", "Font"],+            Some("Cmd+Shift+="),+            "{on?: bool, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| superscript(s, p))+        ),+        cmd!(+            "format.subscript",+            "Subscript",+            ["Home", "Font"],+            Some("Cmd+="),+            "{on?: bool, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| subscript(s, p))+        ),+        cmd!("format.font", "Font", ["Home", "Font"], None, "{family, ids?: [shape ids] (default: the selection)}", has_text_or_shapes, font),+        cmd!(+            "format.size",+            "Font Size",+            ["Home", "Font"],+            None,+            "{size: pt, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| size(s, p))+        ),+        cmd!(+            "format.grow",+            "Increase Font Size",+            ["Home", "Font"],+            Some("Cmd+Shift+."),+            "{ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| grow(s, p))+        ),+        cmd!(+            "format.shrink",+            "Decrease Font Size",+            ["Home", "Font"],+            Some("Cmd+Shift+,"),+            "{ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            strike+            |s, p| on_named(s, p, |s| shrink(s, p))+        ),+        cmd!(+            "format.color",+            "Font Color",+            ["Home", "Font"],+            None,+            "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}, ids?: [shape ids] (default: the selection)} (with a SmartArt graphic selected it colours all its nodes' text)",+            has_text_or_shapes,+            color         ),-        cmd!("format.superscript", "Superscript", ["Home", "Font"], Some("Cmd+Shift+="), "{on?: bool}", has_text_or_shapes, superscript),-        cmd!("format.subscript", "Subscript", ["Home", "Font"], Some("Cmd+="), "{on?: bool}", has_text_or_shapes, subscript),-        cmd!("format.font", "Font", ["Home", "Font"], None, "{family}", has_text_or_shapes, font),-        cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size),-        cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow),-        cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink),-        cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color),         cmd!(             "format.textTransparency",             "Text Transparency",             ["Shape Format", "WordArt Styles"],             None,-            "{value: 0..1} — 0 opaque, 1 invisible; keeps the text's colour (inherited colours included)",+            "{value: 0..1, ids?: [shape ids]} — 0 opaque, 1 invisible; keeps the text's colour (inherited colours included)",             has_text_or_shapes,             text_transparency         ),@@ -56,31 +120,49 @@ pub fn specs() -> Vec<CommandSpec> {             "Language",             ["Review", "Language"],             None,-            "{lang?, noProof?: bool} (proofing language of the selected text, and Do Not Check Spelling)",+            "{lang?, noProof?: bool, ids?: [shape ids] (default: the selection)} (proofing language of the selected text, and Do Not Check Spelling)",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| language(s, p))+        ),+        cmd!(+            "format.highlight",+            "Text Highlight Color",+            ["Home", "Font"],+            None,+            "{color? (absent = none), ids?: [shape ids]}",             has_text_or_shapes,-            language+            highlight         ),-        cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),         cmd!(             "format.case",             "Change Case",             ["Home", "Font"],             Some("Shift+F3"),-            "{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",+            "{mode?: cycle|sentence|lower|upper|title|toggle, ids?: [shape ids] (default: the selection)} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",             has_text_or_shapes,-            case+            |s, p| on_named(s, p, |s| case(s, p))         ),-        cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),+        cmd!("format.caps", "Caps", [], None, "{caps: none|small|all, ids?: [shape ids] (default: the selection)}", has_text_or_shapes, |s, p| {+            on_named(s, p, |s| caps(s, p))+        }),         cmd!(             "format.spacing",             "Character Spacing",             ["Home", "Font"],             None,-            "{pt} (veryTight -3, tight -1.5, normal 0, loose 3, veryLoose 6)",+            "{pt, ids?: [shape ids] (default: the selection)} (veryTight -3, tight -1.5, normal 0, loose 3, veryLoose 6)",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| spacing(s, p))+        ),+        cmd!(+            "format.clear",+            "Clear All Formatting",+            ["Home", "Font"],+            Some("Cmd+Space"),+            "{ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            spacing+            |s, p| on_named(s, p, |s| clear(s, p))         ),-        cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear),         cmd!(             "format.fontDialog",             "Font…",@@ -90,84 +172,140 @@ pub fn specs() -> Vec<CommandSpec> {             has_text_or_shapes,             font_dialog         ),-        cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow),+        cmd!(+            "format.textShadow",+            "Text Shadow",+            ["Home", "Font"],+            None,+            "{on?: bool, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| text_shadow(s, p))+        ),         cmd!(             "format.textOutline",             "Text Outline",             ["Shape Format", "WordArt Styles"],             None,-            "{color?, width?: pt} (no color = none)",+            "{color?, width?: pt, ids?: [shape ids] (default: the selection)} (no color = none)",             has_text_or_shapes,-            text_outline+            |s, p| on_named(s, p, |s| text_outline(s, p))         ),         cmd!(             "format.textWarp",             "Text Transform",             ["Shape Format", "WordArt Styles"],             None,-            "{preset: a prstTxWarp name or none, adj?: [finite raw guide values, at most 8]} — none clears the transform; omitted adj uses the preset's defaults",+            "{preset: a prstTxWarp name or none, adj?: [finite raw guide values, at most 8], ids?: [shape ids] (default: the selection)} — none clears the transform; omitted adj uses the preset's defaults",             has_text_or_shapes,-            text_warp+            |s, p| on_named(s, p, |s| text_warp(s, p))         ),         cmd!(             "format.align",             "Align",             ["Home", "Paragraph"],             None,-            "{align: left|center|right|justify|justifyLow|distributed}",+            "{align: left|center|right|justify|justifyLow|distributed, ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            align+            |s, p| on_named(s, p, |s| align(s, p))         ),         cmd!(             "format.rtl",             "Right-to-Left Text Direction",             ["Home", "Paragraph"],             None,-            "{on?: bool} (off = left-to-right; switching mirrors left/right alignment)",+            "{on?: bool, ids?: [shape ids] (default: the selection)} (off = left-to-right; switching mirrors left/right alignment)",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| rtl(s, p))+        ),+        cmd!(+            "format.alignLeft",+            "Align Left",+            ["Home", "Paragraph"],+            Some("Cmd+L"),+            "{ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            rtl+            |s, p| on_named(s, p, |s| align_to(s, Align::Left))+        ),+        cmd!(+            "format.alignCenter",+            "Center",+            ["Home", "Paragraph"],+            Some("Cmd+E"),+            "{ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| align_to(s, Align::Center))+        ),+        cmd!(+            "format.alignRight",+            "Align Right",+            ["Home", "Paragraph"],+            Some("Cmd+R"),+            "{ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| align_to(s, Align::Right))+        ),+        cmd!(+            "format.justify",+            "Justify",+            ["Home", "Paragraph"],+            Some("Cmd+J"),+            "{ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| align_to(s, Align::Justify))         ),-        cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)),-        cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)),-        cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)),-        cmd!("format.justify", "Justify", ["Home", "Paragraph"], Some("Cmd+J"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Justify)),         cmd!(             "format.bullets",             "Bullets",             ["Home", "Paragraph"],             None,-            "{on?: bool, char?: •|○|■|□|◆|➢|✓|–, color?, size?: % of text}",+            "{on?: bool, char?: •|○|■|□|◆|➢|✓|–, color?, size?: % of text, ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            bullets+            |s, p| on_named(s, p, |s| bullets(s, p))         ),         cmd!(             "format.numbering",             "Numbering",             ["Home", "Paragraph"],             None,-            "{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text}",+            "{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| numbering(s, p))+        ),+        cmd!(+            "format.indent",+            "Increase List Level",+            ["Home", "Paragraph"],+            Some("Tab"),+            "{ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| level(s, 1))+        ),+        cmd!(+            "format.outdent",+            "Decrease List Level",+            ["Home", "Paragraph"],+            Some("Shift+Tab"),+            "{ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            numbering+            |s, p| on_named(s, p, |s| level(s, -1))         ),-        cmd!("format.indent", "Increase List Level", ["Home", "Paragraph"], Some("Tab"), "{}", has_text_or_shapes, |s, _| level(s, 1)),-        cmd!("format.outdent", "Decrease List Level", ["Home", "Paragraph"], Some("Shift+Tab"), "{}", has_text_or_shapes, |s, _| level(s, -1)),         cmd!(             "format.lineSpacing",             "Line Spacing",             ["Home", "Paragraph"],             None,-            "{lines?: 1.0|1.5|2.0…, pt?: exactly}",+            "{lines?: 1.0|1.5|2.0…, pt?: exactly, ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            line_spacing+            |s, p| on_named(s, p, |s| line_spacing(s, p))         ),         cmd!(             "format.paragraph",             "Paragraph…",             ["Format"],             None,-            "{align?, indentLeft?: pt, indentFirst?: pt (negative = hanging), spaceBefore?: pt, spaceAfter?: pt, lineSpacing?: lines}",+            "{align?, indentLeft?: pt, indentFirst?: pt (negative = hanging), spaceBefore?: pt, spaceAfter?: pt, lineSpacing?: lines, ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            paragraph+            |s, p| on_named(s, p, |s| paragraph(s, p))         ),         cmd!(query "format.ruler", "Ruler", [], None, "{} → {x, y, width, rotated, marginLeft, indent, tabs: [{pos, align}], defaultTab} for the paragraph with the caret (x, y: the text area's top-left in slide pt)", has_text, ruler),         cmd!(@@ -188,27 +326,59 @@ pub fn specs() -> Vec<CommandSpec> {             has_text_or_shapes,             tabs         ),-        cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor),+        cmd!(+            "format.anchor",+            "Align Text",+            ["Home", "Paragraph"],+            None,+            "{anchor: top|middle|bottom, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| anchor(s, p))+        ),         cmd!(             "format.direction",             "Text Direction",             ["Home", "Paragraph"],             None,-            "{dir: horizontal|rotate90|rotate270|stacked}",+            "{dir: horizontal|rotate90|rotate270|stacked, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| direction(s, p))+        ),+        cmd!(+            "format.columns",+            "Columns",+            ["Home", "Paragraph"],+            None,+            "{count, spacing?: pt, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| columns(s, p))+        ),+        cmd!(+            "format.autofit",+            "Autofit",+            ["Format Shape", "Text Box"],+            None,+            "{mode: none|shrink|resize, ids?: [shape ids] (default: the selection)}",+            has_text_or_shapes,+            |s, p| on_named(s, p, |s| autofit(s, p))+        ),+        cmd!(+            "format.wrap",+            "Wrap Text in Shape",+            ["Format Shape", "Text Box"],+            None,+            "{on: bool, ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            direction+            |s, p| on_named(s, p, |s| wrap(s, p))         ),-        cmd!("format.columns", "Columns", ["Home", "Paragraph"], None, "{count, spacing?: pt}", has_text_or_shapes, columns),-        cmd!("format.autofit", "Autofit", ["Format Shape", "Text Box"], None, "{mode: none|shrink|resize}", has_text_or_shapes, autofit),-        cmd!("format.wrap", "Wrap Text in Shape", ["Format Shape", "Text Box"], None, "{on: bool}", has_text_or_shapes, wrap),         cmd!(             "format.margins",             "Text Box Margins",             ["Format Shape", "Text Box"],             None,-            "{left?: pt, top?: pt, right?: pt, bottom?: pt}",+            "{left?: pt, top?: pt, right?: pt, bottom?: pt, ids?: [shape ids] (default: the selection)}",             has_text_or_shapes,-            margins+            |s, p| on_named(s, p, |s| margins(s, p))         ),         cmd!(noundo "format.painter", "Format Painter", ["Home", "Clipboard"], Some("Cmd+Shift+C"), "{sticky?: bool}", has_text_or_shapes, painter_pick),         cmd!("format.painterApply", "Paste Formatting", [], Some("Cmd+Shift+V"), "{ids?}", has_text_or_shapes, painter_apply),@@ -216,6 +386,30 @@ pub fn specs() -> Vec<CommandSpec> {     ] } +/// Run `f` on the shapes `ids`/`id` name instead of the selection, as `shape.fill` does; without+/// either, on the selection. The selection is put back after, also when the edit is refused (unless the edit went to another slide).+fn on_named(s: &mut Session, p: &Value, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {+    // An empty `ids` names nothing and means the selection, as `targets` reads it.+    let named = |k: &str| p.get(k).is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty()));+    if !named("ids") && !named("id") {+        return f(s);+    }+    let ids = super::targets(s, p)?;+    let (shapes, text, cells, slide) = {+        let st = s.doc_mut()?;+        (std::mem::replace(&mut st.selection.shapes, ids), st.selection.text.take(), st.selection.cells.take(), st.selection.slide)+    };+    let r = f(s);+    if let Ok(st) = s.doc_mut()+        && st.selection.slide == slide+    {+        st.selection.shapes = shapes;+        st.selection.text = text;+        st.selection.cells = cells;+    }+    r+}+ fn on_param(p: &Value, current: Option<bool>) -> bool {     bool_param(p, "on").unwrap_or(!current.unwrap_or(false)) }@@ -302,11 +496,47 @@ fn subscript(s: &mut Session, p: &Value) -> Result<Value> {     format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 })) } fn font(s: &mut Session, p: &Value) -> Result<Value> {+    on_named(s, p, |s| font_selected(s, p))+}+fn font_selected(s: &mut Session, p: &Value) -> Result<Value> {     let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string();     format_run(s, &move |r| r.font = Some(f.clone())) }+/// Runs `f` and puts the selection back as it was: regenerating a SmartArt graphic selects the+/// graphic alone, which must not drop the other selected shapes or the text range from a format+/// command that goes on to format them.+fn keeping_selection<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {+    let (shapes, text, cells) = {+        let st = s.doc()?;+        (st.selection.shapes.clone(), st.selection.text.clone(), st.selection.cells)+    };+    let r = f(s);+    if let Ok(st) = s.doc_mut() {+        st.selection.shapes = shapes;+        st.selection.text = text;+        st.selection.cells = cells;+    }+    r+}+ fn size(s: &mut Session, p: &Value) -> Result<Value> {     let v = f64_param(p, "size").ok_or_else(|| bad("format.size", "missing `size`"))?.clamp(1.0, 4000.0);+    // A selected SmartArt graphic: one size for all its nodes (like smartArt.fontSize).+    if s.doc()?.selection.text.is_none() {+        let graphics = super::smartart::selected_graphics(s);+        let selected = s.doc()?.selection.shapes.len();+        keeping_selection(s, |s| graphics.iter().try_for_each(|gid| super::smartart::set_font_size(s, *gid, Some(v), "format.size")))?;+        if !graphics.is_empty() {+            if graphics.len() == selected {+                return ok();+            }+            // The other selected shapes take the size; the graphics were sized above.+            return keeping_selection(s, |s| {+                s.doc_mut()?.selection.shapes.retain(|id| !graphics.contains(id));+                format_run(s, &move |r| r.size = Some(v))+            });+        }+    }     format_run(s, &move |r| r.size = Some(v)) } @@ -339,6 +569,9 @@ fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {     grow_shrink(s, false) } fn color(s: &mut Session, p: &Value) -> Result<Value> {+    on_named(s, p, |s| color_selected(s, p))+}+fn color_selected(s: &mut Session, p: &Value) -> Result<Value> {     let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;     // A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),     // which used to answer and change nothing.@@ -360,6 +593,21 @@ fn color(s: &mut Session, p: &Value) -> Result<Value> {                 return ok();             }         }+        // A selected SmartArt graphic: the colour of all its nodes' text.+        let graphics = super::smartart::selected_graphics(s);+        let selected = s.doc()?.selection.shapes.len();+        let tc = c.clone();+        keeping_selection(s, |s| graphics.iter().try_for_each(|gid| super::smartart::set_text_color(s, *gid, tc.clone(), "format.color")))?;+        if !graphics.is_empty() {+            if graphics.len() == selected {+                return ok();+            }+            // The other selected shapes take the colour; the graphics were coloured above.+            return keeping_selection(s, |s| {+                s.doc_mut()?.selection.shapes.retain(|id| !graphics.contains(id));+                format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))+            });+        }     }     format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) }@@ -375,6 +623,9 @@ fn with_transparency(c: &mut ColorRef, transparency: f64) { /// fill takes the colour it shows now (from the placeholder, master or theme) so only its /// opacity changes. fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {+    on_named(s, p, |s| text_transparency_selected(s, p))+}+fn text_transparency_selected(s: &mut Session, p: &Value) -> Result<Value> {     let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?;     if !v.is_finite() {         return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1"));@@ -417,6 +668,9 @@ fn language(s: &mut Session, p: &Value) -> Result<Value> {     }) } fn highlight(s: &mut Session, p: &Value) -> Result<Value> {+    on_named(s, p, |s| highlight_selected(s, p))+}+fn highlight_selected(s: &mut Session, p: &Value) -> Result<Value> {     let c = color_param(p, "color");     format_run(s, &move |r| r.highlight = c.clone()) }diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs--- a/crates/engine/src/cmd/shape.rs+++ b/crates/engine/src/cmd/shape.rs@@ -415,6 +415,13 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> { fn flip(s: &mut Session, p: &Value) -> Result<Value> {     let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');     edit_geometry(s, p, "shape.flip", |sh| {+        // PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it).+        if sh.diagram.is_some() {+            return Err(bad(+                "shape.flip",+                "Flip is not available for a SmartArt graphic (it would turn it into a plain group); use `smartArt.layout` to change how it is laid out",+            ));+        }         if let Some(x) = sh.xfrm.as_mut() {             if v {                 x.flip_v = !x.flip_v;@@ -604,7 +611,29 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {         }         fills     };-    edit_group_appearance(s, p, "shape.fill", |sh| {+    // A picture has no fill of its own (Picture Format has Recolor, not Shape Fill).+    let st = s.doc()?;+    let ids = targets(s, p)?;+    if !ids.is_empty() && ids.iter().all(|id| st.shape(*id).is_some_and(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Picture { .. }))) {+        return Err(bad("shape.fill", "a picture has no fill of its own: use picture.recolor to recolour it"));+    }+    // Pictures in a mixed selection are left out of the edit, so `changed` and `ids` name only the+    // shapes that were filled.+    let fillable: Vec<u32> = ids+        .iter()+        .filter(|id| !st.shape(**id).is_some_and(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Picture { .. })))+        .map(|id| id.0)+        .collect();+    let mut np = p.clone();+    if let Some(o) = np.as_object_mut() {+        o.remove("id");+        o.insert("ids".into(), json!(fillable));+    }+    edit_group_appearance(s, &np, "shape.fill", |sh| {+        // A picture inside a selected group has no fill of its own either.+        if matches!(sh.kind, deckcraft_model::ShapeKind::Picture { .. }) {+            return Ok(());+        }         if let Some(f) = &new_fill {             sh.fill.clone_from(f);         }diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs--- a/crates/engine/src/cmd/smartart.rs+++ b/crates/engine/src/cmd/smartart.rs@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Change Layout",             ["SmartArt Design", "Layouts"],             None,-            "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie|nameTitleOrgChart|verticalBoxList|trapezoidList|tableList|accentProcess|continuousBlockProcess|bendingProcess|linearVenn|stackedVenn|nestedTarget}",+            "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie|nameTitleOrgChart|verticalBoxList|trapezoidList|tableList|accentProcess|continuousBlockProcess|bendingProcess|linearVenn|stackedVenn|nestedTarget} → {id, ids: the regenerated shapes}",             has_selection,             change_layout         ),@@ -243,12 +243,21 @@ pub fn specs() -> Vec<CommandSpec> {             has_selection,             set_picture         ),+        cmd!(+            "smartArt.fontSize",+            "Font Size",+            ["SmartArt Design", "Create Graphic"],+            None,+            "{id?, size: pt|null} → {id, fontSize} (one size for every node's text, as PowerPoint sizes a graphic's text alike; null sizes it automatically again, all nodes alike; format.size with the graphic selected does the same)",+            has_selection,+            font_size+        ),         cmd!(             "smartArt.style",             "SmartArt Styles",             ["SmartArt Design", "SmartArt Styles"],             None,-            "{id?, style: flat|subtle|intense|outline|polished|inset|cartoon|powder|brick}",+            "{id?, style: flat|subtle|intense|outline|polished|inset|cartoon|powder|brick} → {id, ids: the regenerated shapes}",             has_selection,             change_style         ),@@ -257,7 +266,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Change Colors",             ["SmartArt Design", "SmartArt Styles"],             None,-            "{id?, colors: colorful|range23|range34|range45|range56|accent1|…|accent6|accent1Grad|…|accent6Grad}",+            "{id?, colors: colorful|range23|range34|range45|range56|accent1|…|accent6|accent1Grad|…|accent6Grad} → {id, ids: the regenerated shapes}",             has_selection,             change_colors         ),@@ -596,32 +605,62 @@ pub fn layout(d: &Diagram, area: Xfrm) -> Vec<Shape> {     layout_items(d, area).into_iter().map(|(s, _)| s).collect() } -/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is-/// shrunk per shape so its longest word fits on a line.+/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows. All the+/// text of a graphic is sized alike, as PowerPoint does: the one scale that lets every shape's+/// longest word fit on a line, or the size the user set (`Diagram::font_size`) for every node. fn layout_items(d: &Diagram, area: Xfrm) -> Vec<Item> {     let mut v = layout_raw(d, area);+    let scale = v.iter().map(|(sh, _)| word_scale(sh)).fold(1.0_f64, f64::min);     for (sh, _) in &mut v {-        fit_words(sh);+        apply_scale(sh, scale);+        if let Some(size) = d.font_size.filter(|z| z.is_finite() && *z > 0.0) {+            set_size(sh, size);+        }+        if let Some(c) = &d.text_color {+            set_color(sh, c);+        }     }     v } +/// Every run of the shape's text at `size`; a fixed size turns shrink-on-overflow off, so no node+/// is fitted to a size of its own.+fn set_size(sh: &mut Shape, size: f64) {+    let Some(body) = &mut sh.text else { return };+    for p in &mut body.paragraphs {+        for r in &mut p.runs {+            r.props.size = Some(size);+        }+        p.end_props.size = Some(size);+    }+    body.body.autofit = Some(AutoFit::None);+}++fn set_color(sh: &mut Shape, c: &ColorRef) {+    let Some(body) = &mut sh.text else { return };+    for p in &mut body.paragraphs {+        for r in &mut p.runs {+            r.props.fill = Some(Fill::solid(c.clone()));+        }+        p.end_props.fill = Some(Fill::solid(c.clone()));+    }+}+ /// Rough advance of a character as a fraction of the font size (a sans text face). const CHAR_W: f64 = 0.6; /// Smallest font scale shrinking to fit goes to. const MIN_SCALE: f64 = 0.25; -/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so-/// no word is wider than its line and would break mid-word. Overflow in height is left to the-/// body's shrink-on-overflow autofit.-fn fit_words(sh: &mut Shape) {-    let Some(w) = sh.xfrm.map(|x| x.w) else { return };+/// The scale (`MIN_SCALE..=1`) at which no word of the shape's text is wider than its line and+/// would break mid-word. Overflow in height is left to the body's shrink-on-overflow autofit.+fn word_scale(sh: &Shape) -> f64 {+    let Some(w) = sh.xfrm.map(|x| x.w) else { return 1.0 };     // Round shapes keep their text in the inscribed rectangle (about 71% of the width).     let text_w = match sh.geom.preset_name() {         Some("ellipse" | "gear6" | "gear9") => w * 0.71,         _ => w,     };-    let Some(body) = &mut sh.text else { return };+    let Some(body) = &sh.text else { return 1.0 };     let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2);     let mut scale: f64 = 1.0;     for p in &body.paragraphs {@@ -635,10 +674,15 @@ fn fit_words(sh: &mut Shape) {             }         }     }-    let scale = scale.max(MIN_SCALE);+    scale.max(MIN_SCALE)+}++/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does).+fn apply_scale(sh: &mut Shape, scale: f64) {     if scale >= 0.999 {         return;     }+    let Some(body) = &mut sh.text else { return };     let shrink = |v: &mut Option<f64>| {         if let Some(x) = v {             *x = (*x * scale * 2.0).floor() / 2.0;@@ -2000,33 +2044,86 @@ fn set_picture(s: &mut Session, p: &Value) -> Result<Value> {     }) } +/// The SmartArt graphics among the selected shapes (top level), for Home › Font on a graphic.+pub(crate) fn selected_graphics(s: &Session) -> Vec<ShapeId> {+    let Ok(st) = s.doc() else { return vec![] };+    st.selection.shapes.iter().copied().filter(|id| st.shape(*id).is_some_and(|sh| sh.diagram.is_some())).collect()+}++/// Set the size of every node's text of graphic `gid` (`None`: automatic).+pub(crate) fn set_font_size(s: &mut Session, gid: ShapeId, size: Option<f64>, cmd: &str) -> Result<()> {+    edit_diagram(s, &json!({"id": gid.0}), cmd, |d| {+        d.font_size = size;+        Ok(())+    })+}++/// Colour every node's text of graphic `gid`.+pub(crate) fn set_text_color(s: &mut Session, gid: ShapeId, color: ColorRef, cmd: &str) -> Result<()> {+    edit_diagram(s, &json!({"id": gid.0}), cmd, |d| {+        d.text_color = Some(color);+        Ok(())+    })+}++fn font_size(s: &mut Session, p: &Value) -> Result<Value> {+    const CMD: &str = "smartArt.fontSize";+    let size = match p.get("size") {+        Some(Value::Null) => None,+        Some(v) => Some(+            v.as_f64()+                .filter(|z| z.is_finite() && *z > 0.0)+                .ok_or_else(|| bad(CMD, "`size` is a font size in points (or null for automatic)"))?+                .clamp(1.0, 4000.0),+        ),+        None => return Err(bad(CMD, "missing `size` (points, or null for automatic)")),+    };+    let gid = target_group(s, p, CMD)?;+    set_font_size(s, gid, size, CMD)?;+    Ok(json!({"id": gid.0, "fontSize": size}))+}++/// The answer of a command that regenerated a graphic's shapes: `{id, ids}` as `insert.smartArt`.+fn regenerated(s: &Session, gid: ShapeId) -> Value {+    let kids: Vec<u32> = match s.doc().ok().and_then(|st| st.shape(gid).map(|g| g.kind.clone())) {+        Some(ShapeKind::Group { children, .. }) => children.iter().map(|c| c.id.0).collect(),+        _ => vec![],+    };+    json!({"id": gid.0, "ids": kids})+}+ fn change_style(s: &mut Session, p: &Value) -> Result<Value> {     const CMD: &str = "smartArt.style";     let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string();+    let gid = target_group(s, p, CMD)?;     edit_diagram(s, p, CMD, |d| {         if d.style != v {             d.style_xml = None;         }         d.style = v;         Ok(Value::Null)-    })+    })?;+    Ok(regenerated(s, gid)) }  fn change_colors(s: &mut Session, p: &Value) -> Result<Value> {     const CMD: &str = "smartArt.colors";     let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string();+    let gid = target_group(s, p, CMD)?;     edit_diagram(s, p, CMD, |d| {         if d.colors != v {             d.colors_xml = None;         }         d.colors = v;         Ok(Value::Null)-    })+    })?;+    Ok(regenerated(s, gid)) }  fn change_layout(s: &mut Session, p: &Value) -> Result<Value> {     const CMD: &str = "smartArt.layout";     let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string();+    let gid = target_group(s, p, CMD)?;     edit_diagram(s, p, CMD, |d| {         if d.kind != kind {             // PowerPoint's own layout no longer applies.@@ -2035,7 +2132,8 @@ fn change_layout(s: &mut Session, p: &Value) -> Result<Value> {         }         d.kind = kind;         Ok(Value::Null)-    })+    })?;+    Ok(regenerated(s, gid)) }  #[cfg(test)]@@ -2134,7 +2232,7 @@ mod tests {         let items = layout_items(&d, Xfrm::new(0.0, 0.0, 600.0, 300.0));         let size = |k: usize| items[k].0.text.as_ref().and_then(|b| b.paragraphs[0].runs[0].props.size).unwrap_or(0.0);         assert!(size(0) < 20.0 && size(0) >= 20.0 * MIN_SCALE - 0.5, "the tip's long word shrinks: {}", size(0));-        assert!((size(1) - 20.0).abs() < 1e-9, "short words keep their size");+        assert!((size(1) - size(0)).abs() < 1e-9, "the other shapes follow: every node's text is sized alike");     }      #[test]diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -593,7 +593,8 @@ impl Session {             self.end_composition();         }         if let Err(e) = (spec.enabled)(self) {-            let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());+            let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO || e == cmd::SELECT_TEXT_OR_SHAPE)+                && (params.get("ids").is_some() || params.get("id").is_some());             if !named {                 return Err(EngineError::Disabled(id.into(), e));             }diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -4724,3 +4724,322 @@ fn slide_hide_and_layout_reject_invalid_index() {     }     assert!(s.doc().unwrap().doc.slides.iter().all(|x| !x.hidden)); }++fn smartart_runs(s: &Session, gid: u64) -> Vec<(Option<f64>, Option<deckcraft_model::Fill>)> {+    match s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).map(|x| &x.kind) {+        Some(deckcraft_model::ShapeKind::Group { children, .. }) => children+            .iter()+            .filter_map(|c| c.text.as_ref())+            .flat_map(|t| t.paragraphs.iter().flat_map(|p| p.runs.iter().map(|r| (r.props.size, r.props.fill.clone()))))+            .collect(),+        _ => panic!("not a group"),+    }+}++/// HH7: SmartArt text is sized alike for every node, and the user can set that size.+#[test]+fn smartart_font_size_is_one_value_for_every_node() {+    let mut s = session();+    let r = s+        .execute(+            "insert.smartArt",+            &json!({"kind": "blockList", "items": ["Internationalisationinternationalisationinternationalisation", "\tScope", "Build", "\tTest"]}),+        )+        .unwrap();+    let gid = r["id"].as_u64().unwrap();+    // Automatic: the long word shrinks the whole graphic, titles and bullets by the same factor.+    let auto = smartart_runs(&s, gid);+    let (title, bullet) = (auto[0].0.unwrap(), auto[1].0.unwrap());+    assert!(title < 20.0, "the long word shrinks the text: {title}");+    assert_eq!(auto[2].0.unwrap(), title, "the other node's title is sized alike");+    assert_eq!(auto[3].0.unwrap(), bullet, "and its bullet");+    // Set: one size for every node.+    s.execute("smartArt.fontSize", &json!({"id": gid, "size": 18})).unwrap();+    assert!(smartart_runs(&s, gid).iter().all(|r| r.0 == Some(18.0)));+    // format.size with the graphic selected does the same, and it survives an edit.+    s.execute("edit.select", &json!({"ids": [gid]})).unwrap();+    s.execute("format.size", &json!({"size": 26})).unwrap();+    s.execute("smartArt.addNode", &json!({"id": gid, "text": "More"})).unwrap();+    let runs = smartart_runs(&s, gid);+    assert!(runs.len() >= 5 && runs.iter().all(|r| r.0 == Some(26.0)), "{runs:?}");+    // Back to automatic.+    s.execute("smartArt.fontSize", &json!({"id": gid, "size": null})).unwrap();+    assert!(smartart_runs(&s, gid).iter().any(|r| r.0 != Some(26.0)));+    assert!(s.execute("smartArt.fontSize", &json!({"id": gid})).is_err());+    assert!(s.execute("smartArt.fontSize", &json!({"id": gid, "size": -3})).is_err());+}++/// AA4: Font Color on a SmartArt graphic colours every node's text (it answered ok and changed+/// nothing).+#[test]+fn format_color_on_a_smartart_graphic_colours_every_node() {+    let mut s = session();+    let r = s.execute("insert.smartArt", &json!({"kind": "blockList", "items": ["Plan", "\tScope", "Build"]})).unwrap();+    let gid = r["id"].as_u64().unwrap();+    s.execute("edit.select", &json!({"ids": [gid]})).unwrap();+    s.execute("format.color", &json!({"color": "#00AA00"})).unwrap();+    let want = Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_model::Rgba::rgb(0, 0xAA, 0))));+    let runs = smartart_runs(&s, gid);+    assert!(runs.len() >= 3 && runs.iter().all(|r| r.1 == want), "{runs:?}");+    s.execute("smartArt.addNode", &json!({"id": gid, "text": "Ship"})).unwrap();+    assert!(smartart_runs(&s, gid).iter().all(|r| r.1 == want), "the colour survives a regenerate");+}++/// AA4: a picture has no fill of its own; the refusal names Recolor.+#[test]+fn shape_fill_on_a_picture_is_refused_naming_recolor() {+    const PNG: &[u8] = &[+        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,+        0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf,+        0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,+        0x82,+    ];+    let mut s = session();+    let id = s.execute("insert.picture", &json!({"data": cmd::base64_encode(PNG), "name": "a.png"})).unwrap()["id"].clone();+    let e = s.execute("shape.fill", &json!({"id": id, "color": "#FF0000"})).unwrap_err().to_string();+    assert!(e.contains("picture.recolor"), "{e}");+    let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap();+    s.execute("shape.fill", &json!({"id": r["id"], "color": "#FF0000"})).unwrap();+    // A mixed selection fills the shape and leaves the picture alone.+    s.execute("shape.fill", &json!({"ids": [id.clone(), r["id"].clone()], "color": "#00FF00"})).unwrap();+    let pic = s.doc().unwrap().shape(ShapeId(id.as_u64().unwrap() as u32)).unwrap().clone();+    assert!(pic.fill.is_none(), "the picture keeps no fill of its own: {:?}", pic.fill);+    // Grouped with the shape, the picture is left alone when the group is filled.+    s.execute("edit.select", &json!({"ids": [id.clone(), r["id"].clone()]})).unwrap();+    s.execute("arrange.group", &json!({})).unwrap();+    s.execute("shape.fill", &json!({"color": "#0000FF"})).unwrap();+    let st = s.doc().unwrap();+    let pic = st.shape(ShapeId(id.as_u64().unwrap() as u32)).unwrap();+    assert!(pic.fill.is_none(), "a picture in a filled group keeps no fill: {:?}", pic.fill);+    let rect = st.shape(ShapeId(r["id"].as_u64().unwrap() as u32)).unwrap();+    assert!(format!("{:?}", rect.fill).contains("b: 255"), "the shape in the group took the blue fill: {:?}", rect.fill);+}++/// A SmartArt graphic selected together with a text box: `format.size` sizes the graphic's nodes+/// and the text box, and the selection stays as it was (regenerating the graphic used to collapse+/// it to the graphic alone, so the other shapes were skipped).+/// `ids: []` names nothing: a formatter given it acts on the selection, the selected text range+/// included, instead of dropping the range and formatting the whole shape.+#[test]+fn an_empty_ids_list_formats_the_selected_text_range_like_no_ids() {+    let mut s = session();+    let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 300, 60], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap();+    s.execute("text.edit", &json!({"id": id})).unwrap();+    s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();+    s.execute("format.bold", &json!({"on": true, "ids": []})).unwrap();+    let st = s.doc().unwrap();+    assert!(st.selection.text.as_ref().is_some_and(|t| t.is_range()), "the text range stays selected");+    let runs = &st.shape(ShapeId(id as u32)).unwrap().text.as_ref().unwrap().paragraphs[0].runs;+    assert!(runs.len() >= 2, "the range was split off: {runs:?}");+    assert_eq!(runs[0].props.bold, Some(true), "{runs:?}");+    assert_ne!(runs.last().unwrap().props.bold, Some(true), "the rest of the text is not bold: {runs:?}");+}++#[test]+fn format_size_on_a_mixed_selection_reaches_the_other_shapes_and_keeps_the_selection() {+    let mut s = session();+    let g = s.execute("insert.smartArt", &json!({"kind": "blockList", "items": ["Plan", "Build"]})).unwrap()["id"].as_u64().unwrap();+    let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 400, 200, 60], "text": "Note"})).unwrap()["id"].as_u64().unwrap();+    s.execute("edit.select", &json!({"ids": [g, b]})).unwrap();+    s.execute("format.size", &json!({"size": 40})).unwrap();+    let st = s.doc().unwrap();+    assert_eq!(st.selection.shapes, vec![ShapeId(g as u32), ShapeId(b as u32)], "the selection is as it was");+    let run = &st.shape(ShapeId(b as u32)).unwrap().text.as_ref().unwrap().paragraphs[0].runs[0].props;+    assert_eq!(run.size, Some(40.0), "the text box was sized too: {run:?}");+    let graphic = st.shape(ShapeId(g as u32)).unwrap();+    assert_eq!(graphic.diagram.as_ref().unwrap().font_size, Some(40.0));+    assert!(graphic.text.is_none(), "the run formatting skipped the graphic itself, which has no text of its own");+}++/// MM1: the text formatters that took only the selection take `ids` too, like `shape.fill`: the+/// named shapes are formatted with nothing selected, and the selection is left as it was.+#[test]+fn format_color_and_friends_take_ids() {+    use deckcraft_model::{ColorRef, Fill};+    let mut s = session();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    let ins = |s: &mut Session, p: serde_json::Value| s.execute("shape.insert", &p).unwrap()["id"].as_u64().unwrap();+    let a = ins(&mut s, json!({"preset": "rect", "rect": [20, 20, 200, 60], "text": "A"}));+    let b = ins(&mut s, json!({"preset": "rect", "rect": [20, 100, 200, 60], "text": "B"}));+    let c = ins(&mut s, json!({"preset": "rect", "rect": [20, 180, 200, 60], "text": "C"}));+    s.doc_mut().unwrap().selection.shapes.clear();+    s.doc_mut().unwrap().selection.text = None;+    s.execute("format.color", &json!({"ids": [a, b], "color": "#FF0000"})).unwrap();+    let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));+    let fill_of =+        |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().text.as_ref().unwrap().paragraphs[0].runs[0].props.fill.clone();+    assert_eq!(fill_of(&s, a), red);+    assert_eq!(fill_of(&s, b), red);+    assert_ne!(fill_of(&s, c), red, "an unnamed shape is untouched");+    assert!(s.doc().unwrap().selection.shapes.is_empty(), "the selection is left alone");+    s.execute("format.font", &json!({"ids": [c], "family": "Lato"})).unwrap();+    assert_eq!(s.doc().unwrap().shape(ShapeId(c as u32)).unwrap().text.as_ref().unwrap().paragraphs[0].runs[0].props.font.as_deref(), Some("Lato"));+    s.execute("format.highlight", &json!({"ids": [a], "color": "#FFFF00"})).unwrap();+    s.execute("format.textTransparency", &json!({"ids": [b], "value": 0.5})).unwrap();+}++/// MM1: a refused named `format.color` leaves the selection as it was.+#[test]+fn a_refused_named_format_color_restores_the_selection() {+    let mut s = session();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20, 20, 200, 60], "text": "A"})).unwrap()["id"].as_u64().unwrap();+    let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20, 100, 200, 60], "text": "B"})).unwrap()["id"].as_u64().unwrap();+    s.doc_mut().unwrap().selection.shapes = vec![ShapeId(b as u32)];+    let before = s.doc().unwrap().selection.shapes.clone();+    assert!(!before.is_empty());+    assert!(s.execute("format.color", &json!({"ids": [a], "color": "nope"})).is_err());+    assert_eq!(s.doc().unwrap().selection.shapes, before, "the refusal left the selection replaced");+}++/// OO2: SmartArt colours are theme references (accent slots), so a theme change recolours the+/// graphic, as in PowerPoint where Change Colors sets are theme-bound.+#[test]+fn a_theme_change_recolours_a_smartart_graphic() {+    let mut s = session();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    let r = s.execute("insert.smartArt", &json!({"kind": "blockList", "items": ["Plan", "Scope", "Build"]})).unwrap();+    let id = r["id"].clone();+    let sl = s.execute("slide.inspect", &json!({})).unwrap();+    let b = sl["shapes"].as_array().unwrap().iter().find(|x| x["id"] == id).unwrap()["box"].clone();+    let (x0, y0, w, h) = (b[0].as_f64().unwrap(), b[1].as_f64().unwrap(), b[2].as_f64().unwrap(), b[3].as_f64().unwrap());+    let sample = |s: &mut Session| -> std::collections::BTreeSet<String> {+        let mut out = std::collections::BTreeSet::new();+        for i in 0..12 {+            for j in 0..8 {+                let v = s+                    .execute(+                        "slide.colorAt",+                        &json!({"x": x0 + w * (f64::from(i) + 0.5) / 12.0, "y": y0 + h * (f64::from(j) + 0.5) / 8.0, "scale": 1}),+                    )+                    .unwrap();+                out.insert(v["color"].as_str().unwrap().to_string());+            }+        }+        out+    };+    let bg = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 1, "y": 1, "scale": 1})).unwrap()["color"].as_str().unwrap().to_string();+    let (bg_before, mut before) = (bg(&mut s), sample(&mut s));+    s.execute("design.theme", &json!({"name": "Ember"})).unwrap();+    let (bg_after, mut after) = (bg(&mut s), sample(&mut s));+    before.remove(&bg_before);+    after.remove(&bg_after);+    assert!(!before.is_empty(), "the graphic is drawn: {before:?}");+    assert_ne!(before, after, "the graphic keeps its old colours after a theme change");+}++#[test]+fn smartart_colors_layout_and_style_answer_the_regenerated_ids() {+    let mut s = session();+    let r = s.execute("insert.smartArt", &json!({"kind": "process"})).unwrap();+    let gid = r["id"].clone();+    for (cmd, params) in [+        ("smartArt.colors", json!({"id": gid, "colors": "colorful"})),+        ("smartArt.layout", json!({"id": gid, "kind": "cycle"})),+        ("smartArt.style", json!({"id": gid, "style": "intense"})),+    ] {+        let a = s.execute(cmd, &params).unwrap();+        assert_eq!(a["id"], gid, "{cmd}: {a}");+        let st = s.doc().unwrap();+        let g = st+            .doc+            .slides+            .iter()+            .find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(gid.as_u64().unwrap() as u32)))+            .unwrap();+        let deckcraft_model::ShapeKind::Group { children, .. } = &g.kind else { panic!("group") };+        let kids: Vec<u32> = children.iter().map(|c| c.id.0).collect();+        assert_eq!(a["ids"], json!(kids), "{cmd}: {a}");+    }+}++#[test]+fn flip_is_refused_on_a_smartart_graphic_and_leaves_it_a_graphic() {+    let mut s = session();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    s.execute("insert.textBox", &json!({"text": "Goals\nRevenue"})).unwrap();+    let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();+    for cmd in ["arrange.flipHorizontal", "arrange.flipVertical", "shape.flip"] {+        let e = s.execute(cmd, &json!({"ids": [gid]})).unwrap_err().to_string();+        assert!(e.contains("SmartArt") && e.contains("smartArt.layout"), "{cmd}: {e}");+    }+    // Still a SmartArt graphic: smartArt commands keep working.+    s.execute("smartArt.layout", &json!({"id": gid, "kind": "cycle"})).unwrap();+    // Ordinary shapes still flip.+    let b = s.execute("insert.textBox", &json!({"text": "x"})).unwrap();+    let id = b["id"].as_u64().unwrap();+    assert_eq!(s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap(), json!(1));+}++/// ZZ5: the paragraph and text formatters that acted only on the selection take `ids` too: the+/// named shapes change with nothing selected, an unnamed shape and the selection stay as they were,+/// and a refused call restores the selection.+#[test]+fn paragraph_and_text_formatters_take_ids() {+    let mut s = session();+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+    let ins = |s: &mut Session, y: u32| {+        s.execute("shape.insert", &json!({"preset": "rect", "rect": [20, y, 200, 60], "text": "Alpha beta"})).unwrap()["id"].as_u64().unwrap()+    };+    let (a, b, c) = (ins(&mut s, 20), ins(&mut s, 100), ins(&mut s, 180));+    s.doc_mut().unwrap().selection.shapes = vec![ShapeId(c as u32)];+    s.doc_mut().unwrap().selection.text = None;+    let shape = |s: &Session, id: u64| serde_json::to_value(s.doc().unwrap().shape(ShapeId(id as u32)).unwrap()).unwrap();+    let (a0, c0) = (shape(&s, a), shape(&s, c));+    s.execute("format.columns", &json!({"ids": [a, b], "count": 2})).unwrap();+    s.execute("format.lineSpacing", &json!({"ids": [a], "lines": 1.5})).unwrap();+    s.execute("format.align", &json!({"ids": [a], "align": "right"})).unwrap();+    s.execute("format.bold", &json!({"ids": [a], "on": true})).unwrap();+    s.execute("format.indent", &json!({"ids": [b]})).unwrap();+    s.execute("format.bullets", &json!({"ids": [b], "on": true})).unwrap();+    let sh = s.doc().unwrap().shape(ShapeId(a as u32)).unwrap().clone();+    let body = sh.text.as_ref().unwrap();+    assert_eq!(body.paragraphs[0].props.align, Some(deckcraft_model::text::Align::Right));+    assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));+    assert_ne!(shape(&s, a), a0);+    assert_eq!(s.doc().unwrap().shape(ShapeId(b as u32)).unwrap().text.as_ref().unwrap().paragraphs[0].level, 1);+    assert_eq!(shape(&s, c), c0, "an unnamed shape is untouched");+    assert_eq!(s.doc().unwrap().selection.shapes, vec![ShapeId(c as u32)], "the selection is left alone");+    assert!(s.execute("format.size", &json!({"ids": [a]})).is_err());+    assert_eq!(s.doc().unwrap().selection.shapes, vec![ShapeId(c as u32)], "a refusal restores the selection");+    for id in [+        "bold",+        "italic",+        "underline",+        "strikethrough",+        "superscript",+        "subscript",+        "size",+        "grow",+        "shrink",+        "case",+        "caps",+        "spacing",+        "clear",+        "textShadow",+        "textOutline",+        "textWarp",+        "align",+        "rtl",+        "alignLeft",+        "alignCenter",+        "alignRight",+        "justify",+        "bullets",+        "numbering",+        "indent",+        "outdent",+        "lineSpacing",+        "paragraph",+        "anchor",+        "direction",+        "columns",+        "autofit",+        "wrap",+        "margins",+        "language",+    ] {+        let doc = command_specs().iter().find(|x| x.id == format!("format.{id}")).unwrap().params;+        assert!(doc.contains("ids?: [shape ids] (default: the selection)"), "format.{id}: {doc}");+    }+}diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs--- a/crates/model/src/lib.rs+++ b/crates/model/src/lib.rs@@ -377,6 +377,13 @@ pub struct Diagram {     /// Change Colors: `colorful` (default: each shape its own accent) or `accent1`…`accent6`.     #[serde(skip_serializing_if = "String::is_empty")]     pub colors: String,+    /// Font size set for every node's text (`smartArt.fontSize`); none = sized automatically, all+    /// alike.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub font_size: Option<f64>,+    /// Font colour set for every node's text (Home › Font Color on the graphic).+    #[serde(skip_serializing_if = "Option::is_none")]+    pub text_color: Option<ColorRef>,     /// The group's shapes are the file's drawing, not generated from `nodes`: text typed into them     /// isn't mapped back to nodes (the first outline edit regenerates them).     #[serde(skip_serializing_if = "std::ops::Not::not")]diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs--- a/crates/pptx/src/read/shapes.rs+++ b/crates/pptx/src/read/shapes.rs@@ -729,6 +729,7 @@ fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option<de         layout_xml,         style_xml: raw("r:qs"),         colors_xml: raw("r:cs"),+        ..Default::default()     }) }