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.
This commit is contained in:
pierrevbc
2026-10-13 11:23:30 +02:00
parent 1660982e58
commit 8a4342757b
2 changed files with 274 additions and 0 deletions
+107
View File
@@ -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<Shape>, depth: usize) -> Vec<Shape> {
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<Shape> {
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") {
+167
View File
@@ -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}><a:off x="{}" y="{}"/><a:ext cx="{}" cy="{}"/></{tag}>"#, 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#"<dsp:sp modelId="{{00000000-0000-0000-0000-000000000000}}"><dsp:nvSpPr><dsp:cNvPr id="0" name=""/><dsp:cNvSpPr/></dsp:nvSpPr><dsp:spPr>{}<a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="DDEEFF"/></a:solidFill></dsp:spPr><dsp:style><a:lnRef idx="1"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="dk1"/></a:fontRef></dsp:style><dsp:txBody><a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="ctr"><a:noAutofit/></a:bodyPr><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="1000"/><a:t>{text}</a:t></a:r></a:p></dsp:txBody>{}</dsp:sp>"#,
xfrm("a:xfrm", r, x, y, w, h),
xfrm("dsp:txXfrm", tr, tx, ty, tw, th),
)
}
fn entries(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
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<u8>)]) -> Vec<u8> {
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<u8>)], 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<u8> {
let mut files = entries(&deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"));
let (fx, fy, fw, fh) = FRAME;
let frame = format!(
r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="90" name="Diagram 1"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="{}" y="{}"/><a:ext cx="{}" cy="{}"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="rIdDm" r:lo="rIdLo" r:qs="rIdQs" r:cs="rIdCs"/></a:graphicData></a:graphic></p:graphicFrame></p:spTree>"#,
emu(fx),
emu(fy),
emu(fw),
emu(fh)
);
replace(&mut files, "ppt/slides/slide1.xml", "</p:spTree>", &frame);
replace(
&mut files,
"ppt/slides/_rels/slide1.xml.rels",
"</Relationships>",
r#"<Relationship Id="rIdDm" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData" Target="../diagrams/data1.xml"/><Relationship Id="rIdDr" Type="http://schemas.microsoft.com/office/2007/relationships/diagramDrawing" Target="../diagrams/drawing1.xml"/></Relationships>"#,
);
let data = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst/><dgm:extLst><a:ext uri="http://schemas.microsoft.com/office/drawing/2008/diagram"><dsp:dataModelExt xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" relId="rIdDr" minVer="http://schemas.openxmlformats.org/drawingml/2006/diagram"/></a:ext></dgm:extLst></dgm:dataModel>"#;
let drawing = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<dsp:drawing xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dsp:spTree><dsp:nvGrpSpPr><dsp:cNvPr id="0" name=""/><dsp:cNvGrpSpPr/></dsp:nvGrpSpPr><dsp:grpSpPr/>{}</dsp:spTree></dsp:drawing>"#,
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);
}