mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #601 from NikitkaRa: SmartArt: write the outline to .pptx as a diagram and read it back from the data model
# Conflicts: # crates/engine/Cargo.toml # crates/pptx/src/read/shapes.rs # crates/pptx/src/write/shapes.rs
This commit is contained in:
Generated
+1
@@ -957,6 +957,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1728,3 +1728,41 @@ fn smartart_text_pane_edits_regenerate_the_diagram() {
|
||||
let back = deckcraft_format::load(&bytes).unwrap();
|
||||
assert!(back.slides[0].shapes.iter().any(|x| x.diagram.as_ref().is_some_and(|d| d.nodes.len() == 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smartart_round_trips_through_pptx_as_a_diagram() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.smartArt", &json!({"kind": "hierarchy", "items": ["CEO", "\tCTO", "\t\tDev", "\tCFO"]})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.as_ref().clone();
|
||||
let bytes = deckcraft_pptx::export(&doc).unwrap();
|
||||
let names: Vec<String> = {
|
||||
let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
z.file_names().map(String::from).collect()
|
||||
};
|
||||
for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] {
|
||||
assert!(names.iter().any(|n| n == &format!("ppt/diagrams/{part}.xml")), "{part} in {names:?}");
|
||||
}
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
assert!(back.validate().is_empty());
|
||||
let g = back.slides[1].shapes.iter().find(|x| x.diagram.is_some()).expect("a SmartArt group");
|
||||
let d = g.diagram.as_ref().unwrap();
|
||||
assert_eq!(d.kind, "hierarchy");
|
||||
assert!(d.imported);
|
||||
let outline: Vec<(&str, u8)> = d.nodes.iter().map(|n| (n.text.as_str(), n.level)).collect();
|
||||
assert_eq!(outline, [("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1)]);
|
||||
assert!(g.children().len() >= 4, "the cached drawing is read back as shapes");
|
||||
|
||||
// The Text Pane works on the imported diagram, and a second round trip keeps the edit.
|
||||
let gid = g.id.0;
|
||||
let path = std::env::temp_dir().join(format!("deckcraft-smartart-{}.pptx", std::process::id()));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s2 = Session::new();
|
||||
s2.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
s2.execute("slide.go", &json!({"index": 1})).unwrap();
|
||||
s2.execute("smartArt.addNode", &json!({"id": gid, "index": 3, "position": "after", "text": "COO"})).unwrap();
|
||||
let again = deckcraft_pptx::import(&deckcraft_pptx::export(s2.doc().unwrap().doc.as_ref()).unwrap()).unwrap();
|
||||
let d = again.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap();
|
||||
assert_eq!(d.nodes.last().map(|n| (n.text.as_str(), n.level)), Some(("COO", 1)));
|
||||
}
|
||||
|
||||
@@ -339,6 +339,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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
mod cfb;
|
||||
mod custgeom;
|
||||
mod dgm;
|
||||
mod maps;
|
||||
mod opc;
|
||||
mod png;
|
||||
|
||||
@@ -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<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.
|
||||
|
||||
@@ -52,6 +52,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>,
|
||||
@@ -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<Vec<u8>, PptxError> {
|
||||
n_image: 0,
|
||||
n_media: 0,
|
||||
n_chart: 0,
|
||||
n_diagram: 0,
|
||||
slide_parts: HashMap::new(),
|
||||
table_styles: vec![],
|
||||
placeholder: None,
|
||||
|
||||
@@ -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<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)));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+10
-8
@@ -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**.
|
||||
|
||||
Reference in New Issue
Block a user