From b03b19bca73f6e0d0d14c2e5db8683c4219547fd Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:29:49 +0300 Subject: [PATCH] SmartArt: write the outline to .pptx as a diagram; read it back from the data model Export: a group with a diagram becomes a graphicFrame naming ppt/diagrams data, layout, quickStyle and colors parts, plus the group's shapes as the cached drawing (dsp). The data model is built from the outline (doc, node, parTrans/sibTrans points and parOf links). A diagram read from a file keeps its own layout, quick style and colour parts until its layout is switched. Import: the outline is rebuilt from the data model's parOf tree, so PowerPoint diagrams get the Text Pane. Their shapes stay the file's drawing until the first outline edit (typed text isn't mapped back to nodes then). Co-authored-by: Cursor --- Cargo.lock | 1 + crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/smartart.rs | 17 +- crates/engine/src/tests.rs | 38 +++ crates/model/src/lib.rs | 15 + crates/pptx/src/dgm.rs | 451 ++++++++++++++++++++++++++++++ crates/pptx/src/lib.rs | 1 + crates/pptx/src/read/shapes.rs | 40 +++ crates/pptx/src/write/mod.rs | 7 + crates/pptx/src/write/shapes.rs | 99 ++++++- docs/file-format-parity.md | 2 +- docs/gaps.md | 18 +- 12 files changed, 675 insertions(+), 17 deletions(-) create mode 100644 crates/pptx/src/dgm.rs diff --git a/Cargo.lock b/Cargo.lock index 4044fce6..d089e421 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,6 +955,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1f4dbfcd..38794151 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,3 +25,6 @@ i_overlay = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } + +[dev-dependencies] +zip = { workspace = true } diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 0993e87d..cfca7620 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -194,6 +194,9 @@ fn node_map(d: &Diagram) -> Vec> { /// 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(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) -> 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 { 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()); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9d725f88..179b6a4f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -884,3 +884,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 = { + 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))); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index bdd52a63..377f94ef 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -332,6 +332,21 @@ pub struct Diagram { /// list, process, cycle, hierarchy, pyramid or matrix. pub kind: String, pub nodes: Vec, + /// The group's shapes are the file's drawing, not generated from `nodes`: text typed into them + /// isn't mapped back to nodes (the first outline edit regenerates them). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub imported: bool, + /// PowerPoint's layout id (`layoutDef@uniqueId`) the diagram came with. + #[serde(skip_serializing_if = "String::is_empty")] + pub layout_id: String, + /// The file's own layout, quick style and colour definitions, written back unchanged while the + /// layout isn't switched. + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_xml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style_xml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub colors_xml: Option, } /// One line of the Text Pane: its text and outline level (0 = top level). diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs new file mode 100644 index 00000000..a3af2702 --- /dev/null +++ b/crates/pptx/src/dgm.rs @@ -0,0 +1,451 @@ +//! SmartArt (DrawingML diagrams): the outline ⇄ the data model, and the layout, quick style and +//! colour definitions DeckCraft writes for its own layouts. +//! +//! A diagram's data part holds its points (`dgm:pt`: the document, one per node, and the +//! transitions PowerPoint pairs with each link) and its links (`dgm:cxn` of type `parOf`, parent → +//! child, ordered by `srcOrd`). The outline is that tree flattened depth first. + +use std::collections::HashMap; + +use deckcraft_model::{Diagram, DiagramNode}; + +use crate::xml::{A, El, W}; + +pub const NS_DGM: &str = "http://schemas.openxmlformats.org/drawingml/2006/diagram"; +pub const NS_DSP: &str = "http://schemas.microsoft.com/office/drawing/2008/diagram"; +pub const CT_DATA: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml"; +pub const CT_LAYOUT: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml"; +pub const CT_STYLE: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml"; +pub const CT_COLORS: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml"; +pub const CT_DRAWING: &str = "application/vnd.ms-office.drawingml.diagramDrawing+xml"; +pub const RT_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"; +const QS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/simple1"; +const CS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/colors/accent1_2"; +/// Most nodes read from one diagram. +const MAX_NODES: usize = 2000; + +/// PowerPoint's built-in layout DeckCraft's layout `kind` is written as. +pub fn layout_id(kind: &str) -> &'static str { + match kind { + "process" => "urn:microsoft.com/office/officeart/2005/8/layout/process1", + "cycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle2", + "hierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy1", + "pyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid1", + "matrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix3", + _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", + } +} + +/// 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 s = format!("{layout_id} {category}").to_ascii_lowercase(); + if s.contains("hierarchy") || s.contains("orgchart") { + "hierarchy" + } else if s.contains("cycle") || s.contains("radial") { + "cycle" + } else if s.contains("pyramid") { + "pyramid" + } else if s.contains("matrix") { + "matrix" + } else if s.contains("process") || s.contains("chevron") || s.contains("arrow") { + "process" + } else { + "list" + } +} + +fn category(kind: &str) -> &'static str { + match kind { + "process" => "process", + "cycle" => "cycle", + "hierarchy" => "hierarchy", + "pyramid" => "pyramid", + "matrix" => "matrix", + _ => "list", + } +} + +/// A model id, stable for a given diagram (`seed`) and point number. +fn guid(seed: u32, n: usize) -> String { + format!("{{{seed:08X}-D1A6-4C0A-9E5B-{n:012X}}}") +} + +fn text(w: &mut W, t: &str) { + w.open0("dgm:t"); + w.empty0("a:bodyPr"); + w.empty0("a:lstStyle"); + w.open0("a:p"); + if t.is_empty() { + w.empty("a:endParaRPr", A::new().a("lang", "en-US")); + } else { + w.open0("a:r"); + w.empty("a:rPr", A::new().a("lang", "en-US")); + w.elt("a:t", t); + w.close("a:r"); + } + w.close("a:p"); + w.close("dgm:t"); +} + +/// `ppt/diagrams/dataN.xml` for the outline. `drawing_rel` is the slide's relationship id of the +/// cached drawing. +pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { + let lo = if d.layout_id.is_empty() { layout_id(&d.kind).to_string() } else { d.layout_id.clone() }; + let mut w = W::new(); + w.open("dgm:dataModel", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R)); + w.open0("dgm:ptLst"); + let doc = guid(seed, 0); + w.open("dgm:pt", A::new().a("modelId", &doc).a("type", "doc")); + w.empty( + "dgm:prSet", + A::new() + .a("loTypeId", &lo) + .a("loCatId", category(&d.kind)) + .a("qsTypeId", QS_ID) + .a("qsCatId", "simple") + .a("csTypeId", CS_ID) + .a("csCatId", "accent1") + .a("phldr", "1"), + ); + w.empty0("dgm:spPr"); + text(&mut w, ""); + w.close("dgm:pt"); + // 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))); + w.empty("dgm:prSet", A::new().a("phldrT", "[Text]")); + w.empty0("dgm:spPr"); + text(&mut w, &n.text); + w.close("dgm:pt"); + let cxn = guid(seed, 0x10_0000 + k); + for (i, ty) in [(2, "parTrans"), (3, "sibTrans")] { + w.open("dgm:pt", A::new().a("modelId", guid(seed, i + 3 * k)).a("type", ty).a("cxnId", &cxn)); + w.empty0("dgm:prSet"); + w.empty0("dgm:spPr"); + text(&mut w, ""); + w.close("dgm:pt"); + } + } + w.close("dgm:ptLst"); + w.open0("dgm:cxnLst"); + let mut stack: Vec<(u8, String)> = vec![]; + let mut ords: HashMap = HashMap::new(); + for (k, n) in d.nodes.iter().enumerate() { + while stack.last().is_some_and(|(l, _)| *l >= n.level) { + stack.pop(); + } + let parent = stack.last().map(|(_, id)| id.clone()).unwrap_or_else(|| doc.clone()); + let ord = ords.entry(parent.clone()).or_insert(0); + w.empty( + "dgm:cxn", + A::new() + .a("modelId", guid(seed, 0x10_0000 + k)) + .a("srcId", &parent) + .a("destId", node(k)) + .a("srcOrd", *ord) + .a("destOrd", 0) + .a("parTransId", guid(seed, 2 + 3 * k)) + .a("sibTransId", guid(seed, 3 + 3 * k)), + ); + *ord += 1; + stack.push((n.level, node(k))); + } + w.close("dgm:cxnLst"); + w.empty0("dgm:bg"); + w.empty0("dgm:whole"); + w.open0("dgm:extLst"); + w.open("a:ext", A::new().a("uri", "http://schemas.microsoft.com/office/drawing/2008/diagram")); + w.empty( + "dsp:dataModelExt", + A::new().a("xmlns:dsp", NS_DSP).a("relId", drawing_rel).a("minVer", "http://schemas.openxmlformats.org/drawingml/2006/diagram"), + ); + w.close("a:ext"); + w.close("dgm:extLst"); + w.close("dgm:dataModel"); + w.finish() +} + +/// The outline of a data part: the `parOf` tree under the document point, depth first. +pub fn outline(data: &El) -> Vec { + let Some(pts) = data.child("ptLst") else { return vec![] }; + let mut texts: 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 }; + match pt.attr("type").unwrap_or("node") { + "doc" => doc = doc.or(Some(id)), + "node" => { + let t = pt.child("t").map(paragraphs).unwrap_or_default(); + texts.insert(id, t); + } + _ => {} + } + } + 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) { + if c.attr("type").unwrap_or("parOf") != "parOf" { + continue; + } + let (Some(src), Some(dst)) = (c.attr("srcId"), c.attr("destId")) else { continue }; + if texts.contains_key(dst) { + kids.entry(src).or_default().push((c.i64("srcOrd").unwrap_or(0), dst)); + } + } + } + for v in kids.values_mut() { + v.sort_by_key(|(o, _)| *o); + } + 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(); + while let Some((id, level)) = stack.pop() { + if out.len() >= MAX_NODES || !seen.insert(id) { + continue; + } + out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8) }); + if let Some(v) = kids.get(id) { + stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); + } + } + out +} + +/// A text body's paragraphs joined by spaces. +fn paragraphs(t: &El) -> String { + t.children_named("p") + .map(|p| { + let mut v = vec![]; + p.find_all("t", &mut v); + v.iter().map(|e| e.text()).collect::() + }) + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(" ") +} + +/// The layout algorithm and node shape of a DeckCraft layout. +fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) { + match kind { + "process" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), + "cycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "pyramid" => ("pyra", vec![("linDir", "fromT"), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid"), + "matrix" => ("snake", vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], "roundRect"), + _ => ("lin", vec![("linDir", "fromT"), ("vertAlign", "mid")], "roundRect"), + } +} + +fn node_layout(w: &mut W, shape: &str, bullets: bool) { + w.open("dgm:layoutNode", A::new().a("name", "node")); + w.open0("dgm:varLst"); + w.empty("dgm:bulletEnabled", A::new().a("val", if bullets { "1" } else { "0" })); + w.close("dgm:varLst"); + w.empty("dgm:alg", A::new().a("type", "tx")); + w.open("dgm:shape", A::new().a("type", shape).a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty("dgm:presOf", A::new().a("axis", if bullets { "desOrSelf" } else { "self" }).a("ptType", "node")); + w.open0("dgm:constrLst"); + for side in ["tMarg", "bMarg", "lMarg", "rMarg"] { + w.empty("dgm:constr", A::new().a("type", side).a("refType", "primFontSz").a("fact", "0.3")); + } + w.close("dgm:constrLst"); + w.open0("dgm:ruleLst"); + w.empty("dgm:rule", A::new().a("type", "primFontSz").a("val", "5").a("fact", "NaN").a("max", "NaN")); + w.close("dgm:ruleLst"); + w.close("dgm:layoutNode"); +} + +fn space(w: &mut W) { + w.open("dgm:forEach", A::new().a("name", "sibling").a("axis", "followSib").a("ptType", "sibTrans").a("cnt", "1")); + w.open("dgm:layoutNode", A::new().a("name", "space")); + w.empty("dgm:alg", A::new().a("type", "sp")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.close("dgm:layoutNode"); + w.close("dgm:forEach"); +} + +/// `ppt/diagrams/layoutN.xml`: a layout definition for DeckCraft's layout `kind`, under the id of +/// the closest built-in PowerPoint layout. +pub fn layout_xml(kind: &str) -> Vec { + let mut w = W::new(); + w.open( + "dgm:layoutDef", + A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R).a("uniqueId", layout_id(kind)), + ); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", category(kind)).a("pri", "1000")); + w.close("dgm:catLst"); + w.open("dgm:layoutNode", A::new().a("name", "diagram")); + w.open0("dgm:varLst"); + w.empty0("dgm:dir"); + w.empty("dgm:resizeHandles", A::new().a("val", "exact")); + w.close("dgm:varLst"); + if kind == "hierarchy" { + // 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", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.open("dgm:forEach", A::new().a("name", "level").a("axis", "ch").a("ptType", "node")); + w.open("dgm:layoutNode", A::new().a("name", "hierRoot")); + w.empty("dgm:alg", A::new().a("type", "hierRoot")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + node_layout(&mut w, "roundRect", false); + w.open("dgm:layoutNode", A::new().a("name", "hierChild")); + w.empty("dgm:alg", A::new().a("type", "hierChild")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.empty("dgm:forEach", A::new().a("name", "repeat").a("ref", "level")); + w.close("dgm:layoutNode"); + w.close("dgm:layoutNode"); + w.close("dgm:forEach"); + } else { + let (ty, params, shape) = alg(kind); + w.open("dgm:alg", A::new().a("type", ty)); + for (k, v) in params { + w.empty("dgm:param", A::new().a("type", k).a("val", v)); + } + w.close("dgm:alg"); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.open0("dgm:constrLst"); + w.empty("dgm:constr", A::new().a("type", "w").a("for", "ch").a("forName", "node").a("refType", "w")); + w.empty( + "dgm:constr", + A::new().a("type", "h").a("for", "ch").a("forName", "node").a("refType", "w").a("refFor", "ch").a("refForName", "node").a("fact", "0.6"), + ); + w.empty( + "dgm:constr", + A::new().a("type", "w").a("for", "ch").a("forName", "space").a("refType", "w").a("refFor", "ch").a("refForName", "node").a("fact", "0.1"), + ); + w.empty("dgm:constr", A::new().a("type", "primFontSz").a("for", "ch").a("forName", "node").a("op", "equ").a("val", "65")); + w.close("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.open("dgm:forEach", A::new().a("name", "nodes").a("axis", "ch").a("ptType", "node")); + node_layout(&mut w, shape, true); + if kind != "cycle" { + space(&mut w); + } + w.close("dgm:forEach"); + } + w.close("dgm:layoutNode"); + w.close("dgm:layoutDef"); + w.finish() +} + +/// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. +pub fn style_xml() -> Vec { + let mut w = W::new(); + w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", QS_ID)); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", "simple").a("pri", "10100")); + w.close("dgm:catLst"); + w.open0("dgm:scene3d"); + w.empty("a:camera", A::new().a("prst", "orthographicFront")); + w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); + w.close("dgm:scene3d"); + w.open("dgm:styleLbl", A::new().a("name", "node1")); + w.open0("dgm:scene3d"); + w.empty("a:camera", A::new().a("prst", "orthographicFront")); + w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); + w.close("dgm:scene3d"); + w.empty0("dgm:sp3d"); + w.empty("dgm:txPr", A::new()); + w.open0("dgm:style"); + for (tag, idx) in [("a:lnRef", "2"), ("a:fillRef", "1"), ("a:effectRef", "0")] { + w.open(tag, A::new().a("idx", idx)); + w.empty("a:scrgbClr", A::new().a("r", "0").a("g", "0").a("b", "0")); + w.close(tag); + } + w.open("a:fontRef", A::new().a("idx", "minor")); + w.empty("a:schemeClr", A::new().a("val", "lt1")); + w.close("a:fontRef"); + w.close("dgm:style"); + w.close("dgm:styleLbl"); + w.close("dgm:styleDef"); + w.finish() +} + +/// `ppt/diagrams/colorsN.xml`: accent 1 fills with light text. +pub fn colors_xml() -> Vec { + let mut w = W::new(); + w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", CS_ID)); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", "accent1").a("pri", "11200")); + w.close("dgm:catLst"); + w.open("dgm:styleLbl", A::new().a("name", "node1")); + for (tag, clr) in [("dgm:fillClrLst", "accent1"), ("dgm:linClrLst", "lt1")] { + w.open(tag, A::new().a("meth", "repeat")); + w.empty("a:schemeClr", A::new().a("val", clr)); + w.close(tag); + } + w.empty0("dgm:effectClrLst"); + w.empty0("dgm:txLinClrLst"); + w.empty0("dgm:txFillClrLst"); + w.empty0("dgm:txEffectClrLst"); + w.close("dgm:styleLbl"); + w.close("dgm:colorsDef"); + w.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nodes(v: &[(&str, u8)]) -> Vec { + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l }).collect() + } + + #[test] + fn outline_survives_the_data_model() { + let d = + Diagram { kind: "hierarchy".into(), nodes: nodes(&[("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1), ("Board", 0)]), ..Default::default() }; + let xml = data_xml(&d, 7, "rId9"); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(outline(&doc.root), d.nodes); + let s = String::from_utf8(xml).unwrap(); + assert!(s.contains("hierarchy1") && s.contains("relId=\"rId9\"")); + for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml(), colors_xml()] { + assert!(crate::xml::parse(&x).is_ok()); + } + } + + #[test] + fn hostile_links_are_bounded() { + 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:x/layout/chevron2", "process"), "process"); + assert_eq!(kind_for("urn:x/layout/default", "list"), "list"); + } +} diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index 92274b62..a590aca8 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] mod custgeom; +mod dgm; mod maps; mod opc; mod png; diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c4b3f77f..c33dcc03 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -432,9 +432,49 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> let mut g = frame.clone(); let x = frame.xfrm.unwrap_or_default(); g.kind = ShapeKind::Group { children, child: Xfrm::new(0.0, 0.0, x.w, x.h) }; + g.diagram = diagram_outline(imp, part, rel_ids, &data.root); Some(g) } +/// The outline of a SmartArt graphic (for the Text Pane), with its layout, quick style and colour +/// parts kept to be written back. +fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option { + let nodes = crate::dgm::outline(data); + if nodes.is_empty() { + return None; + } + let raw = |attr: &str| -> Option { + let rel = part.rels.get(rel_ids.attr(attr)?)?; + let bytes = imp.pkg.get(&rel.target)?; + (bytes.len() <= 4 << 20).then(|| String::from_utf8(bytes.to_vec()).ok()).flatten() + }; + let layout_xml = raw("r:lo"); + let (layout_id, cat) = layout_xml + .as_deref() + .and_then(|x| crate::xml::parse(x.as_bytes()).ok()) + .map(|d| { + let cat = d.root.child("catLst").and_then(|c| c.child("cat")).and_then(|c| c.attr("type")).unwrap_or("").to_string(); + (d.root.attr("uniqueId").unwrap_or("").to_string(), cat) + }) + .unwrap_or_default(); + let lo_type = data + .path(&["ptLst"]) + .and_then(|l| l.children_named("pt").find(|p| p.attr("type") == Some("doc"))) + .and_then(|p| p.child("prSet")) + .and_then(|p| p.attr("loTypeId")) + .unwrap_or(""); + let id = if layout_id.is_empty() { lo_type.to_string() } else { layout_id }; + Some(deckcraft_model::Diagram { + kind: crate::dgm::kind_for(&id, &cat).to_string(), + nodes, + imported: true, + layout_id: id, + layout_xml, + style_xml: raw("r:qs"), + colors_xml: raw("r:cs"), + }) +} + fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { // A table naming no style is drawn by PowerPoint with thin dark grid lines ("No Style, Table // Grid"), not without lines. diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..96e95dc5 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -43,6 +43,7 @@ pub struct Exp<'a> { n_image: u32, n_media: u32, n_chart: u32, + n_diagram: u32, pub slide_parts: HashMap, pub table_styles: Vec, placeholder: Option, @@ -158,6 +159,11 @@ impl Exp<'_> { }; o.rels.add("image", &opc::relative(&o.name, &part), false) } + /// The number of the next SmartArt graphic's parts. + pub fn next_diagram(&mut self) -> u32 { + self.n_diagram += 1; + self.n_diagram + } pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String { self.n_chart += 1; let name = format!("ppt/charts/chart{}.xml", self.n_chart); @@ -385,6 +391,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { n_image: 0, n_media: 0, n_chart: 0, + n_diagram: 0, slide_parts: HashMap::new(), table_styles: vec![], placeholder: None, diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..bb3b63ac 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -202,7 +202,11 @@ fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) { } fn style(w: &mut W, st: &ShapeStyle) { - w.open0("p:style"); + style_tag(w, st, "p:style"); +} + +fn style_tag(w: &mut W, st: &ShapeStyle, tag: &str) { + w.open0(tag); for (tag, (idx, c)) in [("a:lnRef", &st.line_ref), ("a:fillRef", &st.fill_ref), ("a:effectRef", &st.effect_ref)] { w.open(tag, A::new().a("idx", idx)); dml::color(w, c); @@ -220,12 +224,16 @@ fn style(w: &mut W, st: &ShapeStyle) { } None => w.empty("a:fontRef", A::new().a("idx", idx)), } - w.close("p:style"); + w.close(tag); } /// `p:spPr` contents (also used for pictures and connectors). fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { - w.open0("p:spPr"); + sp_pr_tag(w, x, o, s, geom, "p:spPr"); +} + +fn sp_pr_tag(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool, tag: &str) { + w.open0(tag); if let Some(xf) = &s.xfrm { dml::xfrm(w, "a:xfrm", xf, None); } @@ -244,7 +252,7 @@ fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { w.raw(raw); } } - w.close("p:spPr"); + w.close(tag); } /// Write the children of a shape tree. @@ -297,6 +305,11 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } w.close("p:pic"); } + ShapeKind::Group { children, child } if s.diagram.is_some() && depth < 32 => { + if let Some(d) = &s.diagram { + diagram(w, x, o, cx, s, d, children, child); + } + } ShapeKind::Group { children, child } => { let id = cx.id_for(s); w.open0("p:grpSp"); @@ -400,6 +413,84 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } } +/// A SmartArt graphic: a graphic frame naming its data, layout, quick style and colour parts, +/// with the group's shapes as the cached drawing PowerPoint shows until it lays the diagram out. +#[allow(clippy::too_many_arguments)] +fn diagram(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &deckcraft_model::Diagram, children: &[Shape], child: &Xfrm) { + use crate::dgm; + let id = cx.id_for(s); + let n = x.next_diagram(); + let frame = s.xfrm.unwrap_or(*child); + let name = |kind: &str| format!("ppt/diagrams/{kind}{n}.xml"); + // The drawing: the group's shapes in the frame's own coordinates. + let dname = name("drawing"); + let mut dout = Out::new(&dname); + let mut dw = W::new(); + dw.open( + "dsp:drawing", + A::new().a("xmlns:dgm", dgm::NS_DGM).a("xmlns:dsp", dgm::NS_DSP).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R), + ); + dw.open0("dsp:spTree"); + dw.open0("dsp:nvGrpSpPr"); + dw.empty("dsp:cNvPr", A::new().a("id", 0).a("name", "")); + dw.empty0("dsp:cNvGrpSpPr"); + dw.close("dsp:nvGrpSpPr"); + dw.empty0("dsp:grpSpPr"); + 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()); + } + }); + for mut c in flat.into_iter().take(10_000) { + if let Some(xf) = &mut c.xfrm { + *xf = Xfrm { x: (xf.x - child.x) * sx, y: (xf.y - child.y) * sy, w: xf.w * sx, h: xf.h * sy, ..*xf }; + } + k += 1; + dw.open("dsp:sp", A::new().a("modelId", format!("{{{:08X}-5D1A-4C0A-9E5B-{k:012X}}}", s.id.0))); + dw.open0("dsp:nvSpPr"); + dw.empty("dsp:cNvPr", A::new().a("id", 0).a("name", "")); + dw.empty0("dsp:cNvSpPr"); + dw.close("dsp:nvSpPr"); + sp_pr_tag(&mut dw, x, &mut dout, &c, true, "dsp:spPr"); + if let Some(st) = &c.style { + style_tag(&mut dw, st, "dsp:style"); + } + if let Some(tb) = &c.text { + dml::text_body(&mut dw, x, &mut dout, "dsp:txBody", tb, false); + } + dw.close("dsp:sp"); + } + dw.close("dsp:spTree"); + dw.close("dsp:drawing"); + x.pkg.xml_part(&dname, dgm::CT_DRAWING, dw.finish()); + x.pkg.rels(&dname, &dout.rels); + let rel = |o: &mut Out, kind: &str, part: &str| o.rels.add_new(kind, &crate::opc::relative(&o.name, part), false); + let drawing_rel = rel(o, dgm::RT_DRAWING, &dname); + let data = name("data"); + x.pkg.xml_part(&data, dgm::CT_DATA, dgm::data_xml(d, s.id.0, &drawing_rel)); + let dm = rel(o, "diagramData", &data); + let layout = name("layout"); + let lo_xml = d.layout_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::layout_xml(&d.kind)); + x.pkg.xml_part(&layout, dgm::CT_LAYOUT, lo_xml); + let lo = rel(o, "diagramLayout", &layout); + let style = name("quickStyle"); + x.pkg.xml_part(&style, dgm::CT_STYLE, d.style_xml.clone().map(String::into_bytes).unwrap_or_else(dgm::style_xml)); + let qs = rel(o, "diagramQuickStyle", &style); + let colors = name("colors"); + x.pkg.xml_part(&colors, dgm::CT_COLORS, d.colors_xml.clone().map(String::into_bytes).unwrap_or_else(dgm::colors_xml)); + let cs = rel(o, "diagramColors", &colors); + let mut fs = s.clone(); + fs.xfrm = Some(frame); + frame_open(w, x, o, id, &fs); + w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/diagram")); + w.empty("dgm:relIds", A::new().a("xmlns:dgm", dgm::NS_DGM).a("r:dm", dm).a("r:lo", lo).a("r:qs", qs).a("r:cs", cs)); + w.close("a:graphicData"); + frame_close(w); +} + fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { let pts: Vec<(f64, f64)> = st.points.iter().filter(|p| p.0.is_finite() && p.1.is_finite()).map(|p| (p.0, p.1)).collect(); let (x0, y0) = pts.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1))); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d1427027..8f265645 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -74,7 +74,7 @@ the round trip even though DeckCraft doesn't model it. | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | -| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save (DeckCraft keeps its own outline in `.deckcraft` only) | +| SmartArt (`dgm`) | the drawing part as a group, and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | | Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink | diff --git a/docs/gaps.md b/docs/gaps.md index b737232c..3c7d5b31 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -151,14 +151,16 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). ### SmartArt editing is shallow -- **Evidence:** a SmartArt graphic inserted in DeckCraft keeps its outline (`Shape.diagram`) and - has a Text Pane, Add Shape (after, before, above, below), Promote, Demote, Remove and layout - switching between DeckCraft's six layouts (`crates/engine/src/cmd/smartart.rs`). But - `crates/pptx/src/read/shapes.rs` reads a PowerPoint diagram's drawing part as a plain group (no - outline, so no Text Pane), the outline isn't written to `.pptx` (`dgm` data), and there are six - layouts rather than PowerPoint's ~200. -- **Impact:** editing a SmartArt diagram from PowerPoint breaks it. -- **Estimate:** 8–14 h. +- **Evidence:** a SmartArt graphic keeps its outline (`Shape.diagram`) and has a Text Pane, Add + Shape, Promote, Demote, Remove and layout switching (`crates/engine/src/cmd/smartart.rs`). On + `.pptx` the outline is written as a real diagram (data, layout, quick style, colours and the + cached drawing; `crates/pptx/src/dgm.rs`), and PowerPoint diagrams read their outline from the + data model, so they get the Text Pane too. Still missing: DeckCraft's own layout definitions are + simple (not yet checked in PowerPoint itself); there are six layouts rather than ~200; editing an + imported diagram's outline redraws it with DeckCraft's layout; a diagram with no cached drawing + isn't drawn. +- **Impact:** a PowerPoint diagram edited in DeckCraft changes look. +- **Estimate:** 6–10 h. ### Animation Pane, effect count and preview Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**.