From 8a4342757bfd0c8c1b4c13736689d95f01067d9f Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:22:04 +0200 Subject: [PATCH] SmartArt: text is laid out on its text rectangle (dsp:txXfrm), not in columns A SmartArt drawing shape keeps its text rectangle in dsp:txXfrm, apart from the shape's own box. The importer ignored it and laid the text out in the shape itself. In list layouts the shape is a tall, narrow box turned by 90 degrees, so the text came out as narrow vertical columns, in the editor and in the exported PPTX. The text rectangle uses the shape's coordinate space and is turned by the shape's rotation plus its own: - When it is the shape's text area shrunk by some margin, with the same orientation, the shape keeps its text and the margin is added to its insets. - Otherwise (a rectangle reaching outside the shape, or turned against it) the text moves to a new shape without fill or outline, placed on the rectangle right above its shape in the diagram's group. Both export as plain DrawingML (positive insets, an ordinary rotated shape), which other readers lay out the same way. Test: a SmartArt drawing built in the test, with a text rectangle wider than its shape, a turned shape with upright text, and a rectangle inside its shape; the laid-out text box is checked on import and after a PPTX round trip. --- crates/pptx/src/read/shapes.rs | 107 +++++++++++++++++++++ crates/pptx/tests/smartart.rs | 167 +++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 crates/pptx/tests/smartart.rs diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 51b2b863..b4c3348e 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -429,12 +429,119 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> if children.is_empty() { return None; } + let children = smartart_text_rects(imp, &mut sub, tree, children, depth + 1); 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) }; Some(g) } +/// A SmartArt drawing shape (`dsp:sp`) keeps its text rectangle in `dsp:txXfrm`, apart from the +/// shape's own box: the text of a narrow shape turned by 90° is often laid out across it. Put each +/// shape's text on that rectangle. `shapes` are what [`sp_tree`] read from `tree`, in order. +fn smartart_text_rects(imp: &mut Imp, ctx: &mut IdCtx, tree: &El, shapes: Vec, depth: usize) -> Vec { + if depth > MAX_TREE_DEPTH { + return shapes; + } + let mut out = Vec::with_capacity(shapes.len()); + let mut shapes = shapes.into_iter(); + // The same elements `sp_tree` turns into shapes. + for e in tree.elements().filter(|e| matches!(e.local(), "sp" | "pic" | "grpSp" | "cxnSp" | "graphicFrame")) { + let Some(mut s) = shapes.next() else { break }; + let mut text_box = None; + match (e.local(), &mut s.kind) { + ("grpSp", ShapeKind::Group { children, .. }) => { + let kids = std::mem::take(children); + *children = smartart_text_rects(imp, ctx, e, kids, depth + 1); + } + ("sp", ShapeKind::Shape) => { + if let Some(t) = e.child("txXfrm") { + text_box = text_on_rect(imp, ctx, &mut s, dml::xfrm(t).0); + } + } + _ => {} + } + out.push(s); + out.extend(text_box); + } + out.extend(shapes); + out +} + +/// Lay out the text of `s` on `t`, a text rectangle in the same space as the shape's box, turned by +/// the shape's rotation plus its own. When that is the shape's text area shrunk by some margin, the +/// shape keeps its text with wider insets. Otherwise (a rectangle reaching outside the shape, or +/// turned against it) the text moves to a new frameless text shape over `s`, which is returned: +/// both export as plain DrawingML that any reader lays out the same way. +fn text_on_rect(imp: &mut Imp, ctx: &mut IdCtx, s: &mut Shape, t: Xfrm) -> Option { + const EPS: f64 = 0.01; + let x = s.xfrm?; + if !x.is_finite() || !t.is_finite() || t.w <= EPS || t.h <= EPS { + return None; + } + let body = s.text.as_mut().filter(|b| !b.is_empty())?; + let turn = |a: f64| a.rem_euclid(360.0); + let upright = |a: f64| turn(a + EPS) < 2.0 * EPS; + let bp = &mut body.body; + if !x.flip_h + && !x.flip_v + && upright(t.rot) + && bp.rot.is_none_or(upright) + && bp.vert.is_none_or(|v| v == TextDir::Horizontal) + && bp.upright != Some(true) + { + // Same orientation as the shape: express the rectangle as insets from the shape's text area. + let c = x.affine().inverse() * t.center(); + let (l, top) = (c.x - t.w / 2.0, c.y - t.h / 2.0); + let (r, b) = (l + t.w, top + t.h); + let area = text_area(&s.geom, x.w, x.h); + let extra = [l - area.x0, top - area.y0, area.x1 - r, area.y1 - b]; + if extra.iter().all(|v| v.is_finite() && *v >= -EPS) { + let [el, et, er, eb] = extra.map(|v| v.max(0.0)); + bp.inset_l = Some(bp.inset_l.unwrap_or(7.2) + el); + bp.inset_t = Some(bp.inset_t.unwrap_or(3.6) + et); + bp.inset_r = Some(bp.inset_r.unwrap_or(7.2) + er); + bp.inset_b = Some(bp.inset_b.unwrap_or(3.6) + eb); + return None; + } + } + if ctx.count >= MAX_SHAPES { + return None; + } + ctx.count += 1; + let text = s.text.take(); + // No fill, outline or effects; the style keeps only the text colour and font. + let style = s.style.clone().map(|mut st| { + st.fill_ref.0 = 0; + st.line_ref.0 = 0; + st.effect_ref.0 = 0; + st + }); + Some(Shape { + id: ctx.assign(imp, None), + name: s.name.clone(), + xfrm: Some(Xfrm { rot: turn(x.rot + t.rot), flip_h: false, flip_v: false, ..t }), + fill: Some(Fill::None), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + style, + text, + click: s.click.clone(), + hover: s.hover.clone(), + ..Default::default() + }) +} + +/// Where a shape's geometry puts its text, in shape-local points (as the renderer computes it). +fn text_area(geom: &Geom, w: f64, h: f64) -> deckcraft_geom::Rect { + let full = deckcraft_geom::Rect::new(0.0, 0.0, w, h); + match geom { + Geom::Preset { name, adj } => { + preset::build(name, w, h, adj).or_else(|| preset::build("rect", w, h, &[])).map(|g| g.text_rect).unwrap_or(full) + } + Geom::Custom { .. } => full, + } +} + fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() }; if let Some(pr) = tbl.child("tblPr") { diff --git a/crates/pptx/tests/smartart.rs b/crates/pptx/tests/smartart.rs new file mode 100644 index 00000000..3d3dd996 --- /dev/null +++ b/crates/pptx/tests/smartart.rs @@ -0,0 +1,167 @@ +//! SmartArt drawings: the text of each pre-drawn shape is laid out on its `dsp:txXfrm`. +//! +//! The deck is built here: an export of a new presentation with a SmartArt frame, data part and +//! drawing part written by the test. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_engine::render::place_slide; +use deckcraft_geom::{Point, Rect}; +use deckcraft_model::{Presentation, ShapeKind, defaults}; + +const EMU: f64 = 12_700.0; + +/// Frame of the diagram on the slide, in points. +const FRAME: (f64, f64, f64, f64) = (72.0, 72.0, 432.0, 288.0); + +fn emu(pt: f64) -> i64 { + (pt * EMU).round() as i64 +} + +fn xfrm(tag: &str, rot: f64, x: f64, y: f64, w: f64, h: f64) -> String { + let rot = if rot == 0.0 { String::new() } else { format!(" rot=\"{}\"", (rot * 60_000.0).round() as i64) }; + format!(r#"<{tag}{rot}>"#, emu(x), emu(y), emu(w), emu(h)) +} + +/// One `dsp:sp`: shape box, text rectangle (both in diagram points) and text. +fn drawing_shape(prst: &str, shape: (f64, f64, f64, f64, f64), text_rect: (f64, f64, f64, f64, f64), text: &str) -> String { + let (r, x, y, w, h) = shape; + let (tr, tx, ty, tw, th) = text_rect; + format!( + r#"{}{text}{}"#, + xfrm("a:xfrm", r, x, y, w, h), + xfrm("dsp:txXfrm", tr, tx, ty, tw, th), + ) +} + +fn entries(bytes: &[u8]) -> Vec<(String, Vec)> { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + (0..z.len()) + .map(|i| { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + (f.name().to_string(), d) + }) + .collect() +} + +fn zip_of(files: &[(String, Vec)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, d) in files { + z.start_file(n.as_str(), o).expect("start"); + z.write_all(d).expect("write"); + } + z.finish().expect("finish").into_inner() +} + +fn replace(files: &mut [(String, Vec)], name: &str, from: &str, to: &str) { + let (_, d) = files.iter_mut().find(|(n, _)| n == name).unwrap_or_else(|| panic!("{name} missing")); + let s = String::from_utf8(d.clone()).expect("utf8"); + assert!(s.contains(from), "{name} has no {from}"); + *d = s.replacen(from, to, 1).into_bytes(); +} + +/// A deck whose first slide holds a SmartArt frame drawn by `shapes`. +fn smartart_deck(shapes: &[String]) -> Vec { + let mut files = entries(&deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export")); + let (fx, fy, fw, fh) = FRAME; + let frame = format!( + r#""#, + emu(fx), + emu(fy), + emu(fw), + emu(fh) + ); + replace(&mut files, "ppt/slides/slide1.xml", "", &frame); + replace( + &mut files, + "ppt/slides/_rels/slide1.xml.rels", + "", + r#""#, + ); + let data = r#" +"#; + let drawing = format!( + r#" +{}"#, + shapes.concat() + ); + files.push(("ppt/diagrams/data1.xml".into(), data.as_bytes().to_vec())); + files.push(("ppt/diagrams/drawing1.xml".into(), drawing.into_bytes())); + zip_of(&files) +} + +/// Each text of slide 1 with the slide-space box it is laid out in (insets applied), the number of +/// lines, and the direction of its baseline. +fn text_boxes(p: &Presentation) -> Vec<(String, Rect, usize, Point)> { + place_slide(p, 0) + .texts + .iter() + .map(|t| { + let m = t.transform; + let r = t.layout.inner; + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|c| m * c); + let mut bounds = Rect::from_points(corners[0], corners[2]); + bounds = bounds.union_pt(corners[1]).union_pt(corners[3]); + let dir = (m * Point::new(1.0, 0.0)) - (m * Point::new(0.0, 0.0)); + let dir = Point::new(dir.x / dir.hypot(), dir.y / dir.hypot()); + (t.body.text(), bounds, t.layout.lines.len(), dir) + }) + .collect() +} + +fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.05) +} + +/// Diagram rectangle (x, y, w, h) → slide rectangle. +fn on_slide(x: f64, y: f64, w: f64, h: f64) -> Rect { + Rect::new(FRAME.0 + x, FRAME.1 + y, FRAME.0 + x + w, FRAME.1 + y + h) +} + +fn check(p: &Presentation, expected: &[(&str, Rect)]) { + let boxes = text_boxes(p); + for (text, want) in expected { + let (_, got, lines, dir) = boxes.iter().find(|(t, ..)| t == text).unwrap_or_else(|| panic!("no text {text:?} in {boxes:?}")); + assert!(near(*got, *want), "{text:?}: laid out in {got:?}, want {want:?}"); + assert!((dir.x - 1.0).abs() < 1e-6 && dir.y.abs() < 1e-6, "{text:?}: baseline turned to {dir:?}"); + assert_eq!(*lines, 1, "{text:?}: wrapped into {lines} lines"); + } +} + +#[test] +fn smartart_text_follows_tx_xfrm() { + let deck = smartart_deck(&[ + // A narrow shape whose text rectangle is three times wider. + drawing_shape("rect", (0.0, 0.0, 0.0, 72.0, 144.0), (0.0, 0.0, 36.0, 216.0, 72.0), "wider than its shape"), + // A tall shape turned by 90° (wide on the slide), its text rectangle turned back: horizontal text. + drawing_shape("roundRect", (90.0, 300.0, -60.0, 48.0, 216.0), (-90.0, 220.0, 28.0, 208.0, 40.0), "turned shape, upright text"), + // A text rectangle inside the shape: the shape keeps its text, with wider insets. + drawing_shape("roundRect", (0.0, 0.0, 192.0, 216.0, 72.0), (0.0, 24.0, 204.0, 168.0, 48.0), "inside its shape"), + ]); + let expected = [ + ("wider than its shape", on_slide(0.0, 36.0, 216.0, 72.0)), + ("turned shape, upright text", on_slide(220.0, 28.0, 208.0, 40.0)), + ("inside its shape", on_slide(24.0, 204.0, 168.0, 48.0)), + ]; + + let p = deckcraft_pptx::import(&deck).expect("import"); + assert!(p.validate().is_empty(), "{:?}", p.validate()); + check(&p, &expected); + // The diagram is one group; text that left its shape is in a frameless shape of the group. + let group = p.slides[0].shapes.iter().find(|s| s.name == "Diagram 1").expect("diagram"); + let ShapeKind::Group { children, .. } = &group.kind else { panic!("diagram is not a group") }; + assert_eq!(children.len(), 5); + let inside = children.iter().find(|s| s.text.as_ref().is_some_and(|t| t.text() == "inside its shape")).expect("inside"); + assert!(matches!(inside.geom, deckcraft_model::Geom::Preset { ref name, .. } if name == "roundRect")); + for moved in children.iter().filter(|s| s.text.as_ref().is_some_and(|t| t.text() != "inside its shape")) { + assert_eq!(moved.fill, Some(deckcraft_model::style::Fill::None)); + assert_eq!(moved.line.as_ref().and_then(|l| l.fill.clone()), Some(deckcraft_model::style::Fill::None)); + } + + // The exported deck lays the text out in the same places. + let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("re-import"); + check(&q, &expected); +}