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
@@ -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"); }@@ -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()) }@@ -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); }@@ -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]@@ -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)); }@@ -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, ¶ms).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}");+ }+}@@ -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")]@@ -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() }) }