Files
deckcraft/crates/pptx/tests/it/corpus_media.rs
T
aiongg f001f37906 CI: one test binary per crate, nextest, line tables only (#1213)
* Build: line tables only for workspace crates, no debug info for dependencies

* Tests: one integration-test binary per crate

* xtask ci: nextest, doctests, test build without examples, one wasm check
2026-10-23 12:43:34 -04:00

374 lines
19 KiB
Rust

//! Owned corpus: SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a
//! hidden slide, slide-number and date fields, ink, 3-D models, and animation repeat, bounce and triggers.
//!
//! Relationship-bearing SmartArt and OLE parts stay with issue #203. Media playback options stay
//! with #74. Animation rewind (`cTn fill="remove"`) stays with #77. This file does not assert those.
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_color::Rgba;
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimClass, Animation};
use deckcraft_model::text::{Run, RunKind, RunProps, TextBody};
use deckcraft_model::{Geom, InkStroke, MediaClip, Presentation, Section, Shape, ShapeId, ShapeKind, Slide, SlideId, defaults};
fn png(rgb: [u8; 3]) -> Vec<u8> {
let mut out = Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb(rgb))).write_to(&mut out, image::ImageFormat::Png).expect("png");
out.into_inner()
}
fn glb(marker: u8) -> Vec<u8> {
let mut v = b"glTF\x02\x00\x00\x00\x0d\x00\x00\x00".to_vec();
v.push(marker);
v
}
fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape {
Shape { id: ShapeId(p.alloc_id().expect("id")), 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
}
fn field(text: &str, kind: &str) -> TextBody {
let mut body = TextBody::from_text("");
body.paragraphs[0].runs = vec![Run { text: text.into(), props: RunProps::default(), kind: RunKind::Field { field: kind.into() } }];
body
}
const SMARTART: &str = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr name="Diagram"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="3657600" cy="1828800"/></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"/><dgm:pt xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:t>Quarter</dgm:t></dgm:pt></a:graphicData></a:graphic></p:graphicFrame>"#;
const OLE: &str = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr name="Embedded"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="3810000" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj name="Note" progId="DeckCraft.Note.1"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
fn corpus() -> (Presentation, Vec<u8>, Vec<u8>, Vec<u8>) {
let mut p = defaults::new_presentation(None);
let audio_bytes = b"audio-bytes-deckcraft".to_vec();
let video_bytes = b"video-bytes-deckcraft".to_vec();
let block = glb(1);
let globe = glb(2);
let poster = png([20, 80, 160]);
let audio = p.add_media("clip.m4a", "audio/mp4", audio_bytes).expect("media");
let video = p.add_media("clip.mp4", "video/mp4", video_bytes).expect("media");
let block_id = p.add_media("block.glb", "model/gltf-binary", block.clone()).expect("media");
let globe_id = p.add_media("globe.glb", "model/gltf-binary", globe.clone()).expect("media");
let poster_id = p.add_media("globe-poster.png", "image/png", poster.clone()).expect("media");
let mut objects = new_slide(&mut p, "Objects");
let mut diagram = shape(&mut p, "Diagram", 40.0, 80.0, 288.0, 144.0);
diagram.kind = ShapeKind::Opaque { xml: SMARTART.into(), preview: None, label: "SmartArt".into(), parts: vec![] };
let mut embedded = shape(&mut p, "Embedded", 340.0, 80.0, 144.0, 72.0);
embedded.kind = ShapeKind::Opaque { xml: OLE.into(), preview: None, label: "DeckCraft.Note.1".into(), parts: vec![] };
let mut block_shape = shape(&mut p, "Block", 40.0, 240.0, 120.0, 90.0);
block_shape.kind = ShapeKind::Model3d { media: block_id, poster: None };
let mut globe_shape = shape(&mut p, "Globe", 180.0, 240.0, 160.0, 120.0);
globe_shape.kind = ShapeKind::Model3d { media: globe_id, poster: Some(poster_id) };
let mut number = shape(&mut p, "Slide number", 360.0, 240.0, 80.0, 28.0);
number.text = Some(field("4", "slidenum"));
let mut date = shape(&mut p, "Date", 360.0, 276.0, 160.0, 28.0);
date.text = Some(field("14 October 2026", "datetime4"));
objects.shapes.extend([diagram, embedded, block_shape, globe_shape, number, date]);
p.slides.push(Arc::new(objects));
let mut media = new_slide(&mut p, "Media");
let mut clip = shape(&mut p, "Narration", 40.0, 80.0, 80.0, 80.0);
clip.kind = ShapeKind::Media(MediaClip {
media: audio,
video: false,
trim_start_ms: 400,
trim_end_ms: 2600,
fade_in_ms: 80,
fade_out_ms: 120,
bookmarks: vec![("Intro".into(), 400), ("Outro".into(), 2200)],
volume: 1.0,
..Default::default()
});
let mut film = shape(&mut p, "Film", 160.0, 80.0, 240.0, 135.0);
film.kind = ShapeKind::Media(MediaClip {
media: video,
video: true,
poster: Some(poster_id),
trim_start_ms: 250,
trim_end_ms: 9000,
bookmarks: vec![("Mark".into(), 1500)],
volume: 1.0,
..Default::default()
});
let mut trigger = shape(&mut p, "Trigger", 40.0, 240.0, 100.0, 36.0);
trigger.text = Some(TextBody::from_text("Play"));
let mut fader = shape(&mut p, "Fader", 160.0, 240.0, 140.0, 60.0);
fader.text = Some(TextBody::from_text("Hello"));
let trigger_id = trigger.id;
let fader_id = fader.id;
media.shapes.extend([clip, film, trigger, fader]);
media.animations = vec![
Animation {
shape: fader_id,
effect: "fade".into(),
duration_ms: 600,
repeat: 4,
bounce_end: 0.25,
trigger: Some(trigger_id),
..Default::default()
},
Animation { shape: fader_id, class: AnimClass::Emphasis, effect: "pulse".into(), duration_ms: 400, repeat: u32::MAX, ..Default::default() },
];
p.slides.push(Arc::new(media));
let mut aside = new_slide(&mut p, "Aside");
aside.hidden = true;
let mut ink = shape(&mut p, "Sketch", 48.0, 96.0, 200.0, 80.0);
ink.kind = ShapeKind::Ink {
strokes: vec![
InkStroke {
points: vec![(48.0, 100.0, 0.4), (90.0, 140.0, 0.8), (150.0, 110.0, 0.3)],
color: Rgba::rgb(20, 20, 20),
width: 2.0,
highlighter: false,
},
InkStroke { points: vec![(50.0, 150.0, 1.0), (160.0, 156.0, 1.0)], color: Rgba::rgb(255, 230, 40), width: 12.0, highlighter: true },
],
};
aside.shapes.push(ink);
p.slides.push(Arc::new(aside));
let ids: Vec<SlideId> = p.slides.iter().map(|s| s.id).collect();
let hidden = *ids.last().expect("aside");
p.sections = vec![
Section { name: "Open".into(), slides: ids.iter().copied().filter(|id| *id != hidden).collect() },
Section { name: "Aside".into(), slides: vec![hidden] },
];
p.first_slide_number = 4;
(p, block, globe, poster)
}
fn round(p: &Presentation) -> Presentation {
let bytes = deckcraft_pptx::export(p).expect("export");
deckcraft_pptx::import(&bytes).expect("import")
}
fn by_name<'a>(p: &'a Presentation, name: &str) -> &'a Shape {
let mut found = None;
for s in &p.slides {
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}"))
}
fn media_bytes(p: &Presentation, shape: &str) -> Vec<u8> {
let id = match &by_name(p, shape).kind {
ShapeKind::Model3d { media, .. } | ShapeKind::Media(MediaClip { media, .. }) => *media,
other => panic!("{shape} has no media: {other:?}"),
};
p.media(id).expect("media").data.as_ref().clone()
}
fn assert_field(p: &Presentation, name: &str, kind: &str, text: &str) {
let run = &by_name(p, name).text.as_ref().expect("text").paragraphs[0].runs[0];
assert!(matches!(&run.kind, RunKind::Field { field } if field == kind), "{name}: {:?}", run.kind);
assert_eq!(run.text, text);
}
fn assert_ink(s: &Shape) {
let ShapeKind::Group { children, .. } = &s.kind else { panic!("ink comes back as a group, got {:?}", s.kind) };
assert_eq!(children.len(), 2, "pen and highlighter");
assert!(children.iter().all(|c| matches!(c.geom, Geom::Custom { .. })));
}
#[test]
fn object_corpus_round_trips() {
let (p, block, globe, poster) = corpus();
let bytes = deckcraft_pptx::export(&p).expect("export");
let types = zip_text(&bytes, "[Content_Types].xml");
assert!(types.contains("model/gltf-binary"), "{types}");
assert!(zip_has_payload(&bytes, &block), "block model was not written");
assert!(zip_has_payload(&bytes, &globe), "globe model was not written");
let once = deckcraft_pptx::import(&bytes).expect("import");
let twice = round(&once);
for q in [&once, &twice] {
let diagram = by_name(q, "Diagram");
let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) };
assert_eq!(label, "SmartArt");
assert!(xml.contains("Quarter"), "{xml}");
assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus");
let embedded = by_name(q, "Embedded");
let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) };
assert_eq!(label, "DeckCraft.Note.1");
assert!(xml.contains("DeckCraft.Note.1"), "{xml}");
assert!(!xml.contains("r:id"), "an embedding part is issue #203");
let block_shape = by_name(q, "Block");
assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind);
assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0)));
assert_eq!(media_bytes(q, "Block"), block);
let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) };
let block_media = q.media(*media).expect("block media");
assert_eq!(block_media.content_type, "model/gltf-binary");
assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name);
let globe_shape = by_name(q, "Globe");
let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) };
assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0)));
assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe.as_slice());
assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster.as_slice());
let narration = by_name(q, "Narration");
let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) };
assert!(!m.video);
assert_eq!(m.trim_start_ms, 400);
assert_eq!(m.trim_end_ms, 2600);
assert_eq!(m.fade_in_ms, 80);
assert_eq!(m.fade_out_ms, 120);
assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]);
assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice());
let film = by_name(q, "Film");
let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) };
assert!(m.video);
assert_eq!(m.trim_start_ms, 250);
assert_eq!(m.trim_end_ms, 9000);
assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]);
assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice());
assert_field(q, "Slide number", "slidenum", "4");
assert_field(q, "Date", "datetime4", "14 October 2026");
assert_eq!(q.first_slide_number, 4);
assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Open", "Aside"]);
let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide");
assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1);
assert!(q.sections[1].slides.contains(&q.slides[hidden].id));
assert!(!q.sections[0].slides.contains(&q.slides[hidden].id));
assert_ink(by_name(q, "Sketch"));
let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide");
let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade");
let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse");
assert_eq!(fade.repeat, 4);
assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end);
assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id));
assert_eq!(fade.shape, by_name(q, "Fader").id);
assert_eq!(pulse.repeat, u32::MAX);
assert_eq!(pulse.class, AnimClass::Emphasis);
assert!(pulse.trigger.is_none());
}
}
/// An exported deck with `parts` added (or replaced) and `edit` applied to every XML part.
fn with_parts(parts: &[(&str, Vec<u8>)], edit: impl Fn(&str, String) -> String) -> Vec<u8> {
let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let name = f.name().to_string();
if parts.iter().any(|(n, _)| *n == name) {
continue;
}
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
if name.ends_with(".xml") || name.ends_with(".rels") {
d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes();
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
for (name, data) in parts {
out.start_file(*name, o).expect("start");
out.write_all(data).expect("write");
}
out.finish().expect("finish").into_inner()
}
fn zip_text(bytes: &[u8], name: &str) -> String {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut f = z.by_name(name).unwrap_or_else(|_| panic!("no {name}"));
let mut d = String::new();
f.read_to_string(&mut d).expect("read");
d
}
fn zip_has_payload(bytes: &[u8], payload: &[u8]) -> bool {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
if d == payload {
return true;
}
}
false
}
#[test]
fn an_alternate_content_model_keeps_the_glb_and_its_poster() {
let model = glb(7);
let poster = png([200, 30, 40]);
let frame = r#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" Requires="am3d"><p:contentPart r:id="rIdModel"><p:nvContentPartPr><p:cNvPr id="60" name="Model from file"/><p:cNvContentPartPr/><p:nvPr/></p:nvContentPartPr><p:xfrm><a:off x="508000" y="635000"/><a:ext cx="1524000" cy="1143000"/></p:xfrm></p:contentPart></mc:Choice><mc:Fallback><p:pic><p:nvPicPr><p:cNvPr id="61" name="Model poster"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdPoster"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="508000" y="635000"/><a:ext cx="1524000" cy="1143000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></mc:Fallback></mc:AlternateContent>"#;
let bytes = with_parts(&[("ppt/media/model.glb", model.clone()), ("ppt/media/poster.png", poster.clone())], |name, s| {
if name == "ppt/slides/slide1.xml" {
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
s.replacen(
"</Relationships>",
r#"<Relationship Id="rIdModel" Type="http://schemas.microsoft.com/office/2017/06/relationships/model3d" Target="../media/model.glb"/><Relationship Id="rIdPoster" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/poster.png"/></Relationships>"#,
1,
)
} else {
s
}
});
let once = deckcraft_pptx::import(&bytes).expect("import");
let twice = round(&once);
for q in [&once, &twice] {
assert!(q.slides[0].shapes.iter().all(|s| s.name != "Model poster"), "the fallback picture replaced the model");
let s = by_name(q, "Model from file");
let ShapeKind::Model3d { media, poster: Some(poster_id) } = &s.kind else { panic!("{:?}", s.kind) };
assert_eq!(s.xfrm, Some(Xfrm::new(40.0, 50.0, 120.0, 90.0)));
let model_item = q.media(*media).expect("glb");
assert_eq!(model_item.data.as_ref(), model.as_slice());
assert_eq!(model_item.content_type, "model/gltf-binary");
assert_eq!(q.media(*poster_id).expect("poster").data.as_ref(), poster.as_slice());
}
}
#[test]
fn an_ink_content_part_still_uses_its_fallback_picture() {
let poster = png([10, 10, 10]);
let frame = r#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="p14"><p:contentPart r:id="rIdInk"><p:nvContentPartPr><p:cNvPr id="50" name="Ink marks"/><p:cNvContentPartPr/><p:nvPr/></p:nvContentPartPr></p:contentPart></mc:Choice><mc:Fallback><p:pic><p:nvPicPr><p:cNvPr id="51" name="Ink poster"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdPoster"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></mc:Fallback></mc:AlternateContent>"#;
let bytes = with_parts(&[("ppt/ink/ink1.xml", b"<ink/>".to_vec()), ("ppt/media/ink-poster.png", poster.clone())], |name, s| {
if name == "ppt/slides/slide1.xml" {
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
s.replacen(
"</Relationships>",
r#"<Relationship Id="rIdInk" Type="http://schemas.microsoft.com/office/2007/relationships/ink" Target="../ink/ink1.xml"/><Relationship Id="rIdPoster" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/ink-poster.png"/></Relationships>"#,
1,
)
} else {
s
}
});
let p = deckcraft_pptx::import(&bytes).expect("import");
assert!(p.slides[0].shapes.iter().all(|s| s.name != "Ink marks"));
let s = by_name(&p, "Ink poster");
let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) };
assert_eq!(p.media(fill.media).expect("poster").data.as_ref(), poster.as_slice());
}