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 1/2] 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); +} From daf612b8fd9b527f39e200fe4618749829c0b8f4 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:22:06 +0200 Subject: [PATCH 2/2] Progress docs: SmartArt text on its dsp:txXfrm rectangle - file-format-parity.md: SmartArt row notes the text rectangle; status line and history. - ROADMAP.md: progress log line. No checklist row changes score (Insert SmartArt stays P), and the gaps.md SmartArt entry (pre-drawn shapes only) still holds. --- ROADMAP.md | 3 ++- docs/file-format-parity.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2dba0e27..55b57ada 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** 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-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 @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 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 | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..78a4547e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) |