//! 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}>{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#"{}{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);
}