Merge PR #601 from NikitkaRa: SmartArt: write the outline to .pptx as a diagram and read it back from the data model

# Conflicts:
#	crates/engine/Cargo.toml
#	crates/pptx/src/read/shapes.rs
#	crates/pptx/src/write/shapes.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:26:21 -04:00
12 changed files with 674 additions and 16 deletions
+13 -4
View File
@@ -194,6 +194,9 @@ fn node_map(d: &Diagram) -> Vec<Vec<usize>> {
/// Read text typed into the diagram's shapes back into its nodes.
fn sync_text(d: &mut Diagram, children: &[Shape]) {
if d.imported {
return;
}
for (shape, idx) in children.iter().zip(node_map(d)) {
let Some(body) = &shape.text else { continue };
if body.paragraphs.len() != idx.len() {
@@ -436,6 +439,7 @@ fn edit_diagram<T>(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Di
sync_text(&mut d, &children);
let out = f(&mut d)?;
normalize(&mut d.nodes);
d.imported = false;
let kids = regenerate(doc, &d, child);
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?;
@@ -458,7 +462,7 @@ pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec<DiagramNode>) -> Resul
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect();
}
normalize(&mut nodes);
let d = Diagram { kind: kind.into(), nodes };
let d = Diagram { kind: kind.into(), nodes, ..Default::default() };
let size = s.doc()?.doc.slide_size;
let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6);
let id = s.edit(|doc, sel| {
@@ -542,6 +546,11 @@ fn change_layout(s: &mut Session, p: &Value) -> Result<Value> {
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();
edit_diagram(s, p, CMD, |d| {
if d.kind != kind {
// PowerPoint's own layout no longer applies.
d.layout_id.clear();
d.layout_xml = None;
}
d.kind = kind;
Ok(Value::Null)
})
@@ -586,18 +595,18 @@ 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]) };
let d = Diagram { kind: "list".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());
assert_eq!(t, Some(3), "sub-points are bullets in their parent's shape");
let h = Diagram { kind: "hierarchy".into(), nodes: mk(&[0, 1, 2, 1]) };
let h = Diagram { kind: "hierarchy".into(), nodes: mk(&[0, 1, 2, 1]), ..Default::default() };
let v = layout(&h, area);
assert_eq!(v.len(), 4 + 3, "a box per node and a line per parent link");
for sh in &v {
let x = sh.xfrm.unwrap_or_default();
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(), nodes: vec![] }, area).is_empty());
assert!(layout(&Diagram { kind: "cycle".into(), ..Default::default() }, area).is_empty());
}
}
+38
View File
@@ -1728,3 +1728,41 @@ fn smartart_text_pane_edits_regenerate_the_diagram() {
let back = deckcraft_format::load(&bytes).unwrap();
assert!(back.slides[0].shapes.iter().any(|x| x.diagram.as_ref().is_some_and(|d| d.nodes.len() == 3)));
}
#[test]
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();
let doc = s.doc().unwrap().doc.as_ref().clone();
let bytes = deckcraft_pptx::export(&doc).unwrap();
let names: Vec<String> = {
let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
z.file_names().map(String::from).collect()
};
for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] {
assert!(names.iter().any(|n| n == &format!("ppt/diagrams/{part}.xml")), "{part} in {names:?}");
}
let back = deckcraft_pptx::import(&bytes).unwrap();
assert!(back.validate().is_empty());
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!(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)]);
assert!(g.children().len() >= 4, "the cached drawing is read back as shapes");
// The Text Pane works on the imported diagram, and a second round trip keeps the edit.
let gid = g.id.0;
let path = std::env::temp_dir().join(format!("deckcraft-smartart-{}.pptx", std::process::id()));
std::fs::write(&path, &bytes).unwrap();
let mut s2 = Session::new();
s2.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
let _ = std::fs::remove_file(&path);
s2.execute("slide.go", &json!({"index": 1})).unwrap();
s2.execute("smartArt.addNode", &json!({"id": gid, "index": 3, "position": "after", "text": "COO"})).unwrap();
let again = deckcraft_pptx::import(&deckcraft_pptx::export(s2.doc().unwrap().doc.as_ref()).unwrap()).unwrap();
let d = again.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap();
assert_eq!(d.nodes.last().map(|n| (n.text.as_str(), n.level)), Some(("COO", 1)));
}