From 690171665a8ebefc904061e8e6545b762974f007 Mon Sep 17 00:00:00 2001 From: neilcawse Date: Wed, 11 Nov 2026 13:48:47 -0500 Subject: [PATCH] pptx: preserve SmartArt node text when dgm:cxnLst has no parent-child connections (#1944) * pptx: preserve SmartArt node text when dgm:cxnLst has no parent-child connections * fix(pptx): apply unconnected-point SmartArt outline fallback when type="doc" point is absent * fix(pptx): restrict SmartArt pt_order fallback to empty parOf graphs and preserve cached Picture/Connector children * fix(pptx): convert default rect geometry to line for connectors in diagram drawings * docs(pptx): note flat ptLst order fallback on dgm::outline --- crates/pptx/src/dgm.rs | 34 ++++++++++++++++++++++++++++++--- crates/pptx/src/write/shapes.rs | 18 ++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index 5f85db00b..70cb89cf2 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -403,7 +403,7 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, Strin w.finish() } -/// The outline of a data part: the `parOf` tree under the document point, depth first. +/// The outline of a data part: the `parOf` tree under the document point, depth first, or the data points in `ptLst` order when no `parOf` connections exist. pub fn outline(data: &El) -> Vec { outline_ids(data).into_iter().map(|(_, n)| n).collect() } @@ -429,11 +429,13 @@ pub fn node_pictures(data: &El) -> Vec<(usize, String)> { out } +/// Extracts diagram outline `(modelId, DiagramNode)` pairs in tree order from a `dgm:dataModel` element. 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 pt_order: Vec<&str> = Vec::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 }; @@ -442,6 +444,7 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { ty @ ("node" | "asst") => { let t = pt.child("t").map(paragraphs).unwrap_or_default(); texts.insert(id, t); + pt_order.push(id); if ty == "asst" { assistants.insert(id); } @@ -459,7 +462,6 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { _ => {} } } - let Some(doc) = doc else { return vec![] }; let mut kids: HashMap<&str, Vec<(i64, &str)>> = HashMap::new(); if let Some(cx) = data.child("cxnLst") { for c in cx.children_named("cxn").take(MAX_NODES * 4) { @@ -478,7 +480,7 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { let mut out = vec![]; let mut seen = std::collections::HashSet::new(); // Depth first, iteratively; a node reached twice (a cycle in a hostile file) is skipped. - let mut stack: Vec<(&str, u8)> = kids.get(doc).map(|v| v.iter().rev().map(|(_, id)| (*id, 0u8)).collect()).unwrap_or_default(); + let mut stack: Vec<(&str, u8)> = doc.and_then(|d| kids.get(d)).map(|v| v.iter().rev().map(|(_, id)| (*id, 0u8)).collect()).unwrap_or_default(); while let Some((id, level)) = stack.pop() { if out.len() >= MAX_NODES || !seen.insert(id) { continue; @@ -497,6 +499,23 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); } } + if out.is_empty() && kids.is_empty() { + for id in pt_order { + if out.len() >= MAX_NODES || !seen.insert(id) { + continue; + } + out.push(( + id.to_string(), + DiagramNode { + text: texts.get(id).cloned().unwrap_or_default(), + level: 0, + picture: None, + assistant: assistants.contains(id), + title: titles.get(id).cloned().unwrap_or_default(), + }, + )); + } + } out } @@ -878,5 +897,14 @@ mod tests { assert_eq!(colors_for(&cs_id("accent4")), "accent4"); assert_eq!(colors_for(&cs_id("")), ""); assert_eq!(style_for(&qs_id("outline")), "outline"); + + let unconnected = br#"AlphaBeta"#; + let unconnected_doc = crate::xml::parse(unconnected).unwrap(); + assert_eq!(outline(&unconnected_doc.root), nodes(&[("Alpha", 0), ("Beta", 0)])); + + // When parOf connections exist but no root (type="doc") reaches them, do not flatten the hierarchy to level 0. + let unrooted_hierarchy = br#"ParentChild"#; + let unrooted_doc = crate::xml::parse(unrooted_hierarchy).unwrap(); + assert!(outline(&unrooted_doc.root).is_empty()); } } diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 80eabbcf2..e799ea84f 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -542,10 +542,22 @@ fn diagram(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &dec let (sx, sy) = (frame.w / child.w.max(1e-6), frame.h / child.h.max(1e-6)); let mut k = 0; let mut flat = vec![]; - walk(children, &mut |c, _| { - if matches!(c.kind, ShapeKind::Shape) { - flat.push(c.clone()); + walk(children, &mut |c, _| match &c.kind { + ShapeKind::Shape => flat.push(c.clone()), + ShapeKind::Connector { .. } => { + let mut sc = c.clone(); + if sc.geom.preset_name() == Some("rect") { + sc.geom = Geom::preset("line"); + } + flat.push(sc); } + ShapeKind::Picture { fill } => { + let mut sc = c.clone(); + sc.kind = ShapeKind::Shape; + sc.fill = Some(deckcraft_model::style::Fill::Picture(fill.clone())); + flat.push(sc); + } + _ => {} }); for mut c in flat.into_iter().take(10_000) { if let Some(xf) = &mut c.xfrm {