From 6f19e0bd07102f243876d7abad6a0066fe831137 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:56:36 +0300 Subject: [PATCH] SmartArt: shrink text to fit, picture layouts, 3-D styles and more colours Each shape's text shrinks so its longest word fits on a line (the pyramid tip no longer breaks words). Three picture layouts (Vertical Picture List, Picture Caption List, Picture Accent Process) with a picture placeholder per node and smartArt.picture to fill it. Five 3-D SmartArt Styles (Polished, Inset, Cartoon, Powder, Brick Scene) drawn with lit bevels, and Colorful Range and Gradient Range colours; all written to and read from the .pptx quickStyle and colors parts. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 264 ++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 45 +++++ crates/model/src/lib.rs | 3 + crates/pptx/src/dgm.rs | 120 ++++++++++++-- crates/ui-egui/src/lib.rs | 22 ++- crates/ui-egui/src/ribbon.rs | 14 ++ 6 files changed, 431 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index c4905a2b..b26d0308 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8; const MAX_SHAPES: usize = 40; /// DeckCraft's layouts, with the names PowerPoint's gallery gives them. -pub const LAYOUTS: [(&str, &str); 15] = [ +pub const LAYOUTS: [(&str, &str); 18] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -39,19 +39,41 @@ pub const LAYOUTS: [(&str, &str); 15] = [ ("invertedPyramid", "Inverted Pyramid"), ("matrix", "Basic Matrix"), ("titledMatrix", "Titled Matrix"), + ("pictureList", "Vertical Picture List"), + ("pictureCaption", "Picture Caption List"), + ("pictureProcess", "Picture Accent Process"), ]; /// SmartArt Styles. -pub const STYLES: [(&str, &str); 4] = - [("flat", "Simple Fill"), ("subtle", "Subtle Effect"), ("intense", "Intense Effect"), ("outline", "White Outline")]; +pub const STYLES: [(&str, &str); 9] = [ + ("flat", "Simple Fill"), + ("subtle", "Subtle Effect"), + ("intense", "Intense Effect"), + ("outline", "White Outline"), + ("polished", "Polished"), + ("inset", "Inset"), + ("cartoon", "Cartoon"), + ("powder", "Powder"), + ("brick", "Brick Scene"), +]; /// Change Colors. -pub const COLORS: [(&str, &str); 7] = [ +pub const COLORS: [(&str, &str); 17] = [ ("colorful", "Colorful"), + ("range23", "Colorful Range - Accent Colors 2 to 3"), + ("range34", "Colorful Range - Accent Colors 3 to 4"), + ("range45", "Colorful Range - Accent Colors 4 to 5"), + ("range56", "Colorful Range - Accent Colors 5 to 6"), ("accent1", "Accent 1"), ("accent2", "Accent 2"), ("accent3", "Accent 3"), ("accent4", "Accent 4"), ("accent5", "Accent 5"), ("accent6", "Accent 6"), + ("accent1Grad", "Gradient Range - Accent 1"), + ("accent2Grad", "Gradient Range - Accent 2"), + ("accent3Grad", "Gradient Range - Accent 3"), + ("accent4Grad", "Gradient Range - Accent 4"), + ("accent5Grad", "Gradient Range - Accent 5"), + ("accent6Grad", "Gradient Range - Accent 6"), ]; pub fn is_kind(k: &str) -> bool { @@ -103,16 +125,25 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess}", has_selection, change_layout ), + cmd!( + "smartArt.picture", + "Insert Picture in SmartArt", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index, data?: base64 | path?: string | reset?: bool} (the node's picture placeholder in picture layouts)", + has_selection, + set_picture + ), cmd!( "smartArt.style", "SmartArt Styles", ["SmartArt Design", "SmartArt Styles"], None, - "{id?, style: flat|subtle|intense|outline}", + "{id?, style: flat|subtle|intense|outline|polished|inset|cartoon|powder|brick}", has_selection, change_style ), @@ -121,7 +152,7 @@ pub fn specs() -> Vec { "Change Colors", ["SmartArt Design", "SmartArt Styles"], None, - "{id?, colors: colorful|accent1|…|accent6}", + "{id?, colors: colorful|range23|range34|range45|range56|accent1|…|accent6|accent1Grad|…|accent6Grad}", has_selection, change_colors ), @@ -135,7 +166,7 @@ pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec usize { /// new node's index. pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { let Some(i) = index.filter(|i| *i < nodes.len()) else { - nodes.push(DiagramNode { text: text.into(), level: 0 }); + nodes.push(DiagramNode { text: text.into(), level: 0, picture: None }); return nodes.len() - 1; }; let lv = nodes[i].level; @@ -183,7 +214,7 @@ pub fn add(nodes: &mut Vec, index: Option, position: &str, t } _ => (subtree_end(nodes, i), lv), }; - nodes.insert(at, DiagramNode { text: text.into(), level }); + nodes.insert(at, DiagramNode { text: text.into(), level, picture: None }); at } @@ -272,19 +303,49 @@ fn slot(i: usize) -> SchemeSlot { /// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` /// keeps one. fn accent(d: &Diagram, i: usize) -> SchemeSlot { - match d.colors.strip_prefix("accent").and_then(|n| n.parse::().ok()) { + let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize); + if let Some(r) = d.colors.strip_prefix("range") + && let Some(a @ 1..=6) = digit(r, 0) + { + // Colorful Range: alternate between two neighbouring accents. + return slot(a - 1 + i % 2); + } + match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) { Some(n @ 1..=6) => slot(n - 1), _ => slot(i), } } +/// Gradient Range: each shape a step darker than the one before (the steps cycle). +fn shade(d: &Diagram, i: usize) -> Option { + d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000)) +} + +/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) +/// lit by the three-point rig. +fn three_d(bevel: &str, w: f64, h: f64) -> String { + format!( + "", + (w * 12700.0).round() as i64, + (h * 12700.0).round() as i64 + ) +} + +/// Whether `style` is one of the 3-D SmartArt Styles. +pub fn is_3d(style: &str) -> bool { + matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") +} + /// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet /// boxes of the bullet lists) are a pale tint of the accent with dark text. fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { let a = accent(d, i); - let base = ColorRef::scheme(a); + let base = match shade(d, i) { + Some(t) => ColorRef::scheme(a).with(t), + None => ColorRef::scheme(a), + }; let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000)); - let dark_text = light || d.style == "outline"; + let dark_text = light || matches!(d.style.as_str(), "outline" | "powder"); sh.style = Some(ShapeStyle::accent(a)); match d.style.as_str() { _ if light => { @@ -300,6 +361,23 @@ fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() }); } + "polished" | "inset" | "cartoon" | "powder" | "brick" => { + let (bevel, bw, line, fill) = match d.style.as_str() { + "polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()), + "inset" => ("relaxedInset", 10.0, None, base.clone()), + "cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()), + "powder" => ("convex", 14.0, None, tint(40)), + _ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()), + }; + sh.fill = Some(Fill::solid(fill)); + sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() })); + sh.effects = Some(Effects { + outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), + bevel: Some(bevel.into()), + raw3d: Some(three_d(bevel, bw, bw * 0.6)), + ..Default::default() + }); + } "intense" => { sh.fill = Some(Fill::Gradient(Gradient { stops: vec![ @@ -403,8 +481,73 @@ pub fn layout(d: &Diagram, area: Xfrm) -> Vec { 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. +/// 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. fn layout_items(d: &Diagram, area: Xfrm) -> Vec { + let mut v = layout_raw(d, area); + for (sh, _) in &mut v { + fit_words(sh); + } + v +} + +/// 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 }; + let Some(body) = &mut sh.text else { return }; + let avail = 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 { + let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); + for r in &p.runs { + let size = r.props.size.unwrap_or(18.0); + let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64; + let need = word * size * CHAR_W; + if need > 0.0 { + scale = scale.min(line.max(0.0) / need); + } + } + } + let scale = scale.max(MIN_SCALE); + if scale >= 0.999 { + return; + } + let shrink = |v: &mut Option| { + if let Some(x) = v { + *x = (*x * scale * 2.0).floor() / 2.0; + } + }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + shrink(&mut r.props.size); + } + shrink(&mut p.end_props.size); + shrink(&mut p.props.margin_left); + if let Some(i) = &mut p.props.indent { + *i *= scale; + } + } +} + +/// A picture placeholder for node `node`: its picture when it has one, else a pale box. +fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape { + let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }; + paint(d, &mut sh, i, true); + if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) { + sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + sh +} + +fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { let gs = groups(d); let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() }; let n = gs.len().max(1); @@ -564,10 +707,15 @@ fn layout_items(d: &Diagram, area: Xfrm) -> Vec { paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], }; // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. - let inset = ((it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0).min(w * 0.2); + // The tip band is a triangle: its text sits at the wide end, inset to the width there. + let tip = wt.min(wb) < 1e-6; + let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; if let Some(b) = &mut sh.text { b.body.inset_l = Some(inset); b.body.inset_r = Some(inset); + if tip { + b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } } paint(d, &mut sh, i, false); out.push((sh, g.clone())); @@ -590,6 +738,46 @@ fn layout_items(d: &Diagram, area: Xfrm) -> Vec { out.push((sh, t.clone())); } } + "pictureList" => { + // Vertical Picture List: a picture square at the left of each row, the text beside it. + let h = area.h / nf; + let bh = h * 0.88; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let s = bh.min(area.w * 0.3); + out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); + titled(&mut out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); + } + } + "pictureCaption" | "pictureProcess" => { + // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one + // row, arrows between the steps. + let process = d.kind == "pictureProcess"; + let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; + let rows = n.div_ceil(cols); + let gap = if process { area.w * 0.05 } else { 0.0 }; + let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; + let h = area.h / rows as f64; + let bw = if process { w } else { w * 0.9 }; + let ch = (h * 0.9).min(bw * 1.1); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; + let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; + let y = area.y + r as f64 * h + (h - ch) / 2.0; + let ph = ch * 0.62; + out.push(( + picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), + vec![], + )); + titled(&mut out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); + if process && i + 1 < n { + let a = (gap * 0.7).min(ch * 0.2); + arrow(&mut out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); + } + } + } _ => {} } out @@ -751,7 +939,7 @@ fn index(p: &Value, cmd: &str) -> Result { pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { let kind = if is_kind(kind) { kind } else { "process" }; if nodes.is_empty() { - nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect(); + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None }).collect(); } normalize(&mut nodes); let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; @@ -834,6 +1022,26 @@ fn demote(s: &mut Session, p: &Value) -> Result { edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) } +fn set_picture(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.picture"; + let i = index(p, CMD)?; + let media = if bool_or(p, "reset", false) { + None + } else { + let (name, bytes) = super::insert::media_bytes(p, CMD)?; + let ct = super::insert::content_type(&name, &bytes); + target_group(s, p, CMD)?; + Some(s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?) + }; + edit_diagram(s, p, CMD, |d| { + // A sub-point's picture is its top-level node's: that's the node the placeholder shows. + let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i); + let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?; + n.picture = media; + Ok(Value::Null) + }) +} + fn change_style(s: &mut Session, p: &Value) -> Result { 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(); @@ -880,7 +1088,7 @@ mod tests { n.iter().map(|x| x.level).collect() } fn mk(levels: &[u8]) -> Vec { - levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l }).collect() + levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None }).collect() } #[test] @@ -934,7 +1142,7 @@ mod tests { fn every_layout_shows_every_node_inside_its_area() { let area = Xfrm::new(50.0, 40.0, 800.0, 400.0); for (kind, _) in LAYOUTS { - for style in ["", "subtle", "intense", "outline"] { + for style in std::iter::once("").chain(STYLES.iter().map(|(k, _)| *k)) { let d = Diagram { kind: kind.into(), nodes: mk(&[0, 1, 0, 0, 1, 0]), @@ -956,4 +1164,24 @@ mod tests { } } } + + #[test] + fn long_words_shrink_to_fit_their_shape() { + let d = Diagram { kind: "pyramid".into(), nodes: nodes_from_lines(["Internationalisation", "Build", "Test"]), ..Default::default() }; + 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"); + } + + #[test] + fn colour_variants_pick_accents() { + let d = |c: &str| Diagram { colors: c.into(), ..Default::default() }; + assert_eq!( + (accent(&d("range45"), 0), accent(&d("range45"), 1), accent(&d("range45"), 2)), + (SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent4) + ); + assert_eq!(accent(&d("accent3Grad"), 4), SchemeSlot::Accent3); + assert!(shade(&d("accent3Grad"), 1).is_some() && shade(&d("accent3"), 1).is_none()); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 64b0942c..f66f84fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -925,3 +925,48 @@ fn smartart_round_trips_through_pptx_as_a_diagram() { let d = again.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); assert_eq!(d.nodes.last().map(|n| (n.text.as_str(), n.level)), Some(("COO", 1))); } + +#[test] +fn smartart_pictures_3d_styles_and_colour_variants() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "pictureCaption", "items": ["Lakes", "\tBlue", "Hills"]})).unwrap(); + let pics = |s: &Session| -> Vec { + let st = s.doc().unwrap(); + let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone(); + g.children().iter().filter(|c| c.name == "SmartArt picture").cloned().collect() + }; + assert_eq!(pics(&s).len(), 2, "one picture placeholder per top-level node"); + assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_))))); + // A 1×1 PNG put in through a sub-point lands in its top-level node. + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + s.execute("smartArt.picture", &json!({"index": 1, "name": "p.png", "data": png})).unwrap(); + let p = pics(&s); + assert!(matches!(p[0].fill, Some(deckcraft_model::Fill::Picture(_)))); + assert!(!matches!(p[1].fill, Some(deckcraft_model::Fill::Picture(_)))); + // Text edits keep the picture; reset removes it. + s.execute("smartArt.addNode", &json!({"text": "Woods"})).unwrap(); + assert!(matches!(pics(&s)[0].fill, Some(deckcraft_model::Fill::Picture(_)))); + assert_eq!(pics(&s).len(), 3); + // The picture is written to the .pptx drawing. + let bytes = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert!(back.validate().is_empty()); + assert_eq!(back.slides[1].shapes.iter().find_map(|x| x.diagram.as_ref()).map(|d| d.kind.clone()).as_deref(), Some("pictureCaption")); + s.execute("smartArt.picture", &json!({"index": 0, "reset": true})).unwrap(); + assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_))))); + + // 3-D styles carry a lit bevel; colour variants round trip through .pptx. + s.execute("smartArt.style", &json!({"style": "cartoon"})).unwrap(); + let st = s.doc().unwrap(); + let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone(); + let raw = g.children().iter().find_map(|c| c.effects.as_ref().and_then(|e| e.raw3d.clone())).expect("3-D XML"); + assert!(raw.contains("scene3d") && raw.contains("softRound")); + for (style, colors) in [("polished", "range34"), ("powder", "accent5Grad"), ("brick", "accent2")] { + s.execute("smartArt.style", &json!({"style": style})).unwrap(); + s.execute("smartArt.colors", &json!({"colors": colors})).unwrap(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap()).unwrap(); + let d = back.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!((d.style.as_str(), d.colors.as_str()), (style, colors)); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index cfd30ab1..0d1afb9c 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -362,6 +362,9 @@ pub struct Diagram { pub struct DiagramNode { pub text: String, pub level: u8, + /// The picture shown in the node's picture placeholder (picture layouts). + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, } impl Default for Shape { diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index 993c771f..e748da3e 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -28,6 +28,11 @@ pub fn qs_id(style: &str) -> String { "subtle" => "simple3", "intense" => "simple5", "outline" => "simple4", + "polished" => "3d1", + "inset" => "3d2", + "cartoon" => "3d3", + "powder" => "3d4", + "brick" => "3d5", _ => "simple1", }; format!("{QS_BASE}{s}") @@ -38,6 +43,11 @@ pub fn style_for(qs: &str) -> &'static str { match qs.rsplit('/').next().unwrap_or("") { "simple1" | "" => "", "simple4" => "outline", + "3d1" => "polished", + "3d2" => "inset", + "3d3" => "cartoon", + "3d4" => "powder", + "3d5" => "brick", "simple2" | "simple3" => "subtle", _ => "intense", } @@ -45,17 +55,37 @@ pub fn style_for(qs: &str) -> &'static str { /// PowerPoint's colour definition for Change Colors. pub fn cs_id(colors: &str) -> String { - match colors.strip_prefix("accent").and_then(|n| n.parse::().ok()) { - Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_2"), + if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + return format!("{CS_BASE}colorful{a}"); + } + let grad = colors.ends_with("Grad"); + match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_{}", if grad { 3 } else { 2 }), _ => format!("{CS_BASE}colorful1"), } } +/// The accents a Change Colors choice fills shapes with, in turn. +fn fill_accents(colors: &str) -> Vec { + if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + return vec![format!("accent{a}"), format!("accent{}", a + 1)]; + } + match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => vec![format!("accent{n}")], + _ => (1..=6).map(|k| format!("accent{k}")).collect(), + } +} + /// Change Colors from a colour definition id: `accentN_…` keeps accent N, anything else is colourful. pub fn colors_for(cs: &str) -> String { let last = cs.rsplit('/').next().unwrap_or(""); - match last.strip_prefix("accent").and_then(|r| r.split('_').next()).and_then(|n| n.parse::().ok()) { - Some(n @ 1..=6) => format!("accent{n}"), + if let Some(a @ 2..=5) = last.strip_prefix("colorful").and_then(|n| n.parse::().ok()) { + return format!("range{a}{}", a + 1); + } + let mut parts = last.strip_prefix("accent").unwrap_or("").split('_'); + match (parts.next().and_then(|n| n.parse::().ok()), parts.next()) { + (Some(n @ 1..=6), Some("3" | "4" | "5")) => format!("accent{n}Grad"), + (Some(n @ 1..=6), _) => format!("accent{n}"), _ => String::new(), } } @@ -79,6 +109,9 @@ pub fn layout_id(kind: &str) -> &'static str { "invertedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid3", "matrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix3", "titledMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix1", + "pictureList" => "urn:microsoft.com/office/officeart/2005/8/layout/vList4", + "pictureCaption" => "urn:microsoft.com/office/officeart/2005/8/layout/pList1", + "pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } @@ -102,6 +135,9 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "invertedPyramid", "matrix", "titledMatrix", + "pictureList", + "pictureCaption", + "pictureProcess", "list", ] { if layout_id == layout_id_of(k) { @@ -138,7 +174,7 @@ fn layout_id_of(kind: &str) -> &'static str { fn category(kind: &str) -> &'static str { match kind { - "process" | "chevron" | "verticalProcess" | "stepUp" => "process", + "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" => "process", "cycle" | "blockCycle" | "radial" => "cycle", "hierarchy" => "hierarchy", "pyramid" | "invertedPyramid" => "pyramid", @@ -288,7 +324,7 @@ pub fn outline(data: &El) -> Vec { if out.len() >= MAX_NODES || !seen.insert(id) { continue; } - out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8) }); + out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None }); if let Some(v) = kids.get(id) { stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); } @@ -312,7 +348,7 @@ fn paragraphs(t: &El) -> String { /// The layout algorithm and node shape of a DeckCraft layout. fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) { match kind { - "process" | "stepUp" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), + "process" | "stepUp" | "pictureProcess" | "pictureCaption" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), "cycle" | "radial" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), @@ -450,6 +486,18 @@ pub fn layout_xml(kind: &str) -> Vec { w.finish() } +/// The top bevel (preset, width in EMU) of a 3-D SmartArt Style. +fn bevel(style: &str) -> Option<(&'static str, i64)> { + Some(match style { + "polished" => ("circle", 76200), + "inset" => ("relaxedInset", 63500), + "cartoon" => ("softRound", 50800), + "powder" => ("convex", 101600), + "brick" => ("slope", 50800), + _ => return None, + }) +} + /// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. pub fn style_xml(style: &str) -> Vec { let mut w = W::new(); @@ -457,7 +505,7 @@ pub fn style_xml(style: &str) -> Vec { w.empty("dgm:title", A::new().a("val", "")); w.empty("dgm:desc", A::new().a("val", "")); w.open0("dgm:catLst"); - w.empty("dgm:cat", A::new().a("type", "simple").a("pri", "10100")); + w.empty("dgm:cat", A::new().a("type", if bevel(style).is_some() { "3D" } else { "simple" }).a("pri", "10100")); w.close("dgm:catLst"); w.open0("dgm:scene3d"); w.empty("a:camera", A::new().a("prst", "orthographicFront")); @@ -468,7 +516,14 @@ pub fn style_xml(style: &str) -> Vec { w.empty("a:camera", A::new().a("prst", "orthographicFront")); w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); w.close("dgm:scene3d"); - w.empty0("dgm:sp3d"); + match bevel(style) { + Some((prst, w_emu)) => { + w.open0("dgm:sp3d"); + w.empty("a:bevelT", A::new().a("w", w_emu.to_string()).a("h", (w_emu * 3 / 5).to_string()).a("prst", prst)); + w.close("dgm:sp3d"); + } + None => w.empty0("dgm:sp3d"), + } w.empty("dgm:txPr", A::new()); w.open0("dgm:style"); for (tag, idx) in [("a:lnRef", "2"), ("a:fillRef", "1"), ("a:effectRef", "0")] { @@ -491,19 +546,26 @@ pub fn colors_xml(colors: &str) -> Vec { w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", cs_id(colors))); w.empty("dgm:title", A::new().a("val", "")); w.empty("dgm:desc", A::new().a("val", "")); - let single = colors.starts_with("accent"); + let accents = fill_accents(colors); + let single = accents.len() == 1; + let cat = if single { accents.first().map(String::as_str).unwrap_or("accent1") } else { "colorful" }; w.open0("dgm:catLst"); - w.empty("dgm:cat", A::new().a("type", if single { colors } else { "colorful" }).a("pri", "11200")); + w.empty("dgm:cat", A::new().a("type", cat).a("pri", "11200")); w.close("dgm:catLst"); w.open("dgm:styleLbl", A::new().a("name", "node1")); - if single { + if colors.ends_with("Grad") { + // Gradient Range: the accent shaded darker across the shapes. w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); - w.empty("a:schemeClr", A::new().a("val", colors)); + for k in 0..4 { + w.open("a:schemeClr", A::new().a("val", cat)); + w.empty("a:lumMod", A::new().a("val", (100_000 - k * 15_000).to_string())); + w.close("a:schemeClr"); + } w.close("dgm:fillClrLst"); } else { w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); - for k in 1..=6 { - w.empty("a:schemeClr", A::new().a("val", format!("accent{k}"))); + for a in &accents { + w.empty("a:schemeClr", A::new().a("val", a.as_str())); } w.close("dgm:fillClrLst"); } @@ -524,7 +586,7 @@ mod tests { use super::*; fn nodes(v: &[(&str, u8)]) -> Vec { - v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l }).collect() + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None }).collect() } #[test] @@ -536,11 +598,35 @@ mod tests { assert_eq!(outline(&doc.root), d.nodes); let s = String::from_utf8(xml).unwrap(); assert!(s.contains("hierarchy1") && s.contains("relId=\"rId9\"")); - for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml("subtle"), colors_xml(""), colors_xml("accent3")] { + for x in [ + layout_xml("hierarchy"), + layout_xml("cycle"), + style_xml("subtle"), + colors_xml(""), + colors_xml("accent3"), + style_xml("polished"), + colors_xml("range23"), + colors_xml("accent6Grad"), + layout_xml("pictureList"), + ] { assert!(crate::xml::parse(&x).is_ok()); } } + #[test] + fn style_and_colour_ids_round_trip() { + for st in ["", "subtle", "intense", "outline", "polished", "inset", "cartoon", "powder", "brick"] { + assert_eq!(style_for(&qs_id(st)), st); + } + for c in ["", "range23", "range56", "accent1", "accent4Grad"] { + assert_eq!(colors_for(&cs_id(c)), c); + } + assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT")); + for k in ["pictureList", "pictureCaption", "pictureProcess"] { + assert_eq!(kind_for(layout_id(k), ""), k); + } + } + #[test] fn hostile_links_are_bounded() { let xml = br#"A"#; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b2f0e33..0435923c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -173,6 +173,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"), ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), + ("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"), ("app.insertAudioDialog", "Audio from File…", None, "{}"), ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), @@ -547,6 +548,12 @@ impl SlideApp { "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), "app.openDialog" => self.pick_and_open(), "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), + "app.smartArtPictureDialog" => { + if self.services.pick_open.is_none() { + return Err("picking a SmartArt picture needs the desktop app".into()); + } + self.pick_and_run("picture", "smartArt.picture", p); + } "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), @@ -591,14 +598,25 @@ impl SlideApp { } fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { + self.pick_and_run(purpose, cmd, &json!({})); + } + + /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. + fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick(purpose) { + let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; let r = match self.services.read.as_ref().map(|r| r(&path)) { Some(Ok(bytes)) => { let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - self.session.execute(cmd, &json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&bytes)})) + args["name"] = json!(name); + args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + self.session.execute(cmd, &args) + } + _ => { + args["path"] = json!(path); + self.session.execute(cmd, &args) } - _ => self.session.execute(cmd, &json!({"path": path})), }; if let Err(e) = r { self.set_status(e.to_string()); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f1ca7499..66706d18 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2354,6 +2354,13 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { run(app, "smartArt.demote", json!({"id": gid, "index": at})); } }); + let pictures = kind.starts_with("picture"); + if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false) + .on_hover_text("Put a picture in the selected node's picture placeholder") + .clicked() + { + run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at})); + } }); group(ui, |ui| { let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); @@ -2369,6 +2376,9 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { for (k, label) in deckcraft_engine::cmd::smartart::COLORS { + if matches!(k, "range23" | "accent1" | "accent1Grad") { + ui.separator(); + } let on = colors == k || (colors.is_empty() && k == "colorful"); if ui.selectable_label(on, label).clicked() { run(app, "smartArt.colors", json!({"id": gid, "colors": k})); @@ -2378,6 +2388,10 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { let (r, _) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { for (k, label) in deckcraft_engine::cmd::smartart::STYLES { + if k == "polished" { + ui.separator(); + ui.label("3-D"); + } let on = style == k || (style.is_empty() && k == "flat"); if ui.selectable_label(on, label).clicked() { run(app, "smartArt.style", json!({"id": gid, "style": k}));