Merge PR #242 from pierrevbc: SmartArt: text is laid out on its text rectangle (dsp:txXfrm), not in columns

This commit is contained in:
Brandon Thomas
2026-10-14 12:57:22 -04:00
4 changed files with 279 additions and 2 deletions
+2
View File
@@ -4,6 +4,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (video-seek race fixed and regression-tested; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (presenter keyboard navigation; hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (SmartArt text on its text rectangle; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -138,6 +139,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
| 2026-10-13 | Slide show for interactive decks: click-triggered animations play (one step per click, repeating, applied in click order), a slide the show returns to keeps its state and its triggered animations go on where they stopped, shapes an animation has hidden cannot be clicked, a shape without an action blocks the one beneath it, hand cursor over clickable shapes, "advance on click" off respected |
| 2026-10-12 | SmartArt import: text laid out on its `dsp:txXfrm` text rectangle, not squeezed into the shape (list layouts no longer show text in narrow columns) |
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
+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 {
// A table naming no style is drawn by PowerPoint with thin dark grid lines ("No Style, Table
// Grid"), not without lines.
+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);
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (SmartArt text follows `dsp:txXfrm`; previous: trivial, Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
@@ -74,7 +74,7 @@ the round trip even though DeckCraft doesn't model it.
| Tables with styles | yes | yes | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | |
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save |
| SmartArt (`dgm`) | drawing part only, as a group; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save |
| 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 |
@@ -99,6 +99,7 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box |
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |