From 1866de9895d8ae1b7f76a98b33d190d07756de2c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:42:52 +0300 Subject: [PATCH 1/2] SmartArt: 15 layouts, SmartArt Styles and Change Colors Layouts now include Vertical and Horizontal Bullet List, Basic Block List, Basic/Chevron/Vertical/Step Up Process, Basic and Block Cycle, Basic Radial, Basic and Inverted Pyramid (bands drawn as polygons so the slope and the text stay right), Basic and Titled Matrix, and Hierarchy. Each layout says which nodes each shape shows, so typed text maps back for any layout. SmartArt Styles (flat, subtle, intense, white outline) and Change Colors (colourful or one accent) are kept on the diagram, offered in the SmartArt Design tab, and written to .pptx as quick style / colour definition ids (and read back from them). Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 463 ++++++++++++++++++++++++++---- crates/engine/src/tests.rs | 5 +- crates/model/src/lib.rs | 9 +- crates/pptx/src/dgm.rs | 164 +++++++++-- crates/pptx/src/read/shapes.rs | 5 + crates/pptx/src/write/shapes.rs | 4 +- crates/ui-egui/src/ribbon.rs | 31 +- docs/gaps.md | 6 +- 8 files changed, 585 insertions(+), 102 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index cfca7620..331dabec 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -7,8 +7,11 @@ use deckcraft_color::SchemeSlot; use deckcraft_geom::Xfrm; +use deckcraft_model::style::{Gradient, GradientShape, GradientStop, Shadow}; use deckcraft_model::text::{Align, AutoFit, Paragraph, TextBody}; -use deckcraft_model::{Diagram, DiagramNode, Fill, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle}; +use deckcraft_model::{ + ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Line, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, +}; use serde_json::{Value, json}; use super::*; @@ -19,7 +22,41 @@ pub const MAX_LEVEL: u8 = 8; /// Most top-level shapes a layout draws. const MAX_SHAPES: usize = 40; -pub const KINDS: [&str; 6] = ["list", "process", "cycle", "hierarchy", "pyramid", "matrix"]; +/// DeckCraft's layouts, with the names PowerPoint's gallery gives them. +pub const LAYOUTS: [(&str, &str); 15] = [ + ("list", "Vertical Bullet List"), + ("blockList", "Basic Block List"), + ("hBulletList", "Horizontal Bullet List"), + ("process", "Basic Process"), + ("chevron", "Basic Chevron Process"), + ("verticalProcess", "Vertical Process"), + ("stepUp", "Step Up Process"), + ("cycle", "Basic Cycle"), + ("blockCycle", "Block Cycle"), + ("radial", "Basic Radial"), + ("hierarchy", "Hierarchy"), + ("pyramid", "Basic Pyramid"), + ("invertedPyramid", "Inverted Pyramid"), + ("matrix", "Basic Matrix"), + ("titledMatrix", "Titled Matrix"), +]; +/// SmartArt Styles. +pub const STYLES: [(&str, &str); 4] = + [("flat", "Simple Fill"), ("subtle", "Subtle Effect"), ("intense", "Intense Effect"), ("outline", "White Outline")]; +/// Change Colors. +pub const COLORS: [(&str, &str); 7] = [ + ("colorful", "Colorful"), + ("accent1", "Accent 1"), + ("accent2", "Accent 2"), + ("accent3", "Accent 3"), + ("accent4", "Accent 4"), + ("accent5", "Accent 5"), + ("accent6", "Accent 6"), +]; + +pub fn is_kind(k: &str) -> bool { + LAYOUTS.iter().any(|(id, _)| *id == k) +} pub fn specs() -> Vec { vec![ @@ -66,10 +103,28 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|process|cycle|hierarchy|pyramid|matrix}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix}", has_selection, change_layout ), + cmd!( + "smartArt.style", + "SmartArt Styles", + ["SmartArt Design", "SmartArt Styles"], + None, + "{id?, style: flat|subtle|intense|outline}", + has_selection, + change_style + ), + cmd!( + "smartArt.colors", + "Change Colors", + ["SmartArt Design", "SmartArt Styles"], + None, + "{id?, colors: colorful|accent1|…|accent6}", + has_selection, + change_colors + ), ] } @@ -175,11 +230,11 @@ pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { true } -/// Which nodes each generated text shape shows, in child order (lines drawn after these carry none). -fn node_map(d: &Diagram) -> Vec> { - if d.kind == "hierarchy" { - return (0..d.nodes.len().min(MAX_SHAPES)).map(|i| vec![i]).collect(); - } +/// A generated shape and the nodes whose text it shows (none for arrows and lines). +type Item = (Shape, Vec); + +/// Top-level nodes, each with its sub-points. +fn groups(d: &Diagram) -> Vec> { let mut out: Vec> = vec![]; for (i, n) in d.nodes.iter().enumerate() { match out.last_mut() { @@ -187,8 +242,7 @@ fn node_map(d: &Diagram) -> Vec> { _ => out.push(vec![i]), } } - let cap = if d.kind == "matrix" { 4 } else { MAX_SHAPES }; - out.truncate(cap); + out.truncate(MAX_SHAPES); out } @@ -197,9 +251,10 @@ fn sync_text(d: &mut Diagram, children: &[Shape]) { if d.imported { return; } - for (shape, idx) in children.iter().zip(node_map(d)) { + let map: Vec> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect(); + for (shape, idx) in children.iter().zip(map) { let Some(body) = &shape.text else { continue }; - if body.paragraphs.len() != idx.len() { + if idx.is_empty() || body.paragraphs.len() != idx.len() { continue; } for (p, i) in body.paragraphs.iter().zip(idx) { @@ -214,13 +269,91 @@ fn slot(i: usize) -> SchemeSlot { [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] } -fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Shape { +/// 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()) { + Some(n @ 1..=6) => slot(n - 1), + _ => slot(i), + } +} + +/// 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 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"; + sh.style = Some(ShapeStyle::accent(a)); + match d.style.as_str() { + _ if light => { + sh.fill = Some(Fill::solid(tint(80))); + sh.line = Some(Line::solid(tint(60), 1.0)); + } + "outline" => { + sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + sh.line = Some(Line::solid(base.clone(), 2.25)); + } + "subtle" => { + sh.fill = Some(Fill::solid(base.clone())); + 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() }); + } + "intense" => { + sh.fill = Some(Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: tint(30) }, + GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) }, + ], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); + } + _ => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + } + if let Some(body) = &mut sh.text { + let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } +} + +fn shadow(blur: f64, dist: f64) -> Shadow { + Shadow { + color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)), + blur, + dist, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + } +} + +/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With +/// `title` false every line is a bullet (the bullet boxes of the bullet lists). +fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape { let mut body = TextBody::default(); body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); - let top = lines.first().map(|n| n.level).unwrap_or(0); + let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0); for (k, n) in lines.iter().enumerate() { let mut para = Paragraph::new(&n.text); - if k == 0 { + let head = title && k == 0; + if head { para.props.align = Some(Align::Center); } else { para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); @@ -229,7 +362,7 @@ fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Sh para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); para.props.indent = Some(-10.0); } - let size = if k == 0 { 20.0 } else { 14.0 }; + let size = if head { 20.0 } else { 14.0 }; for r in &mut para.runs { r.props.size = Some(size); } @@ -239,14 +372,17 @@ fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Sh if body.paragraphs.is_empty() { body.paragraphs.push(Paragraph::new("")); } - Shape { - name: format!("SmartArt shape {}", color + 1), - xfrm: Some(x), - geom: Geom::preset(preset), - style: Some(ShapeStyle::accent(slot(color))), - text: Some(body), - ..Default::default() + if !title { + body.body.anchor = Some(deckcraft_model::text::Anchor::Top); } + Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() } +} + +/// A plain shape with no text (arrows between steps). +fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape { + let mut x = x; + x.rot = rot; + Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() } } fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { @@ -256,63 +392,205 @@ fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { name: "SmartArt connector".into(), xfrm: Some(x), geom: Geom::preset("line"), - style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)), ..Default::default() } } /// The diagram's shapes laid out in `area` (ids not yet assigned). pub fn layout(d: &Diagram, area: Xfrm) -> Vec { - let map = node_map(d); - let groups: Vec> = map.iter().map(|g| g.iter().filter_map(|i| d.nodes.get(*i)).collect()).collect(); - let n = groups.len().max(1); - let mut out = 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. +fn layout_items(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); + let nf = n as f64; + let mut out: Vec = vec![]; + // A titled shape for top-level group `i`, painted in its accent. + let titled = |out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]| { + let mut sh = text_shape(preset, x, &nodes(g), true); + paint(d, &mut sh, i, false); + out.push((sh, g.to_vec())); + }; + let arrow = |out: &mut Vec, x: Xfrm, rot: f64, i: usize| { + let mut sh = deco("rightArrow", x, rot); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + }; match d.kind.as_str() { "hierarchy" => return hierarchy(d, area), - "cycle" => { - let r = area.h.min(area.w) * 0.38; + "list" | "hBulletList" => { + // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. + let vertical = d.kind == "list"; + for (i, g) in gs.iter().enumerate() { + let (cell, bar_h) = if vertical { + ( + Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), + (area.h / nf * 0.92).min(area.h * 0.12).max(18.0), + ) + } else { + let w = area.w / nf; + (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) + }; + let has_sub = g.len() > 1; + let bar_h = bar_h.min(cell.h); + titled(&mut out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); + if has_sub { + let pad = cell.w * 0.03; + let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); + let mut sh = text_shape("rect", x, &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + out.push((sh, g[1..].to_vec())); + } + } + } + "blockList" => { + // Basic Block List: equal blocks in rows, as square as the area allows. + let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (w, h) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); + 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) / 2.0; + titled(&mut out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); + } + } + "process" | "chevron" => { + let chevron = d.kind == "chevron"; + let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; + let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); + let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); + let y = area.y + (area.h - h) / 2.0; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + titled(&mut out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); + if !chevron && i + 1 < n { + let aw = gap * 0.7; + arrow(&mut out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); + } + } + } + "verticalProcess" => { + let gap = area.h * 0.06; + let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); + let w = area.w * 0.6; + let x = area.x + (area.w - w) / 2.0; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * (h + gap); + titled(&mut out, "roundRect", Xfrm::new(x, y, w, h), i, g); + if i + 1 < n { + let s = gap * 0.8; + // A right arrow turned to point down. + arrow(&mut out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); + } + } + } + "stepUp" => { + // Step Up Process: each step higher than the one before. + let w = area.w / nf; + let h = area.h / (nf + 1.0) * 1.6; + for (i, g) in gs.iter().enumerate() { + let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); + titled(&mut out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); + } + } + "cycle" | "blockCycle" => { + let block = d.kind == "blockCycle"; + let r = area.h.min(area.w) * 0.36; let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = r * 0.62; - for (i, g) in groups.iter().enumerate() { - let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); - out.push(text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), i, g)); + let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; + titled( + &mut out, + if block { "roundRect" } else { "ellipse" }, + Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), + i, + g, + ); + if n > 1 { + // An arrow halfway to the next node, along the circle. + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.35).max(6.0); + let rot = (i as f64 + 0.5) / nf * 360.0; + arrow(&mut out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); + } } } - "pyramid" => { - let h = area.h / n as f64; - for (i, g) in groups.iter().enumerate() { - let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; - out.push(text_shape("trapezoid", Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i, g)); + "radial" => { + // Basic Radial: the first node in the middle, the others around it with spokes. + let Some((centre, rest)) = gs.split_first() else { return out }; + let m = rest.len().max(1) as f64; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = r * 0.75; + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); + for (k, _) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); + out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); + } + titled(&mut out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); + for (k, g) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + titled(&mut out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); } } - "matrix" => { - let (w, h) = (area.w / 2.0, area.h / 2.0); - for (i, g) in groups.iter().enumerate() { - let x = Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0); - out.push(text_shape("roundRect", x, i, g)); + "pyramid" | "invertedPyramid" => { + // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as + // its own polygon so the sides keep the triangle's slope and the text stays upright. + let inverted = d.kind == "invertedPyramid"; + let h = area.h / nf; + let pw = area.h.min(area.w) * 1.15; + let px = area.x + (area.w - pw) / 2.0; + for (i, g) in gs.iter().enumerate() { + let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); + let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; + let (wt, wb) = (pw * t, pw * bt); + let w = wt.max(wb).max(1.0); + let bh = h * 0.96; + let x = px + (pw - w) / 2.0; + let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); + let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &nodes(g), true); + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], + }; + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); } } - "list" => { - let h = area.h / n as f64; - for (i, g) in groups.iter().enumerate() { - out.push(text_shape("roundRect", Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i, g)); + "matrix" | "titledMatrix" => { + let titled_m = d.kind == "titledMatrix"; + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let (quads, title): (&[Vec], Option<&Vec>) = + if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (&gs, None) }; + for (i, g) in quads.iter().take(4).enumerate() { + let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); + titled(&mut out, "roundRect", x, i + usize::from(titled_m), g); } - } - _ => { - let gap = 24.0; - let w = ((area.w - gap * (n as f64 - 1.0)) / n as f64).max(1.0); - for (i, g) in groups.iter().enumerate() { - let preset = if i + 1 < n { "homePlate" } else { "roundRect" }; - out.push(text_shape(preset, Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), i, g)); + if let Some(t) = title { + let c = s * 0.42; + let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &nodes(t), true); + paint(d, &mut sh, 0, true); + out.push((sh, t.clone())); } } + _ => {} } out } /// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. -fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { +fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; if nodes.is_empty() { return vec![]; @@ -375,11 +653,19 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { Xfrm::new(cx - bw / 2.0, area.y + depth[i] as f64 * row_h + (row_h - bh) / 2.0, bw, bh) }) .collect(); - let mut out: Vec = nodes.iter().enumerate().map(|(i, n)| text_shape("roundRect", boxes[i], depth[i], &[n])).collect(); + let mut out: Vec = nodes + .iter() + .enumerate() + .map(|(i, n)| { + let mut sh = text_shape("roundRect", boxes[i], &[n], true); + paint(d, &mut sh, depth[i], false); + (sh, vec![i]) + }) + .collect(); for i in 0..nodes.len() { if let Some(p) = parent[i] { let (a, b) = (boxes[p], boxes[i]); - out.push(line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y))); + out.push((line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)), vec![])); } } out @@ -457,7 +743,7 @@ fn index(p: &Value, cmd: &str) -> Result { /// Insert a new SmartArt graphic (`insert.smartArt`). pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { - let kind = if KINDS.contains(&kind) { kind } else { "process" }; + let kind = if is_kind(kind) { kind } else { "process" }; if nodes.is_empty() { nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect(); } @@ -542,9 +828,33 @@ 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 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(); + edit_diagram(s, p, CMD, |d| { + if d.style != v { + d.style_xml = None; + } + d.style = v; + Ok(Value::Null) + }) +} + +fn change_colors(s: &mut Session, p: &Value) -> Result { + 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(); + edit_diagram(s, p, CMD, |d| { + if d.colors != v { + d.colors_xml = None; + } + d.colors = v; + Ok(Value::Null) + }) +} + fn change_layout(s: &mut Session, p: &Value) -> Result { const CMD: &str = "smartArt.layout"; - let kind = str_param(p, "kind").filter(|k| KINDS.contains(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); + let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); edit_diagram(s, p, CMD, |d| { if d.kind != kind { // PowerPoint's own layout no longer applies. @@ -595,7 +905,7 @@ mod tests { #[test] fn layouts_map_nodes_to_shapes() { let area = Xfrm::new(0.0, 0.0, 800.0, 400.0); - let d = Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }; + let d = Diagram { kind: "blockList".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }; let v = layout(&d, area); assert_eq!(v.len(), 2); let t = v[0].text.as_ref().map(|b| b.paragraphs.len()); @@ -608,5 +918,36 @@ mod tests { assert!(x.x >= -0.01 && x.x + x.w <= 800.01 && x.y >= -0.01 && x.y + x.h <= 400.01, "{x:?}"); } assert!(layout(&Diagram { kind: "cycle".into(), ..Default::default() }, area).is_empty()); + // Vertical Bullet List: a bar per top node, its sub-points in a box below. + let v = layout_items(&Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }, area); + let maps: Vec> = v.into_iter().map(|(_, m)| m).collect(); + assert_eq!(maps, [vec![0], vec![1, 2], vec![3]]); + } + + #[test] + 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"] { + let d = Diagram { + kind: kind.into(), + nodes: mk(&[0, 1, 0, 0, 1, 0]), + style: style.into(), + colors: "accent2".into(), + ..Default::default() + }; + let items = layout_items(&d, area); + let mut shown: Vec = items.iter().flat_map(|(_, m)| m.clone()).collect(); + shown.sort_unstable(); + let want: Vec = (0..6).collect(); + assert_eq!(shown, want, "{kind}"); + for (sh, _) in &items { + let x = sh.xfrm.unwrap_or_default(); + let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0); + assert!(x.w.is_finite() && x.h.is_finite() && x.w >= 0.0 && x.h >= 0.0, "{kind} {x:?}"); + assert!((49.0..=851.0).contains(&cx) && (39.0..=441.0).contains(&cy), "{kind} {x:?}"); + } + } + } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 179b6a4f..64b0942c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -839,7 +839,7 @@ fn slide_ranges_parse_like_powerpoints_print_box() { #[test] fn smartart_text_pane_edits_regenerate_the_diagram() { let mut s = session(); - let r = s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "\tScope", "Build"]})).unwrap(); + let r = s.execute("insert.smartArt", &json!({"kind": "blockList", "items": ["Plan", "\tScope", "Build"]})).unwrap(); let gid = r["id"].as_u64().unwrap(); let kids = |s: &Session| match s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).map(|x| &x.kind) { Some(deckcraft_model::ShapeKind::Group { children, .. }) => children.clone(), @@ -890,6 +890,8 @@ fn smartart_round_trips_through_pptx_as_a_diagram() { let mut s = session(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); s.execute("insert.smartArt", &json!({"kind": "hierarchy", "items": ["CEO", "\tCTO", "\t\tDev", "\tCFO"]})).unwrap(); + s.execute("smartArt.style", &json!({"style": "intense"})).unwrap(); + s.execute("smartArt.colors", &json!({"colors": "accent3"})).unwrap(); let doc = s.doc().unwrap().doc.as_ref().clone(); let bytes = deckcraft_pptx::export(&doc).unwrap(); let names: Vec = { @@ -904,6 +906,7 @@ fn smartart_round_trips_through_pptx_as_a_diagram() { let g = back.slides[1].shapes.iter().find(|x| x.diagram.is_some()).expect("a SmartArt group"); let d = g.diagram.as_ref().unwrap(); assert_eq!(d.kind, "hierarchy"); + assert_eq!((d.style.as_str(), d.colors.as_str()), ("intense", "accent3"), "SmartArt style and colours come back"); assert!(d.imported); let outline: Vec<(&str, u8)> = d.nodes.iter().map(|n| (n.text.as_str(), n.level)).collect(); assert_eq!(outline, [("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1)]); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 377f94ef..cfd30ab1 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -329,9 +329,16 @@ pub struct Shape { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Diagram { - /// list, process, cycle, hierarchy, pyramid or matrix. + /// The layout: list, blockList, hBulletList, process, chevron, verticalProcess, stepUp, cycle, + /// blockCycle, radial, hierarchy, pyramid, invertedPyramid, matrix or titledMatrix. pub kind: String, pub nodes: Vec, + /// SmartArt Style: `flat` (default), `subtle`, `intense` or `outline`. + #[serde(skip_serializing_if = "String::is_empty")] + pub style: String, + /// Change Colors: `colorful` (default: each shape its own accent) or `accent1`…`accent6`. + #[serde(skip_serializing_if = "String::is_empty")] + pub colors: String, /// 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/dgm.rs b/crates/pptx/src/dgm.rs index a3af2702..993c771f 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -19,48 +19,130 @@ pub const CT_STYLE: &str = "application/vnd.openxmlformats-officedocument.drawin pub const CT_COLORS: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml"; pub const CT_DRAWING: &str = "application/vnd.ms-office.drawingml.diagramDrawing+xml"; pub const RT_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"; -const QS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/simple1"; -const CS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/colors/accent1_2"; +const QS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/"; +const CS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/colors/"; + +/// PowerPoint's quick style for a SmartArt Style. +pub fn qs_id(style: &str) -> String { + let s = match style { + "subtle" => "simple3", + "intense" => "simple5", + "outline" => "simple4", + _ => "simple1", + }; + format!("{QS_BASE}{s}") +} + +/// The SmartArt Style closest to a quick style id. +pub fn style_for(qs: &str) -> &'static str { + match qs.rsplit('/').next().unwrap_or("") { + "simple1" | "" => "", + "simple4" => "outline", + "simple2" | "simple3" => "subtle", + _ => "intense", + } +} + +/// 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"), + _ => format!("{CS_BASE}colorful1"), + } +} + +/// 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}"), + _ => String::new(), + } +} /// Most nodes read from one diagram. const MAX_NODES: usize = 2000; /// PowerPoint's built-in layout DeckCraft's layout `kind` is written as. pub fn layout_id(kind: &str) -> &'static str { match kind { + "blockList" => "urn:microsoft.com/office/officeart/2005/8/layout/default", + "hBulletList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList1", "process" => "urn:microsoft.com/office/officeart/2005/8/layout/process1", + "chevron" => "urn:microsoft.com/office/officeart/2005/8/layout/chevron1", + "verticalProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process2", + "stepUp" => "urn:microsoft.com/office/officeart/2009/3/layout/StepUpProcess", "cycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle2", + "blockCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle5", + "radial" => "urn:microsoft.com/office/officeart/2005/8/layout/radial1", "hierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy1", "pyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid1", + "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", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } /// The DeckCraft layout closest to a PowerPoint layout (by its id and category). pub fn kind_for(layout_id: &str, category: &str) -> &'static str { - let s = format!("{layout_id} {category}").to_ascii_lowercase(); + let last = layout_id.rsplit('/').next().unwrap_or("").to_ascii_lowercase(); + // Exact ids first: the layouts DeckCraft writes, and their closest siblings. + for k in [ + "blockList", + "hBulletList", + "process", + "chevron", + "verticalProcess", + "stepUp", + "cycle", + "blockCycle", + "radial", + "hierarchy", + "pyramid", + "invertedPyramid", + "matrix", + "titledMatrix", + "list", + ] { + if layout_id == layout_id_of(k) { + return k; + } + } + let s = format!("{last} {}", category.to_ascii_lowercase()); if s.contains("hierarchy") || s.contains("orgchart") { "hierarchy" - } else if s.contains("cycle") || s.contains("radial") { + } else if s.contains("radial") { + "radial" + } else if s.contains("cycle") { "cycle" } else if s.contains("pyramid") { "pyramid" } else if s.contains("matrix") { "matrix" - } else if s.contains("process") || s.contains("chevron") || s.contains("arrow") { + } else if s.contains("chevron") { + "chevron" + } else if s.contains("process") || s.contains("arrow") { "process" + } else if last == "default" { + "blockList" + } else if last.starts_with("hlist") { + "hBulletList" } else { "list" } } +fn layout_id_of(kind: &str) -> &'static str { + layout_id(kind) +} + fn category(kind: &str) -> &'static str { match kind { - "process" => "process", - "cycle" => "cycle", + "process" | "chevron" | "verticalProcess" | "stepUp" => "process", + "cycle" | "blockCycle" | "radial" => "cycle", "hierarchy" => "hierarchy", - "pyramid" => "pyramid", - "matrix" => "matrix", + "pyramid" | "invertedPyramid" => "pyramid", + "matrix" | "titledMatrix" => "matrix", _ => "list", } } @@ -101,10 +183,10 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { A::new() .a("loTypeId", &lo) .a("loCatId", category(&d.kind)) - .a("qsTypeId", QS_ID) + .a("qsTypeId", qs_id(&d.style)) .a("qsCatId", "simple") - .a("csTypeId", CS_ID) - .a("csCatId", "accent1") + .a("csTypeId", cs_id(&d.colors)) + .a("csCatId", if d.colors.starts_with("accent") { d.colors.as_str() } else { "colorful" }) .a("phldr", "1"), ); w.empty0("dgm:spPr"); @@ -230,10 +312,20 @@ 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" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), - "cycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), - "pyramid" => ("pyra", vec![("linDir", "fromT"), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid"), - "matrix" => ("snake", vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], "roundRect"), + "process" | "stepUp" => ("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"), + "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), + "pyramid" | "invertedPyramid" => { + ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") + } + "matrix" | "titledMatrix" | "blockList" => ( + "snake", + vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], + if kind == "blockList" { "rect" } else { "roundRect" }, + ), + "hBulletList" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "t")], "roundRect"), _ => ("lin", vec![("linDir", "fromT"), ("vertAlign", "mid")], "roundRect"), } } @@ -348,7 +440,7 @@ pub fn layout_xml(kind: &str) -> Vec { w.empty0("dgm:ruleLst"); w.open("dgm:forEach", A::new().a("name", "nodes").a("axis", "ch").a("ptType", "node")); node_layout(&mut w, shape, true); - if kind != "cycle" { + if !matches!(kind, "cycle" | "blockCycle" | "radial") { space(&mut w); } w.close("dgm:forEach"); @@ -359,9 +451,9 @@ pub fn layout_xml(kind: &str) -> Vec { } /// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. -pub fn style_xml() -> Vec { +pub fn style_xml(style: &str) -> Vec { let mut w = W::new(); - w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", QS_ID)); + w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", qs_id(style))); w.empty("dgm:title", A::new().a("val", "")); w.empty("dgm:desc", A::new().a("val", "")); w.open0("dgm:catLst"); @@ -394,20 +486,30 @@ pub fn style_xml() -> Vec { } /// `ppt/diagrams/colorsN.xml`: accent 1 fills with light text. -pub fn colors_xml() -> Vec { +pub fn colors_xml(colors: &str) -> Vec { let mut w = W::new(); - w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", CS_ID)); + 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"); w.open0("dgm:catLst"); - w.empty("dgm:cat", A::new().a("type", "accent1").a("pri", "11200")); + w.empty("dgm:cat", A::new().a("type", if single { colors } else { "colorful" }).a("pri", "11200")); w.close("dgm:catLst"); w.open("dgm:styleLbl", A::new().a("name", "node1")); - for (tag, clr) in [("dgm:fillClrLst", "accent1"), ("dgm:linClrLst", "lt1")] { - w.open(tag, A::new().a("meth", "repeat")); - w.empty("a:schemeClr", A::new().a("val", clr)); - w.close(tag); + if single { + w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); + w.empty("a:schemeClr", A::new().a("val", colors)); + 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}"))); + } + w.close("dgm:fillClrLst"); } + w.open("dgm:linClrLst", A::new().a("meth", "repeat")); + w.empty("a:schemeClr", A::new().a("val", "lt1")); + w.close("dgm:linClrLst"); w.empty0("dgm:effectClrLst"); w.empty0("dgm:txLinClrLst"); w.empty0("dgm:txFillClrLst"); @@ -434,7 +536,7 @@ 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(), colors_xml()] { + for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml("subtle"), colors_xml(""), colors_xml("accent3")] { assert!(crate::xml::parse(&x).is_ok()); } } @@ -445,7 +547,11 @@ mod tests { let doc = crate::xml::parse(xml).unwrap(); assert_eq!(outline(&doc.root), nodes(&[("A", 0), ("", 1)])); assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "hierarchy"); - assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "process"); - assert_eq!(kind_for("urn:x/layout/default", "list"), "list"); + assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "chevron"); + assert_eq!(kind_for("urn:x/layout/hProcess9", "process"), "process"); + assert_eq!(kind_for("urn:x/layout/default", "list"), "blockList"); + assert_eq!(colors_for(&cs_id("accent4")), "accent4"); + assert_eq!(colors_for(&cs_id("")), ""); + assert_eq!(style_for(&qs_id("outline")), "outline"); } } diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c33dcc03..34d7bbc1 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -464,9 +464,14 @@ fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option (Some(id.0), d.nodes.len(), d.kind.clone()), - None => (None, 0, String::new()), + let (gid, n, kind, style, colors) = match &sel { + Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone(), d.style.clone(), d.colors.clone()), + None => (None, 0, String::new(), String::new(), String::new()), }; let at = row.min(n.saturating_sub(1)); group(ui, |ui| { @@ -2356,16 +2356,35 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { }); }); group(ui, |ui| { - let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", gid.is_some(), false, true); + let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_engine::cmd::smartart::KINDS { - let label = format!("{}{}", k[..1].to_uppercase(), &k[1..]); + for (k, label) in deckcraft_engine::cmd::smartart::LAYOUTS { if ui.selectable_label(kind == k, label).clicked() { run(app, "smartArt.layout", json!({"id": gid, "kind": k})); } } }); }); + group(ui, |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 { + 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})); + } + } + }); + 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 { + 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})); + } + } + }); + }); arrange_group(app, ui); } diff --git a/docs/gaps.md b/docs/gaps.md index 3c7d5b31..28fd297a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -155,8 +155,10 @@ Detail in [file-format-parity.md](file-format-parity.md). Shape, Promote, Demote, Remove and layout switching (`crates/engine/src/cmd/smartart.rs`). On `.pptx` the outline is written as a real diagram (data, layout, quick style, colours and the cached drawing; `crates/pptx/src/dgm.rs`), and PowerPoint diagrams read their outline from the - data model, so they get the Text Pane too. Still missing: DeckCraft's own layout definitions are - simple (not yet checked in PowerPoint itself); there are six layouts rather than ~200; editing an + data model, so they get the Text Pane too. 15 layouts (bullet and block lists, five process, two cycle and + a radial, hierarchy, two pyramids, two matrices), four SmartArt Styles and Change Colors + (colourful or one accent). Still missing: DeckCraft's own layout definitions are simple (not yet + checked in PowerPoint itself); PowerPoint has ~200 layouts, picture layouts and 3-D styles; editing an imported diagram's outline redraws it with DeckCraft's layout; a diagram with no cached drawing isn't drawn. - **Impact:** a PowerPoint diagram edited in DeckCraft changes look. From f894bb1d172f6165ba6061cb8df1b35a6c97fcab Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:47:15 +0300 Subject: [PATCH 2/2] SmartArt: keep pyramid text inside its band Inset each pyramid band's text past the band's mid width so bullets no longer hang over the sloped sides; capped so the tip band still has room. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 331dabec..c4905a2b 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -563,6 +563,12 @@ fn layout_items(d: &Diagram, area: Xfrm) -> Vec { sh.geom = Geom::Custom { 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); + if let Some(b) = &mut sh.text { + b.body.inset_l = Some(inset); + b.body.inset_r = Some(inset); + } paint(d, &mut sh, i, false); out.push((sh, g.clone())); }