Files
deckcraft/crates/pdf/src/tests.rs
skylar taylor-barrick 0cbe475f13 fix(pdf): reconcile writer with notes and handout masters (PDFW)
Preserve notes and handout master export, visible text and native parallel
compression while propagating shared font errors through each export path.
Keep the dependency check accurate for native and WASM feature resolution.

PR #708.
2026-10-14 19:44:21 -06:00

444 lines
21 KiB
Rust
Raw Permalink 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.
use std::sync::Arc;
use super::*;
use deckcraft_model::text::TextBody;
fn deck(n: usize) -> Presentation {
let mut p = deckcraft_model::defaults::new_presentation(None);
let layout = p.slides[0].layout;
while p.slides.len() < n {
let s = deckcraft_model::defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s));
}
for (i, s) in p.slides.iter_mut().enumerate() {
let s = Arc::make_mut(s);
if let Some(sh) = s.shapes.first_mut() {
sh.text = Some(TextBody::from_text(&format!("Slide title {i}")));
}
s.notes = TextBody::from_text("Speaker notes that wrap across the page width to test line breaking of long notes.");
}
p
}
fn opts(layout: PageLayout) -> PdfOptions {
PdfOptions { layout, dpi: 48.0, ..Default::default() }
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn exports_slides_notes_and_handouts() {
let p = deck(5);
for layout in [PageLayout::Slides, PageLayout::Notes, PageLayout::Handouts { per_page: 3 }, PageLayout::Handouts { per_page: 6 }] {
let bytes = export(&p, &opts(layout)).expect("export");
assert!(bytes.starts_with(b"%PDF-"), "{layout:?}");
assert!(bytes.len() > 1000);
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let (pages, size) = match layout {
PageLayout::Slides => (5, (960.0, 540.0)),
PageLayout::Notes => (5, (612.0, 792.0)),
PageLayout::Handouts { per_page: 3 } => (2, (612.0, 792.0)),
_ => (1, (612.0, 792.0)),
};
assert_eq!(pdf.pages().len(), pages);
assert!(pdf.pages().iter().all(|page| page.render_dimensions() == size));
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn empty_range_is_an_error() {
let p = deck(1);
let o = PdfOptions { slides: Some(vec![7]), ..opts(PageLayout::Slides) };
assert!(matches!(export(&p, &o), Err(PdfError::NoPages)));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn hidden_slides_skipped_unless_asked() {
let mut p = deck(2);
Arc::make_mut(&mut p.slides[1]).hidden = true;
let a = export(&p, &opts(PageLayout::Slides)).unwrap();
let b = export(&p, &PdfOptions { include_hidden: true, ..opts(PageLayout::Slides) }).unwrap();
assert_eq!(hayro_syntax::Pdf::new(a.clone()).unwrap().pages().len(), 1);
assert_eq!(hayro_syntax::Pdf::new(b.clone()).unwrap().pages().len(), 2);
let ranged = export(&p, &PdfOptions { slides: Some(vec![1, 0, 9]), ..opts(PageLayout::Slides) }).unwrap();
assert_eq!(hayro_syntax::Pdf::new(ranged.clone()).unwrap().pages().len(), 2);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn handout_boxes_fit_the_paper() {
for n in [1, 2, 3, 4, 6, 9] {
let v = handout_boxes(n, (612.0, 792.0), 16.0 / 9.0);
assert_eq!(v.len(), n as usize);
for r in &v {
assert!(r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= 612.0 && r.y1 <= 792.0, "{n}: {r:?}");
assert!((r.width() / r.height() - 16.0 / 9.0).abs() < 1e-6);
}
for (i, a) in v.iter().enumerate() {
for b in &v[i + 1..] {
assert!(a.intersect(*b).area() <= 0.0, "{n}: overlap");
}
}
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn metadata_retains_values_and_omits_blank_fields() {
let mut p = deck(1);
p.props.title = " Deck title ".into();
p.props.author = " Author ".into();
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
assert_eq!(pdf.metadata().title.as_deref(), Some(b" Deck title ".as_slice()));
assert_eq!(pdf.metadata().author.as_deref(), Some(b" Author ".as_slice()));
assert_eq!(pdf.metadata().creator.as_deref(), Some(b"DeckCraft".as_slice()));
assert_eq!(pdf.metadata().producer.as_deref(), Some(b"DeckCraft".as_slice()));
assert!(pdf.metadata().creation_date.is_none());
p.props.author = " ".into();
let bytes = export(&p, &PdfOptions { title: Some(" ".into()), ..opts(PageLayout::Slides) }).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
assert!(pdf.metadata().title.is_none());
assert!(pdf.metadata().author.is_none());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn text_layer_links_and_bookmarks_survive_serialization() {
use deckcraft_model::text::{Action, Hyperlink};
use hayro_syntax::object::{Array, Dict};
let mut p = deck(2);
let last = p.slides[1].id;
let slide = Arc::make_mut(&mut p.slides[0]);
for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) {
shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None });
}
for text_layer in [true, false] {
let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let annotations = page.raw().get::<Array<'_>>(b"Annots").unwrap();
assert_eq!(annotations.iter::<Dict<'_>>().count(), 2);
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font");
assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), text_layer);
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
let outlines = catalog.get::<Dict<'_>>(b"Outlines").unwrap();
let first = outlines.get::<Dict<'_>>(b"First").unwrap();
assert!(first.get::<Dict<'_>>(b"Next").is_some());
}
}
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
/// embedding. No font bytes or vendor assets are stored as test fixtures.
fn font_without_head() -> Vec<u8> {
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec();
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
let mut offset = 0;
while offset + old.len() <= bytes.len() {
if bytes[offset..offset + old.len()] == *old {
bytes[offset..offset + old.len()].copy_from_slice(new);
offset += old.len();
} else {
offset += 1;
}
}
}
let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
for i in 0..tables {
let offset = 12 + i * 16;
if &bytes[offset..offset + 4] == b"head" {
bytes[offset..offset + 4].copy_from_slice(b"zzzz");
}
}
bytes
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
let db = FontDb::global();
assert_eq!(db.add_font(font_without_head()), 1);
assert_eq!(db.face("BadPDF", "Regular").family, "BadPDF");
let mut p = deck(1);
let slide = Arc::make_mut(&mut p.slides[0]);
let mut body = TextBody::from_text("Text that must not disappear silently");
for paragraph in &mut body.paragraphs {
for run in &mut paragraph.runs {
run.props.font = Some("BadPDF".into());
run.props.size = Some(12.0);
}
}
slide.shapes = vec![Shape { id: ShapeId(9000), xfrm: Some(Xfrm::new(20.0, 20.0, 500.0, 200.0)), text: Some(body), ..Default::default() }];
let error = export(&p, &opts(PageLayout::Slides)).unwrap_err();
assert!(matches!(error, PdfError::Write(_)));
assert!(error.to_string().contains("font could not be read for embedding"));
// Master text remains visible even when the optional slide text layer is off.
// It must propagate the same embedding error through notes and handout pages.
let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, "");
header.text = p.slides[0].shapes[0].text.clone();
for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] {
let mut p = deck(1);
let master = Some(Arc::new(deckcraft_model::Master { shapes: vec![header.clone()], ..Default::default() }));
if layout == PageLayout::Notes {
p.notes_master = master;
} else {
p.handout_master = master;
}
let error = export(&p, &PdfOptions { text_layer: false, ..opts(layout) }).unwrap_err();
assert!(matches!(error, PdfError::Write(_)));
assert!(error.to_string().contains("font could not be read for embedding"), "{layout:?}: {error}");
}
// The failed export leaves no poisoned writer state behind.
assert!(export(&deck(1), &opts(PageLayout::Slides)).is_ok());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let text = "Café Ελληνικά Привет";
let face = FontDb::global().face("Ubuntu", "Regular");
assert_eq!(face.family, "Ubuntu");
assert!(text.chars().all(|c| face.glyph_for(c) != 0), "missing: {:?}", text.chars().filter(|c| face.glyph_for(*c) == 0).collect::<Vec<_>>());
let mut body = TextBody::from_text(text);
for paragraph in &mut body.paragraphs {
for run in &mut paragraph.runs {
run.props.font = Some("Ubuntu".into());
run.props.size = Some(20.0);
}
}
let mut p = deck(1);
Arc::make_mut(&mut p.slides[0]).shapes =
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body), ..Default::default() }];
let placed = deckcraft_render::place_slide(&p, 0);
let runs: Vec<_> = placed.texts.iter().flat_map(|placed| &placed.layout.runs).collect();
assert!(!runs.is_empty());
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
assert_eq!(fonts.len(), 1);
let font = fonts.get::<Dict<'_>>(fonts.keys().next().unwrap()).unwrap();
let cmap = font.get::<Stream<'_>>(b"ToUnicode").unwrap().decoded().unwrap().into_owned();
let cmap = std::str::from_utf8(&cmap).unwrap();
// This fixture uses the writer's two-byte CIDs and bfchar mappings. Decode the
// content's text-showing operators through that map, not document metadata.
let mut mapping = HashMap::<u16, String>::new();
let mut in_char_map = false;
for line in cmap.lines() {
if line.ends_with("beginbfchar") {
in_char_map = true;
} else if line == "endbfchar" {
in_char_map = false;
} else if in_char_map {
let parts: Vec<_> = line.split_whitespace().collect();
let cid = u16::from_str_radix(parts[0].trim_matches(['<', '>']), 16).unwrap();
let value = parts[1].trim_matches(['<', '>']);
let units: Vec<_> = value
.as_bytes()
.as_chunks::<4>()
.0
.iter()
.map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap())
.collect();
mapping.insert(cid, String::from_utf16(&units).unwrap());
}
}
let mut extracted = String::new();
let mut append = |bytes: &[u8]| {
assert!(bytes.len().is_multiple_of(2));
for pair in bytes.as_chunks::<2>().0 {
extracted.push_str(mapping.get(&u16::from_be_bytes([pair[0], pair[1]])).unwrap());
}
};
let mut operations = page.typed_operations();
while let Some(operation) = operations.next() {
match operation {
TypedInstruction::ShowText(value) => append(value.0.as_bytes()),
TypedInstruction::ShowTexts(values) => {
for value in values.0.iter::<Object<'_>>() {
if let Object::String(value) = value {
append(value.as_bytes());
}
}
}
_ => {}
}
}
assert_eq!(extracted, text);
}
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
/// on the page and room left for the notes.
#[test]
fn notes_and_handouts_fit_any_paper_and_orientation() {
let p = deck(3);
for (id, _, _) in PAPERS {
for landscape in [false, true] {
let paper = paper_size(id, landscape).expect("known paper");
assert_eq!(paper.0 > paper.1, landscape, "{id}");
let b = notes_slide_box(paper, 16.0 / 9.0);
assert!(b.x0 >= 0.0 && b.x1 <= paper.0 && b.y1 <= paper.1 * 0.5, "{id} {landscape}: {b:?}");
assert!((b.width() / b.height() - 16.0 / 9.0).abs() < 1e-6);
for n in [1, 3, 6, 9] {
for r in handout_boxes(n, paper, 16.0 / 9.0) {
assert!(r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= paper.0 && r.y1 <= paper.1, "{id} {landscape} {n}: {r:?}");
}
}
for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 6 }] {
let bytes = export(&p, &PdfOptions { paper, ..opts(layout) }).expect("export");
let text = String::from_utf8_lossy(&bytes);
let media = format!("/MediaBox[0 0 {} {}]", fmt(paper.0), fmt(paper.1));
assert!(text.contains(&media), "{id} {landscape} {layout:?}: no {media}");
}
}
}
assert_eq!(paper_size("bogus", false), None);
}
fn fmt(v: f64) -> String {
let s = format!("{:.2}", v as f32);
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn ph(kind: deckcraft_model::PhType, x: f64, y: f64, w: f64, h: f64, text: &str) -> deckcraft_model::Shape {
deckcraft_model::Shape {
xfrm: Some(deckcraft_model::Xfrm::new(x, y, w, h)),
ph: Some(deckcraft_model::Placeholder { kind, ..Default::default() }),
text: Some(TextBody::from_text(text)),
..Default::default()
}
}
#[test]
fn notes_pages_and_handouts_follow_their_masters() {
use deckcraft_model::PhType;
let mut p = deck(2);
let paper = (612.0, 792.0);
// No masters: the default layout (slide at the top, one-inch side margins).
let (slide, notes) = notes_frames(&p, paper);
assert_eq!((slide.x0, slide.y0), (72.0, 54.0));
assert!(notes.y0 > slide.y1);
// A notes master moves the slide and the notes, scaled from the notes size to the paper.
p.notes_size = deckcraft_model::Size::new(612.0, 792.0);
p.notes_master = Some(Arc::new(deckcraft_model::Master {
shapes: vec![
ph(PhType::SlideImage, 100.0, 400.0, 412.0, 300.0, ""),
ph(PhType::Body, 50.0, 50.0, 500.0, 300.0, ""),
ph(PhType::Header, 0.0, 0.0, 300.0, 30.0, "Q3 review"),
ph(PhType::Date, 312.0, 0.0, 300.0, 30.0, ""),
ph(PhType::SlideNum, 312.0, 760.0, 300.0, 30.0, "<#>"),
],
..Default::default()
}));
let (slide, notes) = notes_frames(&p, paper);
assert!(slide.y0 >= 400.0 && slide.y1 <= 700.0 + 1e-6 && slide.x0 >= 100.0 - 1e-6, "{slide:?}");
assert!(((slide.width() / slide.height()) - p.slide_size.width / p.slide_size.height).abs() < 1e-6);
assert_eq!(notes, Rect::new(50.0, 50.0, 550.0, 350.0));
let texts = master_texts(&p, p.notes_master.as_deref().unwrap(), paper, 7);
let got: Vec<(&str, bool)> = texts.iter().map(|(_, t, r, _)| (t.as_str(), *r)).collect();
assert_eq!(got, [("Q3 review", false), ("7", true)], "empty date left out, number is the page");
// Notes pages and handouts export with the masters.
for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] {
if layout != PageLayout::Notes {
p.handout_master = Some(Arc::new(deckcraft_model::Master {
shapes: vec![ph(PhType::Footer, 0.0, 760.0, 300.0, 30.0, "Confidential")],
..Default::default()
}));
}
let bytes = export(&p, &PdfOptions { layout, ..Default::default() }).unwrap();
assert!(bytes.starts_with(b"%PDF"));
}
}
#[test]
fn master_text_keeps_its_own_formatting() {
use deckcraft_model::text::RunProps;
use deckcraft_model::{ColorRef, Fill, PhType, Rgba};
let mut p = deck(1);
p.notes_size = deckcraft_model::Size::new(612.0, 792.0);
let mut header = ph(PhType::Header, 0.0, 0.0, 300.0, 30.0, "Board pack");
if let Some(run) = header.text.as_mut().and_then(|t| t.paragraphs.first_mut()).and_then(|p| p.runs.first_mut()) {
run.props = RunProps {
size: Some(20.0),
bold: Some(true),
fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)))),
font: Some("+mj-lt".into()),
..Default::default()
};
}
let m = deckcraft_model::Master { shapes: vec![header, ph(PhType::Footer, 0.0, 760.0, 300.0, 30.0, "plain")], ..Default::default() };
let texts = master_texts(&p, &m, (612.0, 792.0), 1);
let st = &texts[0].3;
assert_eq!((st.size, st.bold, st.italic, st.color), (20.0, true, false, Rgba::rgb(200, 0, 0)));
assert_eq!(st.family, m.theme.font("+mj-lt"), "theme font resolved");
// Unstyled text falls back through the master's styles; sizes scale with the paper.
let half = master_texts(&p, &m, (306.0, 396.0), 1);
assert!((half[0].3.size - 10.0).abs() < 1e-9);
assert!(texts[1].3.size > 0.0 && !texts[1].3.bold);
p.notes_master = Some(Arc::new(m));
assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF"));
}
#[test]
fn master_text_wraps_and_keeps_mixed_formatting() {
use deckcraft_model::text::{Run, RunProps};
use deckcraft_model::{PhType, Rgba};
let mut p = deck(1);
p.notes_size = deckcraft_model::Size::new(612.0, 792.0);
let mut header = ph(PhType::Header, 0.0, 0.0, 200.0, 80.0, "");
if let Some(para) = header.text.as_mut().and_then(|t| t.paragraphs.first_mut()) {
para.runs = vec![
Run::with("Quarterly board pack ", RunProps { size: Some(18.0), bold: Some(true), ..Default::default() }),
Run::with(
"for internal circulation only, please do not forward",
RunProps {
size: Some(9.0),
fill: Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(200, 0, 0)))),
..Default::default()
},
),
];
}
let m = deckcraft_model::Master { shapes: vec![header, ph(PhType::SlideNum, 312.0, 760.0, 300.0, 30.0, "")], ..Default::default() };
let placed = deckcraft_render::place_master_texts(&p, &m, 4);
assert_eq!(placed.len(), 2, "header and page number");
let h = &placed[0].layout;
assert!(h.lines.len() >= 2, "wraps inside its 200 pt placeholder: {} lines", h.lines.len());
let sizes: Vec<f64> = h.runs.iter().filter(|r| !r.glyphs.is_empty()).map(|r| r.size).collect();
assert!(sizes.iter().any(|s| (*s - 18.0).abs() < 0.5) && sizes.iter().any(|s| (*s - 9.0).abs() < 0.5), "{sizes:?}");
assert!(h.runs.iter().any(|r| r.color == Rgba::rgb(200, 0, 0)));
let num: String = placed[1].body.text();
assert_eq!(num, "4");
// The same layout as pieces for the Notes Page view: on more than one line, scaled to paper.
let pieces = master_text_pieces(&p, &m, (306.0, 396.0), 4);
let mut ys: Vec<i64> = pieces.iter().map(|x| x.at.1.round() as i64).collect();
ys.dedup();
assert!(ys.len() >= 3, "two header lines and the number: {pieces:?}");
assert!(pieces.iter().any(|x| x.bold && (x.size - 9.0).abs() < 0.5), "18 pt bold at half size");
assert!(pieces.iter().any(|x| x.color == Rgba::rgb(200, 0, 0) && !x.bold));
assert!(pieces.iter().any(|x| x.text == "4"));
p.notes_master = Some(Arc::new(m));
assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF"));
}
#[test]
fn glyph_ranges_follow_shaping_clusters() {
// "fl" ligature: its glyph maps to both characters, the rest stay one to one (#164).
assert_eq!(glyph_text_ranges("flash", (0, 5), &[(0, 'f'), (2, 'a'), (3, 's'), (4, 'h')], 4), vec![0..2, 2..3, 3..4, 4..5]);
// Two glyphs in one cluster (base and mark) share its range.
assert_eq!(glyph_text_ranges("ab", (0, 2), &[(0, 'a'), (0, 'a'), (1, 'b')], 3), vec![0..1, 0..1, 1..2]);
// A run starting mid-paragraph, multi-byte characters in visual (right-to-left) order.
assert_eq!(glyph_text_ranges("αβγ", (3, 6), &[(5, 'γ'), (4, 'β'), (3, 'α')], 3), vec![4..6, 2..4, 0..2]);
}