#![allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
use otf_pdf_doc::{AttachmentOptions, Document, Info, OutlineItem, Rect, SaveMode, SaveOptions};
use otf_pdf_syntax::{Dict, ObjId, Object, OpenOptions};
fn build(objects: &[(u32, &str)]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (num, body) in objects {
offsets.push((*num, out.len()));
out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let size = objects.iter().map(|(n, _)| *n).max().unwrap_or(0) + 1;
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for n in 1..size {
match offsets.iter().find(|(m, _)| *m == n) {
Some((_, o)) => out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()),
None => out.extend_from_slice(b"0000000000 65535 f \n"),
}
}
out.extend_from_slice(
format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
);
out
}
fn three_pages() -> Vec<u8> {
let content = |n: u32| format!("<< /Length 14 >>\nstream\nBT (Page {n}) ET\nendstream");
build(&[
(
1,
"<< /Type /Catalog /Pages 2 0 R /Outlines 30 0 R /Dests << /twelve [12 0 R /Fit] >> \
/Names << /Dests << /Names [(eleven) [11 0 R /Fit]] >> >> >>",
),
(2, "<< /Type /Pages /Kids [3 0 R 12 0 R] /Count 3 >>"),
(
3,
"<< /Type /Pages /Parent 2 0 R /Kids [10 0 R 11 0 R] /Count 2 /MediaBox [0 0 300 400] /Rotate 90 >>",
),
(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"),
(
10,
"<< /Type /Page /Parent 3 0 R /Contents 20 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
),
(
11,
"<< /Type /Page /Parent 3 0 R /Contents 21 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
),
(
12,
"<< /Type /Page /Parent 2 0 R /Contents 22 0 R /MediaBox [0 0 612 792] >>",
),
(20, &content(10)),
(21, &content(11)),
(22, &content(12)),
(
30,
"<< /Type /Outlines /First 31 0 R /Last 34 0 R /Count 4 >>",
),
(
31,
"<< /Title (To 11) /Parent 30 0 R /Next 32 0 R /Dest [11 0 R /Fit] /First 35 0 R /Last 35 0 R /Count 1 /C [1 0 0] >>",
),
(
32,
"<< /Title (To 12) /Parent 30 0 R /Prev 31 0 R /Next 33 0 R /Dest /twelve >>",
),
(
33,
"<< /Title (Go 11) /Parent 30 0 R /Prev 32 0 R /Next 34 0 R /A << /S /GoTo /D (eleven) >> >>",
),
(
34,
"<< /Title (To 10) /Parent 30 0 R /Prev 33 0 R /Dest [10 0 R /Fit] >>",
),
(
35,
"<< /Title (Child of 11) /Parent 31 0 R /Dest [10 0 R /XYZ 0 0 0] >>",
),
])
}
fn open(bytes: Vec<u8>) -> Document {
Document::open(bytes, OpenOptions::default()).unwrap()
}
fn reopen(doc: &Document) -> Document {
open(doc.save(&SaveOptions::default()).unwrap())
}
fn texts(doc: &Document) -> Vec<String> {
doc.pages()
.unwrap()
.iter()
.map(|p| {
let Object::Stream(s) = doc.store().resolve(p.dict.get(b"Contents").unwrap()) else {
panic!("no content");
};
String::from_utf8(doc.store().decode_stream(&s).unwrap().data).unwrap()
})
.collect()
}
fn titles(items: &[OutlineItem]) -> Vec<String> {
items.iter().map(|i| i.title.clone()).collect()
}
#[test]
fn reorder_keeps_inherited_attributes() {
let mut doc = open(three_pages());
doc.reorder_pages(&[2, 0, 1]).unwrap();
for d in [&doc, &reopen(&doc)] {
assert_eq!(
texts(d),
["BT (Page 12) ET", "BT (Page 10) ET", "BT (Page 11) ET"]
);
let pages = d.pages().unwrap();
assert_eq!(pages[1].media_box.urx, 300.0);
assert_eq!(pages[1].rotate, 90);
assert_eq!(pages[0].media_box.urx, 612.0);
assert_eq!(pages[0].rotate, 0);
}
assert!(doc.reorder_pages(&[0, 1]).is_err());
assert!(doc.reorder_pages(&[0, 0, 1]).is_err());
}
#[test]
fn delete_cleans_up_outlines_and_named_destinations() {
let mut doc = open(three_pages());
doc.delete_pages(&[1]).unwrap();
for d in [&doc, &reopen(&doc)] {
assert_eq!(texts(d), ["BT (Page 10) ET", "BT (Page 12) ET"]);
let outlines = d.outlines().unwrap();
assert_eq!(titles(&outlines), ["Child of 11", "To 12", "To 10"]);
let names: Vec<_> = d
.named_destinations()
.unwrap()
.into_iter()
.map(|(k, _)| k)
.collect();
assert_eq!(names, [b"twelve".to_vec()]);
}
assert!(doc.delete_pages(&[9]).is_err());
}
#[test]
fn outline_extras_survive_a_rewrite() {
let mut doc = open(three_pages());
doc.delete_pages(&[2]).unwrap();
let outlines = reopen(&doc).outlines().unwrap();
assert_eq!(titles(&outlines), ["To 11", "Go 11", "To 10"]);
assert_eq!(outlines[0].extra.get_array(b"C").map(<[_]>::len), Some(3));
assert_eq!(outlines[0].children.len(), 1);
}
#[test]
fn duplicate_selection_makes_distinct_pages() {
let mut doc = open(three_pages());
doc.select_pages(&[0, 0]).unwrap();
let again = reopen(&doc);
let pages = again.pages().unwrap();
assert_eq!(pages.len(), 2);
assert_ne!(pages[0].id, pages[1].id);
assert_eq!(texts(&again), ["BT (Page 10) ET", "BT (Page 10) ET"]);
}
#[test]
fn rotate_and_crop() {
let mut doc = open(three_pages());
doc.rotate_page(0, 90).unwrap();
doc.rotate_page(0, 90).unwrap();
doc.rotate_page(2, -90).unwrap();
assert!(doc.rotate_page(0, 45).is_err());
assert!(doc.rotate_page(7, 90).is_err());
let crop = Rect {
llx: 10.0,
lly: 10.0,
urx: 100.0,
ury: 100.0,
};
doc.set_crop_box(1, crop).unwrap();
let again = reopen(&doc);
let pages = again.pages().unwrap();
assert_eq!(pages[0].rotate, 270);
assert_eq!(pages[2].rotate, 270);
assert_eq!(pages[1].crop_box, crop);
}
#[test]
fn incremental_edit_appends_only_changes() {
let original = three_pages();
let mut doc = open(original.clone());
doc.rotate_page(2, 180).unwrap();
let saved = doc
.save(&SaveOptions {
mode: SaveMode::Incremental,
..SaveOptions::default()
})
.unwrap();
assert!(saved.starts_with(&original));
let again = open(saved);
assert_eq!(again.pages().unwrap()[2].rotate, 180);
assert_eq!(again.file().sections().len(), 2);
}
#[test]
fn merge_copies_pages_and_shares_objects() {
let mut a = open(three_pages());
let b = open(three_pages());
let before = a.store().object_ids().len();
a.append_pages_from(&b, Some(&[1, 0])).unwrap();
let merged = reopen(&a);
assert_eq!(
texts(&merged),
[
"BT (Page 10) ET",
"BT (Page 11) ET",
"BT (Page 12) ET",
"BT (Page 11) ET",
"BT (Page 10) ET"
]
);
assert_eq!(merged.pages().unwrap()[3].rotate, 90);
let added = a.store().object_ids().len() - before;
assert_eq!(added, 2 + 2 + 1);
let copy = open(three_pages());
a.append_pages_from(©, None).unwrap();
assert_eq!(reopen(&a).page_count().unwrap(), 8);
}
#[test]
fn extract_into_a_new_document() {
let doc = open(three_pages());
let part = doc.extract_pages(&[2]).unwrap();
let again = reopen(&part);
assert_eq!(texts(&again), ["BT (Page 12) ET"]);
assert!(Document::new().unwrap().extract_pages(&[0]).is_err());
}
#[test]
fn info_and_xmp_stay_in_sync() {
let mut doc = open(three_pages());
let info = Info {
title: Some("Résumé Δ".into()),
author: Some("Ann".into()),
mod_date: Some("D:20261027120000Z".into()),
trapped: Some("False".into()),
..Info::default()
};
doc.set_info(&info).unwrap();
for d in [&doc, &reopen(&doc)] {
assert_eq!(d.info(), info);
let xmp = String::from_utf8(d.xmp_metadata().unwrap().unwrap()).unwrap();
assert!(xmp.contains(">Résumé Δ<"), "{xmp}");
assert!(xmp.contains("<xmp:ModifyDate>2026-10-27T12:00:00Z</xmp:ModifyDate>"));
}
doc.set_info(&Info {
author: None,
..info
})
.unwrap();
let again = reopen(&doc);
assert_eq!(again.info().author, None);
let xmp = String::from_utf8(again.xmp_metadata().unwrap().unwrap()).unwrap();
assert!(!xmp.contains("dc:creator"));
}
#[test]
fn outlines_are_written_with_counts() {
let mut doc = open(three_pages());
let page = |n: u32| {
Some(Object::Array(vec![
Object::Ref(ObjId::new(n, 0)),
Object::Name("Fit".into()),
]))
};
let leaf = |t: &str, n: u32| OutlineItem {
id: None,
title: t.into(),
dest: page(n),
action: None,
open: false,
children: vec![],
extra: Dict::new(),
};
let tree = vec![
OutlineItem {
open: true,
children: vec![leaf("a1", 10), leaf("a2", 11)],
..leaf("a", 10)
},
OutlineItem {
open: false,
children: vec![leaf("b1", 12)],
..leaf("b", 12)
},
];
doc.set_outlines(&tree).unwrap();
let again = reopen(&doc);
let read = again.outlines().unwrap();
assert_eq!(titles(&read), ["a", "b"]);
assert_eq!(titles(&read[0].children), ["a1", "a2"]);
assert!(read[0].open && !read[1].open);
let root = again
.store()
.resolve_dict(again.catalog().get(b"Outlines").unwrap())
.unwrap();
assert_eq!(root.get_int(b"Count"), Some(4));
let b = again.store().get(read[1].id.unwrap()).unwrap();
assert_eq!(b.as_dict().unwrap().get_int(b"Count"), Some(-1));
doc.set_outlines(&[]).unwrap();
assert!(reopen(&doc).outlines().unwrap().is_empty());
}
#[test]
fn attachments_add_replace_remove() {
let mut doc = open(three_pages());
let options = AttachmentOptions {
mime_type: Some("text/plain".into()),
description: Some("Notes".into()),
..AttachmentOptions::default()
};
doc.add_attachment("b.txt", b"bee", &options).unwrap();
doc.add_attachment("ä.txt", &vec![b'x'; 1000], &AttachmentOptions::default())
.unwrap();
doc.add_attachment("b.txt", b"bee 2", &options).unwrap();
let again = reopen(&doc);
let atts = again.attachments().unwrap();
let names: Vec<_> = atts.iter().map(|a| a.name.clone()).collect();
assert_eq!(names, ["b.txt", "ä.txt"]);
assert_eq!(atts[0].mime_type.as_deref(), Some("text/plain"));
assert_eq!(atts[0].description.as_deref(), Some("Notes"));
assert_eq!(again.attachment_data(&atts[0]).unwrap(), b"bee 2");
assert_eq!(again.attachment_data(&atts[1]).unwrap(), vec![b'x'; 1000]);
assert_eq!(atts[1].filename.as_deref(), Some("ä.txt"));
let mut again = again;
assert!(again.remove_attachment("b.txt").unwrap());
assert!(!again.remove_attachment("missing").unwrap());
assert!(again.remove_attachment("ä.txt").unwrap());
let empty = reopen(&again);
assert!(empty.attachments().unwrap().is_empty());
assert_eq!(empty.named_destinations().unwrap().len(), 2);
}
#[test]
fn new_document_builds_up() {
let mut doc = Document::new().unwrap();
doc.append_pages_from(&open(three_pages()), Some(&[0]))
.unwrap();
doc.set_info(&Info {
title: Some("Built".into()),
..Info::default()
})
.unwrap();
let again = reopen(&doc);
assert_eq!(again.page_count().unwrap(), 1);
assert_eq!(again.info().title.as_deref(), Some("Built"));
}
#[test]
fn full_save_writes_identical_streams_once() {
let mut doc = open(three_pages());
let copy = open(three_pages());
doc.append_pages_from(©, Some(&[0])).unwrap();
doc.append_pages_from(©, Some(&[0])).unwrap();
let again = reopen(&doc);
assert_eq!(again.page_count().unwrap(), 5);
let contents: std::collections::HashSet<_> = again
.pages()
.unwrap()
.iter()
.map(|p| p.dict.get_ref(b"Contents").unwrap())
.collect();
assert_eq!(contents.len(), 3);
}