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()
     })
 }
 
