Files
deckcraft/crates/pptx/tests/it/roundtrip.rs
T
2026-10-23 17:11:34 -04:00

1928 lines
96 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Round trips of decks built in code: model → PPTX → model.
use std::sync::Arc;
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::style::{
ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill, PictureMode,
Shadow,
};
use deckcraft_model::table::Table;
use deckcraft_model::text::{
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody,
};
use deckcraft_model::{
Background, Comment, CustomPath, CustomShow, Geom, InkStroke, MediaClip, Presentation, Section, Shape, ShapeId, ShapeKind, Slide, SlideId,
defaults,
};
fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let img = image::RgbImage::from_pixel(w, h, image::Rgb(rgb));
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
out.into_inner()
}
fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape {
Shape { id: ShapeId(p.alloc_id().expect("ids")), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() }
}
fn new_slide(p: &mut Presentation, title: &str) -> Slide {
let layout = defaults::layout_of_kind(p, deckcraft_model::LayoutType::TitleOnly).unwrap_or_default();
let mut s = defaults::new_slide(p, layout).expect("slide");
if let Some(t) = s.shapes.first_mut() {
t.text = Some(TextBody::from_text(title));
}
s
}
/// A deck exercising most of what the model can hold.
pub fn rich_deck() -> Presentation {
let mut p = defaults::new_presentation(None);
p.props.title = "Rich deck".into();
p.props.author = "DeckCraft tests".into();
p.props.created = "2025-01-02T03:04:05Z".into();
let img = p.add_media("image1.png", "image/png", png(40, 30, [200, 40, 40])).expect("media");
let img2 = p.add_media("image2.png", "image/png", png(8, 8, [20, 140, 220])).expect("media");
let video = p.add_media("clip.mp4", "video/mp4", b"\x00\x00\x00\x18ftypmp42not-a-real-video".to_vec()).expect("media");
// Slide 1: title slide with notes and formatted text.
{
let first = p.slides[0].id;
let s = p.slide_mut(first).expect("slide");
s.notes = TextBody::from_text("Speaker notes line 1\nline 2");
let t = &mut s.shapes[0];
t.text = Some(TextBody {
paragraphs: vec![Paragraph {
runs: vec![
Run::with("Bold ", RunProps { bold: Some(true), size: Some(40.0), ..Default::default() }),
Run::with(
"red italic",
RunProps {
italic: Some(true),
fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0)))),
underline: Some("sng".into()),
strike: Some(Strike::Single),
caps: Some(Caps::Small),
spacing: Some(1.5),
baseline: Some(0.3),
font: Some("Lato".into()),
lang: Some("en-GB".into()),
..Default::default()
},
),
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break },
Run::with(
"link",
RunProps {
link: Some(Hyperlink {
action: Action::Url { url: "https://example.org/a?b=1&c=2".into() },
tooltip: "tip".into(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
},
),
],
..Default::default()
}],
..Default::default()
});
s.transition = Some(Transition {
kind: "fade".into(),
option: "throughBlack".into(),
duration_ms: 700,
advance_after_ms: Some(3000),
..Default::default()
});
}
// Slide 2: shapes, fills, lines, effects, custom geometry, group, connector.
let mut s = new_slide(&mut p, "Shapes");
let mut a = shape(&mut p, "Gradient Star", 100.0, 120.0, 150.0, 150.0);
a.geom = Geom::Preset { name: "star5".into(), adj: vec![20000.0] };
a.fill = Some(Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![
GradientStop { pos: 0.0, color: ColorRef::scheme(SchemeSlot::Accent1) },
GradientStop { pos: 1.0, color: ColorRef::scheme(SchemeSlot::Accent2).with(ColorTransform::LumMod(75000)) },
],
shape: GradientShape::Linear { angle: 45.0, scaled: false },
rotate_with_shape: true,
}));
a.line = Some(Line { width: Some(3.0), dash: Some(Dash::Preset { name: "dash".into() }), ..Line::solid(ColorRef::rgb(Rgba::rgb(0, 0, 0)), 3.0) });
a.effects = Some(Effects {
outer_shadow: Some(Shadow {
color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(40000)),
blur: 6.0,
dist: 3.0,
dir: 90.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: "b".into(),
rotate_with_shape: false,
}),
glow: Some(Glow { color: ColorRef::scheme(SchemeSlot::Accent6), radius: 8.0 }),
soft_edge: Some(2.0),
..Default::default()
});
a.xfrm = Some(Xfrm { rot: 30.0, flip_h: true, ..Xfrm::new(100.0, 120.0, 150.0, 150.0) });
let mut b = shape(&mut p, "Pattern Box", 300.0, 120.0, 120.0, 80.0);
b.geom = Geom::Preset { name: "roundRect".into(), adj: vec![30000.0] };
b.fill =
Some(Fill::Pattern(PatternFill { preset: "dkDnDiag".into(), fg: ColorRef::scheme(SchemeSlot::Accent3), bg: ColorRef::rgb(Rgba::WHITE) }));
b.text = Some(TextBody {
body: BodyProps { anchor: Some(Anchor::Bottom), inset_l: Some(10.0), wrap: Some(false), autofit: Some(AutoFit::Shape), ..Default::default() },
paragraphs: vec![Paragraph {
props: deckcraft_model::text::ParaProps {
align: Some(Align::Right),
line_spacing: Some(Spacing::Pct(1.5)),
space_before: Some(Spacing::Pts(6.0)),
bullet: Some(Bullet::AutoNum { scheme: "romanUcPeriod".into(), start_at: 3 }),
..Default::default()
},
runs: vec![Run::new("numbered")],
level: 2,
..Default::default()
}],
..Default::default()
});
b.click = Some(Hyperlink {
action: Action::NextSlide,
tooltip: String::new(),
highlight_click: true,
sound: None,
stop_sound: false,
zoom_return: None,
});
let mut c = shape(&mut p, "Freeform", 450.0, 120.0, 100.0, 100.0);
c.geom = Geom::Custom {
paths: vec![CustomPath {
w: 100000.0,
h: 100000.0,
d: "M 0 0 L 100000 0 L 50000 100000 Z".into(),
fill: deckcraft_geom::preset::FillMode::Norm,
stroke: true,
}],
};
c.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent4)));
let child1 = Shape { geom: Geom::preset("ellipse"), ..shape(&mut p, "Child 1", 0.0, 0.0, 50.0, 50.0) };
let child2 = Shape { geom: Geom::preset("rect"), ..shape(&mut p, "Child 2", 60.0, 0.0, 50.0, 50.0) };
let (c1, c2) = (child1.id, child2.id);
let mut g = shape(&mut p, "Group", 600.0, 120.0, 220.0, 100.0);
g.kind = ShapeKind::Group { children: vec![child1, child2], child: Xfrm::new(0.0, 0.0, 110.0, 50.0) };
let mut cx = shape(&mut p, "Connector", 600.0, 300.0, 200.0, 0.0);
cx.kind = ShapeKind::Connector { start: Some((c1, 2)), end: Some((c2, 0)) };
cx.geom = Geom::preset("straightConnector1");
cx.line = Some(Line {
tail: Some(LineEnd { kind: "triangle".into(), w: "med".into(), len: "lg".into() }),
..Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)
});
let mut tb = shape(&mut p, "Text Box", 100.0, 400.0, 300.0, 40.0);
tb.text_box = true;
tb.text = Some(TextBody::from_text("A text box"));
tb.descr = "alt text".into();
let mut bgs = shape(&mut p, "Uses background", 450.0, 400.0, 60.0, 60.0);
bgs.fill = Some(Fill::Background);
s.shapes.extend([a, b, c, g, cx, tb, bgs]);
s.background = Some(Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba::rgb(250, 248, 240))) });
s.transition = Some(Transition { kind: "morph".into(), option: "words".into(), duration_ms: 2000, ..Default::default() });
let ids: Vec<ShapeId> = s.shapes.iter().map(|x| x.id).collect();
s.animations = vec![
Animation { shape: ids[1], effect: "fly".into(), option: "l".into(), duration_ms: 750, ..Default::default() },
Animation { shape: ids[2], effect: "wipe".into(), option: "t".into(), start: AnimStart::WithPrevious, delay_ms: 250, ..Default::default() },
Animation {
shape: ids[3],
class: AnimClass::Emphasis,
effect: "spin".into(),
start: AnimStart::AfterPrevious,
duration_ms: 2000,
..Default::default()
},
Animation { shape: ids[4], class: AnimClass::Exit, effect: "fadeOut".into(), duration_ms: 400, ..Default::default() },
Animation {
shape: ids[2],
class: AnimClass::Path,
effect: "lines".into(),
option: "right".into(),
path: Some("M 0 0 L 0.25 0 E".into()),
duration_ms: 2000,
..Default::default()
},
Animation { shape: ids[6], effect: "zoom".into(), option: "in".into(), trigger: Some(ids[3]), duration_ms: 500, ..Default::default() },
];
s.comments = vec![Comment {
author: "Ada".into(),
initials: "AL".into(),
text: "Nice star".into(),
date: "2025-02-03T04:05:06.000".into(),
x: 120.0,
y: 64.0,
..Default::default()
}];
p.slides.push(Arc::new(s));
// Slide 3: pictures, media, ink.
let mut s = new_slide(&mut p, "Pictures");
let mut pic = shape(&mut p, "Picture", 100.0, 120.0, 200.0, 150.0);
pic.kind = ShapeKind::Picture {
fill: PictureFill {
media: img,
crop: [0.1, 0.05, 0.2, 0.0],
mode: PictureMode::Stretch { fill_rect: [0.0; 4] },
alpha: Some(0.5),
adjust: PictureAdjust { brightness: 0.2, contrast: -0.1, grayscale: true, ..Default::default() },
upright: false,
artistic: None,
},
};
let mut tiled = shape(&mut p, "Tiled", 350.0, 120.0, 200.0, 150.0);
tiled.fill = Some(Fill::Picture(PictureFill {
media: img2,
mode: PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 0.5, sy: 0.5, flip: "none".into(), align: "tl".into() },
..Default::default()
}));
let mut vid = shape(&mut p, "Video", 600.0, 120.0, 240.0, 135.0);
vid.kind = ShapeKind::Media(MediaClip {
media: video,
video: true,
poster: Some(img),
crop: [0.1, 0.0, 0.2, 0.25],
volume: 1.0,
trim_start_ms: 500,
fade_in_ms: 250,
..Default::default()
});
let mut ink = shape(&mut p, "Ink", 100.0, 350.0, 200.0, 100.0);
ink.kind = ShapeKind::Ink {
strokes: vec![InkStroke {
points: vec![(100.0, 350.0, 0.5), (150.0, 400.0, 0.5), (300.0, 360.0, 0.5)],
color: Rgba::rgb(0, 0, 255),
width: 3.0,
highlighter: false,
}],
};
s.shapes.extend([pic, tiled, vid, ink]);
s.transition = Some(Transition { kind: "vortex".into(), option: "r".into(), duration_ms: 3000, ..Default::default() });
s.hidden = true;
p.slides.push(Arc::new(s));
// Slide 4: table with merges and a chart per type.
let mut s = new_slide(&mut p, "Table");
let mut t = Table::new(3, 3, 450.0, 30.0);
for r in 0..3 {
for c in 0..3 {
if let Some(cell) = t.cell_mut(r, c) {
cell.text = TextBody::from_text(&format!("r{r}c{c}"));
}
}
}
t.merge(1, 0, 2, 1);
t.style = "light2-accent3".into();
if let Some(cell) = t.cell_mut(0, 2) {
cell.fill = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 255, 0))));
cell.margins = Some([5.0, 5.0, 2.0, 2.0]);
cell.anchor = Some(Anchor::Middle);
cell.borders[3] = Some(Line::solid(ColorRef::rgb(Rgba::BLACK), 2.0));
}
let mut ts = shape(&mut p, "Table 1", 100.0, 120.0, 450.0, 90.0);
ts.kind = ShapeKind::Table(t);
s.shapes.push(ts);
s.transition = Some(Transition {
kind: "split".into(),
option: "vertIn".into(),
duration_ms: 1500,
advance_on_click: false,
advance_after_ms: Some(2000),
..Default::default()
});
p.slides.push(Arc::new(s));
let kinds = [
ChartType::Column,
ChartType::StackedBar,
ChartType::PercentColumn,
ChartType::Line,
ChartType::LineMarkers,
ChartType::Area,
ChartType::Pie,
ChartType::Doughnut,
ChartType::Scatter,
ChartType::Radar,
ChartType::Bubble,
];
let mut s = new_slide(&mut p, "Charts");
for (i, k) in kinds.iter().enumerate() {
let mut ch = shape(&mut p, &format!("Chart {i}"), 20.0 + (i % 4) as f64 * 230.0, 100.0 + (i / 4) as f64 * 140.0, 220.0, 130.0);
let mut chart = Chart::sample(*k);
chart.title = Some(k.label().to_string());
ch.kind = ShapeKind::Chart(Box::new(chart));
s.shapes.push(ch);
}
let mut combo = Chart::sample(ChartType::Column);
combo.kind = ChartType::Combo;
combo.series[0].kind = Some(ChartType::Column);
combo.series[1].kind = Some(ChartType::Line);
combo.series[2].kind = Some(ChartType::Line);
let mut ch = shape(&mut p, "Combo", 710.0, 380.0, 220.0, 130.0);
ch.kind = ShapeKind::Chart(Box::new(combo));
s.shapes.push(ch);
p.slides.push(Arc::new(s));
// Slide 6: bulleted content with a by-paragraph build, a slide link and an equation.
let layout = defaults::layout_of_kind(&p, deckcraft_model::LayoutType::TitleAndContent).unwrap_or_default();
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
s.shapes[0].text = Some(TextBody::from_text("Build"));
s.shapes[1].text = Some(TextBody::from_text("One\nTwo\nThree"));
let body = s.shapes[1].id;
s.animations = vec![Animation { shape: body, effect: "fade".into(), text_build: TextBuild::ByParagraph, duration_ms: 500, ..Default::default() }];
let target = p.slides[1].id;
let mut link = shape(&mut p, "Go to shapes", 600.0, 450.0, 200.0, 40.0);
link.text = Some(TextBody::from_text("Back to shapes"));
link.click = Some(Hyperlink {
action: Action::Slide { slide: target },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
});
let mut eq = shape(&mut p, "Equation", 100.0, 450.0, 300.0, 40.0);
eq.text = Some(TextBody {
paragraphs: vec![Paragraph {
runs: vec![Run {
text: "x=1".into(),
props: RunProps::default(),
kind: RunKind::Math {
omml: r#"<m:oMathPara xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><m:oMath><m:r><m:t>x=1</m:t></m:r></m:oMath></m:oMathPara>"#.into(),
},
}],
..Default::default()
}],
..Default::default()
});
s.shapes.extend([link, eq]);
s.transition = Some(Transition { kind: "curtains".into(), duration_ms: 2000, ..Default::default() });
p.slides.push(Arc::new(s));
let ids: Vec<SlideId> = p.slides.iter().map(|s| s.id).collect();
p.sections = vec![Section { name: "Start".into(), slides: ids[..2].to_vec() }, Section { name: "Rest".into(), slides: ids[2..].to_vec() }];
p.custom_shows = vec![CustomShow { name: "Short".into(), slides: vec![ids[0], ids[3]] }];
p.first_slide_number = 0;
p
}
fn round(p: &Presentation) -> Presentation {
let bytes = deckcraft_pptx::export(p).expect("export");
deckcraft_pptx::import(&bytes).expect("import")
}
fn by_name<'a>(s: &'a Slide, name: &str) -> &'a Shape {
let mut found = None;
deckcraft_model::walk(&s.shapes, &mut |x, _| {
if found.is_none() && x.name == name {
found = Some(x);
}
});
found.unwrap_or_else(|| panic!("no shape {name}"))
}
#[test]
fn rich_deck_writes_to_dir() {
let p = rich_deck();
let bytes = deckcraft_pptx::export(&p).expect("export");
if let Ok(dir) = std::env::var("DECKCRAFT_PPTX_OUT") {
std::fs::write(format!("{dir}/rich.pptx"), &bytes).expect("write");
}
assert!(bytes.len() > 1000);
}
#[test]
fn deck_level_round_trip() {
let p = rich_deck();
let q = round(&p);
assert_eq!(q.slides.len(), p.slides.len());
assert!(q.validate().is_empty(), "{:?}", q.validate());
assert_eq!(q.slide_size, p.slide_size);
assert_eq!(q.first_slide_number, 0);
assert_eq!(q.props.title, "Rich deck");
assert_eq!(q.props.author, "DeckCraft tests");
assert_eq!(q.props.created, "2025-01-02T03:04:05Z");
assert_eq!(
q.sections.iter().map(|s| (s.name.clone(), s.slides.len())).collect::<Vec<_>>(),
vec![("Start".to_string(), 2), ("Rest".to_string(), 4)]
);
assert_eq!(q.custom_shows.len(), 1);
assert_eq!(q.custom_shows[0].slides, vec![q.slides[0].id, q.slides[3].id]);
assert!(q.slides[2].hidden);
// Themes and layouts.
assert_eq!(q.masters.len(), 1);
let (pm, qm) = (&p.masters[0], &q.masters[0]);
assert_eq!(qm.theme.colors.colors, pm.theme.colors.colors);
assert_eq!(qm.theme.fonts.major.latin, pm.theme.fonts.major.latin);
assert_eq!(qm.layouts.len(), pm.layouts.len());
for (a, b) in pm.layouts.iter().zip(qm.layouts.iter()) {
assert_eq!((a.name.as_str(), a.kind, a.shapes.len()), (b.name.as_str(), b.kind, b.shapes.len()));
}
assert_eq!(qm.title_style, pm.title_style);
assert_eq!(qm.body_style, pm.body_style);
assert_eq!(q.default_text_style, p.default_text_style);
for (a, b) in p.slides.iter().zip(q.slides.iter()) {
let (la, lb) = (p.layout(a.layout).map(|x| &x.1.name), q.layout(b.layout).map(|x| &x.1.name));
assert_eq!(la, lb);
}
}
#[test]
fn text_and_formatting_round_trip() {
let p = rich_deck();
let q = round(&p);
assert_eq!(q.slides[0].notes_text(), "Speaker notes line 1\nline 2");
let (a, b) = (&p.slides[0].shapes[0], &q.slides[0].shapes[0]);
assert_eq!(a.text, b.text);
assert!(b.xfrm.is_none(), "placeholder without xfrm inherits");
let sa = by_name(&p.slides[1], "Pattern Box");
let sb = by_name(&q.slides[1], "Pattern Box");
assert_eq!(sa.text, sb.text);
assert_eq!(sa.click, sb.click);
assert!(by_name(&q.slides[1], "Text Box").text_box);
assert_eq!(by_name(&q.slides[1], "Text Box").descr, "alt text");
let la = by_name(&p.slides[5], "Go to shapes");
let lb = by_name(&q.slides[5], "Go to shapes");
assert_eq!(lb.click.as_ref().map(|h| h.action.clone()), Some(Action::Slide { slide: q.slides[1].id }));
assert!(la.click.is_some());
let eq = by_name(&q.slides[5], "Equation");
let run = &eq.text.as_ref().expect("text").paragraphs[0].runs[0];
assert!(matches!(&run.kind, RunKind::Math { omml } if omml.contains("oMath")), "{run:?}");
assert_eq!(run.text, "x=1");
}
#[test]
fn shapes_fills_lines_effects_round_trip() {
let p = rich_deck();
let q = round(&p);
for name in ["Gradient Star", "Pattern Box", "Freeform", "Uses background"] {
let (a, b) = (by_name(&p.slides[1], name), by_name(&q.slides[1], name));
assert_eq!(a.geom, b.geom, "{name}");
assert_eq!(a.fill, b.fill, "{name}");
assert_eq!(a.line, b.line, "{name}");
assert_eq!(a.effects, b.effects, "{name}");
assert_eq!(a.xfrm, b.xfrm, "{name}");
}
let g = by_name(&q.slides[1], "Group");
assert!(matches!(&g.kind, ShapeKind::Group { children, child } if children.len() == 2 && child.w == 110.0));
let c = by_name(&q.slides[1], "Connector");
let (c1, c2) = (by_name(&q.slides[1], "Child 1").id, by_name(&q.slides[1], "Child 2").id);
assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 2)), end: Some((e, 0)) } if s == c1 && e == c2), "{:?}", c.kind);
assert_eq!(c.line, by_name(&p.slides[1], "Connector").line);
// Ids are kept.
assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id);
}
#[test]
fn pictures_media_and_ink_round_trip() {
let p = rich_deck();
let q = round(&p);
let (a, b) = (by_name(&p.slides[2], "Picture"), by_name(&q.slides[2], "Picture"));
let (ShapeKind::Picture { fill: fa }, ShapeKind::Picture { fill: fb }) = (&a.kind, &b.kind) else { panic!("not pictures") };
assert_eq!(fa.crop, fb.crop);
assert_eq!(fa.alpha, fb.alpha);
assert_eq!(fa.adjust, fb.adjust);
assert_eq!(p.media(fa.media).map(|m| m.data.clone()), q.media(fb.media).map(|m| m.data.clone()));
let t = by_name(&q.slides[2], "Tiled");
assert!(matches!(&t.fill, Some(Fill::Picture(pf)) if matches!(pf.mode, PictureMode::Tile { sx, .. } if sx == 0.5)));
let v = by_name(&q.slides[2], "Video");
let ShapeKind::Media(m) = &v.kind else { panic!("not media: {:?}", v.kind) };
assert!(m.video);
assert_eq!((m.trim_start_ms, m.fade_in_ms), (500, 250));
assert!(q.media(m.media).is_some_and(|x| x.data.starts_with(b"\x00\x00\x00\x18ftyp")));
assert!(m.poster.is_some());
// The crop of the video survives the round trip (#972).
for (got, want) in m.crop.iter().zip([0.1, 0.0, 0.2, 0.25]) {
assert!((got - want).abs() < 1e-4, "video crop {:?}", m.crop);
}
// Ink comes back as freeform lines in a group. A flat 0.5 stroke keeps the pen width.
let ink = by_name(&q.slides[2], "Ink");
let ShapeKind::Group { children, .. } = &ink.kind else { panic!("ink is not a group: {:?}", ink.kind) };
assert_eq!(children.len(), 1);
assert!(matches!(children[0].geom, Geom::Custom { .. }));
let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0);
assert!((w - 3.0).abs() < 0.05, "a flat 0.5 stroke keeps the pen width, got {w}");
}
#[test]
fn pressured_ink_exports_at_its_mean_width() {
let mut p = Presentation::default();
let id = ShapeId(p.alloc_id().expect("id"));
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
slide.shapes.push(Shape {
id,
name: "Ink".into(),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
kind: ShapeKind::Ink {
strokes: vec![InkStroke {
points: vec![(10.0, 10.0, 1.0), (40.0, 10.0, 0.5), (80.0, 10.0, 0.0)],
color: Rgba::rgb(0, 0, 0),
width: 10.0,
highlighter: false,
}],
},
..Default::default()
});
let q = round(&p);
let ink = by_name(&q.slides[0], "Ink");
let ShapeKind::Group { children, .. } = &ink.kind else { panic!("{:?}", ink.kind) };
let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0);
// Full, half, and the 8% floor: (10 + 5 + 0.8) / 3.
assert!((w - (10.0 + 5.0 + 0.8) / 3.0).abs() < 0.05, "{w}");
}
#[test]
fn tables_round_trip() {
let p = rich_deck();
let q = round(&p);
let (ShapeKind::Table(a), ShapeKind::Table(b)) = (&by_name(&p.slides[3], "Table 1").kind, &by_name(&q.slides[3], "Table 1").kind) else {
panic!()
};
// A cell always has at least one paragraph in the file; compare without empty ones.
let norm = |t: &Table| {
let mut t = t.clone();
for r in &mut t.rows {
for c in &mut r.cells {
c.text.paragraphs.retain(|p| !p.is_empty());
}
}
t
};
assert_eq!(norm(a), norm(b));
}
#[test]
fn charts_round_trip() {
let p = rich_deck();
let q = round(&p);
for (sa, sb) in p.slides[4].shapes.iter().zip(q.slides[4].shapes.iter()) {
let (ShapeKind::Chart(a), ShapeKind::Chart(b)) = (&sa.kind, &sb.kind) else { continue };
assert_eq!(a.kind, b.kind, "{}", sa.name);
assert_eq!(a.title, b.title);
assert_eq!(a.series.len(), b.series.len());
for (x, y) in a.series.iter().zip(b.series.iter()) {
assert_eq!(x.name, y.name);
assert_eq!(x.values, y.values, "{}", sa.name);
}
if !matches!(a.kind, ChartType::Scatter | ChartType::Bubble) {
assert_eq!(a.categories, b.categories);
}
assert!(b.raw.is_some());
}
// Second export reuses the kept chart XML unchanged.
let r = round(&q);
for (sa, sb) in q.slides[4].shapes.iter().zip(r.slides[4].shapes.iter()) {
if let (ShapeKind::Chart(a), ShapeKind::Chart(b)) = (&sa.kind, &sb.kind) {
let (mut a, mut b) = (a.clone(), b.clone());
a.raw = None;
b.raw = None;
assert_eq!(a, b);
}
}
}
#[test]
fn transitions_and_animations_round_trip() {
let p = rich_deck();
let q = round(&p);
for (a, b) in p.slides.iter().zip(q.slides.iter()) {
match (&a.transition, &b.transition) {
(Some(x), Some(y)) => {
assert_eq!(
(&x.kind, &x.option, x.duration_ms, x.advance_on_click, x.advance_after_ms),
(&y.kind, &y.option, y.duration_ms, y.advance_on_click, y.advance_after_ms)
);
}
(None, None) => {}
other => panic!("{other:?}"),
}
assert_eq!(a.animations.len(), b.animations.len());
for (x, y) in a.animations.iter().zip(b.animations.iter()) {
assert_eq!(
(&x.effect, &x.option, x.class, x.start, x.duration_ms, x.delay_ms, x.text_build),
(&y.effect, &y.option, y.class, y.start, y.duration_ms, y.delay_ms, y.text_build)
);
assert_eq!(x.shape, y.shape);
assert_eq!(x.trigger, y.trigger);
if x.class == AnimClass::Path {
assert_eq!(x.path, y.path);
}
}
}
}
#[test]
fn comments_round_trip() {
let p = rich_deck();
let q = round(&p);
let c = &q.slides[1].comments;
assert_eq!(c.len(), 1);
assert_eq!((c[0].author.as_str(), c[0].initials.as_str(), c[0].text.as_str()), ("Ada", "AL", "Nice star"));
assert_eq!((c[0].x, c[0].y), (120.0, 64.0));
}
#[test]
fn every_transition_kind_survives() {
let mut p = defaults::new_presentation(None);
let base = p.slides[0].as_ref().clone();
p.slides.clear();
for (kind, ..) in deckcraft_model::anim::TRANSITIONS {
let mut s = base.clone();
s.id = SlideId(p.alloc_id().expect("ids"));
s.transition = Some(Transition { kind: kind.to_string(), duration_ms: 1200, ..Default::default() });
p.slides.push(Arc::new(s));
}
let q = round(&p);
for (a, b) in p.slides.iter().zip(q.slides.iter()) {
let k = a.transition.as_ref().map(|t| t.kind.clone()).unwrap_or_default();
if k == "none" {
continue;
}
assert_eq!(b.transition.as_ref().map(|t| t.kind.clone()), Some(k.clone()));
assert_eq!(b.transition.as_ref().map(|t| t.duration_ms), Some(1200), "{k}");
}
}
#[test]
fn every_animation_effect_survives() {
let mut p = defaults::new_presentation(None);
let mut s = p.slides[0].as_ref().clone();
let mut sh = shape(&mut p, "Target", 100.0, 100.0, 100.0, 100.0);
sh.text = Some(TextBody::from_text("hi"));
let id = sh.id;
s.shapes.push(sh);
for (effect, _, class, _, dur, opts) in deckcraft_model::anim::ANIMATIONS {
s.animations.push(Animation {
shape: id,
class: *class,
effect: effect.to_string(),
option: opts.first().map(|o| o.to_string()).unwrap_or_default(),
duration_ms: (*dur).max(1),
..Default::default()
});
}
p.slides[0] = Arc::new(s);
let q = round(&p);
let (a, b) = (&p.slides[0].animations, &q.slides[0].animations);
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(b.iter()) {
// Effects sharing a preset number come back as the first effect with that number.
let same_preset = deckcraft_model::anim::animation_info(&x.effect, x.class).map(|i| i.3)
== deckcraft_model::anim::animation_info(&y.effect, y.class).map(|i| i.3);
assert!(x.effect == y.effect || same_preset, "{} vs {}", x.effect, y.effect);
assert_eq!(x.class, y.class);
}
}
#[test]
fn animation_rewind_survives() {
let mut p = defaults::new_presentation(None);
let mut s = p.slides[0].as_ref().clone();
let sh = shape(&mut p, "Target", 100.0, 100.0, 100.0, 100.0);
let id = sh.id;
s.shapes.push(sh);
s.animations.push(Animation { shape: id, rewind: true, ..Default::default() });
s.animations.push(Animation { shape: id, effect: "fly".into(), option: "l".into(), start: AnimStart::WithPrevious, ..Default::default() });
p.slides[0] = Arc::new(s);
let q = round(&p);
let b = &q.slides[0].animations;
assert_eq!(b.len(), 2);
assert!(b[0].rewind);
assert!(!b[1].rewind);
}
/// Writes bisection variants of the rich deck to `DECKCRAFT_PPTX_BISECT` (manual checks).
#[test]
fn bisect_variants() {
let Ok(dir) = std::env::var("DECKCRAFT_PPTX_BISECT") else { return };
let p = rich_deck();
let n = p.slides.len();
for i in 0..n {
let mut q = p.clone();
q.slides = vec![p.slides[i].clone()];
q.sections.clear();
q.custom_shows.clear();
std::fs::write(format!("{dir}/bis-slide{i}.pptx"), deckcraft_pptx::export(&q).expect("export")).expect("write");
}
let mut q = p.clone();
q.sections.clear();
q.custom_shows.clear();
q.first_slide_number = 1;
for s in q.slides.iter_mut() {
Arc::make_mut(s).comments.clear();
}
std::fs::write(format!("{dir}/bis-nodeck.pptx"), deckcraft_pptx::export(&q).expect("export")).expect("write");
}
/// Finer bisection of one slide (`DECKCRAFT_PPTX_BISECT_SLIDE=<index>`): one variant per removed shape,
/// plus no animations / no transition.
#[test]
fn bisect_slide_variants() {
let (Ok(dir), Ok(idx)) = (std::env::var("DECKCRAFT_PPTX_BISECT"), std::env::var("DECKCRAFT_PPTX_BISECT_SLIDE")) else { return };
let p = rich_deck();
let i: usize = idx.parse().expect("index");
let base = p.slides[i].as_ref().clone();
let one = |s: Slide| {
let mut q = p.clone();
q.slides = vec![Arc::new(s)];
q.sections.clear();
q.custom_shows.clear();
deckcraft_pptx::export(&q).expect("export")
};
let mut s = base.clone();
s.animations.clear();
std::fs::write(format!("{dir}/v-noanim.pptx"), one(s)).expect("write");
let mut s = base.clone();
s.transition = None;
std::fs::write(format!("{dir}/v-notrans.pptx"), one(s)).expect("write");
for k in 0..base.shapes.len() {
let mut s = base.clone();
s.animations.clear();
s.transition = None;
let keep = s.shapes[k].clone();
s.shapes.retain(|x| x.id == keep.id);
std::fs::write(format!("{dir}/v-only{k}.pptx"), one(s)).expect("write");
}
}
#[test]
fn rtl_paragraphs_script_fonts_and_just_low_round_trip() {
let mut p = rich_deck();
let fonts = &mut Arc::make_mut(&mut p.masters[0]).theme.fonts;
fonts.minor.cs = "Noto Naskh Arabic".into();
fonts.minor.scripts = vec![
deckcraft_model::theme::ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() },
deckcraft_model::theme::ScriptFont { script: "Hebr".into(), typeface: "Noto Sans Hebrew".into() },
];
let slide = Arc::make_mut(&mut p.slides[0]);
let sh = slide.shapes.iter_mut().find(|s| s.text.is_some()).expect("a text shape");
let body = sh.text.as_mut().expect("text");
body.paragraphs = vec![Paragraph { runs: vec![Run::new("سنة 2024 (Retrait) فقط")], ..Default::default() }];
body.paragraphs[0].props.rtl = Some(true);
body.paragraphs[0].props.align = Some(Align::JustLow);
let name = sh.name.clone();
let q = round(&p);
assert_eq!(q.masters[0].theme.fonts.minor.scripts, p.masters[0].theme.fonts.minor.scripts);
assert_eq!(q.masters[0].theme.fonts.minor.script("arab"), Some("Noto Naskh Arabic"));
let para = &by_name(&q.slides[0], &name).text.as_ref().expect("text").paragraphs[0];
assert_eq!(para.props.rtl, Some(true));
assert_eq!(para.props.align, Some(Align::JustLow));
assert_eq!(para.runs[0].text, "سنة 2024 (Retrait) فقط");
}
#[test]
fn date_fields_keep_their_type_and_saved_text() {
// An automatic date is a field (its saved text is what readers without a clock show); a fixed
// date is plain text.
let mut p = Presentation::default();
let mut auto = shape(&mut p, "Auto date", 10.0, 10.0, 200.0, 40.0);
let mut body = TextBody::from_text("");
body.paragraphs[0].runs =
vec![Run { text: "March 3, 2025".into(), props: RunProps::default(), kind: RunKind::Field { field: "datetime4".into() } }];
auto.text = Some(body);
let mut fixed = shape(&mut p, "Fixed date", 10.0, 60.0, 200.0, 40.0);
fixed.text = Some(TextBody::from_text("Launch day"));
Arc::make_mut(&mut p.slides[0]).shapes = vec![auto, fixed];
let q = round(&p);
for name in ["Auto date", "Fixed date"] {
assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}");
}
}
#[test]
fn colour_map_overrides_round_trip() {
let mut p = defaults::new_presentation(None);
let dark: Vec<(String, String)> =
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
let layout = p.slides[0].layout;
std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").color_map = Some(dark.clone());
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").color_map = Some(dark.clone());
let q = round(&p);
let get = |m: &Option<Vec<(String, String)>>, k: &str| m.as_ref().and_then(|m| m.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()));
let ql = q.masters[0].layouts.iter().find(|l| l.color_map.is_some()).expect("the layout keeps its override");
assert_eq!(get(&ql.color_map, "bg1").as_deref(), Some("dk1"));
assert_eq!(get(&q.slides[0].color_map, "tx1").as_deref(), Some("lt1"));
// Layouts and slides without one keep following the master.
assert!(q.masters[0].layouts.iter().filter(|l| l.color_map.is_none()).count() >= 1);
}
#[test]
fn a_picture_duotone_round_trips() {
let mut p = defaults::new_presentation(None);
let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128])).expect("media");
let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0);
let mut pf = PictureFill { media: img, ..Default::default() };
pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200))));
pic.kind = deckcraft_model::ShapeKind::Picture { fill: pf };
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(pic);
let q = round(&p);
let sh = by_name(&q.slides[0], "Duotone");
let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("picture") };
let (d, l) = fill.adjust.duotone.as_ref().expect("duotone kept");
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
}
#[test]
fn line_break_options_round_trip() {
let mut p = defaults::new_presentation(None);
let first = p.slides[0].id;
let s = p.slide_mut(first).expect("slide");
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
t.paragraphs[0].props.latin_line_break = Some(true);
t.paragraphs[0].props.hanging_punct = Some(false);
let q = round(&p);
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
assert_eq!(props.latin_line_break, Some(true));
assert_eq!(props.hanging_punct, Some(false));
}
#[test]
fn a_table_fill_round_trips() {
let mut p = defaults::new_presentation(None);
let mut t = Table::new(2, 2, 200.0, 40.0);
t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192))));
let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0);
sh.kind = deckcraft_model::ShapeKind::Table(t);
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(sh);
let q = round(&p);
let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") };
assert!(
matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192)))
);
}
#[test]
fn wordart_warps_and_their_adjusts_round_trip() {
let mut p = Presentation::default();
let cases: [(&str, &str, Vec<f64>); 5] = [
("Arch pour", "textArchUpPour", vec![13_500_000.0, 30_000.0]),
("Wave", "textWave1", vec![18_000.0, -5_000.0]),
("Inflate", "textInflate", vec![30_000.0]),
("Arch", "textArchUp", vec![]),
("Ring", "textRingInside", vec![75_000.0]),
];
let mut shapes = vec![];
for (i, (name, warp, adj)) in cases.iter().enumerate() {
let mut s = shape(&mut p, name, 10.0, 10.0 + 50.0 * i as f64, 300.0, 40.0);
let mut body = TextBody::from_text("WordArt");
body.body.warp = Some((*warp).into());
body.body.warp_adj = adj.clone();
s.text = Some(body);
shapes.push(s);
}
Arc::make_mut(&mut p.slides[0]).shapes = shapes;
let q = round(&p);
for (name, warp, adj) in &cases {
let b = &by_name(&q.slides[0], name).text.as_ref().expect("text").body;
assert_eq!(b.warp.as_deref(), Some(*warp), "{name}");
assert_eq!(&b.warp_adj, adj, "{name}");
}
// The single-adjust preset is written with the guide name `adj`, two-adjust ones with
// `adj1`/`adj2`, and a preset with no adjusts given gets an empty list.
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read");
assert!(xml.contains(r#"<a:prstTxWarp prst="textInflate"><a:avLst><a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstTxWarp>"#), "{xml}");
assert!(xml.contains(r#"<a:gd name="adj1" fmla="val 13500000"/><a:gd name="adj2" fmla="val 30000"/>"#));
assert!(xml.contains(r#"<a:prstTxWarp prst="textArchUp"><a:avLst/></a:prstTxWarp>"#));
// A file that gives only `adj2` keeps the preset's default for `adj1`; hostile values are
// read as numbers or dropped.
let edited = xml
.replace(r#"<a:gd name="adj1" fmla="val 13500000"/>"#, "")
.replace(r#"<a:gd name="adj" fmla="val 30000"/>"#, r#"<a:gd name="adj" fmla="val NaN"/>"#);
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
if name == "ppt/slides/slide1.xml" {
data = edited.clone().into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
std::io::Write::write_all(&mut out, &data).expect("write");
}
let bytes = out.finish().expect("finish").into_inner();
let q = deckcraft_pptx::import(&bytes).expect("import");
let pour = &by_name(&q.slides[0], "Arch pour").text.as_ref().expect("text").body;
assert_eq!(pour.warp_adj, vec![10_800_000.0, 30_000.0]);
let inflate = &by_name(&q.slides[0], "Inflate").text.as_ref().expect("text").body;
assert_eq!(inflate.warp.as_deref(), Some("textInflate"));
assert!(inflate.warp_adj.is_empty());
}
/// Slide XML of a deck exported with one slide per transition (kind, option, ms, advance after).
fn transition_deck(list: &[(&str, &str, u32, Option<u32>)]) -> (Presentation, Vec<String>) {
let mut p = defaults::new_presentation(None);
let base = p.slides[0].as_ref().clone();
p.slides.clear();
for (kind, option, ms, adv) in list {
let mut s = base.clone();
s.id = SlideId(p.alloc_id().expect("ids"));
s.transition = Some(Transition {
kind: kind.to_string(),
option: option.to_string(),
duration_ms: *ms,
advance_on_click: adv.is_none(),
advance_after_ms: *adv,
..Default::default()
});
p.slides.push(Arc::new(s));
}
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let xml = (1..=list.len())
.map(|i| {
let mut x = String::new();
std::io::Read::read_to_string(&mut zip.by_name(&format!("ppt/slides/slide{i}.xml")).expect("slide"), &mut x).expect("read");
x
})
.collect();
(p, xml)
}
#[test]
fn transition_xml_matches_powerpoints_elements() {
// The elements, attributes and namespaces PowerPoint itself writes for these effect options
// (observed on files it saved), inside `mc:AlternateContent` with a `p:fade` fallback for
// the 2010+ effects.
let cases: &[(&str, &str, &[&str], &str)] = &[
("wipe", "ru", &[r#"<p:strips dir="ru"/>"#], "p14"),
("wipe", "u", &[r#"<p:wipe dir="u"/>"#], "p14"),
("zoom", "in", &[r#"<p14:warp dir="in"/>"#], "p14"),
("zoom", "rotate", &["<p:newsflash/>"], "p14"),
("shape", "out", &[r#"<p:zoom dir="out"/>"#], "p14"),
("glitter", "hexD", &[r#"<p14:glitter dir="d" pattern="hexagon"/>"#], "p14"),
("shred", "particlesOut", &[r#"<p14:shred pattern="rectangle" dir="out"/>"#], "p14"),
("wind", "l", &[r#"<p15:prstTrans prst="wind" invX="1"/>"#], "p15"),
("wind", "r", &[r#"<p15:prstTrans prst="wind"/>"#], "p15"),
("fallOver", "r", &[r#"<p15:prstTrans prst="fallOver" invX="1"/>"#], "p15"),
("pageCurlDouble", "singleR", &[r#"<p15:prstTrans prst="pageCurlSingle" invX="1"/>"#], "p15"),
("orbit", "u", &[r#"<p14:prism dir="u" isContent="1" isInverted="1"/>"#], "p14"),
("vortex", "r", &[r#"<p14:vortex dir="r"/>"#], "p14"),
("morph", "words", &[r#"<p159:morph option="byWord"/>"#], "p159"),
];
let list: Vec<(&str, &str, u32, Option<u32>)> = cases.iter().map(|c| (c.0, c.1, 2000, Some(1000))).collect();
let (p, xml) = transition_deck(&list);
for ((kind, option, wants, req), x) in cases.iter().zip(&xml) {
for w in *wants {
assert!(x.contains(w), "{kind}/{option}: {w} in {x}");
}
assert!(x.contains(&format!(r#"Requires="{req}""#)), "{kind}/{option}");
assert!(x.contains(r#"p14:dur="2000""#) && x.contains(r#"advTm="1000""#) && x.contains(r#"advClick="0""#), "{kind}/{option}");
assert!(x.contains("<mc:Fallback>"), "{kind}/{option}");
}
// Back in, everything is as it was.
let bytes = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&bytes).expect("import");
for ((kind, option, ..), s) in cases.iter().zip(&q.slides) {
let t = s.transition.as_ref().expect("transition");
assert_eq!(
(t.kind.as_str(), t.option.as_str(), t.duration_ms, t.advance_after_ms, t.advance_on_click),
(*kind, *option, 2000, Some(1000), false)
);
}
}
#[test]
fn transition_defaults_read_like_powerpoint() {
// PowerPoint leaves out an option when it is the element's default; these are its defaults.
let cases: &[(&str, &str, &str)] = &[
("<p:push/>", "push", "l"),
("<p:wipe/>", "wipe", "l"),
("<p:strips/>", "wipe", "lu"),
("<p:randomBar/>", "randomBar", "horz"),
("<p:checker/>", "checker", "horz"),
("<p:blinds/>", "blinds", "horz"),
("<p:comb/>", "comb", "horz"),
("<p:zoom/>", "shape", "out"),
(r#"<p:zoom dir="in"/>"#, "shape", "in"),
("<p:split/>", "split", "horzOut"),
("<p14:doors/>", "doors", "horz"),
("<p14:window/>", "window", "horz"),
("<p14:pan/>", "pan", "l"),
("<p14:warp/>", "zoom", "out"),
("<p14:glitter/>", "glitter", "l"),
("<p14:shred/>", "shred", "stripsIn"),
("<p14:reveal/>", "reveal", "smoothlyLeft"),
("<p14:prism/>", "cube", "l"),
(r#"<p15:prstTrans prst="airplane"/>"#, "airplane", "r"),
(r#"<p15:prstTrans prst="peelOff"/>"#, "peelOff", "l"),
(r#"<p15:prstTrans prst="pageCurlSingle"/>"#, "pageCurlDouble", "singleL"),
];
let (_, xml) = transition_deck(&[("fade", "smoothly", 2000, None)]);
let Some(x) = xml.first() else { panic!("no slide") };
let start = x.find("<mc:AlternateContent").expect("transition");
let end = x.find("</mc:AlternateContent>").expect("transition end") + "</mc:AlternateContent>".len();
let p14 = r#"xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main""#;
let p15 = r#"xmlns:p15="http://schemas.microsoft.com/office/powerpoint/2012/main""#;
for (el, kind, option) in cases {
let tr = format!(r#"<p:transition {p14} {p15} spd="slow" p14:dur="2000">{el}</p:transition>"#);
let edited = format!("{}{}{}", &x[..start], tr, &x[end..]);
let mut p = defaults::new_presentation(None);
let base = p.slides[0].as_ref().clone();
p.slides = vec![Arc::new(base)];
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
if name == "ppt/slides/slide1.xml" {
data = edited.clone().into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
std::io::Write::write_all(&mut out, &data).expect("write");
}
let bytes = out.finish().expect("finish").into_inner();
let q = deckcraft_pptx::import(&bytes).expect("import");
let t = q.slides[0].transition.as_ref().expect("transition");
assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}");
}
}
#[test]
fn linked_pictures_are_written_with_r_link() {
// A picture whose bytes live outside the package must be referenced with `r:link`; `r:embed`
// may only name a part inside the package (PowerPoint won't load the linked image otherwise).
let mut p = defaults::new_presentation(None);
let media = deckcraft_model::MediaId(5000);
p.media.push(deckcraft_model::MediaItem {
id: media,
name: "linked.png".into(),
content_type: "image/png".into(),
data: Arc::new(Vec::new()),
link: Some("file:///tmp/linked.png".into()),
});
let mut pic = shape(&mut p, "Linked", 100.0, 100.0, 200.0, 150.0);
pic.kind = ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } };
Arc::make_mut(&mut p.slides[0]).shapes.push(pic);
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read");
assert!(xml.contains(r#"r:link="rId"#), "{xml}");
assert!(!xml.contains(r#"r:embed="rId"#), "{xml}");
let mut rels = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/_rels/slide1.xml.rels").expect("rels"), &mut rels).expect("read");
assert!(rels.contains(r#"Target="file:///tmp/linked.png" TargetMode="External""#), "{rels}");
}
/// #210: `.potx` and `.ppsx` were written with the `.pptx` main content type.
#[test]
fn templates_and_slide_shows_declare_their_own_main_content_type() {
let p = rich_deck();
for (ext, kind) in [("pptx", "presentation"), ("potx", "template"), ("ppsx", "slideshow")] {
let bytes = deckcraft_pptx::export_as(&p, ext).expect("export");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let ct = std::io::read_to_string(z.by_name("[Content_Types].xml").expect("content types")).expect("read");
assert!(ct.contains(&format!("presentationml.{kind}.main+xml")), "{ext}: {ct}");
assert_eq!(deckcraft_pptx::import(&bytes).expect("import").slides.len(), p.slides.len(), "{ext} reads back");
}
}
/// #214: a lone guide was always written `adj`, but these presets name it `adj1`, and PowerPoint
/// won't open the file otherwise. (Names as PowerPoint writes them.)
#[test]
fn single_guide_names_follow_the_preset() {
let mut p = defaults::new_presentation(None);
let mut s = new_slide(&mut p, "Guides");
let cases = [
("bentConnector3", "adj1"),
("curvedConnector3", "adj1"),
("frame", "adj1"),
("mathPlus", "adj1"),
("mathMinus", "adj1"),
("mathMultiply", "adj1"),
("roundRect", "adj"),
("chevron", "adj"),
];
for (prst, _) in cases {
let mut sh = shape(&mut p, prst, 0.0, 0.0, 100.0, 50.0);
sh.geom = Geom::Preset { name: prst.into(), adj: vec![20000.0] };
s.shapes.push(sh);
}
p.slides.push(Arc::new(s));
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let xml = std::io::read_to_string(z.by_name("ppt/slides/slide2.xml").expect("slide")).expect("read");
for (prst, gd) in cases {
assert!(xml.contains(&format!(r#"<a:prstGeom prst="{prst}"><a:avLst><a:gd name="{gd}" fmla="val 20000"/>"#)), "{prst} → {gd}: {xml}");
}
}
/// #147: PowerPoint's Float Down is preset 47 (Float Up is 42); it imported as a plain fade.
#[test]
fn float_down_is_preset_47_both_ways() {
let mut p = defaults::new_presentation(None);
let mut s = new_slide(&mut p, "Float");
let sh = shape(&mut p, "Building", 0.0, 0.0, 100.0, 100.0);
let id = sh.id;
s.shapes.push(sh);
s.animations = vec![
Animation { shape: id, class: AnimClass::Entrance, effect: "float".into(), option: "d".into(), ..Default::default() },
Animation { shape: id, class: AnimClass::Exit, effect: "floatOut".into(), option: "d".into(), ..Default::default() },
Animation { shape: id, class: AnimClass::Exit, effect: "floatOut".into(), option: "u".into(), ..Default::default() },
];
p.slides.push(Arc::new(s));
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let xml = std::io::read_to_string(z.by_name("ppt/slides/slide2.xml").expect("slide")).expect("read");
assert!(xml.contains(r#"presetID="47" presetClass="entr" presetSubtype="0""#), "{xml}");
assert!(xml.contains(r#"presetID="47" presetClass="exit" presetSubtype="0""#), "{xml}");
assert!(xml.contains(r#"presetID="42" presetClass="exit" presetSubtype="0""#), "{xml}");
let q = deckcraft_pptx::import(&bytes).expect("import");
let got: Vec<(&str, &str)> = q.slides[1].animations.iter().map(|a| (a.effect.as_str(), a.option.as_str())).collect();
assert_eq!(got, [("float", "d"), ("floatOut", "d"), ("floatOut", "u")]);
}
#[test]
fn embedded_fonts_round_trip() {
let mut p = Presentation::default();
let regular = p.add_media("font1.fntdata", "application/x-fontdata", b"REGULAR-FONT-BYTES-0123456789".to_vec()).expect("media");
let bold = p.add_media("font2.fntdata", "application/x-fontdata", b"BOLD-FONT-BYTES-0123456789abcd".to_vec()).expect("media");
p.embedded_fonts.push(deckcraft_model::EmbeddedFont {
typeface: "Deck Test Face".into(),
panose: Some("020B0604020202020204".into()),
pitch_family: Some(34),
charset: Some(0),
regular: Some(regular),
bold: Some(bold),
italic: Some(regular),
bold_italic: None,
});
// The same typeface again is one entry: PowerPoint requires each typeface once.
p.embedded_fonts.push(deckcraft_model::EmbeddedFont { typeface: "Deck Test Face".into(), regular: Some(bold), ..Default::default() });
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).expect("zip");
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/presentation.xml").expect("pres"), &mut xml).expect("read");
assert!(xml.contains("embedTrueTypeFonts=\"1\""));
let notes = xml.find("<p:notesSz").expect("notesSz");
let list = xml.find("<p:embeddedFontLst>").expect("list");
let dts = xml.find("<p:defaultTextStyle").expect("style");
assert!(notes < list && list < dts);
assert!(xml.contains(r#"<p:font typeface="Deck Test Face" panose="020B0604020202020204" pitchFamily="34" charset="0"/>"#));
assert_eq!(xml.matches("<p:embeddedFont>").count(), 1);
let mut rels = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/_rels/presentation.xml.rels").expect("rels"), &mut rels).expect("read");
assert_eq!(rels.matches("relationships/font").count(), 2, "regular and italic share one part");
let q = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(q.embedded_fonts.len(), 1);
let f = &q.embedded_fonts[0];
assert_eq!(f.typeface, "Deck Test Face");
assert_eq!(f.panose.as_deref(), Some("020B0604020202020204"));
assert_eq!(f.pitch_family, Some(34));
assert_eq!(f.charset, Some(0));
assert_eq!(q.media(f.regular.expect("regular")).expect("media").data.as_slice(), b"REGULAR-FONT-BYTES-0123456789");
assert_eq!(q.media(f.bold.expect("bold")).expect("media").data.as_slice(), b"BOLD-FONT-BYTES-0123456789abcd");
assert_eq!(f.italic, f.regular);
assert!(f.bold_italic.is_none());
}
/// A package whose font list this writer did not produce: bold only, relationship id not `rIdN`.
#[test]
fn an_embedded_font_list_already_in_the_package_is_read() {
let bytes = deckcraft_pptx::export(&Presentation::default()).expect("export");
let font = b"FACE-BYTES-FROM-THE-PACKAGE";
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
if name == "ppt/presentation.xml" {
let xml = String::from_utf8(data).expect("utf8");
let xml = xml.replacen(
"<p:defaultTextStyle",
concat!(
r#"<p:embeddedFontLst><p:embeddedFont><p:font typeface="Package Face" pitchFamily="2"/>"#,
r#"<p:bold r:id="rIdFont"/></p:embeddedFont></p:embeddedFontLst><p:defaultTextStyle"#
),
1,
);
data = xml.into_bytes();
} else if name == "ppt/_rels/presentation.xml.rels" {
let xml = String::from_utf8(data).expect("utf8");
let xml = xml.replacen(
"</Relationships>",
r#"<Relationship Id="rIdFont" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/font" Target="fonts/font1.fntdata"/></Relationships>"#,
1,
);
data = xml.into_bytes();
} else if name == "[Content_Types].xml" {
let xml = String::from_utf8(data).expect("utf8");
let xml = xml.replacen("</Types>", r#"<Default Extension="fntdata" ContentType="application/x-fontdata"/></Types>"#, 1);
data = xml.into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
std::io::Write::write_all(&mut out, &data).expect("write");
}
out.start_file("ppt/fonts/font1.fntdata", zip::write::SimpleFileOptions::default()).expect("font");
std::io::Write::write_all(&mut out, font).expect("font bytes");
let patched = out.finish().expect("finish").into_inner();
let p = deckcraft_pptx::import(&patched).expect("import");
let f = p.embedded_fonts.first().expect("font");
assert_eq!(f.typeface, "Package Face");
assert_eq!(f.pitch_family, Some(2));
assert!(f.panose.is_none() && f.regular.is_none() && f.italic.is_none() && f.bold_italic.is_none());
assert_eq!(p.media(f.bold.expect("bold")).expect("media").data.as_slice(), font);
}
/// Text transparency (#32) is `a:alpha` on the run's colour, and survives a save.
#[test]
fn text_transparency_round_trips() {
use deckcraft_engine::Session;
let mut s = Session::with_new();
s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap();
s.execute("format.color", &serde_json::json!({"color": "#336699"})).unwrap();
s.execute("format.textTransparency", &serde_json::json!({"value": 0.4})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let bytes = deckcraft_pptx::export(&doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
let sh = back.slides[0].shapes.iter().find(|sh| sh.text.as_ref().is_some_and(|t| t.text().contains("Hi"))).unwrap();
let Some(deckcraft_model::Fill::Solid { color }) = &sh.text.as_ref().unwrap().paragraphs[0].runs[0].props.fill else { panic!() };
assert!((color.alpha() - 0.6).abs() < 1e-9, "{color:?}");
}
/// A tiny 8 kHz mono 16-bit WAV of `n` silent samples.
fn wav(n: u32) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&(36 + n * 2).to_le_bytes());
v.extend_from_slice(b"WAVEfmt ");
v.extend_from_slice(&16u32.to_le_bytes());
v.extend_from_slice(&1u16.to_le_bytes());
v.extend_from_slice(&1u16.to_le_bytes());
v.extend_from_slice(&8000u32.to_le_bytes());
v.extend_from_slice(&16000u32.to_le_bytes());
v.extend_from_slice(&2u16.to_le_bytes());
v.extend_from_slice(&16u16.to_le_bytes());
v.extend_from_slice(b"data");
v.extend_from_slice(&(n * 2).to_le_bytes());
v.resize(v.len() + n as usize * 2, 0);
v
}
/// Transitions › Sound: a chosen sound is embedded, saved as `p:sndAc`, read back, and No Sound
/// removes it; a file that isn't audio is refused.
#[test]
fn transition_sound_is_set_saved_and_removed() {
use deckcraft_engine::Session;
use serde_json::json;
let b64 = deckcraft_engine::cmd::base64_encode(&wav(800));
let mut s = Session::with_new();
s.execute("transition.set", &json!({"kind": "fade"})).unwrap();
let r = s.execute("transition.sound", &json!({"data": b64, "name": "chime.wav"})).unwrap();
assert_eq!(r["playable"], true, "{r}");
let doc = s.doc().unwrap().doc.clone();
let m = doc.slides[0].transition.as_ref().and_then(|t| t.sound).expect("sound set");
assert_eq!(doc.media(m).map(|i| i.name.as_str()), Some("chime.wav"));
let bytes = deckcraft_pptx::export(&doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
let t = back.slides[0].transition.as_ref().expect("transition");
assert_eq!(t.kind, "fade");
let m = t.sound.expect("sound survives the save");
assert_eq!(back.media(m).map(|i| i.data.len()), Some(wav(800).len()));
s.execute("transition.sound", &json!({"none": true})).unwrap();
assert!(s.doc().unwrap().doc.slides[0].transition.as_ref().is_some_and(|t| t.sound.is_none()));
let not_audio = deckcraft_engine::cmd::base64_encode(b"hello, not a sound");
assert!(s.execute("transition.sound", &json!({"data": not_audio, "name": "notes.txt"})).is_err());
}
/// Built-in transition sounds, Loop Until Next Sound and [Stop Previous Sound] are set, saved as
/// PowerPoint writes them (`p:stSnd loop="1"`, `p:endSnd`) and read back.
#[test]
fn transition_sound_options_round_trip() {
use deckcraft_engine::Session;
use serde_json::json;
let mut s = Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("transition.sound", &json!({"builtin": "chime", "loop": true, "index": 0})).unwrap();
s.execute("transition.sound", &json!({"builtin": "Chime", "index": 1})).unwrap();
s.execute("transition.sound", &json!({"stopPrevious": true, "index": 2})).unwrap();
let doc = s.doc().unwrap().doc.clone();
assert_eq!(doc.media.len(), 1, "the same built-in sound is embedded once");
assert_eq!(doc.media[0].name, "Chime.wav");
let bytes = deckcraft_pptx::export(&doc).unwrap();
let slide1 = {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut x = String::new();
std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").unwrap(), &mut x).unwrap();
x
};
assert!(slide1.contains(r#"<p:stSnd loop="1">"#) && slide1.contains(r#"name="Chime.wav""#), "{slide1}");
let back = deckcraft_pptx::import(&bytes).unwrap();
let t: Vec<_> = back.slides.iter().map(|s| s.transition.clone().unwrap_or_default()).collect();
assert!(t[0].sound.is_some() && t[0].sound_loop && !t[0].stop_sound);
assert!(t[1].sound.is_some() && !t[1].sound_loop);
assert!(t[2].sound.is_none() && t[2].stop_sound, "{:?}", t[2]);
// Turning the loop off alone keeps the sound.
s.execute("transition.sound", &json!({"loop": false, "index": 0})).unwrap();
let t0 = s.doc().unwrap().doc.slides[0].transition.clone().unwrap();
assert!(t0.sound.is_some() && !t0.sound_loop);
assert!(s.execute("transition.sound", &json!({"builtin": "foghorn"})).is_err());
}
/// Replace `from` with `to` in every slide part of an exported deck.
fn patch_slides(bytes: Vec<u8>, from: &str, to: &str) -> Vec<u8> {
use std::io::{Read, Write};
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
data = String::from_utf8(data).expect("utf8").replace(from, to).into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
out.write_all(&data).expect("write");
}
out.finish().expect("finish").into_inner()
}
fn runs_of(p: &Presentation, needle: &str) -> Vec<(RunKind, String)> {
for s in &p.slides {
for sh in &s.shapes {
let Some(t) = &sh.text else { continue };
for para in &t.paragraphs {
if para.runs.iter().any(|r| r.text.contains(needle)) {
return para.runs.iter().map(|r| (r.kind.clone(), r.text.clone())).collect();
}
}
}
}
panic!("no paragraph with {needle}");
}
#[test]
fn literal_line_ends_in_run_text_read_as_breaks_and_survive_save() {
let mut p = defaults::new_presentation(None);
let mut s = shape(&mut p, "Code", 10.0, 10.0, 400.0, 100.0);
s.text = Some(TextBody::from_text("CRLFHERE"));
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(s);
let bytes = patch_slides(deckcraft_pptx::export(&p).expect("export"), "CRLFHERE", "one\r\n two\rthree");
let a = deckcraft_pptx::import(&bytes).expect("import");
let want = vec![
(RunKind::Text, "one".to_string()),
(RunKind::Break, String::new()),
(RunKind::Text, " two".to_string()),
(RunKind::Break, String::new()),
(RunKind::Text, "three".to_string()),
];
assert_eq!(runs_of(&a, "one"), want);
assert_eq!(runs_of(&round(&a), "one"), want, "save and reopen keep the runs");
}
#[test]
fn color_pulse_keeps_its_effect_colour_and_duration() {
let mut p = defaults::new_presentation(None);
let s = shape(&mut p, "Pulsed", 10.0, 10.0, 200.0, 100.0);
let id = s.id;
let first = p.slides[0].id;
let sl = p.slide_mut(first).expect("slide");
sl.shapes.push(s);
let white = ColorRef::rgb(Rgba::rgb(255, 255, 255));
sl.animations.push(Animation {
shape: id,
class: AnimClass::Emphasis,
effect: "colorPulse".into(),
duration_ms: 1000,
color: Some(white.clone()),
..Default::default()
});
let q = round(&p);
let a = &q.slides[0].animations[0];
assert_eq!((a.class, a.effect.as_str()), (AnimClass::Emphasis, "colorPulse"));
assert_eq!(a.color, Some(white));
assert_eq!(a.amount, None, "no grow/shrink scale is invented");
assert_eq!(a.duration_ms, 1000);
}
#[test]
fn notes_master_placeholders_and_colour_map_survive_save() {
let mut p = defaults::new_presentation(None);
let mut box1 = shape(&mut p, "Confidential", 100000.0, 200000.0, 3000000.0, 400000.0);
box1.text = Some(TextBody::from_text("Company confidential"));
let map: Vec<(String, String)> = [("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")]
.iter()
.chain(
[
("accent1", "accent1"),
("accent2", "accent2"),
("accent3", "accent3"),
("accent4", "accent4"),
("accent5", "accent5"),
("accent6", "accent6"),
("hlink", "hlink"),
("folHlink", "folHlink"),
]
.iter(),
)
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect();
let m = deckcraft_model::Master { name: "Notes Master".into(), shapes: vec![box1], color_map: map.clone(), ..Default::default() };
p.notes_master = Some(Arc::new(m));
// No slide has notes: the notes master must still be written.
let q = round(&p);
let nm = q.notes_master.as_deref().expect("notes master kept");
assert_eq!(nm.shapes.len(), 1, "own shapes, not the defaults");
let sh = &nm.shapes[0];
assert_eq!(sh.name, "Confidential");
assert_eq!(sh.text.as_ref().map(|t| t.text()), Some("Company confidential".to_string()));
assert_eq!(nm.color_map, map);
}
/// A part's bytes, if the package has it.
fn pkg_part(pkg: &[u8], name: &str) -> Option<Vec<u8>> {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap();
let mut f = z.by_name(name).ok()?;
let mut v = vec![];
f.read_to_end(&mut v).unwrap();
Some(v)
}
/// Rewrite a package's parts with `edit`, then add `extra` parts.
fn repack(pkg: &[u8], edit: impl Fn(&str, String) -> String, extra: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::{Read, Write};
let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).unwrap();
if name.ends_with(".xml") || name.ends_with(".rels") {
data = edit(&name, String::from_utf8(data).unwrap()).into_bytes();
}
out.start_file(name, opts).unwrap();
out.write_all(&data).unwrap();
}
for (name, data) in extra {
out.start_file(*name, opts).unwrap();
out.write_all(data).unwrap();
}
out.finish().unwrap().into_inner()
}
const WATERFALL_CX: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cx:chartSpace xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chartData><cx:externalData r:id="rId1" cx:autoUpdate="0"/><cx:data id="0"><cx:strDim type="cat"><cx:f>Sheet1!$A$2:$A$5</cx:f><cx:lvl ptCount="4"><cx:pt idx="0">Start</cx:pt><cx:pt idx="1">Sales</cx:pt><cx:pt idx="2">Costs</cx:pt><cx:pt idx="3">End</cx:pt></cx:lvl></cx:strDim><cx:numDim type="val"><cx:f>Sheet1!$B$2:$B$5</cx:f><cx:lvl ptCount="4" formatCode="General"><cx:pt idx="0">100</cx:pt><cx:pt idx="1">40</cx:pt><cx:pt idx="2">-25</cx:pt><cx:pt idx="3">115</cx:pt></cx:lvl></cx:numDim></cx:data></cx:chartData><cx:chart><cx:title pos="t" align="ctr" overlay="0"><cx:tx><cx:txData><cx:v>Cash flow</cx:v></cx:txData></cx:tx></cx:title><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="waterfall" uniqueId="{1}"><cx:tx><cx:txData><cx:f>Sheet1!$B$1</cx:f><cx:v>Amount</cx:v></cx:txData></cx:tx><cx:dataLabels pos="outEnd"><cx:visibility seriesName="0" categoryName="0" value="1"/></cx:dataLabels><cx:dataId val="0"/><cx:layoutPr><cx:subtotals><cx:idx val="0"/><cx:idx val="3"/></cx:subtotals></cx:layoutPr></cx:series></cx:plotAreaRegion><cx:axis id="0"><cx:catScaling gapWidth="0.5"/><cx:tickLabels/></cx:axis><cx:axis id="1"><cx:valScaling/><cx:majorGridlines/><cx:tickLabels/></cx:axis></cx:plotArea><cx:legend pos="b" align="ctr" overlay="0"/></cx:chart></cx:chartSpace>"#;
/// A waterfall frame as PowerPoint writes it: the chart in a `cx1` choice, a text box fallback.
const WATERFALL_FRAME: &str = r#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" Requires="cx1"><p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="90" name="Waterfall 1"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="914400" y="914400"/><a:ext cx="6096000" cy="4064000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chart xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" r:id="rIdCx"/></a:graphicData></a:graphic></p:graphicFrame></mc:Choice><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="90" name="Waterfall 1"/><p:cNvSpPr><a:spLocks noTextEdit="1"/></p:cNvSpPr><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="6096000" cy="4064000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:p><a:r><a:t>This chart isn't available in your version of PowerPoint.</a:t></a:r></a:p></p:txBody></p:sp></mc:Fallback></mc:AlternateContent>"#;
/// Office 2016 charts (`chartEx`): a waterfall reads as a waterfall chart with its data, not as
/// the "isn't available" fallback text, and an unchanged one is written back as itself with a
/// plain chart fallback; an edited one is written as a plain chart.
#[test]
fn chartex_waterfall_reads_and_round_trips() {
use deckcraft_engine::Session;
let mut s = Session::with_new();
s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap();
let base = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let slide = (1..=9).map(|i| format!("ppt/slides/slide{i}.xml")).find(|n| pkg_part(&base, n).is_some()).unwrap();
let slide_rels = slide.replace("slides/", "slides/_rels/") + ".rels";
let cx_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/></Relationships>"#;
let pptx = repack(
&base,
|name, x| {
if name == slide {
x.replacen("</p:spTree>", &format!("{WATERFALL_FRAME}</p:spTree>"), 1)
} else if name == slide_rels {
x.replace("</Relationships>", r#"<Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="../charts/chartEx1.xml"/></Relationships>"#)
} else if name == "[Content_Types].xml" {
x.replace(
"</Types>",
r#"<Override PartName="/ppt/charts/chartEx1.xml" ContentType="application/vnd.ms-office.chartex+xml"/></Types>"#,
)
} else {
x
}
},
&[("ppt/charts/chartEx1.xml", WATERFALL_CX.as_bytes()), ("ppt/charts/_rels/chartEx1.xml.rels", cx_rels.as_bytes())],
);
let doc = deckcraft_pptx::import(&pptx).unwrap();
let chart = |d: &Presentation| -> Option<Chart> {
d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind {
ShapeKind::Chart(c) => Some((**c).clone()),
_ => None,
})
};
let c = chart(&doc).expect("the waterfall is a chart, not the fallback text");
assert_eq!(c.kind, ChartType::Waterfall);
assert_eq!(c.title.as_deref(), Some("Cash flow"));
assert_eq!(c.categories, ["Start", "Sales", "Costs", "End"]);
assert_eq!(c.series.len(), 1);
assert_eq!(c.series[0].name, "Amount");
assert_eq!(c.series[0].values, [Some(100.0), Some(40.0), Some(-25.0), Some(115.0)]);
assert!(c.data_labels);
assert_eq!(c.legend.as_deref(), Some("b"));
let no_fallback_text = doc.slides.iter().flat_map(|s| s.shapes.iter()).all(|sh| !format!("{:?}", sh.kind).contains("isn't available"));
assert!(no_fallback_text);
// Unchanged: chartEx back in a cx1 choice, a plain chart as fallback, no workbook link.
let out = deckcraft_pptx::export(&doc).unwrap();
let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("chartEx part")).unwrap();
assert!(ex.contains(r#"layoutId="waterfall""#) && !ex.contains("externalData"), "{ex}");
let types = String::from_utf8(pkg_part(&out, "[Content_Types].xml").unwrap()).unwrap();
assert!(types.contains("application/vnd.ms-office.chartex+xml"));
let sx = String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap();
assert!(sx.contains(r#"Requires="cx1""#) && sx.contains("mc:Fallback") && sx.contains("c:chart "), "{sx}");
let srels = String::from_utf8(pkg_part(&out, &slide_rels).unwrap()).unwrap();
assert!(srels.contains("relationships/chartEx") && srels.contains("relationships/chart\""), "{srels}");
assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some());
let again = deckcraft_pptx::import(&out).unwrap();
assert_eq!(chart(&again).map(|c| (c.kind, c.categories, c.series.len())), Some((ChartType::Waterfall, c.categories.clone(), 1)));
// Edited: the kept part no longer matches, so a chartEx part is written from the model,
// still a waterfall with the new value.
let mut edited = doc.clone();
for sl in &mut edited.slides {
for sh in &mut Arc::make_mut(sl).shapes {
if let ShapeKind::Chart(ch) = &mut sh.kind {
ch.series[0].values[1] = Some(55.0);
}
}
}
let out = deckcraft_pptx::export(&edited).unwrap();
let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("a written chartEx part")).unwrap();
assert!(ex.contains(r#"layoutId="waterfall""#), "{ex}");
assert!(String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1"));
let again = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap();
assert_eq!(again.kind, ChartType::Waterfall);
assert_eq!(again.series[0].values[1], Some(55.0));
assert_eq!(again.subtotals, c.subtotals);
}
/// New Office 2016 charts made in DeckCraft are written as chartEx parts that read back as the
/// same type, data and options.
#[test]
fn chartex_new_charts_save_as_their_own_type() {
use deckcraft_engine::Session;
for (ty, kind) in [
("waterfall", ChartType::Waterfall),
("funnel", ChartType::Funnel),
("treemap", ChartType::Treemap),
("sunburst", ChartType::Sunburst),
("histogram", ChartType::Histogram),
("pareto", ChartType::Pareto),
("boxwhisker", ChartType::BoxWhisker),
] {
let chart = |d: &Presentation| -> Option<Chart> {
d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind {
ShapeKind::Chart(c) => Some((**c).clone()),
_ => None,
})
};
let mut s = Session::with_new();
s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap();
s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap();
let id = s.doc().unwrap().selection.shapes[0].0;
let opts = match kind {
ChartType::Histogram | ChartType::Pareto => serde_json::json!({"id": id, "binCount": 4, "title": "T"}),
ChartType::Waterfall => serde_json::json!({"id": id, "subtotals": [3], "title": "T"}),
_ => serde_json::json!({"id": id, "title": "T"}),
};
s.execute("chart.options", &opts).unwrap();
let doc = &s.doc().unwrap().doc;
let before = chart(doc).unwrap();
let out = deckcraft_pptx::export(doc).unwrap();
assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_some(), "{ty}: no chartEx part");
assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some(), "{ty}: no fallback chart");
let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap();
assert_eq!(after.kind, kind, "{ty}");
assert_eq!(after.title.as_deref(), Some("T"), "{ty}");
assert_eq!(after.categories, before.categories, "{ty}");
let n = if matches!(kind, ChartType::BoxWhisker) { before.series.len() } else { 1 };
assert_eq!(after.series.len(), n, "{ty}");
assert_eq!(after.series[0].values, before.series[0].values, "{ty}");
assert_eq!((after.bin_count, after.subtotals.clone()), (before.bin_count, before.subtotals.clone()), "{ty}");
}
}
/// A two-level treemap keeps its parent level, and a box chart its inclusive quartiles, through
/// a save and reopen.
#[test]
fn chartex_levels_and_quartiles_round_trip() {
use deckcraft_engine::Session;
let chart = |d: &Presentation| -> Option<Chart> {
d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind {
ShapeKind::Chart(c) => Some((**c).clone()),
_ => None,
})
};
for ty in ["treemap", "sunburst", "boxwhisker"] {
let mut s = Session::with_new();
s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap();
s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap();
let id = s.doc().unwrap().selection.shapes[0].0;
if ty == "boxwhisker" {
s.execute("chart.options", &serde_json::json!({"id": id, "quartiles": "inclusive"})).unwrap();
} else {
s.execute(
"chart.data",
&serde_json::json!({"id": id, "levels": [["Q1", "", "", "Q2", "", "", "Q3", ""], ["H1", "", "", "", "", "", "H2", ""]]}),
)
.unwrap();
}
let before = chart(&s.doc().unwrap().doc).unwrap();
let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap();
assert_eq!(after.category_levels, before.category_levels, "{ty}");
assert_eq!(after.inclusive_quartiles, before.inclusive_quartiles, "{ty}");
assert_eq!(after.category_tree(), before.category_tree(), "{ty}");
// Unchanged since import, the part is written back as read.
let again = deckcraft_pptx::export(&deckcraft_pptx::import(&out).unwrap()).unwrap();
assert_eq!(chart(&deckcraft_pptx::import(&again).unwrap()).unwrap().category_levels, before.category_levels, "{ty}");
}
}
/// Radar, stock (with and without up/down bars) and combo charts keep their own plot groups
/// through a save and reopen, written as PowerPoint writes them.
#[test]
fn radar_stock_and_combo_round_trip() {
use deckcraft_engine::Session;
let chart = |d: &Presentation| -> Option<Chart> {
d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind {
ShapeKind::Chart(c) => Some((**c).clone()),
_ => None,
})
};
for (ty, bars) in [("radar", false), ("filledradar", false), ("stock", false), ("stock", true), ("combo", false)] {
let mut s = Session::with_new();
s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap();
s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap();
let id = s.doc().unwrap().selection.shapes[0].0;
if bars {
s.execute("chart.options", &serde_json::json!({"id": id, "upDownBars": true})).unwrap();
}
let before = chart(&s.doc().unwrap().doc).unwrap();
let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap();
assert_eq!(after.kind, before.kind, "{ty}");
assert_eq!(after.up_down_bars, bars, "{ty}");
assert_eq!(after.series.len(), before.series.len(), "{ty}");
let kinds: Vec<_> = after.series.iter().map(|x| after.series_kind(x)).collect();
let want: Vec<_> = before.series.iter().map(|x| before.series_kind(x)).collect();
assert_eq!(kinds, want, "{ty}");
for (a, b) in after.series.iter().zip(&before.series) {
assert_eq!(a.values, b.values, "{ty}");
}
}
}
/// Home › New Slide › Reuse Slides: slides from another .pptx land after the current slide with
/// their pictures (fresh media ids, same bytes) and animations on the renumbered shapes. By
/// default they take a destination layout; Keep Source Formatting brings the source master.
#[test]
fn reuse_slides_from_another_presentation() {
use deckcraft_engine::Session;
use serde_json::json;
let b64 = deckcraft_engine::cmd::base64_encode;
// Source: three slides, a picture with an animation on the second.
let mut src = Session::with_new();
src.execute("slide.new", &json!({})).unwrap();
let pic = png(8, 8, [10, 200, 30]);
src.execute("insert.picture", &json!({"data": b64(&pic), "name": "green.png"})).unwrap();
src.execute("animation.add", &json!({"effect": "fade"})).unwrap();
src.execute("slide.new", &json!({})).unwrap();
let src_bytes = deckcraft_pptx::export(&src.doc().unwrap().doc).unwrap();
let mut s = Session::with_new();
// Media ids in the two decks overlap; the destination's own picture must stay its own.
let red = png(4, 4, [200, 0, 0]);
s.execute("insert.picture", &json!({"data": b64(&red), "name": "red.png"})).unwrap();
let r = s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "slides": [1]})).unwrap();
assert_eq!(r["slides"], 1, "{r}");
let doc = s.doc().unwrap().doc.clone();
assert_eq!(doc.slides.len(), 2);
assert_eq!(s.doc().unwrap().selection.slide, 1, "the reused slide is selected");
let pictures = |sl: &deckcraft_model::Slide| {
let mut v = vec![];
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
if let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind {
v.push(fill.media);
}
});
v
};
let mine = pictures(&doc.slides[0]);
let reused = pictures(&doc.slides[1]);
assert_eq!(doc.media(mine[0]).map(|m| m.data.as_slice() == red.as_slice()), Some(true), "own picture untouched");
assert_eq!(doc.media(reused[0]).map(|m| m.data.as_slice() == pic.as_slice()), Some(true), "reused picture copied");
let anim = &doc.slides[1].animations[0];
assert!(doc.slides[1].shapes.iter().any(|sh| sh.id == anim.shape), "animation follows its shape");
assert!(doc.layout(doc.slides[1].layout).is_some(), "uses a destination layout");
assert_eq!(doc.masters.len(), 1);
// The result saves and reopens.
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&doc).unwrap()).unwrap();
assert_eq!(back.slides.len(), 2);
// Keep Source Formatting: every slide, and the source master comes along.
let r = s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "keepFormatting": true, "at": 0})).unwrap();
assert_eq!(r["slides"], 3);
let doc = s.doc().unwrap().doc.clone();
assert_eq!(doc.slides.len(), 5);
assert_eq!(doc.masters.len(), 2);
for sl in &doc.slides[..3] {
assert_eq!(doc.layout(sl.layout).map(|(m, _)| m.id), Some(doc.masters[1].id), "source master");
}
// One undo removes the whole reuse.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides.len(), 2);
assert!(s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "slides": [7]})).is_err());
assert!(s.execute("slide.reuse", &json!({"data": b64(&[0u8, 159, 146, 150]), "name": "x.bin"})).is_err());
}
/// Picture Format › Compress Pictures: a big picture shown small is downsampled, its original
/// goes when nothing else uses it, the saved file shrinks, a copy that wasn't compressed keeps
/// the original, and undo brings everything back.
#[test]
fn compress_pictures_shrinks_the_file_and_keeps_shared_originals() {
use deckcraft_engine::Session;
use serde_json::json;
let b64 = deckcraft_engine::cmd::base64_encode;
// A noisy 1600 × 1600 picture (doesn't compress away on its own).
let big = {
let img = image::RgbImage::from_fn(1600, 1600, |x, y| image::Rgb([(x * 31 % 251) as u8, (y * 17 % 253) as u8, ((x * y) % 255) as u8]));
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
let mut s = Session::with_new();
s.execute("insert.picture", &json!({"data": b64(&big), "name": "noise.png"})).unwrap();
s.execute("shape.resize", &json!({"w": 144, "h": 144})).unwrap();
let size_before = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap().len();
let r = s.execute("picture.compress", &json!({"ppi": 150})).unwrap();
assert_eq!(r["pictures"], 1, "{r}");
let doc = s.doc().unwrap().doc.clone();
assert_eq!(doc.media.len(), 1, "the original is gone");
let img = image::load_from_memory(&doc.media[0].data).unwrap();
assert_eq!((img.width(), img.height()), (300, 300), "2 in at 150 ppi");
let size_after = deckcraft_pptx::export(&doc).unwrap().len();
assert!(size_after * 4 < size_before, "{size_before} → {size_after}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.media[0].data.len(), big.len(), "undo restores the original");
// A duplicate that isn't compressed still needs the original.
s.execute("edit.duplicate", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes.clone();
let first: Vec<_> = s.doc().unwrap().doc.slides[0].shapes.iter().map(|sh| sh.id).filter(|id| !copy.contains(id)).collect();
s.execute("picture.compress", &json!({"ppi": 150, "ids": [first.last().unwrap().0]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
assert_eq!(doc.media.len(), 2);
assert!(doc.media.iter().any(|m| m.data.len() == big.len()), "the copy keeps the original");
// All pictures: the copy gets compressed too, and the original goes.
s.execute("picture.compress", &json!({"ppi": 150, "all": true})).unwrap();
let doc = s.doc().unwrap().doc.clone();
assert!(doc.media.iter().all(|m| m.data.len() < big.len() / 4));
assert!(s.execute("picture.compress", &json!({"ppi": 5, "all": true})).is_err());
}
/// #203: SmartArt we can't draw kept its XML but lost its diagram parts on save.
#[test]
fn smartart_without_a_drawing_keeps_its_parts() {
let mut p = defaults::new_presentation(None);
let data = p.add_media(
"data1.xml",
"application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml",
br#"<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst><dgm:pt modelId="1"><dgm:t><a:p><a:r><a:t>Manager</a:t></a:r></a:p></dgm:t></dgm:pt></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="rId6"/></a:ext></dgm:extLst></dgm:dataModel>"#.to_vec(),
).expect("media");
let mut parts = vec![("rId2".to_string(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData".to_string(), data)];
for (i, kind) in ["Layout", "QuickStyle", "Colors"].into_iter().enumerate() {
let xml = format!(r#"<dgm:{kind} xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"/>"#).into_bytes();
let m = p
.add_media(&format!("{kind}1.xml"), &format!("application/vnd.openxmlformats-officedocument.drawingml.diagram{kind}+xml"), xml)
.expect("media");
parts.push((format!("rId{}", i + 3), format!("http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagram{kind}"), m));
}
let xml = r#"<p:graphicFrame xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:nvGraphicFramePr><p:cNvPr id="4" name="Diagram 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></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="rId2" r:lo="rId3" r:qs="rId4" r:cs="rId5"/></a:graphicData></a:graphic></p:graphicFrame>"#;
let mut s = new_slide(&mut p, "SmartArt");
let mut frame = shape(&mut p, "Diagram 3", 0.0, 0.0, 72.0, 72.0);
frame.kind = ShapeKind::Opaque { xml: xml.into(), preview: None, label: "SmartArt".into(), parts };
s.shapes.push(frame);
p.slides.push(Arc::new(s));
// Twice: the second save starts from what the first one wrote.
let q = round(&round(&p));
let ShapeKind::Opaque { parts, .. } = &by_name(&q.slides[1], "Diagram 3").kind else { panic!("not kept as an object") };
assert_eq!(parts.len(), 4, "data, layout, style and colours all kept");
let data = q.media(parts.iter().find(|(_, k, _)| k.ends_with("/diagramData")).expect("data").2).expect("media");
let data = String::from_utf8_lossy(&data.data);
assert!(data.contains("Manager"), "{data}");
assert!(!data.contains("dataModelExt"), "the link to a drawing that isn't written is dropped: {data}");
}
/// #203: a legacy OLE object (preview in the VML drawing we don't write) is saved in PowerPoint's
/// fallback form, without `spid` and with a `p:pic` preview, or PowerPoint won't open the file.
#[test]
fn legacy_ole_object_is_written_in_the_fallback_form() {
let mut p = defaults::new_presentation(None);
let bin = p
.add_media("oleObject1.bin", "application/vnd.openxmlformats-officedocument.oleObject", b"\xD0\xCF\x11\xE0not-a-real-ole".to_vec())
.expect("media");
let prev = p.add_media("image1.png", "image/png", png(4, 3, [10, 20, 30])).expect("media");
let xml = r#"<p:graphicFrame xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:nvGraphicFramePr><p:cNvPr id="4" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s1026" name="Slide" r:id="rId4" progId="PowerPoint.Slide.8"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
let parts = vec![("rId4".to_string(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject".to_string(), bin)];
let mut s = new_slide(&mut p, "OLE");
let mut frame = shape(&mut p, "Object 3", 0.0, 0.0, 72.0, 72.0);
frame.kind = ShapeKind::Opaque { xml: xml.into(), preview: Some(prev), label: "Slide".into(), parts };
s.shapes.push(frame);
p.slides.push(Arc::new(s));
let q = round(&round(&p));
let ShapeKind::Opaque { xml, parts, preview, .. } = &by_name(&q.slides[1], "Object 3").kind else { panic!("not kept as an object") };
assert!(xml.contains("<p:pic>") && !xml.contains("spid"), "{xml}");
assert!(preview.is_some(), "the preview picture is kept");
assert!(parts.iter().any(|(_, k, _)| k.ends_with("/oleObject")), "the embedding is kept: {parts:?}");
}
/// Insert › Zoom: slide, section and summary zooms are linked pictures of their targets, with
/// Return to Zoom on section and summary zooms; a summary zoom makes its own slide; all of it is
/// one undo step and saves as linked pictures.
#[test]
fn zoom_inserts_linked_slide_pictures() {
use deckcraft_engine::Session;
use deckcraft_model::text::Action;
use serde_json::json;
let mut s = Session::with_new();
for t in ["Intro", "Plan", "Budget", "Risks"] {
s.execute("slide.new", &json!({"title": t})).unwrap();
}
// Slides: 0 title, 1 Intro, 2 Plan, 3 Budget, 4 Risks.
s.execute("slide.go", &json!({"index": 0})).unwrap();
let r = s.execute("insert.zoom", &json!({"slides": [2]})).unwrap();
assert_eq!(r["returnToZoom"], false);
let doc = s.doc().unwrap().doc.clone();
let zoom = doc.slides[0].shapes.iter().find(|sh| sh.name.starts_with("Slide Zoom")).expect("slide zoom");
let link = zoom.click.as_ref().unwrap();
assert_eq!(link.action, Action::Slide { slide: doc.slides[2].id });
assert_eq!(link.zoom_return, None);
assert_eq!(link.tooltip, "Plan");
let ShapeKind::Picture { fill } = &zoom.kind else { panic!() };
assert!(doc.media(fill.media).is_some_and(|m| m.data.starts_with(b"\x89PNG")));
// Section zoom: back after the section's last slide.
s.execute("section.add", &json!({"name": "Start", "at": 0})).unwrap();
s.execute("section.add", &json!({"name": "Money", "at": 3})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let money = doc.sections.iter().position(|x| x.name == "Money").unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
s.execute("insert.zoom", &json!({"kind": "section", "sections": [money]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let sec = doc.slides[0].shapes.iter().find(|sh| sh.name.starts_with("Section Zoom")).unwrap();
let last = *doc.sections[money].slides.last().unwrap();
assert_eq!(sec.click.as_ref().unwrap().zoom_return, Some(last));
// Summary zoom on slides 1 and 3: a new slide in front, parts 1–2 and 3–4.
let before = doc.slides.len();
s.execute("insert.zoom", &json!({"kind": "summary", "slides": [1, 3]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
assert_eq!(doc.slides.len(), before + 1);
let sum = &doc.slides[1];
assert!(sum.title().contains("Summary"));
let tiles: Vec<_> = sum.shapes.iter().filter_map(|sh| sh.click.as_ref()).collect();
assert_eq!(tiles.len(), 2);
assert_eq!(tiles[0].zoom_return, Some(doc.slides[3].id), "part one runs to the slide before part two");
assert_eq!(tiles[1].zoom_return, Some(doc.slides.last().unwrap().id));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides.len(), before, "one undo step");
// Saved as linked pictures.
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap()).unwrap();
let z = back.slides[0].shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Picture { .. }) && sh.click.is_some()).unwrap();
assert!(matches!(z.click.as_ref().unwrap().action, Action::Slide { .. }));
assert!(s.execute("insert.zoom", &json!({"slides": [42]})).is_err());
assert!(s.execute("insert.zoom", &json!({"kind": "slide"})).is_err());
}