diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index aa76b6e7..cd3534be 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); 28] = [ +pub const LAYOUTS: [(&str, &str); 35] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -52,6 +52,13 @@ pub const LAYOUTS: [(&str, &str); 28] = [ ("gridMatrix", "Grid Matrix"), ("gear", "Gear"), ("funnel", "Funnel"), + ("segmentedCycle", "Segmented Cycle"), + ("multidirectionalCycle", "Multidirectional Cycle"), + ("orgChart", "Organization Chart"), + ("halfCircleOrgChart", "Half Circle Organization Chart"), + ("cycleMatrix", "Cycle Matrix"), + ("segmentedPyramid", "Segmented Pyramid"), + ("pie", "Basic Pie"), ]; /// SmartArt Styles. pub const STYLES: [(&str, &str); 9] = [ @@ -115,7 +122,7 @@ pub fn specs() -> Vec { "Add Shape", ["SmartArt Design", "Create Graphic"], None, - "{id?, index?, position?: after|before|below|above, text?} → {index} (default: after the last node)", + "{id?, index?, position?: after|before|below|above|assistant, text?} → {index} (default: after the last node)", has_selection, add_node ), @@ -135,7 +142,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|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie}", has_selection, change_layout ), @@ -176,7 +183,7 @@ pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec usize { /// new node's index. pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { let Some(i) = index.filter(|i| *i < nodes.len()) else { - nodes.push(DiagramNode { text: text.into(), level: 0, picture: None }); + nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false }); return nodes.len() - 1; }; let lv = nodes[i].level; @@ -222,9 +229,10 @@ pub fn add(nodes: &mut Vec, index: Option, position: &str, t } (i, lv) } + "assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)), _ => (subtree_end(nodes, i), lv), }; - nodes.insert(at, DiagramNode { text: text.into(), level, picture: None }); + nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant" }); at } @@ -585,6 +593,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { match d.kind.as_str() { "hierarchy" => return hierarchy(d, area, false), "horizontalHierarchy" => return hierarchy(d, area, true), + "orgChart" | "halfCircleOrgChart" => return org_chart(d, area, d.kind == "halfCircleOrgChart"), "list" | "hBulletList" => { // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. let vertical = d.kind == "list"; @@ -842,6 +851,145 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { out.push((sh, g.clone())); } } + "segmentedCycle" | "pie" => { + // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. + // The text sits in a clear box over each segment's middle. + let ring = d.kind == "segmentedCycle"; + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let gap = if ring { 2.0 } else { 0.0 }; + for (i, g) in gs.iter().enumerate() { + // Angles clockwise from 3 o'clock, starting at the top. + let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; + let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; + let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); + let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); + // blockArc runs from its first angle to its second clockwise; so does pie. + sh.geom = Geom::Preset { + name: if ring { "blockArc" } else { "pie" }.into(), + adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, + }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let m = ((a0 + a1) / 2.0).to_radians(); + let rr = if ring { s * 0.4375 } else { s * 0.3 }; + let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); + let th = s * 0.16; + let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + "multidirectionalCycle" => { + // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. + 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.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.5).max(8.0); + let mut sh = deco( + "leftRightArrow", + Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), + (i as f64 + 0.5) / nf * 360.0, + ); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + } + } + } + "cycleMatrix" => { + // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each + // quarter's sub-points in a box at its corner. + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let c = s * 0.56; + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let b = s * 0.4; + let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); + if g.len() > 1 { + let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(body) = &mut sh.text { + // Text in the box's outer part, clear of the circle. + body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); + body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); + body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + out.push((sh, g[1..].to_vec())); + } + } + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; + let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); + sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let q = c * 0.3; + let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g[..1].to_vec())); + } + } + "segmentedPyramid" => { + // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node, + // every other one upside down. + let rows = (nf.sqrt().ceil() as usize).max(1); + let s = area.h.min(area.w / 1.15); + let pw = s * 1.15; + let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); + let (tw, th) = (pw / rows as f64, s / rows as f64); + let mut k = 0; + 'rows: for r in 0..rows { + let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; + for c in 0..(2 * r + 1) { + let Some(g) = gs.get(k) else { break 'rows }; + let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); + let mut sh = text_shape("rect", x, &nodes(g), true); + // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. + let path = if c % 2 == 1 { + format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) + } else { + format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w: tw, + h: th, + d: path, + fill: deckcraft_geom::preset::FillMode::Norm, + stroke: true, + }], + }; + if let Some(b) = &mut sh.text { + // The text in the triangle's wide half. + b.body.inset_l = Some(tw * 0.22); + b.body.inset_r = Some(tw * 0.22); + if c % 2 == 1 { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + b.body.inset_b = Some(th * 0.35); + } else { + b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); + b.body.inset_t = Some(th * 0.35); + } + } + paint(d, &mut sh, k, false); + out.push((sh, g.clone())); + k += 1; + } + } + } "gridMatrix" => { // Grid Matrix: four quadrants on crossed axes. let s = area.h.min(area.w); @@ -957,7 +1105,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.27; + let p = dc * 0.36; 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)); @@ -1003,6 +1151,135 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { out } +/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on +/// a row of their own between the parent and its other children. `half_circle` draws each node as +/// its text in an open half ring (Half Circle Organization Chart). +fn org_chart(d: &Diagram, area: Xfrm, half_circle: bool) -> Vec { + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let asst = |i: usize| nodes[i].assistant && parent[i].is_some(); + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + // Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent. + let mut slot = vec![0usize; nodes.len()]; + for i in 0..nodes.len() { + slot[i] = match parent[i] { + None => 0, + Some(p) if asst(i) => slot[p] + 1, + Some(p) => slot[p] + 2, + }; + } + let mut used: Vec = slot.clone(); + used.sort_unstable(); + used.dedup(); + let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0); + let rows = used.len().max(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).min(area.w * 0.3), row_h * 0.62); + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + if asst(i) { + continue; + } + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let mut side = vec![0usize; nodes.len()]; + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0; + match parent[i] { + Some(p) if asst(i) => { + // Assistants alternate right and left of the parent's trunk. + let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count(); + side[i] = k; + let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w; + let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1; + let x = if k % 2 == 0 { pc + off } else { pc - off - bw }; + Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh) + } + _ => { + let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + Xfrm::new(cx - bw / 2.0, y, bw, bh) + } + } + }) + .collect(); + let mut out: Vec = vec![]; + for i in 0..nodes.len() { + let Some(p) = parent[i] else { continue }; + let (a, b) = (boxes[p], boxes[i]); + let pc = a.x + a.w / 2.0; + if asst(i) { + let my = b.y + b.h / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![])); + let ex = if b.x > pc { b.x } else { b.x + b.w }; + out.push((line_shape((pc, my), (ex, my)), vec![])); + } else { + let mid = b.y - (row_h - bh) / 2.0; + let cc = b.x + b.w / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![])); + out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![])); + out.push((line_shape((cc, mid), (cc, b.y)), vec![])); + } + } + let _ = side; + for (i, n) in nodes.iter().enumerate() { + let depth = slot[i] / 2; + if half_circle { + let s = boxes[i].h.min(boxes[i].w); + let x = boxes[i]; + let mut arc = deco("blockArc", Xfrm::new(x.x + (x.w - s) / 2.0, x.y, s, s), 0.0); + arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![180.0 * 60_000.0, 0.0, 8_000.0] }; + paint(d, &mut arc, depth, false); + out.push((arc, vec![])); + let mut t = text_shape("rect", x, &[n], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, vec![i])); + } else { + let mut sh = text_shape("rect", boxes[i], &[n], true); + paint(d, &mut sh, depth, false); + out.push((sh, vec![i])); + } + } + out +} + /// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec { let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; @@ -1167,7 +1444,7 @@ fn index(p: &Value, cmd: &str) -> Result { pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { let kind = if is_kind(kind) { kind } else { "process" }; if nodes.is_empty() { - nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None }).collect(); + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false }).collect(); } normalize(&mut nodes); let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; @@ -1316,7 +1593,7 @@ mod tests { n.iter().map(|x| x.level).collect() } fn mk(levels: &[u8]) -> Vec { - levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None }).collect() + levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None, assistant: false }).collect() } #[test] @@ -1413,4 +1690,17 @@ mod tests { assert_eq!(accent(&d("accent3Grad"), 4), SchemeSlot::Accent3); assert!(shade(&d("accent3Grad"), 1).is_some() && shade(&d("accent3"), 1).is_none()); } + + #[test] + fn assistants_sit_beside_their_parent() { + let mut v = mk(&[0, 1, 1]); + let a = add(&mut v, Some(0), "assistant", "Aide"); + assert_eq!((a, v[1].level, v[1].assistant), (1, 1, true)); + let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() }; + let items = layout_items(&d, Xfrm::new(0.0, 0.0, 600.0, 400.0)); + let boxed = |k: usize| items.iter().find(|(_, m)| m == &vec![k]).and_then(|(s, _)| s.xfrm).unwrap_or_default(); + let (ceo, aide, kid) = (boxed(0), boxed(1), boxed(2)); + assert!(aide.y > ceo.y && aide.y < kid.y, "the assistant's row is between its parent and the children"); + assert!(aide.x > ceo.x, "beside the parent's trunk"); + } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 0d1afb9c..2d836397 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -365,6 +365,9 @@ pub struct DiagramNode { /// The picture shown in the node's picture placeholder (picture layouts). #[serde(skip_serializing_if = "Option::is_none")] pub picture: Option, + /// An assistant (organisation charts): drawn beside its parent, off the row of its siblings. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub assistant: bool, } impl Default for Shape { diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index eff93b10..90d1bedc 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -122,6 +122,13 @@ pub fn layout_id(kind: &str) -> &'static str { "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", + "segmentedCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle8", + "multidirectionalCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle7", + "orgChart" => "urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", + "halfCircleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/HalfCircleOrganizationChart", + "cycleMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle4", + "segmentedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid4", + "pie" => "urn:microsoft.com/office/officeart/2005/8/layout/chart3", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } @@ -163,6 +170,13 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "gridMatrix", "gear", "funnel", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "cycleMatrix", + "segmentedPyramid", + "pie", "list", ] { if layout_id == layout_id_of(k) { @@ -180,7 +194,9 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "target" } else if s.contains("timeline") { "timeline" - } else if s.contains("hierarchy") || s.contains("orgchart") { + } else if s.contains("orgchart") || s.contains("organizationchart") || s.contains("organizationalchart") { + "orgChart" + } else if s.contains("hierarchy") { "hierarchy" } else if s.contains("radial") { "radial" @@ -211,9 +227,11 @@ fn category(kind: &str) -> &'static str { match kind { "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" | "gear" | "funnel" => "process", "venn" | "target" => "relationship", - "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" => "cycle", - "hierarchy" | "horizontalHierarchy" => "hierarchy", - "pyramid" | "invertedPyramid" => "pyramid", + "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "cycleMatrix" | "pie" => { + "cycle" + } + "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" => "hierarchy", + "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", "matrix" | "titledMatrix" | "gridMatrix" => "matrix", _ => "list", } @@ -267,7 +285,11 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, Strin // Node k: model id 1 + 3k; its link's parent and sibling transitions 2 + 3k and 3 + 3k. let node = |k: usize| guid(seed, 1 + 3 * k); for (k, n) in d.nodes.iter().enumerate() { - w.open("dgm:pt", A::new().a("modelId", node(k))); + let mut a = A::new().a("modelId", node(k)); + if n.assistant { + a = a.a("type", "asst"); + } + w.open("dgm:pt", a); w.empty("dgm:prSet", A::new().a("phldrT", "[Text]")); w.empty0("dgm:spPr"); text(&mut w, &n.text); @@ -367,14 +389,18 @@ pub fn node_pictures(data: &El) -> Vec<(usize, String)> { fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { let Some(pts) = data.child("ptLst") else { return vec![] }; let mut texts: HashMap<&str, String> = HashMap::new(); + let mut assistants: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut doc: Option<&str> = None; for pt in pts.children_named("pt").take(MAX_NODES * 4) { let Some(id) = pt.attr("modelId") else { continue }; match pt.attr("type").unwrap_or("node") { "doc" => doc = doc.or(Some(id)), - "node" => { + ty @ ("node" | "asst") => { let t = pt.child("t").map(paragraphs).unwrap_or_default(); texts.insert(id, t); + if ty == "asst" { + assistants.insert(id); + } } _ => {} } @@ -403,7 +429,10 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { if out.len() >= MAX_NODES || !seen.insert(id) { continue; } - out.push((id.to_string(), DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None })); + out.push(( + id.to_string(), + DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None, assistant: assistants.contains(id) }, + )); if let Some(v) = kids.get(id) { stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); } @@ -432,11 +461,19 @@ 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" | "textCycle" | "continuousCycle" | "gear" => { - ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse") - } + "cycle" + | "radial" + | "venn" + | "target" + | "textCycle" + | "continuousCycle" + | "gear" + | "segmentedCycle" + | "multidirectionalCycle" + | "cycleMatrix" + | "pie" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), - "pyramid" | "invertedPyramid" => { + "pyramid" | "invertedPyramid" | "segmentedPyramid" => { ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") } "matrix" | "titledMatrix" | "gridMatrix" | "blockList" => ( @@ -502,7 +539,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 matches!(kind, "hierarchy" | "horizontalHierarchy") { + if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart") { // 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", "")); @@ -671,7 +708,7 @@ mod tests { use super::*; fn nodes(v: &[(&str, u8)]) -> Vec { - v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None }).collect() + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false }).collect() } #[test] @@ -698,6 +735,17 @@ mod tests { } } + #[test] + fn assistants_survive_the_data_model() { + let mut v = nodes(&[("CEO", 0), ("Aide", 1), ("CTO", 1)]); + v[1].assistant = true; + let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() }; + let xml = data_xml(&d, 5, "rId1", &[]); + assert!(String::from_utf8(xml.clone()).unwrap().contains("type=\"asst\"")); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(outline(&doc.root), d.nodes); + } + #[test] fn node_pictures_come_from_presentation_points() { let d = Diagram { kind: "pictureList".into(), nodes: nodes(&[("A", 0), ("a", 1), ("B", 0)]), ..Default::default() }; @@ -734,6 +782,13 @@ mod tests { "gridMatrix", "gear", "funnel", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "cycleMatrix", + "segmentedPyramid", + "pie", ] { assert_eq!(kind_for(layout_id(k), ""), k); } @@ -744,7 +799,8 @@ mod tests { let xml = br#"A"#; 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:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "orgChart"); + assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "orgChart"); 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"); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 66706d18..f996a8dd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2338,9 +2338,13 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { rows(ui, |ui| { let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); egui::Popup::menu(&r).show(|ui| { - for (label, pos) in - [("Add Shape After", "after"), ("Add Shape Before", "before"), ("Add Shape Above", "above"), ("Add Shape Below", "below")] - { + for (label, pos) in [ + ("Add Shape After", "after"), + ("Add Shape Before", "before"), + ("Add Shape Above", "above"), + ("Add Shape Below", "below"), + ("Add Assistant", "assistant"), + ] { if ui.button(label).clicked() { let index = if n == 0 { Value::Null } else { json!(at) }; run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos}));