diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index b26d0308..6a533015 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8; const MAX_SHAPES: usize = 40; /// DeckCraft's layouts, with the names PowerPoint's gallery gives them. -pub const LAYOUTS: [(&str, &str); 18] = [ +pub const LAYOUTS: [(&str, &str); 22] = [ ("list", "Vertical Bullet List"), ("blockList", "Basic Block List"), ("hBulletList", "Horizontal Bullet List"), @@ -42,6 +42,10 @@ pub const LAYOUTS: [(&str, &str); 18] = [ ("pictureList", "Vertical Picture List"), ("pictureCaption", "Picture Caption List"), ("pictureProcess", "Picture Accent Process"), + ("pictureHList", "Horizontal Picture List"), + ("timeline", "Basic Timeline"), + ("venn", "Basic Venn"), + ("target", "Basic Target"), ]; /// SmartArt Styles. pub const STYLES: [(&str, &str); 9] = [ @@ -125,7 +129,7 @@ pub fn specs() -> Vec { "Change Layout", ["SmartArt Design", "Layouts"], None, - "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess}", + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target}", has_selection, change_layout ), @@ -323,14 +327,17 @@ fn shade(d: &Diagram, i: usize) -> Option { /// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) /// lit by the three-point rig. -fn three_d(bevel: &str, w: f64, h: f64) -> String { +fn three_d(bevel: &str, w: f64, h: f64, camera: &str) -> String { format!( - "", + "", (w * 12700.0).round() as i64, (h * 12700.0).round() as i64 ) } +/// Brick Scene's camera: the diagram's shapes seen from above and to the side. +pub const BRICK_CAMERA: &str = "isometricOffAxis2Top"; + /// Whether `style` is one of the 3-D SmartArt Styles. pub fn is_3d(style: &str) -> bool { matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") @@ -374,7 +381,7 @@ fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { sh.effects = Some(Effects { outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), bevel: Some(bevel.into()), - raw3d: Some(three_d(bevel, bw, bw * 0.6)), + raw3d: Some(three_d(bevel, bw, bw * 0.6, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })), ..Default::default() }); } @@ -778,6 +785,105 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { } } } + "pictureHList" => { + // Horizontal Picture List: a column per node, a big picture over its text. + let w = area.w / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * w + w * 0.04; + let bw = w * 0.92; + let ph = area.h * 0.55; + out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } + } + "timeline" => { + // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and + // below in turn. + let mid = area.y + area.h / 2.0; + let bar = (area.h * 0.08).max(6.0); + let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + let w = area.w * 0.92 / nf; + for (i, g) in gs.iter().enumerate() { + let cx = area.x + w * (i as f64 + 0.5); + let dot = bar * 1.4; + let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); + paint(d, &mut dsh, i, false); + out.push((dsh, vec![])); + let th = area.h / 2.0 - bar * 1.6; + let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; + let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &nodes(g), true); + if let Some(b) = &mut t.text { + b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); + } + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, g.clone())); + } + } + "venn" => { + // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = if n == 1 { s } else { s * 0.62 }; + let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); + let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(50_000)); + } + if let Some(b) = &mut sh.text { + // Text towards the circle's outer side, clear of the overlap. + let p = dc * 0.18; + let (dx, dy) = (a.cos() * p, a.sin() * p); + b.body.inset_l = Some((7.2 + dx).max(2.0)); + b.body.inset_r = Some((7.2 - dx).max(2.0)); + b.body.inset_t = Some((3.6 + dy).max(2.0)); + b.body.inset_b = Some((3.6 - dy).max(2.0)); + } + out.push((sh, g.clone())); + } + } + "target" => { + // Basic Target: nested rings, the outermost first, each ring's text in a box beside it + // with a line from the ring. + let s = area.h.min(area.w * 0.5); + let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); + let band = s / 2.0 / nf; + for (i, _) in gs.iter().enumerate() { + let dc = s - 2.0 * band * i as f64; + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + let tx = area.x + s + area.w * 0.06; + let rh = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let ty = area.y + rh * (i as f64 + 0.5); + // The point on ring i's band level with the text, else its top. + let ry = cy - s / 2.0 + band * (i as f64 + 0.5); + out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); + let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &nodes(g), true); + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + p.props.align = Some(Align::Left); + } + } + out.push((t, g.clone())); + } + } _ => {} } out diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f66f84fe..e75b25a8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -952,7 +952,12 @@ fn smartart_pictures_3d_styles_and_colour_variants() { let bytes = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap(); let back = deckcraft_pptx::import(&bytes).unwrap(); assert!(back.validate().is_empty()); - assert_eq!(back.slides[1].shapes.iter().find_map(|x| x.diagram.as_ref()).map(|d| d.kind.clone()).as_deref(), Some("pictureCaption")); + let bd = back.slides[1].shapes.iter().find_map(|x| x.diagram.as_ref()).unwrap(); + assert_eq!(bd.kind, "pictureCaption"); + // The node's picture comes back from the data model's blip, as its media. + let m = bd.nodes[0].picture.expect("node picture read back"); + assert!(back.media(m).is_some_and(|x| !x.data.is_empty())); + assert!(bd.nodes.iter().skip(1).all(|n| n.picture.is_none())); s.execute("smartArt.picture", &json!({"index": 0, "reset": true})).unwrap(); assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_))))); diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs index e748da3e..be5a280e 100644 --- a/crates/pptx/src/dgm.rs +++ b/crates/pptx/src/dgm.rs @@ -111,7 +111,11 @@ pub fn layout_id(kind: &str) -> &'static str { "titledMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix1", "pictureList" => "urn:microsoft.com/office/officeart/2005/8/layout/vList4", "pictureCaption" => "urn:microsoft.com/office/officeart/2005/8/layout/pList1", - "pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2", + "pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess10", + "pictureHList" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2", + "timeline" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess11", + "venn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn1", + "target" => "urn:microsoft.com/office/officeart/2005/8/layout/target1", _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", } } @@ -138,6 +142,10 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { "pictureList", "pictureCaption", "pictureProcess", + "pictureHList", + "timeline", + "venn", + "target", "list", ] { if layout_id == layout_id_of(k) { @@ -145,7 +153,13 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str { } } let s = format!("{last} {}", category.to_ascii_lowercase()); - if s.contains("hierarchy") || s.contains("orgchart") { + if s.contains("venn") { + "venn" + } else if s.contains("target") { + "target" + } else if s.contains("timeline") { + "timeline" + } else if s.contains("hierarchy") || s.contains("orgchart") { "hierarchy" } else if s.contains("radial") { "radial" @@ -174,7 +188,8 @@ fn layout_id_of(kind: &str) -> &'static str { fn category(kind: &str) -> &'static str { match kind { - "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" => "process", + "process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" => "process", + "venn" | "target" => "relationship", "cycle" | "blockCycle" | "radial" => "cycle", "hierarchy" => "hierarchy", "pyramid" | "invertedPyramid" => "pyramid", @@ -206,8 +221,8 @@ fn text(w: &mut W, t: &str) { } /// `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 { +/// cached drawing; `pics` the data part's relationship ids of node pictures, by node index. +pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, String)]) -> 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)); @@ -245,6 +260,24 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { w.close("dgm:pt"); } } + // A node's picture sits on the presentation point of its picture placeholder, as PowerPoint + // stores it (`presAssocID` names the node, the blip is a relationship of the data part). + for (k, rid) in pics { + if *k >= d.nodes.len() { + continue; + } + w.open("dgm:pt", A::new().a("modelId", guid(seed, 0x20_0000 + k)).a("type", "pres")); + w.empty("dgm:prSet", A::new().a("presAssocID", node(*k)).a("presName", "Image").a("presStyleLbl", "fgImgPlace1")); + w.open0("dgm:spPr"); + w.open0("a:blipFill"); + w.empty("a:blip", A::new().a("r:embed", rid.as_str())); + w.open0("a:stretch"); + w.empty0("a:fillRect"); + w.close("a:stretch"); + w.close("a:blipFill"); + w.close("dgm:spPr"); + w.close("dgm:pt"); + } w.close("dgm:ptLst"); w.open0("dgm:cxnLst"); let mut stack: Vec<(u8, String)> = vec![]; @@ -286,6 +319,31 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec { /// The outline of a data part: the `parOf` tree under the document point, depth first. pub fn outline(data: &El) -> Vec { + outline_ids(data).into_iter().map(|(_, n)| n).collect() +} + +/// The relationship ids (of the data part) of node pictures, by node index in [`outline`]: the +/// blip fill of a presentation point associated with the node. +pub fn node_pictures(data: &El) -> Vec<(usize, String)> { + let ids: HashMap = outline_ids(data).into_iter().enumerate().map(|(k, (id, _))| (id, k)).collect(); + let Some(pts) = data.child("ptLst") else { return vec![] }; + let mut out: Vec<(usize, String)> = vec![]; + for pt in pts.children_named("pt").take(MAX_NODES * 8) { + if pt.attr("type") != Some("pres") { + continue; + } + let Some(k) = pt.child("prSet").and_then(|p| p.attr("presAssocID")).and_then(|a| ids.get(a)) else { continue }; + let Some(rid) = pt.child("spPr").and_then(|s| s.child("blipFill")).and_then(|b| b.child("blip")).and_then(|b| b.attr("r:embed")) else { + continue; + }; + if !out.iter().any(|(j, _)| j == k) { + out.push((*k, rid.to_string())); + } + } + out +} + +fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { let Some(pts) = data.child("ptLst") else { return vec![] }; let mut texts: HashMap<&str, String> = HashMap::new(); let mut doc: Option<&str> = None; @@ -324,7 +382,7 @@ pub fn outline(data: &El) -> Vec { if out.len() >= MAX_NODES || !seen.insert(id) { continue; } - out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None }); + out.push((id.to_string(), DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None })); if let Some(v) = kids.get(id) { stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); } @@ -348,10 +406,12 @@ fn paragraphs(t: &El) -> String { /// 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" | "stepUp" | "pictureProcess" | "pictureCaption" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"), + "process" | "stepUp" | "pictureProcess" | "pictureCaption" | "pictureHList" | "timeline" => { + ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect") + } "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), - "cycle" | "radial" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "cycle" | "radial" | "venn" | "target" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), "pyramid" | "invertedPyramid" => { ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") @@ -500,6 +560,8 @@ fn bevel(style: &str) -> Option<(&'static str, i64)> { /// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. pub fn style_xml(style: &str) -> Vec { + // Brick Scene looks at the diagram from above and to the side. + let camera = if style == "brick" { "isometricOffAxis2Top" } else { "orthographicFront" }; 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(style))); w.empty("dgm:title", A::new().a("val", "")); @@ -508,12 +570,12 @@ pub fn style_xml(style: &str) -> Vec { w.empty("dgm:cat", A::new().a("type", if bevel(style).is_some() { "3D" } else { "simple" }).a("pri", "10100")); w.close("dgm:catLst"); w.open0("dgm:scene3d"); - w.empty("a:camera", A::new().a("prst", "orthographicFront")); + w.empty("a:camera", A::new().a("prst", camera)); 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:camera", A::new().a("prst", camera)); w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); w.close("dgm:scene3d"); match bevel(style) { @@ -593,7 +655,7 @@ mod tests { 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 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(); @@ -613,6 +675,14 @@ mod tests { } } + #[test] + fn node_pictures_come_from_presentation_points() { + let d = Diagram { kind: "pictureList".into(), nodes: nodes(&[("A", 0), ("a", 1), ("B", 0)]), ..Default::default() }; + let xml = data_xml(&d, 3, "rId1", &[(2, "rId7".into()), (9, "rId8".into())]); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(node_pictures(&doc.root), vec![(2, "rId7".to_string())]); + } + #[test] fn style_and_colour_ids_round_trip() { for st in ["", "subtle", "intense", "outline", "polished", "inset", "cartoon", "powder", "brick"] { @@ -622,7 +692,9 @@ mod tests { assert_eq!(colors_for(&cs_id(c)), c); } assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT")); - for k in ["pictureList", "pictureCaption", "pictureProcess"] { + assert!(layout_id("pictureProcess").ends_with("/hProcess10"), "Picture Accent Process"); + assert!(layout_id("pictureHList").ends_with("/pList2"), "Horizontal Picture List"); + for k in ["pictureList", "pictureCaption", "pictureProcess", "pictureHList", "timeline", "venn", "target"] { assert_eq!(kind_for(layout_id(k), ""), k); } } diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 34d7bbc1..e9ea5ffb 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -433,6 +433,17 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> 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); + // Node pictures, from the blips of the data model's presentation points. + if let Some(d) = &mut g.diagram { + let rels = imp.pkg.rels(&dm.target); + for (k, rid) in crate::dgm::node_pictures(&data.root) { + let Some(target) = rels.get(&rid).filter(|r| !r.external).map(|r| r.target.clone()) else { continue }; + let media = imp.media_for(&target); + if let Some(n) = d.nodes.get_mut(k) { + n.picture = media; + } + } + } Some(g) } diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 26cb67de..536ecfdd 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -470,7 +470,12 @@ fn diagram(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &dec 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)); + // Node pictures: on the data model's presentation points, through the data part's rels. + let mut mout = Out::new(&data); + let pics: Vec<(usize, String)> = + d.nodes.iter().enumerate().filter_map(|(k, n)| n.picture.and_then(|m| x.media_rel(&mut mout, m, "image")).map(|r| (k, r))).collect(); + x.pkg.xml_part(&data, dgm::CT_DATA, dgm::data_xml(d, s.id.0, &drawing_rel, &pics)); + x.pkg.rels(&data, &mout.rels); 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));