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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 01:29:49 +03:00
co-authored by Cursor
parent 6e40335424
commit b03b19bca7
12 changed files with 675 additions and 17 deletions
Generated
+1
View File
@@ -955,6 +955,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"zip",
]
[[package]]
+3
View File
@@ -25,3 +25,6 @@ i_overlay = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
image = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
+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
@@ -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<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)));
}
+15
View File
@@ -332,6 +332,21 @@ pub struct Diagram {
/// list, process, cycle, hierarchy, pyramid or matrix.
pub kind: String,
pub nodes: Vec<DiagramNode>,
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub style_xml: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub colors_xml: Option<String>,
}
/// One line of the Text Pane: its text and outline level (0 = top level).
+451
View File
@@ -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<u8> {
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<String, usize> = 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<DiagramNode> {
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::<String>()
})
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.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<u8> {
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<u8> {
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<u8> {
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<DiagramNode> {
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#"<dgm:dataModel xmlns:dgm="d"><dgm:ptLst><dgm:pt modelId="0" type="doc"/><dgm:pt modelId="1"><dgm:t><a:p xmlns:a="a"><a:r><a:t>A</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="2"/></dgm:ptLst><dgm:cxnLst><dgm:cxn srcId="0" destId="1"/><dgm:cxn srcId="1" destId="2"/><dgm:cxn srcId="2" destId="1"/><dgm:cxn type="presOf" srcId="0" destId="2"/></dgm:cxnLst></dgm:dataModel>"#;
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");
}
}
+1
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
mod custgeom;
mod dgm;
mod maps;
mod opc;
mod png;
+40
View File
@@ -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<deckcraft_model::Diagram> {
let nodes = crate::dgm::outline(data);
if nodes.is_empty() {
return None;
}
let raw = |attr: &str| -> Option<String> {
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.
+7
View File
@@ -43,6 +43,7 @@ pub struct Exp<'a> {
n_image: u32,
n_media: u32,
n_chart: u32,
n_diagram: u32,
pub slide_parts: HashMap<SlideId, String>,
pub table_styles: Vec<String>,
placeholder: Option<String>,
@@ -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<Vec<u8>, PptxError> {
n_image: 0,
n_media: 0,
n_chart: 0,
n_diagram: 0,
slide_parts: HashMap::new(),
table_styles: vec![],
placeholder: None,
+95 -4
View File
@@ -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<Shape> {
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)));
+1 -1
View File
@@ -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 |
+10 -8
View File
@@ -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**.