From 00dd069678296a18c88efc3b5b0ee1492ac6c8e6 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:07:51 +0300 Subject: [PATCH] SmartArt: six more layouts, clear Venn labels, read back older Picture Accent Process files Text Cycle, Continuous Cycle, Horizontal Hierarchy, Grid Matrix, Gear and Funnel. Venn labels sit further out, clear of the overlaps. Files where Picture Accent Process was written as pList2 (with DeckCraft's process-category layout part) import as Picture Accent Process again. Round shapes' text shrinks to the inscribed width. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 149 +++++++++++++++++++++++++++--- crates/pptx/src/dgm.rs | 58 ++++++++++-- 2 files changed, 185 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 6a533015..aa76b6e7 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); 22] = [ +pub const LAYOUTS: [(&str, &str); 28] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -46,6 +46,12 @@ pub const LAYOUTS: [(&str, &str); 22] = [ ("timeline", "Basic Timeline"), ("venn", "Basic Venn"), ("target", "Basic Target"), + ("textCycle", "Text Cycle"), + ("continuousCycle", "Continuous Cycle"), + ("horizontalHierarchy", "Horizontal Hierarchy"), + ("gridMatrix", "Grid Matrix"), + ("gear", "Gear"), + ("funnel", "Funnel"), ]; /// SmartArt Styles. pub const STYLES: [(&str, &str); 9] = [ @@ -129,7 +135,7 @@ 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|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel}", has_selection, change_layout ), @@ -508,8 +514,13 @@ const MIN_SCALE: f64 = 0.25; /// body's shrink-on-overflow autofit. fn fit_words(sh: &mut Shape) { let Some(w) = sh.xfrm.map(|x| x.w) else { return }; + // Round shapes keep their text in the inscribed rectangle (about 71% of the width). + let text_w = match sh.geom.preset_name() { + Some("ellipse" | "gear6" | "gear9") => w * 0.71, + _ => w, + }; let Some(body) = &mut sh.text else { return }; - let avail = w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); + let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); let mut scale: f64 = 1.0; for p in &body.paragraphs { let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); @@ -572,7 +583,8 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { out.push((sh, vec![])); }; match d.kind.as_str() { - "hierarchy" => return hierarchy(d, area), + "hierarchy" => return hierarchy(d, area, false), + "horizontalHierarchy" => return hierarchy(d, area, true), "list" | "hBulletList" => { // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. let vertical = d.kind == "list"; @@ -785,6 +797,108 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } } } + "textCycle" | "continuousCycle" => { + // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on + // a ring. + let text_only = d.kind == "textCycle"; + 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.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + if !text_only { + let ring = r * 2.0 + dd * 0.3; + let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); + sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = (dd * 1.35, dd * 0.7); + let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &nodes(g), true); + paint(d, &mut sh, i, false); + if text_only { + sh.fill = Some(Fill::None); + sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + sh.effects = None; + let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); + if let Some(b) = &mut sh.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.3).max(6.0); + arrow( + &mut out, + Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), + (i as f64 + 0.5) / nf * 360.0, + i, + ); + } + } + out.push((sh, g.clone())); + } + } + "gridMatrix" => { + // Grid Matrix: four quadrants on crossed axes. + 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 t = s * 0.06; + for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] + { + let mut sh = deco(preset, x, 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + let q = s / 2.0 - t * 1.2; + for (i, g) in gs.iter().take(4).enumerate() { + let (c, r) = ((i % 2) as f64, (i / 2) as f64); + let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); + titled(&mut out, "rect", x, i, g); + } + } + "gear" => { + // Gear: up to three interlocking gears, the first the biggest. + let s = area.h.min(area.w * 0.62); + let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); + let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; + for (i, g) in gs.iter().take(3).enumerate() { + let (fx, fy, fs, preset) = spots[i]; + let z = s * fs; + titled(&mut out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); + } + } + "funnel" => { + // Funnel: the ideas poured in at the top (up to three), the last one coming out below. + let s = area.h.min(area.w); + let (cx, y0) = (area.x + area.w / 2.0, area.y); + let fw = s * 0.8; + let fh = s * 0.62; + let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); + paint(d, &mut f, 0, true); + out.push((f, vec![])); + let (inside, last) = match gs.split_last() { + Some((l, r)) if !r.is_empty() => (r, Some(l)), + _ => (&gs[..], None), + }; + let k = inside.len().min(3); + let dc = fw * 0.27; + let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; + for (i, g) in inside.iter().take(3).enumerate() { + let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; + titled(&mut out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); + } + if let Some(g) = last { + let a = s * 0.12; + let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); + paint(d, &mut ar, k, true); + out.push((ar, vec![])); + titled(&mut out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); + } + } "pictureHList" => { // Horizontal Picture List: a column per node, a big picture over its text. let w = area.w / nf; @@ -843,7 +957,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } if let Some(b) = &mut sh.text { // Text towards the circle's outer side, clear of the overlap. - let p = dc * 0.18; + let p = dc * 0.27; let (dx, dy) = (a.cos() * p, a.sin() * p); b.body.inset_l = Some((7.2 + dx).max(2.0)); b.body.inset_r = Some((7.2 - dx).max(2.0)); @@ -890,7 +1004,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } /// 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, horizontal: bool) -> Vec { let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; if nodes.is_empty() { return vec![]; @@ -922,9 +1036,11 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { .collect(); let rows = depth.iter().max().copied().unwrap_or(0) + 1; let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); - let cell_w = area.w / total as f64; - let row_h = area.h / rows as f64; - let (bw, bh) = (cell_w * 0.85, row_h * 0.6); + // Horizontal Hierarchy: the same tree turned a quarter, levels from left to right. + let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) }; + let cell_w = span / total as f64; + let row_h = across / rows as f64; + let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) }; // Left edge (in leaves) of each node's span. let mut left = vec![0usize; nodes.len()]; let mut next_root = 0; @@ -949,8 +1065,9 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { } let boxes: Vec = (0..nodes.len()) .map(|i| { - let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; - Xfrm::new(cx - bw / 2.0, area.y + depth[i] as f64 * row_h + (row_h - bh) / 2.0, bw, bh) + let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0; + if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) } }) .collect(); let mut out: Vec = nodes @@ -965,7 +1082,12 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec { 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)), vec![])); + let (p, q) = if horizontal { + ((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0)) + } else { + ((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)) + }; + out.push((line_shape(p, q), vec![])); } } out @@ -1259,7 +1381,8 @@ mod tests { 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(); + // Gear draws three gears, as PowerPoint does: the fourth group isn't shown. + let want: Vec = (0..if kind == "gear" { 5 } else { 6 }).collect(); assert_eq!(shown, want, "{kind}"); for (sh, _) in &items { let x = sh.xfrm.unwrap_or_default(); diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index be5a280e..eff93b10 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -116,6 +116,12 @@ pub fn layout_id(kind: &str) -> &'static str { "timeline" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess11", "venn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn1", "target" => "urn:microsoft.com/office/officeart/2005/8/layout/target1", + "textCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle1", + "continuousCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle3", + "horizontalHierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy2", + "gridMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix2", + "gear" => "urn:microsoft.com/office/officeart/2005/8/layout/gear1", + "funnel" => "urn:microsoft.com/office/officeart/2005/8/layout/funnel1", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } @@ -123,6 +129,11 @@ pub fn layout_id(kind: &str) -> &'static str { /// 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 last = layout_id.rsplit('/').next().unwrap_or("").to_ascii_lowercase(); + // DeckCraft before Picture Accent Process got its own id wrote it as pList2 (Horizontal + // Picture List) with its own layout part in the process category; PowerPoint's pList2 is a list. + if last == "plist2" && category.eq_ignore_ascii_case("process") { + return "pictureProcess"; + } // Exact ids first: the layouts DeckCraft writes, and their closest siblings. for k in [ "blockList", @@ -146,6 +157,12 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "timeline", "venn", "target", + "textCycle", + "continuousCycle", + "horizontalHierarchy", + "gridMatrix", + "gear", + "funnel", "list", ] { if layout_id == layout_id_of(k) { @@ -153,7 +170,11 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { } } let s = format!("{last} {}", category.to_ascii_lowercase()); - if s.contains("venn") { + if s.contains("gear") { + "gear" + } else if s.contains("funnel") { + "funnel" + } else if s.contains("venn") { "venn" } else if s.contains("target") { "target" @@ -188,12 +209,12 @@ fn layout_id_of(kind: &str) -> &'static str { fn category(kind: &str) -> &'static str { match kind { - "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" => "process", + "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" | "gear" | "funnel" => "process", "venn" | "target" => "relationship", - "cycle" | "blockCycle" | "radial" => "cycle", - "hierarchy" => "hierarchy", + "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" => "cycle", + "hierarchy" | "horizontalHierarchy" => "hierarchy", "pyramid" | "invertedPyramid" => "pyramid", - "matrix" | "titledMatrix" => "matrix", + "matrix" | "titledMatrix" | "gridMatrix" => "matrix", _ => "list", } } @@ -411,12 +432,14 @@ fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static } "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), - "cycle" | "radial" | "venn" | "target" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "cycle" | "radial" | "venn" | "target" | "textCycle" | "continuousCycle" | "gear" => { + ("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" => ( + "matrix" | "titledMatrix" | "gridMatrix" | "blockList" => ( "snake", vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], if kind == "blockList" { "rect" } else { "roundRect" }, @@ -479,7 +502,7 @@ pub fn layout_xml(kind: &str) -> Vec { w.empty0("dgm:dir"); w.empty("dgm:resizeHandles", A::new().a("val", "exact")); w.close("dgm:varLst"); - if kind == "hierarchy" { + if matches!(kind, "hierarchy" | "horizontalHierarchy") { // Each node over the row of its children, recursively. w.empty("dgm:alg", A::new().a("type", "hierChild")); w.open("dgm:shape", A::new().a("r:blip", "")); @@ -694,7 +717,24 @@ mod tests { assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT")); assert!(layout_id("pictureProcess").ends_with("/hProcess10"), "Picture Accent Process"); assert!(layout_id("pictureHList").ends_with("/pList2"), "Horizontal Picture List"); - for k in ["pictureList", "pictureCaption", "pictureProcess", "pictureHList", "timeline", "venn", "target"] { + // Picture Accent Process as an earlier DeckCraft wrote it (pList2, process category). + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/pList2", "process"), "pictureProcess"); + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/pList2", "list"), "pictureHList"); + for k in [ + "pictureList", + "pictureCaption", + "pictureProcess", + "pictureHList", + "timeline", + "venn", + "target", + "textCycle", + "continuousCycle", + "horizontalHierarchy", + "gridMatrix", + "gear", + "funnel", + ] { assert_eq!(kind_for(layout_id(k), ""), k); } }