SmartArt: Name and Title org chart, closer Half Circle, ten more layouts, convert to and from text and shapes

Name and Title Organization Chart with a title box per node (DiagramNode.title, smartArt.setNodes title; written as the title box's presentation point text and read back). Half Circle Org Chart draws two half rings per node. New layouts: Vertical Box List, Trapezoid List, Table List, Accent Process, Continuous Block Process, Basic Bending Process, Linear Venn, Stacked Venn, Nested Target. smartArt.fromText (Convert to SmartArt), smartArt.toText and smartArt.toShapes, with a Convert menu in SmartArt Design.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 02:26:58 +03:00
co-authored by Cursor
parent b4c03f0a75
commit 7764c27cc4
5 changed files with 505 additions and 23 deletions
+383 -16
View File
@@ -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); 35] = [
pub const LAYOUTS: [(&str, &str); 45] = [
("list", "Vertical Bullet List"),
("blockList", "Basic Block List"),
("hBulletList", "Horizontal Bullet List"),
@@ -59,6 +59,16 @@ pub const LAYOUTS: [(&str, &str); 35] = [
("cycleMatrix", "Cycle Matrix"),
("segmentedPyramid", "Segmented Pyramid"),
("pie", "Basic Pie"),
("nameTitleOrgChart", "Name and Title Organization Chart"),
("verticalBoxList", "Vertical Box List"),
("trapezoidList", "Trapezoid List"),
("tableList", "Table List"),
("accentProcess", "Accent Process"),
("continuousBlockProcess", "Continuous Block Process"),
("bendingProcess", "Basic Bending Process"),
("linearVenn", "Linear Venn"),
("stackedVenn", "Stacked Venn"),
("nestedTarget", "Nested Target"),
];
/// SmartArt Styles.
pub const STYLES: [(&str, &str); 9] = [
@@ -113,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit SmartArt Text",
["SmartArt Design", "Create Graphic"],
None,
"{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?} (index + text edits one node)",
"{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?, title?} (index + text edits one node; title: its Name and Title box)",
has_selection,
set_nodes
),
@@ -142,10 +152,37 @@ pub fn specs() -> Vec<CommandSpec> {
"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|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie}",
"{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|nameTitleOrgChart|verticalBoxList|trapezoidList|tableList|accentProcess|continuousBlockProcess|bendingProcess|linearVenn|stackedVenn|nestedTarget}",
has_selection,
change_layout
),
cmd!(
"smartArt.fromText",
"Convert to SmartArt",
["Home", "Paragraph"],
None,
"{id?, kind?} → {id} (a text box or placeholder becomes a SmartArt graphic in its place: one node per paragraph, indent levels kept)",
has_selection,
from_text
),
cmd!(
"smartArt.toText",
"Convert to Text",
["SmartArt Design", "Reset"],
None,
"{id?} → {id} (the graphic becomes a text box of its outline, bulleted by level)",
has_selection,
to_text
),
cmd!(
"smartArt.toShapes",
"Convert to Shapes",
["SmartArt Design", "Reset"],
None,
"{id?} (the graphic becomes a plain group of its shapes; the outline is dropped)",
has_selection,
to_shapes
),
cmd!(
"smartArt.picture",
"Insert Picture in SmartArt",
@@ -183,7 +220,13 @@ pub fn nodes_from_lines<'a>(lines: impl IntoIterator<Item = &'a str>) -> Vec<Dia
.filter(|l| !l.trim().is_empty())
.map(|l| {
let tabs = l.chars().take_while(|c| *c == '\t').count();
DiagramNode { text: l.trim().to_string(), level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), picture: None, assistant: false }
DiagramNode {
text: l.trim().to_string(),
level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL),
picture: None,
assistant: false,
title: String::new(),
}
})
.collect()
}
@@ -215,7 +258,7 @@ fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize {
/// new node's index.
pub fn add(nodes: &mut Vec<DiagramNode>, index: Option<usize>, 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, assistant: false });
nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false, title: String::new() });
return nodes.len() - 1;
};
let lv = nodes[i].level;
@@ -232,7 +275,7 @@ pub fn add(nodes: &mut Vec<DiagramNode>, index: Option<usize>, position: &str, t
"assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)),
_ => (subtree_end(nodes, i), lv),
};
nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant" });
nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() });
at
}
@@ -593,7 +636,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
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"),
"orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area),
"list" | "hBulletList" => {
// Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below.
let vertical = d.kind == "list";
@@ -990,6 +1033,176 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
}
}
}
"verticalBoxList" => {
// Vertical Box List: a pale box per node with its sub-points, its title on a tab
// overlapping the box's top left.
let h = area.h / nf;
for (i, g) in gs.iter().enumerate() {
let y = area.y + i as f64 * h;
let tab_h = (h * 0.3).min(40.0);
let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &nodes(&g[1..]), false);
paint(d, &mut bx, i, true);
if let Some(b) = &mut bx.text {
b.body.inset_t = Some(tab_h * 0.6);
}
out.push((bx, g[1..].to_vec()));
titled(&mut out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]);
}
}
"trapezoidList" | "tableList" => {
// Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the
// first node as a header bar over a column per other node.
let table = d.kind == "tableList";
let (head, cols): (Option<&Vec<usize>>, &[Vec<usize>]) =
if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, &gs) };
let top = if head.is_some() { area.h * 0.28 } else { 0.0 };
if let Some(h) = head {
titled(&mut out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h);
}
let m = cols.len().max(1) as f64;
let w = area.w / m;
for (k, g) in cols.iter().enumerate() {
let i = k + usize::from(table);
let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top);
let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &nodes(g), true);
if let Some(b) = &mut sh.text {
b.body.anchor = Some(deckcraft_model::text::Anchor::Top);
if !table {
b.body.inset_l = Some(x.w * 0.2);
b.body.inset_r = Some(x.w * 0.2);
b.body.inset_t = Some(x.h * 0.08);
}
}
paint(d, &mut sh, i, table);
out.push((sh, g.clone()));
}
}
"accentProcess" => {
// Accent Process: a title box per step with its sub-points in a pale box below and to the
// right, arrows between the titles.
let gap = area.w * 0.05;
let w = (area.w - gap * (nf - 1.0)) / nf;
for (i, g) in gs.iter().enumerate() {
let x = area.x + i as f64 * (w + gap);
let th = area.h * 0.22;
if g.len() > 1 {
let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &nodes(&g[1..]), false);
paint(d, &mut sh, i, true);
if let Some(b) = &mut sh.text {
b.body.inset_t = Some(th * 0.45);
}
out.push((sh, g[1..].to_vec()));
}
titled(&mut out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]);
if i + 1 < n {
let a = gap * 0.8;
arrow(&mut out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i);
}
}
}
"continuousBlockProcess" => {
// Continuous Block Process: one big pale arrow behind a row of boxes.
let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0);
paint(d, &mut big, 0, true);
out.push((big, vec![]));
let w = area.w * 0.85 / nf;
let h = area.h * 0.32;
for (i, g) in gs.iter().enumerate() {
let x = area.x + area.w * 0.02 + i as f64 * w;
titled(&mut out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g);
}
}
"bendingProcess" => {
// Basic Bending Process: rows of boxes read left to right, wrapping, arrows between.
let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n);
let rows = n.div_ceil(cols);
let (cw, rh) = (area.w / cols as f64, area.h / rows as f64);
let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6));
for (i, g) in gs.iter().enumerate() {
let (c, r) = (i % cols, i / cols);
let x = area.x + c as f64 * cw;
let y = area.y + r as f64 * rh + (rh - bh) / 2.0;
titled(&mut out, "rect", Xfrm::new(x, y, bw, bh), i, g);
if i + 1 < n {
let a = (cw - bw) * 0.7;
if c + 1 < cols {
arrow(&mut out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i);
} else {
// The bend: down from the row's last box towards the next row's first.
let a = (rh - bh).min(a * 1.5).max(6.0);
arrow(&mut out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i);
}
}
}
}
"linearVenn" | "stackedVenn" => {
// Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom,
// the text near each one's top.
let linear = d.kind == "linearVenn";
if linear {
let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h);
let step = dc * 0.75;
let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0;
for (i, g) in gs.iter().enumerate() {
let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &nodes(g), true);
paint(d, &mut sh, i, false);
if let Some(Fill::Solid { color: c }) = &mut sh.fill {
*c = c.clone().with(ColorTransform::Alpha(55_000));
}
out.push((sh, g.clone()));
}
} else {
let s = area.h.min(area.w);
let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0);
for (i, _) in gs.iter().enumerate() {
let dc = s * (1.0 - i as f64 / nf);
let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0);
paint(d, &mut sh, i, false);
out.push((sh, vec![]));
}
// Each circle's text in the band between its top and the next circle's.
let band = s / nf;
for (i, g) in gs.iter().enumerate() {
let dc = s * (1.0 - i as f64 / nf);
let top = bottom - dc;
let h = if i + 1 < n { band } else { dc };
let w = dc * if i + 1 < n { 0.6 } else { 0.7 };
let mut t =
text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &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()));
}
}
}
"nestedTarget" => {
// Nested Target: rounded boxes nested inside each other, the text on each one's top band.
let band_x = area.w / 2.0 / nf * 0.6;
let band_y = area.h / nf * 0.9 / 2.0;
for (i, _) in gs.iter().enumerate() {
let (dx, dy) = (band_x * i as f64, band_y * i as f64);
let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0);
paint(d, &mut sh, i, false);
out.push((sh, vec![]));
}
for (i, g) in gs.iter().enumerate() {
let (dx, dy) = (band_x * i as f64, band_y * i as f64);
let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy };
let mut t = text_shape(
"rect",
Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h),
&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()));
}
}
"gridMatrix" => {
// Grid Matrix: four quadrants on crossed axes.
let s = area.h.min(area.w);
@@ -1154,7 +1367,9 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
/// 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<Item> {
fn org_chart(d: &Diagram, area: Xfrm) -> Vec<Item> {
let half_circle = d.kind == "halfCircleOrgChart";
let name_title = d.kind == "nameTitleOrgChart";
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
if nodes.is_empty() {
return vec![];
@@ -1260,21 +1475,51 @@ fn org_chart(d: &Diagram, area: Xfrm, half_circle: bool) -> Vec<Item> {
for (i, n) in nodes.iter().enumerate() {
let depth = slot[i] / 2;
if half_circle {
let s = boxes[i].h.min(boxes[i].w);
// Two half rings round the text: the upper left one in the accent, the lower right one
// in its tint.
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![]));
for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() {
let mut arc = deco("blockArc", x, 0.0);
arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] };
paint(d, &mut arc, depth, false);
if k == 1 {
let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000));
arc.fill = Some(Fill::solid(c));
}
arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
arc.effects = None;
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);
let mut x = boxes[i];
if name_title {
// The name in the upper part; the title box overlaps the lower edge.
x.h *= 0.85;
}
let mut sh = text_shape("rect", x, &[n], true);
paint(d, &mut sh, depth, false);
out.push((sh, vec![i]));
if name_title {
let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42);
let title = DiagramNode { text: n.title.clone(), ..Default::default() };
let mut t = text_shape("rect", tb, &[&title], true);
paint(d, &mut t, depth, true);
t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1)));
if let Some(b) = &mut t.text {
for p in &mut b.paragraphs {
for r in &mut p.runs {
r.props.size = Some(11.0);
}
p.end_props.size = Some(11.0);
}
}
out.push((t, vec![]));
}
}
}
out
@@ -1444,7 +1689,7 @@ fn index(p: &Value, cmd: &str) -> Result<usize> {
pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec<DiagramNode>) -> Result<Value> {
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, assistant: false }).collect();
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false, title: String::new() }).collect();
}
normalize(&mut nodes);
let d = Diagram { kind: kind.into(), nodes, ..Default::default() };
@@ -1488,6 +1733,7 @@ fn set_nodes(s: &mut Session, p: &Value) -> Result<Value> {
};
let one = p.get("index").map(|_| index(p, CMD)).transpose()?;
let text = str_param(p, "text").map(String::from);
let title = str_param(p, "title").map(String::from);
edit_diagram(s, p, CMD, |d| {
if let Some(n) = given {
d.nodes = n;
@@ -1495,6 +1741,9 @@ fn set_nodes(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(i), Some(t)) = (one, text) {
d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t;
}
if let (Some(i), Some(t)) = (one, title) {
d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t;
}
Ok(Value::Null)
})
}
@@ -1527,6 +1776,120 @@ fn demote(s: &mut Session, p: &Value) -> Result<Value> {
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")) })
}
/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in
/// the shape's place.
fn from_text(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.fromText";
let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string();
let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?;
let (area, nodes) = {
let st = s.doc()?;
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?;
let body =
sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?;
let mut nodes: Vec<DiagramNode> = body
.paragraphs
.iter()
.filter(|p| !p.text().trim().is_empty())
.map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() })
.collect();
normalize(&mut nodes);
// A small text box grows to a size a diagram can use, keeping its top left on the slide.
let mut a = xfrm_of(&st.doc, &st.selection, sh);
let size = st.doc.slide_size;
a.w = a.w.max(size.width * 0.5).min(size.width);
a.h = a.h.max(size.height * 0.35).min(size.height);
a.x = a.x.min(size.width - a.w).max(0.0);
a.y = a.y.min(size.height - a.h).max(0.0);
a.rot = 0.0;
(a, nodes)
};
let d = Diagram { kind: kind.clone(), nodes, ..Default::default() };
let gid = s.edit(|doc, sel| {
let children = regenerate(doc, &d, area);
let gid = ShapeId(doc.alloc_id());
let g = Shape {
id: gid,
name: format!("SmartArt ({kind})"),
xfrm: Some(area),
kind: ShapeKind::Group { children, child: area },
diagram: Some(d),
..Default::default()
};
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?;
// In the text shape's place in the z-order.
shapes[at] = g;
sel.shapes = vec![gid];
sel.text = None;
Ok(gid)
})?;
Ok(json!({"id": gid.0}))
}
/// Convert to Text: a text box with the outline, one bulleted paragraph per node.
fn to_text(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.toText";
let gid = target_group(s, p, CMD)?;
let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?;
let new = s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?;
let area = shapes[at].xfrm.unwrap_or_default();
let mut body = TextBody::default();
for n in &d.nodes {
let mut para = Paragraph::new(&n.text);
para.level = n.level.min(MAX_LEVEL);
para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() });
para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0));
para.props.indent = Some(-14.0);
body.paragraphs.push(para);
}
if body.paragraphs.is_empty() {
body.paragraphs.push(Paragraph::new(""));
}
body.body.wrap = Some(true);
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
let id = ShapeId(doc.alloc_id());
let tb =
Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() };
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
shapes[at] = tb;
sel.shapes = vec![id];
sel.text = None;
Ok(id)
})?;
Ok(json!({"id": new.0}))
}
/// Convert to Shapes: keep the drawing as an ordinary group.
fn to_shapes(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.toShapes";
let gid = target_group(s, p, CMD)?;
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?;
g.diagram = None;
g.name = "Group".into();
sel.shapes = vec![gid];
Ok(())
})?;
Ok(Value::Null)
}
/// The graphic `gid` with its outline (typed text synced).
fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> {
let st = s.doc().ok()?;
let shapes = current_shapes(&st.doc, &st.selection)?;
let g = diagram_group(shapes, gid)?;
let mut d = g.diagram.clone()?;
if let ShapeKind::Group { children, .. } = &g.kind {
sync_text(&mut d, children);
}
Some((g.id, d))
}
fn set_picture(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.picture";
let i = index(p, CMD)?;
@@ -1593,7 +1956,11 @@ mod tests {
n.iter().map(|x| x.level).collect()
}
fn mk(levels: &[u8]) -> Vec<DiagramNode> {
levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None, assistant: false }).collect()
levels
.iter()
.enumerate()
.map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None, assistant: false, title: String::new() })
.collect()
}
#[test]
+32
View File
@@ -975,3 +975,35 @@ fn smartart_pictures_3d_styles_and_colour_variants() {
assert_eq!((d.style.as_str(), d.colors.as_str()), (style, colors));
}
}
#[test]
fn smartart_converts_to_and_from_text_and_shapes() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("insert.textBox", &json!({"text": "Goals\nRevenue\nGrowth"})).unwrap();
let before = s.doc().unwrap().doc.slides[1].shapes.len();
let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.slides[1].shapes.len(), before, "the text box is replaced, not added to");
let d = st.doc.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap();
assert_eq!(d.nodes.iter().map(|n| n.text.as_str()).collect::<Vec<_>>(), ["Goals", "Revenue", "Growth"]);
// Name and Title: titles show in their own box and survive a .pptx round trip.
s.execute("smartArt.layout", &json!({"id": gid, "kind": "nameTitleOrgChart"})).unwrap();
s.execute("smartArt.setNodes", &json!({"id": gid, "index": 0, "title": "Lead"})).unwrap();
let back = deckcraft_pptx::import(&deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap()).unwrap();
let bd = back.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap();
assert_eq!((bd.kind.as_str(), bd.nodes[0].title.as_str()), ("nameTitleOrgChart", "Lead"));
let tid = s.execute("smartArt.toText", &json!({"id": gid})).unwrap()["id"].as_u64().unwrap();
let st = s.doc().unwrap();
let tb = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == tid).unwrap();
assert!(tb.diagram.is_none());
assert_eq!(tb.text.as_ref().unwrap().paragraphs.len(), 3);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("smartArt.toShapes", &json!({"id": gid})).unwrap();
let st = s.doc().unwrap();
let g = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == gid).unwrap();
assert!(g.diagram.is_none() && !g.children().is_empty(), "a plain group of the same shapes");
}
+3
View File
@@ -368,6 +368,9 @@ pub struct DiagramNode {
/// 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,
/// The job title under the name (Name and Title Organization Chart).
#[serde(skip_serializing_if = "String::is_empty")]
pub title: String,
}
impl Default for Shape {
+78 -7
View File
@@ -126,6 +126,16 @@ pub fn layout_id(kind: &str) -> &'static str {
"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",
"nameTitleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/NameandTitleOrganizationalChart",
"verticalBoxList" => "urn:microsoft.com/office/officeart/2005/8/layout/list1",
"trapezoidList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList6",
"tableList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList3",
"accentProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process3",
"continuousBlockProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess9",
"bendingProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process5",
"linearVenn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn3",
"stackedVenn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn2",
"nestedTarget" => "urn:microsoft.com/office/officeart/2005/8/layout/target2",
"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",
@@ -174,6 +184,16 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str {
"multidirectionalCycle",
"orgChart",
"halfCircleOrgChart",
"nameTitleOrgChart",
"verticalBoxList",
"trapezoidList",
"tableList",
"accentProcess",
"continuousBlockProcess",
"bendingProcess",
"linearVenn",
"stackedVenn",
"nestedTarget",
"cycleMatrix",
"segmentedPyramid",
"pie",
@@ -194,6 +214,8 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str {
"target"
} else if s.contains("timeline") {
"timeline"
} else if s.contains("nameandtitle") {
"nameTitleOrgChart"
} else if s.contains("orgchart") || s.contains("organizationchart") || s.contains("organizationalchart") {
"orgChart"
} else if s.contains("hierarchy") {
@@ -225,12 +247,22 @@ fn layout_id_of(kind: &str) -> &'static str {
fn category(kind: &str) -> &'static str {
match kind {
"process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" | "gear" | "funnel" => "process",
"venn" | "target" => "relationship",
"process"
| "chevron"
| "verticalProcess"
| "stepUp"
| "pictureProcess"
| "timeline"
| "gear"
| "funnel"
| "accentProcess"
| "continuousBlockProcess"
| "bendingProcess" => "process",
"venn" | "target" | "linearVenn" | "stackedVenn" | "nestedTarget" => "relationship",
"cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "cycleMatrix" | "pie" => {
"cycle"
}
"hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" => "hierarchy",
"hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => "hierarchy",
"pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid",
"matrix" | "titledMatrix" | "gridMatrix" => "matrix",
_ => "list",
@@ -305,6 +337,17 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, Strin
}
// A node's picture sits on the presentation point of its picture placeholder, as PowerPoint
// stores it (`presAssocID` names the node, the blip is a relationship of the data part).
// Name and Title: a node's title is the text of its title box's presentation point.
for (k, n) in d.nodes.iter().enumerate() {
if n.title.is_empty() {
continue;
}
w.open("dgm:pt", A::new().a("modelId", guid(seed, 0x30_0000 + k)).a("type", "pres"));
w.empty("dgm:prSet", A::new().a("presAssocID", node(k)).a("presName", "titleText1").a("presStyleLbl", "fgAcc1").a("custT", "1"));
w.empty0("dgm:spPr");
text(&mut w, &n.title);
w.close("dgm:pt");
}
for (k, rid) in pics {
if *k >= d.nodes.len() {
continue;
@@ -390,6 +433,7 @@ 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 titles: HashMap<&str, String> = HashMap::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 };
@@ -402,6 +446,16 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
assistants.insert(id);
}
}
"pres" => {
// Name and Title: the title box's own text, on its presentation point.
let pr = pt.child("prSet");
if pr.and_then(|p| p.attr("presName")).is_some_and(|n| n.starts_with("titleText"))
&& let Some(node) = pr.and_then(|p| p.attr("presAssocID"))
&& let Some(t) = pt.child("t").map(paragraphs).filter(|t| !t.is_empty())
{
titles.insert(node, t);
}
}
_ => {}
}
}
@@ -431,7 +485,13 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
}
out.push((
id.to_string(),
DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None, assistant: assistants.contains(id) },
DiagramNode {
text: texts.get(id).cloned().unwrap_or_default(),
level: level.min(8),
picture: None,
assistant: assistants.contains(id),
title: titles.get(id).cloned().unwrap_or_default(),
},
));
if let Some(v) = kids.get(id) {
stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1))));
@@ -539,7 +599,7 @@ pub fn layout_xml(kind: &str) -> Vec<u8> {
w.empty0("dgm:dir");
w.empty("dgm:resizeHandles", A::new().a("val", "exact"));
w.close("dgm:varLst");
if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart") {
if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart") {
// 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", ""));
@@ -708,7 +768,7 @@ mod tests {
use super::*;
fn nodes(v: &[(&str, u8)]) -> Vec<DiagramNode> {
v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false }).collect()
v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false, title: String::new() }).collect()
}
#[test]
@@ -739,6 +799,7 @@ mod tests {
fn assistants_survive_the_data_model() {
let mut v = nodes(&[("CEO", 0), ("Aide", 1), ("CTO", 1)]);
v[1].assistant = true;
v[0].title = "Chief Executive".into();
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\""));
@@ -789,6 +850,16 @@ mod tests {
"cycleMatrix",
"segmentedPyramid",
"pie",
"nameTitleOrgChart",
"verticalBoxList",
"trapezoidList",
"tableList",
"accentProcess",
"continuousBlockProcess",
"bendingProcess",
"linearVenn",
"stackedVenn",
"nestedTarget",
] {
assert_eq!(kind_for(layout_id(k), ""), k);
}
@@ -800,7 +871,7 @@ 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", ""), "orgChart");
assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "orgChart");
assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "nameTitleOrgChart");
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");
+9
View File
@@ -2375,6 +2375,15 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
}
}
});
let (r, _) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
if ui.button("Convert to Text").clicked() {
run(app, "smartArt.toText", json!({"id": gid}));
}
if ui.button("Convert to Shapes").clicked() {
run(app, "smartArt.toShapes", json!({"id": gid}));
}
});
});
group(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true);