From c5a63fc8b7c9d0e58d88b39b38d9996e2f78f83e Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:02:42 +0300 Subject: [PATCH] SmartArt: node pictures from the data model, Brick Scene camera, four more layouts Node pictures are written on the data model's presentation points (blip through the data part's rels) and read back into the outline on import. Picture Accent Process is hProcess10, not pList2 (checked against PowerPoint's layout list); pList2 is now Horizontal Picture List. Brick Scene looks through an isometric camera. New layouts: Horizontal Picture List, Basic Timeline, Basic Venn, Basic Target. Co-authored-by: Cursor --- crates/engine/src/cmd/smartart.rs | 116 ++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 7 +- crates/pptx/src/dgm.rs | 96 +++++++++++++++++++++---- crates/pptx/src/read/shapes.rs | 11 +++ crates/pptx/src/write/shapes.rs | 7 +- 5 files changed, 218 insertions(+), 19 deletions(-) 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));