diff --git a/Cargo.lock b/Cargo.lock index 2c3ea457..9e71a6e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,6 +957,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9493ddba..778664a7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -28,7 +28,9 @@ image = { workspace = true } [dev-dependencies] deckcraft-anim = { workspace = true } +zip = { workspace = true } # The browser clock for comment timestamps (wasm has no std::time::SystemTime). [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" + 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 0863db3e..898be261 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 = { + 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 465f7840..9f7fca8e 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -339,6 +339,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 638fc3f3..e5176d79 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -9,6 +9,7 @@ mod cfb; 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 b59828d0..9d27a22e 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -508,6 +508,7 @@ 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) } @@ -617,6 +618,45 @@ fn text_area(geom: &Geom, w: f64, h: f64) -> deckcraft_geom::Rect { } } +/// 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 27f532e8..43655864 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -52,6 +52,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, @@ -168,6 +169,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); @@ -467,6 +473,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> 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 8bc5d5fb..0b104e61 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -205,7 +205,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); @@ -223,12 +227,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); } @@ -249,7 +257,7 @@ fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { w.raw(&xml); } } - w.close("p:spPr"); + w.close(tag); } /// Write the children of a shape tree. @@ -302,6 +310,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"); @@ -464,6 +477,84 @@ fn write_model( w.close("mc:AlternateContent"); } +/// 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 ffb31385..305cbacd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -93,6 +93,7 @@ the round trip even though DeckCraft doesn't model it. | SmartArt (`dgm`) | drawing part only, as a group; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) | | 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 2faa4291..274378df 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -212,14 +212,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**.