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); +}