diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index cfca7620..c4905a2b 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,211 @@ 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 }], + }; + // 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())); } } - "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 +659,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 +749,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 +834,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 +911,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 +924,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 898be261..2d16556f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1683,7 +1683,7 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() { #[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(), @@ -1734,6 +1734,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 = { @@ -1748,6 +1750,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 9f7fca8e..651ab459 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -336,9 +336,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 9d27a22e..6721eca9 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -646,9 +646,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| { @@ -2617,16 +2617,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 274378df..06f6bfcc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -216,8 +216,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.