Files
pdfcraft/crates/organize/src/tests.rs
T

2811 lines
147 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.
use std::sync::Arc;
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
use super::*;
#[test]
fn page_rotation_resolves_inheritance_overrides_and_missing_pages() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
for page in 0..3 {
assert_eq!(page_rotation(&doc, page).unwrap(), 90);
}
rotate_pages(&mut doc, &[1], -180).unwrap();
assert_eq!(page_rotation(&doc, 1).unwrap(), 270);
assert_eq!(page_rotation(&doc, 0).unwrap(), 90);
assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3)));
}
#[test]
fn indirect_page_rotation_is_read_and_updated_from_its_effective_value() {
let large = i64::MAX - i64::MAX % 90;
for (current, degrees, expected) in [(270, 90, 0), (-90, 90, 0), (large, 90, 90), (90, -180, 270)] {
for level in 0..3 {
let mut doc = open(fixture());
let rotation = doc.add(Object::Int(current));
let page = pages(&doc).unwrap()[0].obj;
let target = match level {
0 => page,
1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(),
_ => pages_root(&doc).unwrap(),
};
doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
let mut doc = open(bytes);
let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap();
let initial = page_rotation(&doc, 0).unwrap();
rotate_pages(&mut doc, &[0], degrees).unwrap();
assert_eq!(*doc.get(rotation), Object::Int(current), "shared rotation object unchanged");
for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] {
let reopened = open(saved.clone());
let pdf = hayro_syntax::Pdf::new(saved).unwrap();
let dimensions = if expected % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) };
assert_eq!(pdf.pages()[0].render_dimensions(), dimensions, "written PDF has the correct orientation");
assert_eq!(page_rotation(&reopened, 0).unwrap(), expected);
assert_eq!(page_rotation(&reopened, 1).unwrap(), if level == 0 { 90 } else { current.rem_euclid(360) }, "sibling unchanged");
assert_eq!(page_rotation(&reopened, 2).unwrap(), if level == 2 { current.rem_euclid(360) } else { 90 }, "other subtree unchanged");
}
assert_eq!(initial, current.rem_euclid(360), "effective indirect rotation at level {level}");
}
}
}
#[test]
fn malformed_indirect_rotations_keep_the_zero_fallback() {
for value in [Object::Null, Object::name("invalid"), Object::Ref(ObjRef::new(99_999, 0))] {
let mut doc = open(fixture());
let rotation = doc.add(value);
let page = pages(&doc).unwrap()[0].obj;
doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap();
assert_eq!(page_rotation(&doc, 0).unwrap(), 0);
rotate_pages(&mut doc, &[0], 90).unwrap();
assert_eq!(page_rotation(&doc, 0).unwrap(), 90);
}
}
#[test]
fn rotate_rejects_angles_that_are_not_multiples_of_90() {
// The page tree only turns in quarter steps, so anything else used to be silently truncated
// by the integer division. It has to be an error, and the document must stay as it was.
let mut doc = Document::open(Arc::new(fixture())).unwrap();
assert_eq!(rotate_pages(&mut doc, &[0], 45), Err(OrganizeError::Invalid("rotation must be a multiple of 90 degrees".into())));
assert_eq!(rotate_pages(&mut doc, &[0], 91), Err(OrganizeError::Invalid("rotation must be a multiple of 90 degrees".into())));
assert_eq!(page_rotation(&doc, 0).unwrap(), 90);
rotate_pages(&mut doc, &[0], -90).unwrap();
assert_eq!(page_rotation(&doc, 0).unwrap(), 0);
}
#[test]
fn rotate_handles_large_quarter_turns() {
let large = i64::MAX - i64::MAX % 90;
for (current, degrees, expected) in [(90, large, 90), (-90, -large, 270), (large, 90, 90), (-large, -90, 270), (180, large - 90, 90)] {
for inherited in [false, true] {
let mut doc = open(fixture());
let target = if inherited { pages_root(&doc).unwrap() } else { pages(&doc).unwrap()[0].obj };
doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Int(current))).unwrap();
rotate_pages(&mut doc, &[0], degrees).unwrap();
assert_eq!(page_rotation(&doc, 0).unwrap(), expected);
}
}
}
/// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root,
/// Resources from an intermediate node; each page's content says which page it is.
fn fixture() -> Vec<u8> {
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), // 1
b"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 3 /MediaBox [0 0 300 400] /Rotate 90 >>".to_vec(), // 2
b"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R 5 0 R] /Count 2 /Resources << /Font << /F1 10 0 R >> >> >>".to_vec(), // 3
b"<< /Type /Page /Parent 3 0 R /Contents 7 0 R >>".to_vec(), // 4
b"<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>".to_vec(), // 5
b"<< /Type /Page /Parent 2 0 R /Contents 9 0 R /Resources << /Font << /F1 10 0 R >> >> /MediaBox [0 0 500 500] >>".to_vec(), // 6
stream(b"BT /F1 12 Tf 20 20 Td (Page 1) Tj ET"), // 7
stream(b"BT /F1 12 Tf 20 20 Td (Page 2) Tj ET"), // 8
stream(b"BT /F1 12 Tf 20 20 Td (Page 3) Tj ET"), // 9
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), // 10
b"<< /Title (Original) /Producer (fixture) >>".to_vec(), // 11
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 11 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn stream(body: &[u8]) -> Vec<u8> {
let mut v = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes();
v.extend_from_slice(body);
v.extend_from_slice(b"\nendstream");
v
}
fn open(bytes: Vec<u8>) -> Document {
Document::open(Arc::new(bytes)).expect("opens")
}
/// The text label of each page, via its content stream, in order.
fn labels(doc: &Document) -> Vec<String> {
pages(doc)
.unwrap()
.iter()
.map(|p| {
let page = doc.get(p.obj);
let c = page.as_dict().and_then(|d| d.get(b"Contents").cloned());
match c.map(|c| doc.resolve(&c)).as_deref() {
Some(Object::Stream(s)) => {
let data = s.decoded().unwrap();
let t = String::from_utf8_lossy(&data);
t.split('(').nth(1).and_then(|x| x.split(')').next()).unwrap_or("").to_string()
}
_ => "blank".into(),
}
})
.collect()
}
/// Save incrementally, verify the prefix is untouched, and reopen — both with our reader and with
/// an independent parser (hayro-syntax) that must agree on the page count.
fn save_and_reopen(doc: &Document) -> Document {
let original = doc.bytes().clone();
let bytes = write_incremental(doc, &SaveOptions::default()).expect("saves");
assert_eq!(&bytes[..original.len()], &original[..], "incremental save must not modify existing bytes");
let reopened = open(bytes.clone());
let independent = hayro_syntax::Pdf::new(bytes).expect("hayro opens our output");
assert_eq!(independent.pages().len(), page_count(&reopened).unwrap(), "readers agree on page count");
reopened
}
#[test]
fn walks_nested_tree_in_order() {
let doc = open(fixture());
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]);
}
#[test]
fn rotate_uses_inherited_rotation_and_round_trips() {
let mut doc = open(fixture());
rotate_pages(&mut doc, &[0, 2], 90).unwrap();
rotate_pages(&mut doc, &[2], -270).unwrap(); // page 3: inherited 90 + 90 - 270 = -90 ≡ 270
let doc = save_and_reopen(&doc);
let rot = |i: usize| {
let p = pages(&doc).unwrap()[i].obj;
doc.get(p).as_dict().and_then(|d| d.int(b"Rotate"))
};
assert_eq!(rot(0), Some(180), "inherited 90 + 90");
assert_eq!(rot(1), None, "untouched page keeps inheriting");
assert_eq!(rot(2), Some(270), "normalised into 0..360");
}
#[test]
fn delete_keeps_inherited_attributes_on_survivors() {
let mut doc = open(fixture());
delete_pages(&mut doc, &[0]).unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(labels(&doc), ["Page 2", "Page 3"]);
// Page 2 inherited Resources (from the intermediate node) and MediaBox/Rotate (from the root);
// the tree was flattened, so they must now be on the page itself.
let p = doc.get(pages(&doc).unwrap()[0].obj);
let d = p.as_dict().unwrap();
assert!(d.contains(b"Resources") && d.contains(b"MediaBox"));
assert_eq!(d.int(b"Rotate"), Some(90));
let root = doc.get(doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap());
assert_eq!(root.as_dict().unwrap().int(b"Count"), Some(2));
}
#[test]
fn deleted_pages_are_not_kept_by_what_points_at_them() {
// A1 links to A3, a bookmark goes to A3, and A2 holds the form's only field. After deleting
// A2 and A3 neither page, its content nor the field may stay in the saved file.
let mut doc = doc_a();
add_bookmark(&mut doc, &[], 0, "To A3", 2).unwrap();
delete_pages(&mut doc, &[1, 2]).unwrap();
let out = full_roundtrip(&doc);
assert_eq!(labels(&out), ["A1"]);
// The bookmark and A1's link now go nowhere instead of to a missing page.
let mark = out.get(bookmarks(&out)[0].obj).as_dict().cloned().unwrap();
assert!(!mark.contains(b"Dest") && !mark.contains(b"A"), "{mark:?}");
let link = &annots(&out, 0)[0];
assert!(!link.contains(b"A") && !link.contains(b"Dest"), "{link:?}");
let mut page_objects = 0;
for n in out.object_numbers() {
let o = out.get(ObjRef::new(n, out.generation(n)));
if let Object::Stream(s) = &*o {
let data = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
assert!(!data.contains("(A2)") && !data.contains("(A3)"), "object {n} still holds a deleted page's content");
}
page_objects += usize::from(o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Page")));
}
assert_eq!(page_objects, 1);
let cat = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
let form = out.resolve(cat.get(b"AcroForm").unwrap());
assert_eq!(form.as_dict().and_then(|f| f.get(b"Fields")).and_then(Object::as_array).map(Vec::len), Some(0), "the field went with its page");
}
#[test]
fn deleting_the_opening_page_drops_the_open_action() {
let open_action = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"OpenAction").cloned();
let set_open = |doc: &mut Document, value: Object| {
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), value)).unwrap();
};
let page = |doc: &Document, i: usize| pages(doc).unwrap()[i].obj;
for as_action in [false, true] {
let target = |doc: &Document, i: usize| {
let dest = Object::Array(vec![Object::Ref(page(doc, i)), Object::Name(b"Fit".to_vec())]);
if as_action {
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::Name(b"GoTo".to_vec()));
a.set(b"D".to_vec(), dest);
Object::Dict(a)
} else {
dest
}
};
// The opening page goes: no OpenAction is left to point at nothing.
let mut doc = doc_a();
let open = target(&doc, 0);
set_open(&mut doc, open);
delete_pages(&mut doc, &[0]).unwrap();
let out = full_roundtrip(&doc);
assert_eq!(labels(&out), ["A2", "A3"]);
assert_eq!(open_action(&out), None);
// Another page goes: the opening page keeps its OpenAction.
let mut doc = doc_a();
let open = target(&doc, 0);
set_open(&mut doc, open);
delete_pages(&mut doc, &[2]).unwrap();
let out = full_roundtrip(&doc);
assert_eq!(labels(&out), ["A1", "A2"]);
assert!(open_action(&out).is_some());
}
}
#[test]
fn cannot_delete_every_page_or_missing_pages() {
let mut doc = open(fixture());
assert_eq!(delete_pages(&mut doc, &[0, 1, 2]), Err(OrganizeError::WouldRemoveAllPages));
assert_eq!(delete_pages(&mut doc, &[7]), Err(OrganizeError::NoSuchPage(7)));
assert_eq!(rotate_pages(&mut doc, &[3], 90), Err(OrganizeError::NoSuchPage(3)));
assert_eq!(OrganizeError::NoSuchPage(3).to_string(), "page 4 does not exist", "the message counts pages from 1 (#1550)");
assert!(!doc.is_modified(), "failed operations leave the document untouched");
}
#[test]
fn move_pages_reorders() {
let mut doc = open(fixture());
move_pages(&mut doc, &[2], 0).unwrap();
assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]);
move_pages(&mut doc, &[0, 1], 3).unwrap();
assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]);
let doc = save_and_reopen(&doc);
assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]);
}
#[test]
fn reverse_pages_reorders() {
let mut doc = open(fixture());
reverse_pages(&mut doc, &[0, 1, 2]).unwrap();
assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]);
// Unsorted, repeated indices name the same slots; page 1's slot stays put.
reverse_pages(&mut doc, &[2, 1, 2]).unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]);
}
#[test]
fn cannot_reverse_missing_or_fewer_than_two_pages() {
let mut doc = open(fixture());
assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3)));
assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_))));
assert!(!doc.is_modified(), "failed operations leave the document untouched");
}
#[test]
fn inserts_blank_page() {
let mut doc = open(fixture());
insert_blank_page(&mut doc, 1, 612.0, 792.0).unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(labels(&doc), ["Page 1", "blank", "Page 2", "Page 3"]);
let hay = hayro_syntax::Pdf::new(doc.bytes().clone()).unwrap();
let (w, h) = hay.pages().get(1).unwrap().render_dimensions();
assert_eq!((w, h), (612.0, 792.0));
}
#[test]
fn document_info_edits_round_trip_with_unicode() {
let mut doc = open(fixture());
set_info(&mut doc, "Title", "Résumé — 履歴書").unwrap();
set_info(&mut doc, "Author", "PdfCraft").unwrap();
set_info(&mut doc, "Producer", "").unwrap(); // clearing removes the key
let doc = save_and_reopen(&doc);
assert_eq!(info(&doc, "Title").as_deref(), Some("Résumé — 履歴書"));
assert_eq!(info(&doc, "Author").as_deref(), Some("PdfCraft"));
assert_eq!(info(&doc, "Producer"), None);
}
/// Page transitions (§12.4.4): written as `/Trans` and `/Dur` with only the entries the style
/// reads and that differ from the defaults, removed again, checked before any page changes, and
/// kept through a save.
#[test]
fn page_transitions_are_set_removed_and_checked() {
let mut doc = open(fixture());
let wipe = Transition { duration: 2.5, direction: Some(270), ..Transition::new("Wipe") };
set_transition(&mut doc, &[0, 2], &TransitionSpec { transition: Some(wipe), display: Some(5.0) }).unwrap();
let d = page_dict(&doc, 0);
let t = d.get(b"Trans").and_then(|o| o.as_dict()).expect("/Trans").clone();
assert_eq!(t.name(b"Type"), Some(&b"Trans"[..]));
assert_eq!(t.name(b"S"), Some(&b"Wipe"[..]));
assert_eq!(t.get(b"D"), Some(&Object::Real(2.5)));
assert_eq!(t.get(b"Di"), Some(&Object::Int(270)));
assert!(
t.get(b"Dm").is_none() && t.get(b"M").is_none() && t.get(b"SS").is_none() && t.get(b"B").is_none(),
"entries Wipe does not read stay out: {t:?}"
);
assert_eq!(d.get(b"Dur"), Some(&Object::Real(5.0)));
assert!(page_dict(&doc, 1).get(b"Trans").is_none(), "page 2 was not asked for");
// Fly with everything non-default; the defaults themselves are not written.
let fly = Transition { style: "Fly".into(), duration: 1.0, horizontal: false, inward: false, direction: None, scale: 0.5, opaque: true };
set_transition(&mut doc, &[1], &TransitionSpec { transition: Some(fly), display: None }).unwrap();
let t = page_dict(&doc, 1).get(b"Trans").and_then(|o| o.as_dict()).expect("/Trans").clone();
assert_eq!((t.name(b"S"), t.name(b"M"), t.name(b"Di")), (Some(&b"Fly"[..]), Some(&b"O"[..]), Some(&b"None"[..])));
assert_eq!((t.get(b"SS"), t.get(b"B")), (Some(&Object::Real(0.5)), Some(&Object::Bool(true))));
assert!(t.get(b"D").is_none() && t.get(b"Dm").is_none(), "{t:?}");
// Removed again.
set_transition(&mut doc, &[0], &TransitionSpec { transition: None, display: None }).unwrap();
let d = page_dict(&doc, 0);
assert!(d.get(b"Trans").is_none() && d.get(b"Dur").is_none());
// Checked before anything changes.
for (what, spec) in [
("style", TransitionSpec { transition: Some(Transition::new("Swirl")), display: None }),
("direction", TransitionSpec { transition: Some(Transition { direction: Some(45), ..Transition::new("Wipe") }), display: None }),
("duration", TransitionSpec { transition: Some(Transition { duration: -1.0, ..Transition::new("Fade") }), display: None }),
("scale", TransitionSpec { transition: Some(Transition { scale: 0.0, ..Transition::new("Fly") }), display: None }),
("display", TransitionSpec { transition: None, display: Some(f32::NAN) }),
] {
assert!(set_transition(&mut doc, &[2], &spec).is_err(), "{what} must be refused");
}
assert!(set_transition(&mut doc, &[7], &TransitionSpec { transition: None, display: None }).is_err(), "no such page");
let t = page_dict(&doc, 2).get(b"Trans").and_then(|o| o.as_dict()).expect("page 3 unchanged").clone();
assert_eq!(t.name(b"S"), Some(&b"Wipe"[..]));
// Kept through a save.
let doc = save_and_reopen(&doc);
// (A whole number of seconds may come back as an integer: the writer's choice.)
assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0));
}
/// Custom document properties (Document Properties ▸ Custom): any `/Info` key the spec does not
/// name, set and cleared like the standard ones, listed apart from them, and with the names
/// Acrobat refuses refused here too.
#[test]
fn custom_document_properties_are_listed_set_removed_and_their_names_checked() {
let mut doc = open(fixture());
assert!(custom_info(&doc).is_empty());
set_info(&mut doc, "Title", "Report").unwrap();
set_info(&mut doc, "Project", "Apollo").unwrap();
set_info(&mut doc, "Revision", "7").unwrap();
let mut listed = custom_info(&doc);
listed.sort();
assert_eq!(listed, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "standard keys are not custom");
assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo"));
set_info(&mut doc, "Project", "").unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())], "cleared, and the rest survives a save");
assert_eq!(info(&doc, "Title").as_deref(), Some("Report"));
for bad in ["", "Title", "ModDate", "Bad Name", "a/b", "tab\tbed", "hash#tag", "(x)"] {
assert!(check_custom_key(bad).is_err(), "{bad:?} must be refused");
}
let long = "x".repeat(MAX_CUSTOM_KEY_LEN + 1);
assert!(check_custom_key(&long).is_err());
for good in ["Project", "Révision", "部門", "key-2.0", &"y".repeat(MAX_CUSTOM_KEY_LEN)] {
assert!(check_custom_key(good).is_ok(), "{good:?} is a usable name");
}
let mut doc = open(fixture());
assert!(set_info(&mut doc, "Bad Name", "x").is_err(), "set_info checks custom names");
assert!(custom_info(&doc).is_empty());
}
/// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only
/// padding is not listed, and such entries don't count towards the 256 listed: a real property
/// after 300 blank ones is still there.
#[test]
fn custom_document_properties_drop_padding_before_the_cap() {
let mut doc = open(fixture());
set_info(&mut doc, "Title", "Report").unwrap();
let Some(Object::Ref(info_ref)) = doc.trailer().get(b"Info").cloned() else { panic!("an /Info reference") };
let text = |s: &str| Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false });
doc.update_dict(info_ref, |d| {
d.set(b"Mixed".to_vec(), text(" \0Lab\0 "));
d.set(b"Pad".to_vec(), text(" \0 \0"));
for i in 0..300 {
d.set(format!("Blank{i}").into_bytes(), text(" "));
}
d.set(b"Keep".to_vec(), text("yes"));
})
.unwrap();
assert_eq!(custom_info(&doc), [("Mixed".to_string(), "Lab".to_string()), ("Keep".to_string(), "yes".to_string())]);
}
/// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the
/// same title and author in `/Info`, as LibreOffice writes them.
fn doc_with_xmp(xmp: &[u8]) -> Document {
let b = build(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(),
body("X"),
"<< /Title (Untitled 1) /Author (Old Author) >>".into(),
],
"/Root 1 0 R /Info 5 0 R",
);
let mut doc = open(b);
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("Metadata"));
dict.set(b"Subtype".to_vec(), Object::name("XML"));
let m = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(dict, xmp)));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(m))).unwrap();
full_roundtrip(&doc)
}
/// The catalog's metadata stream.
fn xmp_stream(doc: &Document) -> Option<pdfcraft_cos::Stream> {
match &*doc.get(catalog(doc).reference(b"Metadata")?) {
Object::Stream(s) => Some(s.clone()),
_ => None,
}
}
fn xmp_text(doc: &Document) -> Option<String> {
xmp_stream(doc).map(|s| String::from_utf8(s.decoded().unwrap()).unwrap())
}
/// The values the XMP packet gives a property: the items of its rdf:Alt or rdf:Seq, else its text.
fn xmp_values(xmp: &str, name: &str) -> Vec<String> {
let Some(start) = xmp.find(&format!("<{name}>")) else { return Vec::new() };
let end = xmp[start..].find(&format!("</{name}>")).unwrap() + start;
let inner = &xmp[start + name.len() + 2..end];
if !inner.contains("<rdf:li") {
return vec![inner.to_string()];
}
inner.split("<rdf:li").skip(1).map(|li| li[li.find('>').unwrap() + 1..li.find("</rdf:li>").unwrap()].to_string()).collect()
}
const LIBREOFFICE_XMP: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Producer>Writer 1.0</pdf:Producer></rdf:Description>\n\
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\
<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Untitled 1</rdf:li></rdf:Alt></dc:title>\n\
<dc:creator><rdf:Seq><rdf:li>Old Author</rdf:li></rdf:Seq></dc:creator>\n\
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">Old subject</rdf:li></rdf:Alt></dc:description>\n\
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
#[test]
fn document_info_edits_update_the_xmp_packet() {
// Viewers that read XMP first (pdf.js takes dc:title) showed the old title after a change.
let mut doc = doc_with_xmp(LIBREOFFICE_XMP.as_bytes());
assert_eq!(xmp_values(&xmp_text(&doc).unwrap(), "dc:title"), ["Untitled 1"]);
set_info(&mut doc, "Title", "Sales Guide v1.0 & <draft>").unwrap();
set_info(&mut doc, "Author", "Demo Consulting").unwrap();
set_info(&mut doc, "Subject", "").unwrap();
set_info(&mut doc, "Keywords", "alpha, beta").unwrap();
set_info(&mut doc, "Rating", "5").unwrap(); // no XMP counterpart: /Info only
let doc = save_and_reopen(&doc);
assert_eq!(info(&doc, "Title").as_deref(), Some("Sales Guide v1.0 & <draft>"));
let xmp = xmp_text(&doc).unwrap();
assert_eq!(xmp_values(&xmp, "dc:title"), ["Sales Guide v1.0 &amp; &lt;draft&gt;"]);
assert_eq!(xmp_values(&xmp, "dc:creator"), ["Demo Consulting"]);
assert!(xmp_values(&xmp, "dc:description").is_empty(), "{xmp}");
assert_eq!(xmp_values(&xmp, "pdf:Keywords"), ["alpha, beta"]);
assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]);
assert!(xmp.starts_with("<?xpacket begin=") && xmp.ends_with("<?xpacket end=\"w\"?>"), "{xmp}");
// Stored unfiltered, still typed as metadata.
let dict = xmp_stream(&doc).unwrap().dict;
assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None));
}
#[test]
fn document_info_edits_leave_files_without_usable_xmp_alone() {
// No packet: none is added.
let mut doc = open(fixture());
set_info(&mut doc, "Title", "New").unwrap();
assert_eq!(xmp_text(&save_and_reopen(&doc)), None);
// A packet that isn't UTF-8 is not rewritten.
let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec();
latin1.extend_from_slice(b"<!-- \xE9 -->");
let mut doc = doc_with_xmp(&latin1);
set_info(&mut doc, "Title", "New").unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(info(&doc, "Title").as_deref(), Some("New"));
assert_eq!(xmp_stream(&doc).unwrap().decoded().unwrap(), latin1);
}
#[test]
fn successive_incremental_saves_stack_revisions() {
let mut doc = open(fixture());
rotate_pages(&mut doc, &[0], 90).unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(doc.revisions().len(), 2);
let mut doc = doc;
delete_pages(&mut doc, &[1]).unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(doc.revisions().len(), 3);
assert_eq!(labels(&doc), ["Page 1", "Page 3"]);
}
#[test]
fn full_save_drops_unreachable_objects() {
let mut doc = open(fixture());
delete_pages(&mut doc, &[0, 1]).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
let full = open(bytes.clone());
assert_eq!(labels(&full), ["Page 3"]);
// Deleted pages, their content streams and the orphaned intermediate node are gone.
assert!(full.object_numbers().len() < doc.object_numbers().len());
assert_eq!(info(&full, "Title").as_deref(), Some("Original"));
assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1);
}
#[test]
fn deleting_a_page_drops_the_named_destinations_to_it() {
// Four names (two in the /Names tree, two in the legacy /Dests dictionary) go to P1 or P3;
// P2 has a link for each. Deleting P1 must drop its names and their links, keep P3's.
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 6 0 R >> /Dests 7 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), // 4
"<< /Type /Page /Parent 2 0 R >>".into(), // 5
"<< /Names [(treeFirst) [3 0 R /Fit] (treeLast) [5 0 R /Fit]] >>".into(), // 6
"<< /legacyFirst [3 0 R /Fit] /legacyLast << /D [5 0 R /Fit] >> >>".into(), // 7
"<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest (treeFirst) >>".into(), // 8
"<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest (treeLast) >>".into(), // 9
"<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest /legacyFirst >>".into(), // 10
"<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /A << /S /GoTo /D /legacyLast >> >>".into(), // 11
];
let mut doc = open(build(&b, "/Root 1 0 R"));
delete_pages(&mut doc, &[0]).unwrap();
let out = full_roundtrip(&doc);
let cat = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
let tree = out.resolve(out.resolve(cat.get(b"Names").unwrap()).as_dict().unwrap().get(b"Dests").unwrap());
let pairs = tree.as_dict().unwrap().get(b"Names").and_then(Object::as_array).cloned().unwrap();
assert_eq!(pairs.len(), 2, "{pairs:?}");
assert_eq!(pairs[0].as_string().map(|s| s.bytes.clone()), Some(b"treeLast".to_vec()));
let page3 = pages(&out).unwrap()[1].obj;
assert_eq!(out.resolve(&pairs[1]).as_array().and_then(|a| a.first()).and_then(Object::as_ref), Some(page3));
let legacy = out.resolve(cat.get(b"Dests").unwrap()).as_dict().cloned().unwrap();
assert!(!legacy.contains(b"legacyFirst") && legacy.contains(b"legacyLast"), "{legacy:?}");
let links = annots(&out, 0);
assert!(!links[0].contains(b"Dest"), "{:?}", links[0]);
assert!(links[1].contains(b"Dest"), "{:?}", links[1]);
assert!(!links[2].contains(b"Dest"), "{:?}", links[2]);
assert!(links[3].contains(b"A"), "{:?}", links[3]);
}
// ── Combine / extract / split ──────────────────────────────────────────────────────────────────
/// Build a classic-xref PDF from object bodies (object i+1 = bodies[i]).
fn build(bodies: &[String], trailer: &str) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
out
}
fn body(text: &str) -> String {
let s = format!("BT /F1 12 Tf 20 20 Td ({text}) Tj ET");
format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len())
}
/// Document A: pages A1–A3 sharing one font. A1 links to A3 (GoTo action), A2 links to A1
/// (/Dest) and carries a text field widget; A3 has a note with a popup (a reference cycle).
fn doc_a() -> Document {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [12 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> /Annots [10 0 R] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R /Resources << /Font << /F1 9 0 R >> >> /Annots [11 0 R 12 0 R] >>".into(), // 4
"<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Resources << /Font << /F1 9 0 R >> >> /Annots [13 0 R] /StructParents 4 >>".into(), // 5
body("A1"), // 6
body("A2"), // 7
body("A3"), // 8
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), // 9
"<< /Type /Annot /Subtype /Link /Rect [0 0 50 50] /P 3 0 R /A << /S /GoTo /D [5 0 R /Fit] >> >>".into(), // 10
"<< /Type /Annot /Subtype /Link /Rect [0 0 50 50] /P 4 0 R /Dest [3 0 R /XYZ 0 400 0] >>".into(), // 11
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [60 60 200 80] /P 4 0 R >>".into(), // 12
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (note) /Popup 14 0 R /P 5 0 R /StructParent 7 >>".into(), // 13
"<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 13 0 R >>".into(), // 14
"<< /Title (Doc A) /Author (Alice) >>".into(), // 15
];
open(build(&b, "/Root 1 0 R /Info 15 0 R"))
}
/// Document B: pages B1, B2 in a nested tree with MediaBox and Rotate inherited from the root.
fn doc_b() -> Document {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 2 /MediaBox [0 0 500 250] /Rotate 90 >>".into(),
"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R 5 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 3 0 R /Contents 6 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(),
"<< /Type /Page /Parent 3 0 R /Contents 7 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(),
body("B1"),
body("B2"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(),
];
open(build(&b, "/Root 1 0 R"))
}
/// Write in full, reopen with our reader, and require hayro to agree on the page count.
fn full_roundtrip(doc: &Document) -> Document {
let bytes = write_full(doc, &SaveOptions::default()).expect("writes");
let reopened = open(bytes.clone());
let hay = hayro_syntax::Pdf::new(bytes).expect("hayro opens");
assert_eq!(hay.pages().len(), page_count(&reopened).unwrap());
reopened
}
fn page_dict(doc: &Document, i: usize) -> Dict {
doc.get(pages(doc).unwrap()[i].obj).as_dict().cloned().unwrap()
}
fn annots(doc: &Document, i: usize) -> Vec<Dict> {
let p = page_dict(doc, i);
let list = p.get(b"Annots").map(|a| doc.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default();
list.iter().map(|a| doc.resolve(a).as_dict().cloned().unwrap()).collect()
}
fn count_fonts(doc: &Document) -> usize {
doc.object_numbers().iter().filter(|n| doc.get(ObjRef::new(**n, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Font"))).count()
}
#[test]
fn combine_concatenates_with_a_bookmark_per_file() {
let out = full_roundtrip(&combine(&[("Report A", &doc_a()), ("Report B", &doc_b())]).unwrap());
assert_eq!(labels(&out), ["A1", "A2", "A3", "B1", "B2"]);
// Shared resources are copied once per source: one Helvetica, one Times.
assert_eq!(count_fonts(&out), 2);
// Bookmarks: one per source file, pointing at its first page.
let cat = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
let outlines = out.get(cat.reference(b"Outlines").unwrap()).as_dict().cloned().unwrap();
let first = out.get(outlines.reference(b"First").unwrap()).as_dict().cloned().unwrap();
assert_eq!(first.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).as_deref(), Some("Report A"));
let second = out.get(first.reference(b"Next").unwrap()).as_dict().cloned().unwrap();
assert_eq!(second.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).as_deref(), Some("Report B"));
let dest_page = second.get(b"Dest").and_then(|d| d.as_array()).and_then(|a| a[0].as_ref()).unwrap();
assert_eq!(dest_page, pages(&out).unwrap()[3].obj);
}
#[test]
fn combiner_adds_each_source_then_releases_it() {
let bytes = Arc::new(fixture());
let src = Document::open(bytes.clone()).unwrap();
let mut combiner = crate::Combiner::new();
combiner.add("A", &src, None).unwrap();
drop(src);
// No output intents to copy, so the combine keeps nothing of the source's file.
assert_eq!(Arc::strong_count(&bytes), 1);
let picked: &[usize] = &[1];
combiner.add("B", &open(fixture()), Some(picked)).unwrap();
let streamed = combiner.finish().unwrap();
let whole = combine_selected(&[("A", &open(fixture()), None), ("B", &open(fixture()), Some(picked))]).unwrap();
assert_eq!(write_full(&streamed, &SaveOptions::default()).unwrap(), write_full(&whole, &SaveOptions::default()).unwrap());
}
#[test]
fn combining_hostile_outlines_ends_their_runs_too() {
// The bookmark cases of `hostile_outlines_end_their_runs_and_never_loop`, combined: each run ends
// where it would repeat, and the source's bookmarks sit under its own entry.
let cases: [(&[&str], &str); 3] = [
(&["<< /Title (Self) /Parent 3 0 R /Next 5 0 R >>"], "H[Self]"),
(&["<< /Title (A) /Parent 3 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 3 0 R /Next 5 0 R >>"], "H[A,B]"),
(&["<< /Title (A) /Parent 3 0 R /Next 99 0 R >>"], "H[A]"),
];
for (items, listed) in cases {
let out = full_roundtrip(&combine(&[("H", &outline_with(items))]).unwrap());
assert_eq!(titles(&crate::bookmarks(&out)), [listed]);
}
// A bookmark whose first child is itself: nesting is cut off, so the combine still ends.
let looped = outline_with(&["<< /Title (Self) /Parent 3 0 R /First 5 0 R >>"]);
let out = full_roundtrip(&combine(&[("H", &looped)]).unwrap());
assert_eq!(crate::bookmarks(&out).first().map(|b| b.title.as_str()), Some("H"));
}
#[test]
fn inherited_attributes_travel_with_copied_pages() {
let out = full_roundtrip(&extract_pages(&doc_b(), &[1]).unwrap());
let p = page_dict(&out, 0);
assert_eq!(p.int(b"Rotate"), Some(90));
assert!(p.contains(b"MediaBox") && p.contains(b"Resources"));
let hay = hayro_syntax::Pdf::new(write_full(&out, &SaveOptions::default()).unwrap()).unwrap();
assert_eq!(hay.pages().first().unwrap().render_dimensions(), (250.0, 500.0), "rotated 90°");
}
#[test]
fn resources_split_between_page_and_tree_travel_together() {
// The page names its own font but draws a form that only the page-tree node lists. Readers
// (ours included) look a name up the whole chain, so the page shows; a copy that kept only
// the page's own dictionary drew nothing (#1628).
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] /Resources << /XObject << /X0 5 0 R >> /Font << /F1 6 0 R /F2 6 0 R >> >> >>"
.into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F2 7 0 R >> >> >>".into(),
{
let s = "q 100 0 0 100 50 50 cm /X0 Do Q BT /F2 12 Tf (x) Tj ET";
format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len())
},
{
let s = "0 0 1 rg 0 0 1 1 re f";
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Length {} >>\nstream\n{s}\nendstream", s.len())
},
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
];
let src = open(build(&b, "/Root 1 0 R"));
let out = full_roundtrip(&combine(&[("S", &src), ("S again", &src)]).unwrap());
for i in 0..2 {
let res = out.dict(page_dict(&out, i).get(b"Resources").unwrap()).unwrap();
let entry = |cat: &[u8], name: &[u8]| res.get(cat).and_then(|c| out.dict(c)).and_then(|d| d.get(name).map(|v| out.resolve(v)));
let form = entry(b"XObject", b"X0").expect("the tree's form comes along");
assert_eq!(form.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Form"[..]));
let font = |name: &[u8]| entry(b"Font", name).and_then(|f| f.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec)));
assert_eq!(font(b"F1").as_deref(), Some(&b"Helvetica"[..]), "a name only the tree has is added");
assert_eq!(font(b"F2").as_deref(), Some(&b"Courier"[..]), "the page's own entry wins over the tree's");
}
}
#[test]
fn links_are_rewired_to_copied_pages_or_dropped() {
let a = doc_a();
// Extract A3, A1 (reordered): A1's GoTo A3 must now point at the copy of A3 (index 0).
let out = full_roundtrip(&extract_pages(&a, &[2, 0]).unwrap());
assert_eq!(labels(&out), ["A3", "A1"]);
let link = &annots(&out, 1)[0];
let action = out.resolve(link.get(b"A").unwrap());
let target = action.as_dict().unwrap().get(b"D").and_then(|d| d.as_array()).and_then(|a| a[0].as_ref()).unwrap();
assert_eq!(target, pages(&out).unwrap()[0].obj);
assert_eq!(link.reference(b"P"), Some(pages(&out).unwrap()[1].obj), "/P points at the new page");
// Extract only A2: its link to A1 would dangle, so the destination is dropped.
let out = full_roundtrip(&extract_pages(&a, &[1]).unwrap());
let link = annots(&out, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Link")).unwrap();
assert!(!link.contains(b"Dest") && !link.contains(b"A"));
}
/// A widget annotation that is its own form field (no `/Parent`).
fn own_field(doc: &Document, r: ObjRef) -> bool {
doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent"))
}
#[test]
fn repeated_page_gets_independent_annotations() {
// A1 has a link, A2 a link and a form field, A3 a note with a popup; each is listed twice.
let out = full_roundtrip(&extract_pages(&doc_a(), &[0, 0, 1, 1, 2, 2]).unwrap());
let ps = pages(&out).unwrap();
let refs: Vec<Vec<ObjRef>> = (0..6)
.map(|i| page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default())
.map(|list| list.iter().filter_map(|a| a.as_ref()).collect())
.collect();
for i in (0..6).step_by(2) {
assert!(!refs[i].is_empty());
assert_eq!(refs[i].len(), refs[i + 1].len());
for (a, b) in refs[i].iter().zip(&refs[i + 1]) {
// A widget that is its own field stays one object on both pages, so the field stays
// one (#790); every other annotation is copied.
if !own_field(&out, *a) {
assert_ne!(a, b, "each copy of the page has its own annotation objects");
}
}
}
for (i, list) in refs.iter().enumerate() {
for a in list.iter().filter(|a| !own_field(&out, **a)) {
assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy");
}
}
// Each note keeps its own popup, pointing back at that note.
let popups: Vec<ObjRef> = [4, 5].iter().map(|&i| out.get(refs[i][0]).as_dict().unwrap().reference(b"Popup").unwrap()).collect();
assert_ne!(popups[0], popups[1]);
for (k, p) in popups.iter().enumerate() {
assert_eq!(out.get(*p).as_dict().unwrap().reference(b"Parent"), Some(refs[4 + k][0]));
}
}
#[test]
fn form_fields_come_along_and_are_registered() {
let out = full_roundtrip(&extract_pages(&doc_a(), &[1]).unwrap());
let cat = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
let form = out.resolve(cat.get(b"AcroForm").unwrap());
let fields = out.resolve(form.as_dict().unwrap().get(b"Fields").unwrap()).as_array().cloned().unwrap();
assert_eq!(fields.len(), 1);
let field = out.resolve(&fields[0]).as_dict().cloned().unwrap();
assert_eq!(field.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada"));
assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj));
}
#[test]
fn popup_cycles_copy_once_and_structure_links_are_removed() {
let out = full_roundtrip(&extract_pages(&doc_a(), &[2]).unwrap());
let p = page_dict(&out, 0);
assert!(!p.contains(b"StructParents"));
let note = &annots(&out, 0)[0];
assert!(!note.contains(b"StructParent"));
let popup = out.get(note.reference(b"Popup").unwrap()).as_dict().cloned().unwrap();
assert_eq!(
out.get(popup.reference(b"Parent").unwrap()).as_dict().unwrap().get(b"Contents").and_then(|c| c.as_string()).map(|s| s.to_text()).as_deref(),
Some("note")
);
// Nothing unrelated was dragged in: no pages A1/A2, no other content streams.
assert_eq!(labels(&out), ["A3"]);
}
#[test]
fn inserting_pages_from_another_file_is_an_incremental_edit() {
let mut a = doc_a();
import_pages(&mut a, &doc_b(), &[1, 0], 1).unwrap();
let a = save_and_reopen(&a); // checks the prefix invariant and hayro agreement
assert_eq!(labels(&a), ["A1", "B2", "B1", "A2", "A3"]);
assert_eq!(info(&a, "Title").as_deref(), Some("Doc A"));
assert_eq!(import_pages(&mut a.clone(), &doc_b(), &[5], 0), Err(OrganizeError::NoSuchPage(5)));
}
#[test]
fn split_ranges_cover_every_page_once() {
assert_eq!(split_ranges(5, &SplitBy::PageCount(2)), [0..2, 2..4, 4..5]);
assert_eq!(split_ranges(5, &SplitBy::PageCount(0)), [0..1, 1..2, 2..3, 3..4, 4..5]);
assert_eq!(split_ranges(5, &SplitBy::Before(vec![3, 1, 1, 9, 0])), [0..1, 1..3, 3..5]);
assert_eq!(split_ranges(3, &SplitBy::PageCount(10)), vec![0..3]);
}
#[test]
fn split_produces_standalone_documents_with_metadata() {
let parts = split(&doc_a(), &SplitBy::PageCount(1)).unwrap();
assert_eq!(parts.len(), 3);
for (i, part) in parts.iter().enumerate() {
let part = full_roundtrip(part);
assert_eq!(labels(&part), [format!("A{}", i + 1)]);
assert_eq!(info(&part, "Author").as_deref(), Some("Alice"));
}
}
// ── PDF/X (#263) ───────────────────────────────────────────────────────────────────────────────
/// PDF/X-4 identification in XMP, as an attribute, next to a PDF/UA claim.
const X4_XMP: &str = "<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF \
xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description rdf:about=\"\" \
xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\" xmlns:pdfuaid=\"http://www.aiim.org/pdfua/ns/id/\" \
pdfxid:GTS_PDFXVersion=\"PDF/X-4\" pdfuaid:part=\"1\"/></rdf:RDF></x:xmpmeta><?xpacket end=\"r\"?>";
/// A two-page print file whose output intent prints to `condition` with `profile` as its ICC
/// profile, with `info` as its document information and `xmp` as its XMP metadata.
fn doc_print(condition: &str, profile: &str, info: &str, xmp: Option<&str>) -> Document {
let metadata = if xmp.is_some() { " /Metadata 10 0 R" } else { "" };
let mut b: Vec<String> = vec![
format!("<< /Type /Catalog /Pages 2 0 R /OutputIntents [7 0 R]{metadata} >>"), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 252 144] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /TrimBox [9 9 243 135] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /TrimBox [9 9 243 135] >>".into(), // 4
body("X1"), // 5
body("X2"), // 6
format!("<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier ({condition}) /DestOutputProfile 8 0 R >>"), // 7
format!("<< /N 4 /Length {} >>\nstream\n{profile}\nendstream", profile.len()), // 8
format!("<< {info} >>"), // 9
];
if let Some(x) = xmp {
b.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{x}\nendstream", x.len())); // 10
}
open(build(&b, "/Root 1 0 R /Info 9 0 R"))
}
/// A PDF/X-4 business card printing to `condition` with `profile`.
fn doc_x4(condition: &str, profile: &str) -> Document {
doc_print(condition, profile, "/Title (Card) /GTS_PDFXVersion (PDF/X-4) /Trapped /False", Some(X4_XMP))
}
/// What a document says about PDF/X.
#[derive(Debug, Default, PartialEq)]
struct Pdfx {
/// Each output intent's printing condition and decoded profile.
intents: Vec<(String, Vec<u8>)>,
version: Option<String>,
trapped: Option<Vec<u8>>,
xmp: Option<String>,
}
fn pdfx_of(doc: &Document) -> Pdfx {
let cat = catalog(doc);
let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o).as_array().cloned().unwrap_or_default()).unwrap_or_default();
let intents = list
.iter()
.filter_map(|oi| {
let d = doc.resolve(oi).as_dict().cloned()?;
let id = d.get(b"OutputConditionIdentifier").and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
let profile = match d.get(b"DestOutputProfile").map(|p| doc.resolve(p)).as_deref() {
Some(Object::Stream(s)) => s.decoded().unwrap(),
_ => Vec::new(),
};
Some((id, profile))
})
.collect();
let trapped = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().and_then(|d| d.name(b"Trapped")).map(<[u8]>::to_vec));
let xmp = match cat.get(b"Metadata").map(|m| doc.resolve(m)).as_deref() {
Some(Object::Stream(s)) => Some(String::from_utf8(s.decoded().unwrap()).unwrap()),
_ => None,
};
Pdfx { intents, version: info(doc, "GTS_PDFXVersion"), trapped, xmp }
}
#[test]
fn extract_and_split_keep_the_pdfx_output_intent_and_identification() {
// #263: the parts had no /OutputIntents, no GTS_PDFXVersion and no XMP pdfxid, so a PDF/X
// file stopped being one.
let src = doc_x4("FOGRA39", "icc-fogra39");
let mut outs = vec![extract_pages(&src, &[1]).unwrap()];
outs.extend(split(&src, &SplitBy::PageCount(1)).unwrap());
for out in &outs {
let x = pdfx_of(&full_roundtrip(out));
assert_eq!(x.intents, [("FOGRA39".to_string(), b"icc-fogra39".to_vec())]);
assert_eq!(x.version.as_deref(), Some("PDF/X-4"));
assert_eq!(x.trapped.as_deref(), Some(&b"False"[..]));
let xmp = x.xmp.expect("XMP metadata");
assert!(xmp.contains("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>"), "{xmp}");
assert!(xmp.contains(">Card</rdf:li>") && xmp.contains("<pdf:Trapped>False</pdf:Trapped>"), "{xmp}");
// The structure tree is not copied, so the source's PDF/UA claim must not be either.
assert!(!xmp.contains("pdfuaid"), "{xmp}");
}
}
#[test]
fn combine_keeps_pdfx_when_every_source_agrees() {
// #263: a PDF/X-4 card combined with itself.
let (a, b) = (doc_x4("FOGRA39", "icc-fogra39"), doc_x4("FOGRA39", "icc-fogra39"));
let out = full_roundtrip(&combine(&[("a", &a), ("b", &b)]).unwrap());
assert_eq!(labels(&out), ["X1", "X2", "X1", "X2"]);
let x = pdfx_of(&out);
assert_eq!(x.intents, [("FOGRA39".to_string(), b"icc-fogra39".to_vec())], "one output intent, not one per source");
assert_eq!(x.version.as_deref(), Some("PDF/X-4"));
assert!(x.xmp.is_some_and(|x| x.contains("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>")));
}
#[test]
fn combine_claims_no_pdfx_when_the_sources_disagree() {
// Another printing condition, the same name with another profile, or a source that is not
// PDF/X: no one standard describes the result, so it claims none.
let x4 = doc_x4("FOGRA39", "icc-fogra39");
for other in [doc_x4("GRACoL2013", "icc-gracol"), doc_x4("FOGRA39", "icc-other"), doc_b()] {
let out = full_roundtrip(&combine(&[("a", &x4), ("b", &other)]).unwrap());
assert_eq!(pdfx_of(&out), Pdfx::default());
}
}
#[test]
fn documents_without_a_print_standard_gain_none() {
assert_eq!(pdfx_of(&full_roundtrip(&extract_pages(&doc_a(), &[0]).unwrap())), Pdfx::default());
assert_eq!(pdfx_of(&full_roundtrip(&combine(&[("a", &doc_a()), ("b", &doc_b())]).unwrap())), Pdfx::default());
}
#[test]
fn the_pdfx_version_comes_from_xmp_or_the_document_information() {
// A PDF/X-4 file may name its version only in XMP (here as an element); a PDF/X-1a:2001 file
// (PDF 1.3) only in its document information, with no XMP, and gets no XMP added.
let xmp = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description \
rdf:about=\"\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\
<pdfxid:GTS_PDFXVersion> PDF/X-4 </pdfxid:GTS_PDFXVersion><pdf:Trapped>True</pdf:Trapped></rdf:Description></rdf:RDF></x:xmpmeta>";
let x = pdfx_of(&full_roundtrip(&extract_pages(&doc_print("FOGRA51", "icc", "/Title (Flyer)", Some(xmp)), &[0]).unwrap()));
assert_eq!((x.version.as_deref(), x.trapped.as_deref()), (Some("PDF/X-4"), Some(&b"True"[..])));
assert!(x.xmp.is_some_and(|x| x.contains("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>")));
let x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001) /Trapped (False)", None);
let out = full_roundtrip(&extract_pages(&x1a, &[0]).unwrap());
let x = pdfx_of(&out);
assert_eq!((x.version.as_deref(), x.trapped.as_deref()), (Some("PDF/X-1:2001"), Some(&b"False"[..])));
assert_eq!(info(&out, "GTS_PDFXConformance").as_deref(), Some("PDF/X-1a:2001"));
assert_eq!(x.xmp, None);
}
#[test]
fn malformed_output_intents_and_identification_do_not_fail() {
// Untrusted input: intents that are not dictionaries, a missing profile, an intent that is
// its own profile, an XMP packet that never closes, and a version full of markup and NULs.
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [5 0 R 42 (junk) 6 0 R] /Metadata 7 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(), // 3
body("M1"), // 4
"<< /Type /OutputIntent /S /GTS_PDFX /DestOutputProfile 99 0 R >>".into(), // 5
"<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (x) /DestOutputProfile 6 0 R >>".into(), // 6
"<< /Length 30 >>\nstream\n<pdfxid:GTS_PDFXConformance>X\nendstream".into(), // 7
"<< /GTS_PDFXVersion (<PDF/X-4 & \"more\">\\000) /Trapped /Maybe >>".into(), // 8
];
let src = open(build(&b, "/Root 1 0 R /Info 8 0 R"));
let out = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert_eq!(labels(&out), ["M1"]);
let list = catalog(&out).get(b"OutputIntents").map(|o| out.resolve(o).as_array().cloned().unwrap_or_default()).unwrap_or_default();
assert_eq!(list.len(), 4, "every entry is kept as written");
let x = pdfx_of(&out);
assert_eq!(x.trapped, None, "/Maybe is not a trapping state");
let xmp = x.xmp.expect("XMP metadata");
assert!(xmp.contains("<pdfxid:GTS_PDFXVersion>&lt;PDF/X-4 &amp; &quot;more&quot;&gt;</pdfxid:GTS_PDFXVersion>"), "{xmp}");
assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}");
}
#[test]
fn duplicate_pages_become_independent_copies() {
let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap());
assert_eq!(labels(&out), ["B1", "B1"]);
let ps = pages(&out).unwrap();
assert_ne!(ps[0].obj, ps[1].obj, "a page object may appear only once in the tree");
}
/// Document C: two pages. Page 1 has a link to the named destination `chap2` (page 2); page 2's
/// content uses a layer that is off by default. It has a bookmark tree and one attachment.
fn doc_c() -> Document {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 9 0 R /EmbeddedFiles 13 0 R >> /OCProperties << /OCGs [8 0 R] /D << /OFF [8 0 R] >> >> /Outlines 10 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> /Annots [<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest (chap2) >>] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> /Properties << /L1 8 0 R >> >> >>".into(), // 4
body("C1"), // 5
body("C2"), // 6
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), // 7
"<< /Type /OCG /Name (Draft marks) >>".into(), // 8
"<< /Names [(chap2) [4 0 R /Fit]] >>".into(), // 9
"<< /Type /Outlines /First 11 0 R /Last 11 0 R /Count 1 >>".into(), // 10
"<< /Title (Chapter one) /Parent 10 0 R /Dest [3 0 R /Fit] /First 12 0 R /Last 12 0 R /Count 1 >>".into(), // 11
"<< /Title (Section) /Parent 11 0 R /A << /S /GoTo /D (chap2) >> >>".into(), // 12
"<< /Names [(notes.txt) 14 0 R] >>".into(), // 13
"<< /Type /Filespec /F (notes.txt) /EF << /F 15 0 R >> >>".into(), // 14
"<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream".into(), // 15
];
open(build(&b, "/Root 1 0 R"))
}
fn catalog(doc: &Document) -> Dict {
doc.get(doc.root().unwrap()).as_dict().cloned().unwrap()
}
#[test]
fn named_destinations_are_resolved_to_the_copies() {
let out = full_roundtrip(&extract_pages(&doc_c(), &[0, 1]).unwrap());
let link = &annots(&out, 0)[0];
let dest = link.get(b"Dest").and_then(|d| d.as_array()).expect("explicit destination");
assert_eq!(dest[0].as_ref(), Some(pages(&out).unwrap()[1].obj));
// Without the target page, the link loses its destination instead of dangling.
let alone = full_roundtrip(&extract_pages(&doc_c(), &[0]).unwrap());
assert!(!annots(&alone, 0)[0].contains(b"Dest"));
}
#[test]
fn layers_are_registered_with_their_default_state() {
let out = full_roundtrip(&extract_pages(&doc_c(), &[1]).unwrap());
let props = out.resolve(catalog(&out).get(b"OCProperties").unwrap()).as_dict().cloned().unwrap();
let ocgs = props.get(b"OCGs").and_then(|o| o.as_array()).unwrap().clone();
assert_eq!(ocgs.len(), 1);
let name = out.resolve(&ocgs[0]).as_dict().unwrap().get(b"Name").and_then(|n| n.as_string()).map(|s| s.to_text());
assert_eq!(name.as_deref(), Some("Draft marks"));
let d = props.get(b"D").and_then(|d| d.as_dict()).unwrap();
assert_eq!(d.get(b"OFF").and_then(|o| o.as_array()).unwrap(), &ocgs, "stays off by default");
// Page 1 uses no layer, so extracting it alone adds no OCProperties.
let out = extract_pages(&doc_c(), &[0]).unwrap();
assert!(!catalog(&out).contains(b"OCProperties"));
}
/// The top-level bookmarks' titles, in order.
fn top_titles(doc: &Document) -> Vec<String> {
let Some(outlines) = catalog(doc).reference(b"Outlines") else { return Vec::new() };
let mut item = doc.get(outlines).as_dict().and_then(|o| o.reference(b"First"));
let mut titles = Vec::new();
while let Some(r) = item {
let d = doc.get(r).as_dict().cloned().unwrap();
titles.push(d.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).unwrap());
item = d.reference(b"Next");
}
titles
}
#[test]
fn a_file_split_around_another_is_copied_once_with_one_bookmark() {
// C's first page, then all of A, then C's second page.
let (a, c) = (doc_a(), doc_c());
let out = full_roundtrip(&crate::combine_grouped(&[(0, "C", &c, Some(&[0])), (1, "A", &a, None), (0, "C", &c, Some(&[1]))]).unwrap());
assert_eq!(labels(&out), ["C1", "A1", "A2", "A3", "C2"]);
assert_eq!(top_titles(&out), ["C", "A"], "one bookmark per file, not per run");
let ps = pages(&out).unwrap();
let outlines = out.get(catalog(&out).reference(b"Outlines").unwrap()).as_dict().cloned().unwrap();
let file = out.get(outlines.reference(b"First").unwrap()).as_dict().cloned().unwrap();
assert_eq!(file.get(b"Dest").and_then(|d| d.as_array()).unwrap()[0].as_ref(), Some(ps[0].obj), "C's bookmark: its first page shown");
// Its own bookmarks follow its pages wherever they went: the section is on C2, now last.
let chapter = out.get(file.reference(b"First").unwrap()).as_dict().cloned().unwrap();
let section = out.get(chapter.reference(b"First").unwrap()).as_dict().cloned().unwrap();
assert_eq!(section.get(b"Dest").and_then(|d| d.as_array()).unwrap()[0].as_ref(), Some(ps[4].obj));
// The link from C1 to C2 still works across A's pages between them.
let link = &annots(&out, 0)[0];
assert_eq!(link.get(b"Dest").and_then(|d| d.as_array()).expect("kept").first().and_then(|o| o.as_ref()), Some(ps[4].obj));
// Its attachment once, not once per run.
let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap();
let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
let keys: Vec<String> = tree.get(b"Names").and_then(|n| n.as_array()).unwrap().chunks(2).map(|p| p[0].as_string().unwrap().to_text()).collect();
assert_eq!(keys, ["notes.txt"]);
}
#[test]
fn a_page_shown_twice_gets_its_own_annotations() {
// One page with an indirect comment (and its popup), and a second page.
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [6 0 R] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(), // 4
body("D1"), // 5
"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (note) /P 3 0 R /Popup 8 0 R >>".into(), // 6
body("D2"), // 7
"<< /Type /Annot /Subtype /Popup /Rect [10 10 50 50] /Parent 6 0 R >>".into(), // 8
];
let d = open(build(&b, "/Root 1 0 R"));
// Page 1 twice, from one file split around its page 2 (and the same through combine_selected).
for out in [
crate::combine_grouped(&[(0, "D", &d, Some(&[0, 1])), (0, "D", &d, Some(&[0]))]).unwrap(),
combine_selected(&[("D", &d, Some(&[0, 1, 0]))]).unwrap(),
] {
let out = full_roundtrip(&out);
assert_eq!(labels(&out), ["D1", "D2", "D1"]);
let ps = pages(&out).unwrap();
let annot_of = |i: usize| page_dict(&out, i).get(b"Annots").and_then(|a| a.as_array()).and_then(|a| a[0].as_ref()).unwrap();
let (first, again) = (annot_of(0), annot_of(2));
assert_ne!(first, again, "two pages, two comments");
for (annot, page) in [(first, ps[0].obj), (again, ps[2].obj)] {
let a = out.get(annot).as_dict().cloned().unwrap();
assert_eq!(a.reference(b"P"), Some(page), "each comment belongs to its own page");
let popup = out.get(a.reference(b"Popup").unwrap()).as_dict().cloned().unwrap();
assert_eq!(popup.reference(b"Parent"), Some(annot), "and its popup to it");
}
}
}
#[test]
fn grouped_runs_keep_their_order_repeats_and_separate_files() {
let (a, b) = (doc_a(), doc_b());
// B's page 2, A's page 1, B's page 1, A's page 1 again (a repeat is a page of its own).
let out = full_roundtrip(
&crate::combine_grouped(&[(0, "B", &b, Some(&[1])), (1, "A", &a, Some(&[0])), (0, "B", &b, Some(&[0])), (1, "A", &a, Some(&[0]))]).unwrap(),
);
assert_eq!(labels(&out), ["B2", "A1", "B1", "A1"]);
assert_eq!(top_titles(&out), ["B", "A"]);
let ps = pages(&out).unwrap();
assert_ne!(ps[1].obj, ps[3].obj, "a page shown twice is two pages");
// The same file added twice on purpose (two groups) keeps two bookmarks, as combine does.
let twice = full_roundtrip(&crate::combine_grouped(&[(0, "A", &a, None), (1, "A again", &a, None)]).unwrap());
assert_eq!(top_titles(&twice), ["A", "A again"]);
// A page that isn't there is refused, as combine_selected does.
assert!(matches!(crate::combine_grouped(&[(0, "A", &a, Some(&[9]))]), Err(crate::OrganizeError::NoSuchPage(9))));
}
#[test]
fn combine_nests_source_bookmarks_and_keeps_attachments() {
let out = full_roundtrip(&combine(&[("C", &doc_c()), ("C again", &doc_c())]).unwrap());
assert_eq!(labels(&out), ["C1", "C2", "C1", "C2"]);
let outlines = out.get(catalog(&out).reference(b"Outlines").unwrap()).as_dict().cloned().unwrap();
let file = out.get(outlines.reference(b"First").unwrap()).as_dict().cloned().unwrap();
assert!(file.int(b"Count").unwrap() < 0, "source bookmarks start collapsed");
let chapter = out.get(file.reference(b"First").unwrap()).as_dict().cloned().unwrap();
assert_eq!(chapter.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).as_deref(), Some("Chapter one"));
let section = out.get(chapter.reference(b"First").unwrap()).as_dict().cloned().unwrap();
let dest = section.get(b"Dest").and_then(|d| d.as_array()).expect("GoTo (named) became an explicit destination");
assert_eq!(dest[0].as_ref(), Some(pages(&out).unwrap()[1].obj));
// The second copy's bookmarks point into the second copy.
let file2 = out.get(file.reference(b"Next").unwrap()).as_dict().cloned().unwrap();
let chapter2 = out.get(file2.reference(b"First").unwrap()).as_dict().cloned().unwrap();
assert_eq!(chapter2.get(b"Dest").and_then(|d| d.as_array()).unwrap()[0].as_ref(), Some(pages(&out).unwrap()[2].obj));
// Attachments from both sources, the duplicate name disambiguated.
let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap();
let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
let keys: Vec<String> = tree.get(b"Names").and_then(|n| n.as_array()).unwrap().chunks(2).map(|p| p[0].as_string().unwrap().to_text()).collect();
assert_eq!(keys, ["notes.txt", "notes.txt (2)"]);
}
/// Insert Pages from File brings the inserted file's bookmarks along (they used to be dropped),
/// collapsed under one bookmark named after the file at its first inserted page, placed among the
/// host's bookmarks where the pages went. Destinations follow the copies; named ones are resolved.
#[test]
fn inserting_a_file_brings_its_bookmarks_under_one_named_after_it() {
let mut host = open(fixture());
add_bookmark(&mut host, &[], 0, "Start", 0).unwrap();
add_bookmark(&mut host, &[], 1, "End", 2).unwrap();
insert_file_pages(&mut host, &doc_c(), "annex", &[0, 1], 1, FieldConflicts::Rename).unwrap();
let host = save_and_reopen(&host);
assert_eq!(labels(&host), ["Page 1", "C1", "C2", "Page 2", "Page 3"]);
let marks = bookmarks(&host);
assert_eq!(titles(&marks), ["Start", "annex[Chapter one[Section]]", "End"]);
let page_of = |title: &str| marks.iter().find(|b| b.title == title).and_then(|b| b.page);
assert_eq!((page_of("annex"), page_of("Chapter one"), page_of("Section"), page_of("End")), (Some(1), Some(1), Some(2), Some(4)));
assert!(!marks.iter().find(|b| b.title == "annex").unwrap().open, "the file's bookmarks start collapsed");
// Inserted last, after every bookmark; only the pages chosen keep a destination.
let mut host = open(fixture());
add_bookmark(&mut host, &[], 0, "Start", 0).unwrap();
insert_file_pages(&mut host, &doc_c(), "annex", &[0], 9, FieldConflicts::Rename).unwrap();
let marks = bookmarks(&host);
assert_eq!(titles(&marks), ["Start", "annex[Chapter one[Section]]"]);
assert_eq!(marks.iter().find(|b| b.title == "Section").unwrap().page, None, "its page was left out");
// A file without bookmarks adds none, and a host without an outline gets one only when needed.
let mut host = open(fixture());
insert_file_pages(&mut host, &doc_b(), "plain", &[0], 0, FieldConflicts::Rename).unwrap();
assert!(bookmarks(&host).is_empty());
assert!(!catalog(&host).contains(b"Outlines"));
}
/// Bookmarks say where on their page they go, through every kind of destination: explicit,
/// named (resolved), and a GoTo action's.
#[test]
fn bookmarks_report_the_view_their_destination_asks_for() {
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R /Names << /Dests << /Names [(sec) [4 0 R /FitH 300]] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 400] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Type /Outlines /First 6 0 R /Last 9 0 R /Count 4 >>".into(),
];
objs.push("<< /Title (Explicit) /Parent 5 0 R /Next 7 0 R /Dest [4 0 R /XYZ 20 250 null] >>".into()); // 6
objs.push("<< /Title (Named) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /Dest (sec) >>".into()); // 7
objs.push("<< /Title (Action) /Parent 5 0 R /Prev 7 0 R /Next 9 0 R /A << /S /GoTo /D [3 0 R /FitR 10 20 110 (x) 999] >> >>".into()); // 8
objs.push("<< /Title (Nowhere) /Parent 5 0 R /Prev 8 0 R /Dest (missing) >>".into()); // 9
let doc = open(build(&objs, "/Root 1 0 R"));
type View = Option<(Vec<u8>, Vec<Option<f64>>)>;
let views: Vec<(String, Option<usize>, View)> = bookmarks(&doc).into_iter().map(|b| (b.title, b.page, b.view)).collect();
assert_eq!(
views,
[
("Explicit".into(), Some(1), Some((b"XYZ".to_vec(), vec![Some(20.0), Some(250.0), None]))),
("Named".into(), Some(1), Some((b"FitH".to_vec(), vec![Some(300.0)]))),
("Action".into(), Some(0), Some((b"FitR".to_vec(), vec![Some(10.0), Some(20.0), Some(110.0), None]))),
("Nowhere".into(), None, None),
]
);
}
#[test]
fn combine_and_insert_share_identical_resources() {
let a = doc_a();
let fonts = |d: &Document| {
d.object_numbers()
.into_iter()
.filter(|n| d.get(ObjRef::new(*n, d.generation(*n))).as_dict().is_some_and(|x| x.name(b"Type") == Some(b"Font")))
.count()
};
assert_eq!(fonts(&a), 1);
// The same document three times: one font, and every page still renders its own text.
let out = full_roundtrip(&crate::combine(&[("one", &a), ("two", &a), ("three", &a)]).unwrap());
assert_eq!(fonts(&out), 1);
assert_eq!(labels(&out), ["A1", "A2", "A3", "A1", "A2", "A3", "A1", "A2", "A3"]);
// Inserting pages that use the same font reuses the font already there, without touching it.
let mut d = doc_a();
let font_before =
d.object_numbers().into_iter().find(|n| d.get(ObjRef::new(*n, 0)).as_dict().is_some_and(|x| x.name(b"Type") == Some(b"Font"))).unwrap();
crate::import_pages(&mut d, &doc_a(), &[0, 1], 3).unwrap();
assert_eq!(fonts(&d), 1);
assert!(!d.modified_objects().contains(&font_before));
let d = full_roundtrip(&d);
assert_eq!(labels(&d), ["A1", "A2", "A3", "A1", "A2"]);
}
/// A one-page document drawing one image, written the way ImageMagick writes it: the colour
/// space is an indirect `/DeviceRGB` object of its own (#878).
fn indirect_colour_space_doc() -> Document {
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>".to_vec(),
stream(b"q 100 0 0 100 0 0 cm /Im0 Do Q"),
[
b"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace 6 0 R /BitsPerComponent 8 /Length 6 >>\nstream\n".as_slice(),
&[255, 0, 0, 0, 0, 255],
b"\nendstream",
]
.concat(),
b"/DeviceRGB".to_vec(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
open(out)
}
#[test]
fn combine_stores_an_image_once_when_its_colour_space_is_an_indirect_name() {
let images = |d: &Document| {
d.object_numbers()
.into_iter()
.filter(|n| matches!(&*d.get(ObjRef::new(*n, d.generation(*n))), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")))
.count()
};
let src = indirect_colour_space_doc();
let out = full_roundtrip(&crate::combine(&[("one", &src), ("two", &src), ("three", &src)]).unwrap());
assert_eq!(images(&out), 1);
assert_eq!(pages(&out).unwrap().len(), 3);
// Inserting the same pages again reuses the image already there.
let mut d = indirect_colour_space_doc();
crate::import_pages(&mut d, &indirect_colour_space_doc(), &[0], 1).unwrap();
assert_eq!(images(&full_roundtrip(&d)), 1);
}
// ---- bookmarks (M4.6) --------------------------------------------------------------------------
/// The titles with children in brackets (`Parent[Child,Child]`), one per top-level bookmark.
fn titles(b: &[crate::Bookmark]) -> Vec<String> {
fn level(b: &[crate::Bookmark], at: &mut usize, depth: usize) -> Vec<String> {
let mut out = Vec::new();
while let Some(x) = b.get(*at).filter(|x| x.path.len() == depth + 1) {
*at += 1;
let kids = level(b, at, depth + 1);
out.push(if kids.is_empty() { x.title.clone() } else { format!("{}[{}]", x.title, kids.join(",")) });
}
out
}
let mut at = 0;
level(b, &mut at, 0)
}
fn count_of(doc: &Document, r: ObjRef) -> Option<i64> {
doc.get(r).as_dict().and_then(|d| d.int(b"Count"))
}
#[test]
fn bookmarks_add_rename_move_delete() {
let mut d = doc_a();
assert!(crate::bookmarks(&d).is_empty());
assert_eq!(crate::add_bookmark(&mut d, &[], 0, "Intro", 0).unwrap(), vec![0]);
assert_eq!(crate::add_bookmark(&mut d, &[], 9, "Results", 2).unwrap(), vec![1]);
assert_eq!(crate::add_bookmark(&mut d, &[1], 0, "Table", 1).unwrap(), vec![1, 0]);
crate::add_bookmark(&mut d, &[1], 1, "Chart", 2).unwrap();
assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Chart]"]);
crate::rename_bookmark(&mut d, &[1, 1], "Figure 1").unwrap();
assert_eq!(crate::move_bookmark(&mut d, &[1, 1], &[], 0).unwrap(), vec![0]);
assert_eq!(titles(&crate::bookmarks(&d)), ["Figure 1", "Intro", "Results[Table]"]);
// Into a later sibling: the target path is adjusted for the removal.
assert_eq!(crate::move_bookmark(&mut d, &[0], &[2], 9).unwrap(), vec![1, 1]);
assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Figure 1]"]);
assert_eq!(crate::move_bookmark(&mut d, &[1], &[1, 0], 0), Err(crate::OutlineError::IntoItself));
crate::delete_bookmark(&mut d, &[1, 0]).unwrap();
assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Figure 1]"]);
// Counts: the root counts visible items; open items count their visible descendants.
let b = crate::bookmarks(&d);
let root = d.get(d.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap();
assert_eq!(count_of(&d, root), Some(3));
assert_eq!(count_of(&d, b[1].obj), Some(1));
crate::set_bookmark_open(&mut d, &[1], false).unwrap();
assert_eq!((count_of(&d, b[1].obj), count_of(&d, root)), (Some(-1), Some(2)));
// Destinations point at the chosen page; errors are specific.
crate::set_bookmark_page(&mut d, &[0], 2).unwrap();
let dest = d.get(b[0].obj).as_dict().unwrap().get(b"Dest").cloned().unwrap();
let pages = crate::walk(&d).unwrap();
assert_eq!(dest.as_array().unwrap()[0], Object::Ref(pages[2].0));
assert_eq!(crate::rename_bookmark(&mut d, &[5], "x"), Err(crate::OutlineError::NoSuchBookmark(vec![5])));
assert_eq!(crate::rename_bookmark(&mut d, &[0], " "), Err(crate::OutlineError::EmptyTitle));
assert!(crate::add_bookmark(&mut d, &[], 0, "Nowhere", 7).is_err());
// Everything survives a save and reopen.
let back = full_roundtrip(&d);
assert_eq!(titles(&crate::bookmarks(&back)), ["Intro", "Results[Figure 1]"]);
}
#[test]
fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() {
let mut d = doc_a();
crate::add_bookmark(&mut d, &[], 0, "Existing", 0).unwrap();
let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element };
let se = ObjRef::new(4242, 0);
let entries = [e(1, "One", 0, Some(se)), e(3, "Deep", 1, None), e(2, "Two", 1, None), e(1, "Next", 2, None)];
assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &entries).unwrap(), vec![0]);
assert_eq!(titles(&crate::bookmarks(&d)), ["Untitled[One[Deep,Two],Next]", "Existing"]);
let b = crate::bookmarks(&d);
let root = d.get(d.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap();
// Listing order: Untitled, One, Deep, Two, Next, Existing.
assert_eq!((count_of(&d, root), count_of(&d, b[0].obj), count_of(&d, b[1].obj)), (Some(6), Some(4), Some(2)));
let one = d.get(b[1].obj).as_dict().cloned().unwrap();
assert_eq!(one.get(b"SE"), Some(&Object::Ref(se)));
let pages = crate::walk(&d).unwrap();
let next = d.get(b[4].obj).as_dict().cloned().unwrap();
assert_eq!(next.get(b"Dest").and_then(|x| x.as_array()).map(|a| a[0].clone()), Some(Object::Ref(pages[2].0)));
assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &[]), Err(crate::OutlineError::NoEntries));
assert!(crate::add_bookmark_tree(&mut d, "Untitled", &[e(1, "Nowhere", 9, None)]).is_err());
assert_eq!(titles(&crate::bookmarks(&full_roundtrip(&d))), ["Untitled[One[Deep,Two],Next]", "Existing"]);
}
#[test]
fn bookmark_edits_keep_unknown_keys_and_touch_few_objects() {
let mut d = doc_a();
crate::add_bookmark(&mut d, &[], 0, "One", 0).unwrap();
crate::add_bookmark(&mut d, &[], 1, "Two", 1).unwrap();
let first = crate::bookmarks(&d)[0].obj;
d.update_dict(first, |x| x.set(b"C".to_vec(), Object::Array(vec![Object::Real(1.0), Object::Int(0), Object::Int(0)]))).unwrap();
let bytes = std::sync::Arc::new(write_full(&d, &SaveOptions::default()).unwrap());
let mut d = Document::open(bytes).unwrap();
crate::rename_bookmark(&mut d, &[0], "Uno").unwrap();
let b = crate::bookmarks(&d);
assert_eq!(b[0].title, "Uno");
assert!(d.get(b[0].obj).as_dict().unwrap().get(b"C").is_some(), "colour kept");
assert_eq!(d.modified_objects(), vec![b[0].obj.num], "a rename rewrites only that item");
}
/// A flat outline of `n` top-level bookmarks, `Item 0` to `Item n-1`, all going to one page.
fn flat_outline(n: usize) -> Vec<u8> {
let mut b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
format!("<< /Type /Outlines /First 5 0 R /Last {} 0 R /Count {n} >>", 4 + n),
"<< /Type /Page /Parent 2 0 R >>".into(),
];
for k in 0..n {
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
b.push(format!("<< /Title (Item {k}) /Parent 3 0 R{prev}{next} /Dest [4 0 R /Fit] >>"));
}
build(&b, "/Root 1 0 R")
}
#[test]
fn a_bookmark_past_the_hundred_thousandth_is_listed() {
let d = open(flat_outline(100_005));
let all = crate::bookmarks(&d);
assert_eq!(all.len(), 100_005);
assert_eq!(all.last().map(|b| b.title.as_str()), Some("Item 100004"));
}
/// A chain of `n` nested bookmarks: each is the first child of the one before it.
fn deep_outline(n: usize) -> Vec<u8> {
let mut b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
];
for k in 0..n {
let parent = if k == 0 { 3 } else { 4 + k };
let first = if k + 1 == n { String::new() } else { format!(" /First {} 0 R /Count -1", 6 + k) };
b.push(format!("<< /Title (Deep {k}) /Parent {parent} 0 R{first} /Dest [4 0 R /Fit] >>"));
}
build(&b, "/Root 1 0 R")
}
#[test]
fn deep_bookmark_chains_edit_without_recursion() {
let mut d = open(deep_outline(100_000));
// Adding at the top level recounts the whole outline, which walks the entire chain.
crate::add_bookmark(&mut d, &[], 1, "Extra", 0).unwrap();
// Listing enters levels 0 to 32 of the chain; the rest is reached by path only.
let listed = crate::bookmarks(&d);
assert_eq!(listed.len(), 34);
assert_eq!(listed.last().map(|b| (b.title.as_str(), b.path.clone())), Some(("Extra", vec![1])));
assert_eq!(listed[32].path.len(), 33);
assert_eq!(crate::bookmark_page(&d, 33, 10).bookmarks.len(), 1);
}
/// A one-page document whose outline is the given bookmark bodies, as objects 5, 6, … in order.
fn outline_with(items: &[&str]) -> Document {
let mut b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()),
"<< /Type /Page /Parent 2 0 R >>".into(),
];
b.extend(items.iter().map(|s| s.to_string()));
open(build(&b, "/Root 1 0 R"))
}
#[test]
fn hostile_outlines_end_their_runs_and_never_loop() {
let listed = |items: &[&str]| titles(&crate::bookmarks(&outline_with(items)));
// A bookmark that is its own next sibling, or its own first child.
assert_eq!(listed(&["<< /Title (Self) /Parent 3 0 R /Next 5 0 R >>"]), ["Self"]);
assert_eq!(listed(&["<< /Title (Self) /Parent 3 0 R /First 5 0 R >>"]), ["Self"]);
// Two siblings that point at each other: the run ends where it would repeat.
assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 3 0 R /Next 5 0 R >>"]), ["A", "B"]);
// A child that leads back to its ancestor.
assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /First 6 0 R >>", "<< /Title (B) /Parent 5 0 R /First 5 0 R >>"]), ["A[B]"]);
// A broken `/Next` (a missing object, then a number) and a missing first bookmark.
assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 99 0 R >>"]), ["A"]);
assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 6 0 R >>", "42"]), ["A"]);
assert!(listed(&[]).is_empty());
}
#[test]
fn bookmark_pages_list_every_bookmark_once() {
let d = open(flat_outline(250));
let mut seen = Vec::new();
let mut offset = 0;
while let Some(next) = {
let page = crate::bookmark_page(&d, offset, 100);
assert!(page.bookmarks.len() <= 100);
seen.extend(page.bookmarks.iter().map(|b| b.title.clone()));
page.next
} {
assert_eq!(next, seen.len(), "a page ends where the next one starts");
offset = next;
}
assert_eq!(seen.len(), 250);
assert_eq!(seen.last().map(String::as_str), Some("Item 249"));
let big = open(flat_outline(100_005));
let first = crate::bookmark_page(&big, 0, 100);
assert_eq!((first.bookmarks.len(), first.next), (100, Some(100)));
let late = crate::bookmark_page(&big, 100_000, 100);
assert_eq!(
late.bookmarks.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
["Item 100000", "Item 100001", "Item 100002", "Item 100003", "Item 100004"]
);
assert_eq!((late.bookmarks[4].path.clone(), late.next), (vec![100_004], None));
}
#[test]
fn bookmarks_name_their_pages_through_every_kind_of_destination() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R /Names << /Dests 9 0 R >> >>".into(),
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Outlines /First 6 0 R /Last 11 0 R /Count 6 >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Title (Named) /Parent 3 0 R /Next 7 0 R /Dest (intro) >>".into(),
"<< /Title (GoTo) /Parent 3 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoTo /D [5 0 R /Fit] >> >>".into(),
"<< /Title (Appendix) /Parent 3 0 R /Prev 7 0 R /Next 10 0 R /Dest /appendix >>".into(),
"<< /Names [(appendix) 12 0 R (intro) [4 0 R /Fit]] >>".into(),
"<< /Title <FEFF00480069> /Parent 3 0 R /Prev 8 0 R /Next 11 0 R /Dest [1 /Fit] >>".into(),
"<< /Title ( Padded ) /Parent 3 0 R /Prev 10 0 R /Dest [2 0 R /Fit] >>".into(),
"<< /D [5 0 R /Fit] >>".into(),
];
let d = open(build(&objs, "/Root 1 0 R"));
let listed: Vec<String> = crate::bookmarks(&d).iter().map(|b| format!("{}@{:?}", b.title, b.page)).collect();
assert_eq!(listed, ["Named@Some(0)", "GoTo@Some(1)", "Appendix@Some(1)", "Hi@Some(1)", "Padded@None"]);
}
// ---- page labels (M4.4) ------------------------------------------------------------------------
#[test]
fn number_pages_like_acrobat() {
use crate::LabelStyle::*;
let mut d = doc_a();
crate::insert_blank_page(&mut d, 3, 300.0, 400.0).unwrap();
crate::insert_blank_page(&mut d, 4, 300.0, 400.0).unwrap();
crate::insert_blank_page(&mut d, 5, 300.0, 400.0).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["1", "2", "3", "4", "5", "6"]);
// Front matter in roman numerals; later pages keep their labels.
crate::number_pages(&mut d, 0, 1, LowerRoman, "", 1).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["i", "ii", "3", "4", "5", "6"]);
// The body restarts at 1, an appendix gets a prefix.
crate::number_pages(&mut d, 2, 5, Decimal, "", 1).unwrap();
crate::number_pages(&mut d, 4, 5, Decimal, "A-", 1).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["i", "ii", "1", "2", "A-1", "A-2"]);
assert_eq!(crate::page_label_ranges(&d).len(), 3, "continuations are merged");
// Relabelling the middle keeps the pages after it.
crate::number_pages(&mut d, 3, 3, UpperAlpha, "Fig ", 3).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["i", "ii", "1", "Fig C", "A-1", "A-2"]);
assert!(crate::number_pages(&mut d, 4, 9, Decimal, "", 1).is_err());
// Survives a save and reopen; back to plain numbers removes /PageLabels.
let back = full_roundtrip(&d);
assert_eq!(crate::page_labels(&back).unwrap(), ["i", "ii", "1", "Fig C", "A-1", "A-2"]);
crate::number_pages(&mut d, 0, 5, Decimal, "", 1).unwrap();
assert!(crate::page_label_ranges(&d).is_empty());
assert!(d.get(d.root().unwrap()).as_dict().unwrap().get(b"PageLabels").is_none());
}
/// Deleting pages moves the label ranges with the pages they label (#2091): later ranges start that
/// many pages earlier and keep numbering on, a range whose first page went starts on the next page
/// left, and a range left with no pages gives way to the one after it.
#[test]
fn deleting_pages_keeps_the_label_ranges_on_their_pages() {
use crate::{LabelRange, LabelStyle::*};
let ten = || {
let mut d = doc_a();
for at in 3..10 {
crate::insert_blank_page(&mut d, at, 300.0, 400.0).unwrap();
}
let ranges = [
LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 },
LabelRange { start: 3, style: Decimal, prefix: String::new(), first: 1 },
LabelRange { start: 7, style: Decimal, prefix: "A-".into(), first: 1 },
];
crate::set_page_label_ranges(&mut d, &ranges).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["i", "ii", "iii", "1", "2", "3", "4", "A-1", "A-2", "A-3"]);
d
};
for (deleted, expected) in [
(&[4, 5][..], &["i", "ii", "iii", "1", "2", "A-1", "A-2", "A-3"][..]),
(&[3], &["i", "ii", "iii", "1", "2", "3", "A-1", "A-2", "A-3"]),
(&[3, 4, 5, 6], &["i", "ii", "iii", "A-1", "A-2", "A-3"]),
] {
let mut d = ten();
crate::delete_pages(&mut d, deleted).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), expected, "after deleting {deleted:?}");
assert_eq!(crate::page_labels(&full_roundtrip(&d)).unwrap(), expected, "reopened after deleting {deleted:?}");
}
}
#[test]
fn page_label_limits_reject_unsupported_edits_before_mutating() {
use crate::LabelStyle::*;
for (style, prefix, first) in [
(LowerRoman, String::new(), 1_025_000),
(LowerAlpha, String::new(), 26 * 1024 + 1),
(Decimal, "x".repeat(1025), 1),
(Decimal, String::new(), u32::MAX),
] {
let mut d = doc_a();
let before = d.modified_objects();
assert!(crate::number_pages(&mut d, 0, 1, style, &prefix, first).is_err());
assert_eq!(d.modified_objects(), before, "a refused label edit must not change the tree");
assert!(crate::page_label_ranges(&d).is_empty());
}
}
#[test]
fn page_label_limits_reject_unsupported_ranges_before_mutating() {
let mut d = doc_a();
let before = d.modified_objects();
let ranges = [crate::LabelRange { start: 0, style: crate::LabelStyle::LowerRoman, prefix: String::new(), first: 1_025_000 }];
assert!(crate::set_page_label_ranges(&mut d, &ranges).is_err());
assert_eq!(d.modified_objects(), before);
}
#[test]
fn page_label_limits_reject_cyclic_old_trees_without_rewriting() {
let mut d = doc_a();
let tree = d.add(Object::Null);
let mut dict = Dict::new();
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)]));
d.set(tree, Object::Dict(dict));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
let before = d.modified_objects();
assert!(crate::page_labels(&d).is_err());
assert!(crate::page_label_ranges(&d).is_empty(), "compatibility query has documented empty fallback");
assert!(crate::number_pages(&mut d, 0, 1, crate::LabelStyle::Decimal, "", 1).is_err());
assert_eq!(d.modified_objects(), before);
assert_eq!(d.get(d.root().unwrap()).as_dict().unwrap().reference(b"PageLabels"), Some(tree));
}
#[test]
fn page_label_limits_reject_oversized_old_arrays_without_rewriting() {
let mut d = doc_a();
let mut dict = Dict::new();
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Null; pdfcraft_cos::page_labels::MAX_LABEL_TREE_WORK + 1]));
let tree = d.add(Object::Dict(dict));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
let before = d.modified_objects();
assert!(crate::page_labels(&d).is_err());
assert!(crate::number_pages(&mut d, 0, 1, crate::LabelStyle::Decimal, "", 1).is_err());
assert_eq!(d.modified_objects(), before);
}
#[test]
fn page_label_limits_keep_preview_fallback_and_valid_boundary_edits() {
use crate::LabelStyle::*;
assert_eq!(LowerRoman.format(u32::MAX), u32::MAX.to_string());
assert_eq!(LowerAlpha.format(u32::MAX), u32::MAX.to_string());
let mut d = doc_a();
crate::number_pages(&mut d, 0, 2, None, &"x".repeat(1024), 1).unwrap();
assert!(crate::page_labels(&d).unwrap().iter().all(|s| s.len() == 1024));
}
#[test]
fn page_label_limits_preserve_prefix_only_max_first_and_aliases() {
let mut d = doc_a();
crate::number_pages(&mut d, 0, 2, crate::LabelStyle::None, "prefix", u32::MAX).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["prefix", "prefix", "prefix"]);
crate::number_pages(&mut d, 0, 0, crate::LabelStyle::Decimal, "", 1).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["1", "prefix", "prefix"]);
let prefix = d.add(Object::String(PdfString::text("alias")));
let prefix_alias = d.add(Object::Ref(prefix));
let mut spec = Dict::new();
spec.set(b"P".to_vec(), Object::Ref(prefix_alias));
spec.set(b"St".to_vec(), Object::Int(i64::MAX)); // unused without /S
let spec = d.add(Object::Dict(spec));
let spec_alias = d.add(Object::Ref(spec));
let nums = d.add(Object::Array(vec![Object::Int(0), Object::Ref(spec_alias)]));
let nums_alias = d.add(Object::Ref(nums));
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Ref(nums_alias));
let tree = d.add(Object::Dict(tree));
let tree_alias = d.add(Object::Ref(tree));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree_alias))).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["alias", "alias", "alias"]);
crate::number_pages(&mut d, 0, 0, crate::LabelStyle::Decimal, "", 1).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["1", "alias", "alias"]);
}
#[test]
fn page_label_limits_refuse_reference_only_cycles_without_rewriting() {
let mut d = doc_a();
let tree = d.add(Object::Null);
d.set(tree, Object::Ref(tree));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
let before = d.modified_objects();
assert!(crate::page_labels(&d).is_err());
assert!(crate::number_pages(&mut d, 0, 1, crate::LabelStyle::Decimal, "", 1).is_err());
assert_eq!(d.modified_objects(), before);
}
#[test]
fn page_label_limits_validate_the_same_ranges_that_are_written() {
use crate::{LabelRange, LabelStyle};
let mut d = doc_a();
let duplicates = [
LabelRange { start: 0, style: LabelStyle::None, prefix: "ok".into(), first: 1 },
LabelRange { start: 0, style: LabelStyle::None, prefix: "x".repeat(1024), first: 1 },
];
let before = d.modified_objects();
assert!(crate::set_page_label_ranges(&mut d, &duplicates).unwrap_err().to_string().contains("unique"));
assert_eq!(d.modified_objects(), before);
let unsorted = [
LabelRange { start: 1, style: LabelStyle::UpperAlpha, prefix: "".into(), first: 1 },
LabelRange { start: 0, style: LabelStyle::LowerRoman, prefix: "".into(), first: 4 },
];
crate::set_page_label_ranges(&mut d, &unsorted).unwrap();
assert_eq!(crate::page_labels(&d).unwrap(), ["iv", "A", "B"]);
let catalog = d.get(d.root().unwrap());
let tree = d.resolve(catalog.as_dict().unwrap().get(b"PageLabels").unwrap());
let nums = tree.as_dict().unwrap().get(b"Nums").unwrap().as_array().unwrap();
assert_eq!(nums[0].as_int(), Some(0));
assert_eq!(nums[2].as_int(), Some(1));
}
/// Each label dictionary is its own object, referenced from `/Nums`, as Acrobat writes them. A
/// direct dictionary is just as valid, but some readers (pdfannots2json, through unipdf) skip it
/// and fall back to physical page numbers (#1763).
#[test]
fn page_labels_are_written_as_indirect_dictionaries() {
use crate::{LabelRange, LabelStyle};
let mut d = doc_a();
let ranges = [
LabelRange { start: 0, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 },
LabelRange { start: 1, style: LabelStyle::Decimal, prefix: "A-".into(), first: 324 },
LabelRange { start: 2, style: LabelStyle::None, prefix: "Cover".into(), first: 1 },
];
crate::set_page_label_ranges(&mut d, &ranges).unwrap();
let back = full_roundtrip(&d);
let catalog = back.get(back.root().unwrap());
let tree = back.resolve(catalog.as_dict().unwrap().get(b"PageLabels").unwrap());
let nums = tree.as_dict().unwrap().get(b"Nums").unwrap().as_array().unwrap();
assert_eq!(nums.len(), 6);
let expected = [(0, Some(b"r".as_slice()), None, None), (1, Some(b"D".as_slice()), Some("A-"), Some(324)), (2, None, Some("Cover"), None)];
for (pair, (start, style, prefix, first)) in nums.chunks(2).zip(expected) {
assert_eq!(pair[0].as_int(), Some(start));
let Object::Ref(r) = &pair[1] else { panic!("the label of page {start} is not an indirect object: {:?}", pair[1]) };
let spec = back.get(*r);
let spec = spec.as_dict().unwrap();
assert_eq!(spec.name(b"S"), style);
assert_eq!(spec.get(b"P").and_then(|p| p.as_string()).map(|p| p.to_text()).as_deref(), prefix);
assert_eq!(spec.int(b"St"), first);
}
assert_eq!(crate::page_label_ranges(&back), ranges);
assert_eq!(crate::page_labels(&back).unwrap(), ["i", "A-324", "Cover"]);
}
#[test]
fn page_label_duplicate_starts_keep_the_later_range() {
let mut d = doc_a();
let mut a = Dict::new();
a.set(b"P".to_vec(), Object::String(PdfString::text("first")));
let mut b = Dict::new();
b.set(b"P".to_vec(), Object::String(PdfString::text("last")));
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(a), Object::Int(0), Object::Dict(b)]));
let tree = d.add(Object::Dict(tree));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
assert_eq!(crate::page_labels(&d).unwrap()[0], "last");
crate::number_pages(&mut d, 0, 0, crate::LabelStyle::Decimal, "", 1).unwrap();
}
/// Two kids naming objects the file doesn't have both resolve to a fresh `Null`; they are not
/// the same node, so the tree isn't refused as cyclic.
#[test]
fn page_label_missing_kids_are_not_a_cycle() {
let mut d = doc_a();
let mut leaf = Dict::new();
let mut spec = Dict::new();
spec.set(b"P".to_vec(), Object::String(PdfString::text("p-")));
spec.set(b"S".to_vec(), Object::name("D"));
leaf.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)]));
let leaf = d.add(Object::Dict(leaf));
let mut tree = Dict::new();
let missing = |n| Object::Ref(pdfcraft_cos::ObjRef { num: n, generation: 0 });
tree.set(b"Kids".to_vec(), Object::Array(vec![missing(9_000), missing(9_001), Object::Ref(leaf)]));
let tree = d.add(Object::Dict(tree));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
assert_eq!(crate::page_labels(&d).unwrap()[0], "p-1");
}
/// A range whose labels would be too long shows physical page numbers; other ranges keep
/// their labels.
#[test]
fn page_label_too_long_range_falls_back_alone() {
let mut d = doc_a();
let mut ok = Dict::new();
ok.set(b"P".to_vec(), Object::String(PdfString::text("ok-")));
ok.set(b"S".to_vec(), Object::name("D"));
let mut huge = Dict::new();
huge.set(b"S".to_vec(), Object::name("r"));
huge.set(b"St".to_vec(), Object::Int(1_025_000));
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(ok), Object::Int(1), Object::Dict(huge)]));
let tree = d.add(Object::Dict(tree));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
let labels = crate::page_labels(&d).unwrap();
assert_eq!(labels[0], "ok-1");
assert_eq!(labels[1], "2");
}
/// A document whose page tree is one flat node over `pages` leaves. The label tests below need
/// more pages than `doc_a` has.
fn flat_doc(pages: usize) -> Document {
let kids = (0..pages).map(|i| format!("{} 0 R", i + 3)).collect::<Vec<_>>().join(" ");
let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>")];
objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string()));
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
open(out)
}
/// Each label is valid (1,004 bytes), but the 5,000 of them total about 5 MB, past the old 4 MiB
/// limit on the whole sequence. Every label is readable.
#[test]
fn page_labels_past_four_mib_in_total_stay_readable() {
let mut d = flat_doc(5_000);
let mut spec = Dict::new();
spec.set(b"P".to_vec(), Object::String(PdfString::text(&"x".repeat(1000))));
spec.set(b"S".to_vec(), Object::name("D"));
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)]));
let tree = d.add(Object::Dict(tree));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
let labels = crate::page_labels(&d).unwrap();
assert_eq!(labels.len(), 5_000);
assert_eq!(labels[0], format!("{}1", "x".repeat(1000)));
assert_eq!(labels[4_999], format!("{}5000", "x".repeat(1000)));
}
/// The same 5,000 labels written by an edit: the edit is accepted and reads back.
#[test]
fn number_pages_past_four_mib_in_total_is_accepted() {
let mut d = flat_doc(5_000);
crate::number_pages(&mut d, 0, 4_999, crate::LabelStyle::Decimal, &"x".repeat(1000), 1).unwrap();
assert_eq!(crate::page_labels(&d).unwrap()[4_999], format!("{}5000", "x".repeat(1000)));
}
/// Each range keeps its own decoded prefix, so a small file sharing one prefix object asks for a
/// copy per range (about 200 MiB at the tree-work limit). Past 4 MiB of decoded prefixes in all
/// the tree is refused, and so is relabelling it, which changes nothing.
#[test]
fn page_label_prefixes_are_bounded_in_total() {
// `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style.
let shared = |ranges: i64, prefix: usize| {
let mut d = doc_a();
let prefix = d.add(Object::String(PdfString::text(&"x".repeat(prefix))));
let mut spec = Dict::new();
spec.set(b"P".to_vec(), Object::Ref(prefix));
let spec = d.add(Object::Dict(spec));
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array((0..ranges).flat_map(|i| [Object::Int(i), Object::Ref(spec)]).collect()));
let tree = d.add(Object::Dict(tree));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
d
};
let mut d = shared(5_000, 1_000);
let e = crate::page_labels(&d).unwrap_err().to_string();
assert!(e.contains("decoded prefixes exceed 4 MiB in total"), "{e}");
let before = d.modified_objects();
assert!(crate::number_pages(&mut d, 0, 1, crate::LabelStyle::Decimal, "", 1).is_err());
assert_eq!(d.modified_objects(), before, "a refused relabelling changes nothing");
// Exactly 4 MiB of prefixes is still read.
let max = pdfcraft_cos::page_labels::MAX_PREFIX_TOTAL_BYTES / 1024;
assert!(crate::page_labels(&shared(max as i64, 1024)).unwrap().iter().all(|l| l.len() == 1024));
}
#[test]
fn page_boxes_default_inherit_and_set() {
let mut doc = open(fixture());
let b = page_boxes(&doc).unwrap();
// Pages 1–2 inherit a 300×400 media box; page 3 has its own 500×500. Crop defaults to media.
assert_eq!(b[0][0], [0.0, 0.0, 300.0, 400.0]);
assert_eq!(b[2][1], [0.0, 0.0, 500.0, 500.0]);
set_page_box(&mut doc, &[0, 2], PageBox::Crop, BoxSpec::Margins([10.0, 20.0, 30.0, 40.0])).unwrap();
set_page_box(&mut doc, &[1], PageBox::Trim, BoxSpec::Rect([-50.0, 50.0, 100.0, 1000.0])).unwrap();
let doc = open(write_incremental(&doc, &SaveOptions::default()).unwrap());
let b = page_boxes(&doc).unwrap();
assert_eq!(b[0][1], [10.0, 20.0, 270.0, 360.0]);
assert_eq!(b[2][1], [10.0, 20.0, 470.0, 460.0], "margins apply to each page's own media box");
assert_eq!(b[1][3], [0.0, 50.0, 100.0, 400.0], "clipped to the media box");
assert_eq!(b[1][4], b[1][1], "art box defaults to the crop box");
// Removing goes back to the default; impossible requests change nothing.
let mut doc = doc;
set_page_box(&mut doc, &[0], PageBox::Crop, BoxSpec::Remove).unwrap();
assert_eq!(page_boxes(&doc).unwrap()[0][1], [0.0, 0.0, 300.0, 400.0]);
let before = page_boxes(&doc).unwrap();
assert!(matches!(set_page_box(&mut doc, &[0, 1], PageBox::Crop, BoxSpec::Margins([200.0, 0.0, 200.0, 0.0])), Err(OrganizeError::InvalidBox(_))));
assert!(matches!(set_page_box(&mut doc, &[0], PageBox::Media, BoxSpec::Remove), Err(OrganizeError::InvalidBox(_))));
assert_eq!(page_boxes(&doc).unwrap(), before);
assert_eq!(set_page_box(&mut doc, &[7], PageBox::Crop, BoxSpec::Remove), Err(OrganizeError::NoSuchPage(7)));
}
#[test]
fn duplicate_pages_inserts_copies_after_the_last() {
let mut doc = open(fixture());
duplicate_pages(&mut doc, &[0, 1]).unwrap();
let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]);
}
#[test]
fn replace_pages_swaps_content_but_keeps_annotations() {
// Page 2 of the target gets an annotation; replacing its content keeps it.
let mut doc = open(fixture());
let p2 = pages(&doc).unwrap()[1].obj;
let annot = doc.add(Object::Dict({
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name("Square"));
d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
d
}));
doc.update_dict(p2, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annot)]))).unwrap();
let src = open(fixture());
replace_pages(&mut doc, &[1], &src, &[2]).unwrap();
let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
assert_eq!(labels(&doc), ["Page 1", "Page 3", "Page 3"]);
let p2 = doc.get(pages(&doc).unwrap()[1].obj);
assert!(p2.as_dict().unwrap().contains(b"Annots"), "the original page's annotations stay");
let mut doc = doc;
assert!(replace_pages(&mut doc, &[0, 1], &src, &[0]).is_err(), "counts must match");
}
#[test]
fn a_page_becomes_a_form_xobject_upright() {
let src = open(fixture());
let mut dst = Document::new_empty();
// Page 1 inherits a 300 × 400 media box, rotation 90 and its font resources.
let (r, size) = crate::page_as_form(&mut dst, &src, 0).unwrap();
assert_eq!(size, (400.0, 300.0), "displayed size of a quarter-turned page");
let obj = dst.get(r);
let Object::Stream(s) = &*obj else { panic!("a stream") };
let m: Vec<f64> = s.dict.get(b"Matrix").unwrap().as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert_eq!(m, vec![0.0, -1.0, 1.0, 0.0, 0.0, 300.0]);
assert!(String::from_utf8_lossy(&s.decoded().unwrap()).contains("(Page 1) Tj"));
let res = s.dict.get(b"Resources").and_then(|r| dst.resolve(r).as_dict().cloned()).unwrap();
assert!(res.get(b"Font").is_some(), "inherited resources come along");
assert!(crate::page_as_form(&mut dst, &src, 7).is_err());
}
#[test]
fn initial_view_round_trips_and_keeps_scripts() {
use crate::view::{Layout, Magnification, Navigation};
let mut doc = open(fixture());
assert_eq!(crate::initial_view(&doc), crate::InitialView::default());
let v = crate::InitialView {
navigation: Navigation::Bookmarks,
layout: Layout::TwoUpContinuousCoverPage,
magnification: Magnification::Percent(150.0),
page: 2,
fit_window: true,
display_title: true,
hide_toolbar: true,
language: Some("fr-FR".into()),
right_to_left: true,
..Default::default()
};
crate::set_initial_view(&mut doc, &v).unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(crate::initial_view(&doc), v);
// A script opening action survives a change that needs no destination.
let mut doc = doc;
let root = doc.root().unwrap();
let mut js = pdfcraft_cos::Dict::new();
js.set(b"S".to_vec(), Object::name("JavaScript"));
js.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text("app.alert('hi')"));
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Dict(js))).unwrap();
crate::set_initial_view(&mut doc, &crate::InitialView { layout: Layout::SinglePage, ..Default::default() }).unwrap();
assert!(doc.get(root).as_dict().unwrap().get(b"OpenAction").and_then(|o| o.as_dict()).is_some_and(|d| d.name(b"S") == Some(b"JavaScript")));
assert!(crate::set_initial_view(&mut doc, &crate::InitialView { page: 9, ..Default::default() }).is_err());
}
// ── Split / Extract keep only the resources the part draws (#204) ──────────────────────────────
/// A content stream with no text operators (an image draw).
fn raw(content: &str) -> String {
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())
}
fn image(width: u32, height: u32, payload: usize) -> String {
let data = "x".repeat(payload);
format!(
"<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {payload} >>\nstream\n{data}\nendstream"
)
}
/// Document D: two pages sharing one resources dictionary with two image XObjects; each page
/// draws a different one — the shared-dictionary shape of #204.
fn doc_images() -> Document {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] /Resources 7 0 R >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(), // 4
raw("/ImA Do"), // 5
raw("/ImB Do"), // 6
"<< /XObject << /ImA 8 0 R /ImB 9 0 R >> >>".into(), // 7
image(1, 1, 1), // 8
image(1, 1, 2000), // 9
];
open(build(&b, "/Root 1 0 R"))
}
fn xobject_names(doc: &Document, page: usize) -> Vec<Vec<u8>> {
let resources = page_dict(doc, page).get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).expect("page resources");
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).expect("xobject dict");
xobjects.iter().map(|(k, _)| k.clone()).collect()
}
#[test]
fn extract_keeps_only_the_images_the_part_draws() {
let src = doc_images();
let whole = write_full(&src, &SaveOptions::default()).expect("writes").len();
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "the drawn image stays");
assert!(!names.iter().any(|n| n.as_slice() == b"ImB"), "an image no page of the part draws must go");
let part_bytes = write_full(&part, &SaveOptions::default()).expect("writes").len();
assert!(part_bytes + 1000 < whole, "the part must not carry the other page's image: {part_bytes} vs {whole}");
}
#[test]
fn extract_keeps_the_union_when_the_part_has_both_pages() {
let part = full_roundtrip(&extract_pages(&doc_images(), &[0, 1]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"));
assert!(names.iter().any(|n| n.as_slice() == b"ImB"));
}
/// Like `doc_images`, but the second page's content can't be decoded and the first page also
/// lists a font: pruning must not guess what the unreadable page draws.
fn doc_images_with(page2_content: &str, font: &str) -> Document {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] /Resources 7 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(),
raw("/ImA Do"),
page2_content.into(),
format!("<< /XObject << /ImA 8 0 R /ImB 9 0 R >> {font} >>"),
image(1, 1, 1),
image(1, 1, 2000),
];
open(build(&b, "/Root 1 0 R"))
}
#[test]
fn extract_keeps_images_an_undecodable_page_might_draw() {
let bogus = "<< /Length 7 /Filter /NoSuchFilter >>\nstream\n/ImB Do\nendstream";
let part = full_roundtrip(&extract_pages(&doc_images_with(bogus, ""), &[0, 1]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "an image the unreadable page may draw must stay");
}
#[test]
fn extract_keeps_images_type3_glyphs_may_draw() {
// A Type 3 font without its own /Resources draws through the page's: its glyphs could `Do`.
let font = "/Font << /T3 << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /CharProcs << >> /Encoding << /Differences [] >> /FirstChar 0 /LastChar 0 /Widths [0] >> >>";
let part = full_roundtrip(&extract_pages(&doc_images_with(&raw("/ImB Do"), font), &[0]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "pruning must not run through Type 3 resources");
}
/// The font has its own resources, but its XObject dictionary is also used by the page.
/// Looking only at page `Do` operators must not discard the image painted by a glyph.
fn doc_type3_shared_xobjects(font_resources: &str) -> Document {
let objects: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources 5 0 R >>".into(),
raw("q 30 0 0 30 10 10 cm /ImA Do Q BT /T3 50 Tf 50 80 Td (A) Tj ET"),
"<< /XObject 6 0 R /Font << /T3 7 0 R >> >>".into(),
"<< /ImA 9 0 R /ImB 10 0 R /Unused 11 0 R >>".into(),
format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /CharProcs << /A 8 0 R >> /Encoding << /Type /Encoding /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [1] /Resources {font_resources} >>"
),
raw("1 0 0 0 1 1 d1 /ImB Do"),
image(1, 1, 1),
image(1, 1, 1),
image(1, 1, 2000),
];
open(build(&objects, "/Root 1 0 R"))
}
#[test]
fn extract_keeps_shared_xobjects_used_by_type3_own_resources() {
for resources in ["<< /XObject 6 0 R >>", "5 0 R"] {
let src = doc_type3_shared_xobjects(resources);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "the Type 3 glyph's image must survive extraction: {resources}");
}
}
#[test]
fn extract_still_prunes_page_xobjects_when_type3_resources_are_separate() {
let src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "only the page's unused entries are removed");
let resources = part.resolve(page_dict(&part, 0).get(b"Resources").unwrap());
let fonts = part.resolve(resources.as_dict().unwrap().get(b"Font").unwrap());
let font = part.resolve(fonts.as_dict().unwrap().get(b"T3").unwrap());
let resources = part.resolve(font.as_dict().unwrap().get(b"Resources").unwrap());
let xobjects = part.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
assert!(xobjects.as_dict().unwrap().contains(b"ImB"), "the separate glyph resource survives the full rewrite");
}
fn doc_type3_glyph_form_with_shared_resources(cycle: bool) -> Document {
let mut doc = doc_type3_shared_xobjects("<< >>");
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Ref(ObjRef::new(6, 0)));
let mut form = pdfcraft_cos::Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(1), Object::Int(1)]));
form.set(b"Resources".to_vec(), Object::Dict(resources));
let form = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(form, b"/ImB Do".to_vec())));
let mut xobjects = pdfcraft_cos::Dict::new();
xobjects.set(b"Fm".to_vec(), Object::Ref(form));
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
doc.update_dict(ObjRef::new(7, 0), |font| font.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
doc.set(ObjRef::new(8, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"1 0 0 0 1 1 d1 /Fm Do".to_vec())));
if cycle {
doc.update_dict(ObjRef::new(6, 0), |xobjects| xobjects.set(b"Again".to_vec(), Object::Ref(form))).unwrap();
}
full_roundtrip(&doc)
}
#[test]
fn extract_keeps_shared_resources_reached_through_a_type3_glyph_form() {
for cycle in [false, true] {
let src = doc_type3_glyph_form_with_shared_resources(cycle);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "a glyph's nested form draws ImB; resource cycle: {cycle}");
}
}
#[test]
fn extract_keeps_an_indirect_resource_owner_shared_with_a_type3_font() {
let mut src = doc_type3_shared_xobjects("5 0 R");
let xobjects = src.get(ObjRef::new(6, 0)).as_dict().unwrap().clone();
src.update_dict(ObjRef::new(5, 0), |resources| resources.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "the resource owner itself is shared, with inline XObjects");
}
#[test]
fn extract_skips_pruning_when_direct_glyph_resource_children_exceed_the_budget() {
let mut src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
let font = src.get(ObjRef::new(7, 0));
let mut resources = font.as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone();
let mut unknown = vec![Object::Null; 20_000];
unknown.push(Object::Ref(ObjRef::new(6, 0)));
resources.set(b"Unknown".to_vec(), Object::Array(unknown));
src.update_dict(ObjRef::new(7, 0), |font| font.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert_eq!(xobject_names(&part, 0), xobject_names(&src, 0), "an incomplete glyph resource walk cannot authorize pruning any owners");
}
fn doc_type3_resources_of_undecodable_content(form: bool) -> Document {
let mut doc = doc_type3_shared_xobjects("<< /XObject 6 0 R >>");
doc.update_dict(ObjRef::new(5, 0), |resources| {
resources.remove(b"Font");
})
.unwrap();
doc.set(ObjRef::new(4, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/ImA Do".to_vec())));
let mut fonts = pdfcraft_cos::Dict::new();
fonts.set(b"T3".to_vec(), Object::Ref(ObjRef::new(7, 0)));
let mut glyph_resources = pdfcraft_cos::Dict::new();
glyph_resources.set(b"Font".to_vec(), Object::Dict(fonts));
let mut unreadable = pdfcraft_cos::Dict::new();
unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
if form {
unreadable.set(b"Type".to_vec(), Object::name("XObject"));
unreadable.set(b"Subtype".to_vec(), Object::name("Form"));
unreadable.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(200), Object::Int(200)]));
unreadable.set(b"Resources".to_vec(), Object::Dict(glyph_resources.clone()));
}
let unreadable = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(unreadable, b"BT /T3 50 Tf 50 80 Td (A) Tj ET".to_vec())));
let (contents, resources) = if form {
let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/Fm Do".to_vec())));
let mut xobjects = pdfcraft_cos::Dict::new();
xobjects.set(b"Fm".to_vec(), Object::Ref(unreadable));
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
(contents, resources)
} else {
(unreadable, glyph_resources)
};
let mut page = pdfcraft_cos::Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(ObjRef::new(2, 0)));
page.set(b"Contents".to_vec(), Object::Ref(contents));
page.set(b"Resources".to_vec(), Object::Dict(resources));
let page = doc.add(page);
doc.update_dict(ObjRef::new(2, 0), |pages| {
pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(page)]));
pages.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
full_roundtrip(&doc)
}
#[test]
fn extract_keeps_type3_glyph_resources_of_an_undecodable_page() {
let part = full_roundtrip(&extract_pages(&doc_type3_resources_of_undecodable_content(false), &[0, 1]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "an unreadable page's font shares a different page's XObject dictionary");
let contents = part.resolve(page_dict(&part, 1).get(b"Contents").unwrap());
let Object::Stream(contents) = &*contents else { panic!("content stream") };
assert_eq!(contents.dict.name(b"Filter"), Some(b"NoSuchFilter".as_slice()));
assert_eq!(contents.raw.as_ref(), b"BT /T3 50 Tf 50 80 Td (A) Tj ET");
}
#[test]
fn extract_keeps_type3_glyph_resources_of_an_undecodable_form() {
let part = full_roundtrip(&extract_pages(&doc_type3_resources_of_undecodable_content(true), &[0, 1]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "an unreadable form's font shares a different page's XObject dictionary");
}
#[test]
fn extract_still_prunes_separate_resources_when_many_pages_share_a_type3_font() {
let mut src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
src.set(ObjRef::new(4, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/ImA Do".to_vec())));
let page = src.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let mut kids = vec![Object::Ref(ObjRef::new(3, 0))];
for _ in 1..4_000 {
kids.push(Object::Ref(src.add(page.clone())));
}
src.update_dict(ObjRef::new(2, 0), |pages| {
pages.set(b"Kids".to_vec(), Object::Array(kids));
pages.set(b"Count".to_vec(), Object::Int(4_000));
})
.unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &(0..4_000).collect::<Vec<_>>()).unwrap());
assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "one shared font's direct resource graph is protected once");
}
// Both sides of the comparison go through the same pdftoppm, so the assertion is about our two
// documents rather than about Poppler's output, and no Poppler version can change the verdict.
// It skips where pdftoppm is absent and fails where PDFCRAFT_ORACLES_REQUIRED is set.
#[test]
fn extract_type3_shared_xobjects_matches_poppler_pixels() {
let pdftoppm = pdfcraft_oracle::or_skip!(pdfcraft_oracle::pdftoppm());
let dir = std::env::temp_dir().join(format!("pdfcraft-type3-extract-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fixtures = [
doc_type3_shared_xobjects("<< /XObject 6 0 R >>"),
doc_type3_shared_xobjects("5 0 R"),
doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>"),
doc_type3_glyph_form_with_shared_resources(false),
doc_type3_glyph_form_with_shared_resources(true),
];
for (i, src) in fixtures.into_iter().enumerate() {
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
let render = |doc: &Document, name: &str| {
let path = dir.join(format!("{i}-{name}.pdf"));
std::fs::write(&path, write_full(doc, &SaveOptions::default()).unwrap()).unwrap();
let output = pdftoppm.command().args(["-singlefile", "-r", "72", "-gray"]).arg(path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
output.stdout
};
let before = render(&src, "source");
let after = render(&part, "extracted");
assert!(before.starts_with(b"P5\n200 200\n255\n"));
assert!(before.iter().filter(|&&v| v < 200).count() > 2500, "the source contains a drawn glyph");
assert_eq!(after, before, "extraction must keep every rendered pixel in fixture {i}");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A page listed twice whose widget's field keeps `/Kids` as an array object of its own: the
/// second widget joins that array, and the field keeps every widget it had.
#[test]
fn a_repeated_widget_joins_an_indirect_kids_array() {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R] >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 10 90 30] /P 3 0 R >>".into(), // 5
"<< /FT /Tx /T (name) /V (Ada) /Kids 7 0 R >>".into(), // 6
"[5 0 R 8 0 R]".into(), // 7
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 40 90 60] /P 4 0 R >>".into(), // 8
];
let src = open(build(&b, "/Root 1 0 R"));
let out = full_roundtrip(&extract_pages(&src, &[0, 0, 1]).unwrap());
let form = out.resolve(catalog(&out).get(b"AcroForm").unwrap());
let fields = form.as_dict().unwrap().get(b"Fields").and_then(Object::as_array).cloned().unwrap();
assert_eq!(fields.len(), 1, "one field");
let field = out.resolve(&fields[0]).as_dict().cloned().unwrap();
let kids = out.resolve(field.get(b"Kids").unwrap()).as_array().cloned().unwrap();
assert_eq!(kids.len(), 3, "both original widgets and the repeat's own: {kids:?}");
}
/// A one-page document whose form holds one text field named `name` (a widget that is its own
/// field, the smallest form a page can carry).
fn doc_with_field(name: &str) -> Document {
doc_with_fields(&[name])
}
/// A one-page document whose form holds one widget field per name, in order.
fn doc_with_fields(names: &[&str]) -> Document {
let first = 5; // object number of the first field
let refs: Vec<String> = names.iter().enumerate().map(|(i, _)| format!("{} 0 R", first + i)).collect();
let mut b: Vec<String> = vec![
format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{}] >> >>", refs.join(" ")), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs.join(" ")), // 3
"<< /Title (form) >>".into(), // 4
];
for name in names.iter() {
b.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>")); // 5 + i
}
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
}
/// The /T names of a document's /AcroForm /Fields, in order.
fn form_field_names(doc: &Document) -> Vec<String> {
let Some(form) = catalog(doc).get(b"AcroForm").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else {
return Vec::new();
};
form.get(b"Fields")
.map(|f| doc.resolve(f))
.and_then(|f| f.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|f| {
doc.resolve(f)
.as_dict()
.and_then(|d| d.get(b"T").cloned())
.map(|t| doc.resolve(&t))
.and_then(|t| t.as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned()))
.unwrap_or_default()
})
.collect()
}
/// Two documents whose forms both name a field Text1 cannot just be put together: fields of
/// one fully-qualified name are one field, so the values would blur into it. The incoming
/// field is renamed to the first free variant, Text1-1.
#[test]
fn import_renames_a_colliding_field_name() {
let mut dst = doc_with_field("Text1");
let src = doc_with_field("Text1");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1"]);
}
/// The rename takes the first free variant: with Text1 and Text1-1 already in the form, a
/// colliding Text1 becomes Text1-2 (not Text1-1, which would collide again).
#[test]
fn import_renames_to_the_first_free_variant() {
let mut dst = doc_with_fields(&["Text1", "Text1-1"]);
let src = doc_with_field("Text1");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1", "Text1-2"]);
}
/// A one-page document whose form holds one text field whose `/T` is a UTF-16BE hex string —
/// the standard encoding for non-ASCII names — decoding to `name`.
fn doc_with_field_utf16(name: &str) -> Document {
let t: String = std::iter::once(0xFEFFu16).chain(name.encode_utf16()).map(|u| format!("{u:04X}")).collect();
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3
"<< /Title (form) >>".into(), // 4
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T <{t}> /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 5
];
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
}
/// The decoded `/T` names of a document's /AcroForm /Fields, in order (whatever encoding each
/// was written in).
fn decoded_form_field_names(doc: &Document) -> Vec<String> {
let Some(form) = catalog(doc).get(b"AcroForm").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else {
return Vec::new();
};
form.get(b"Fields")
.map(|f| doc.resolve(f))
.and_then(|f| f.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|f| {
doc.resolve(f)
.as_dict()
.and_then(|d| d.get(b"T").cloned())
.map(|t| doc.resolve(&t))
.and_then(|t| t.as_string().map(|s| s.to_text()))
.unwrap_or_default()
})
.collect()
}
/// A field's name is its text, not the bytes it was written in: a destination field named in
/// PDFDocEncoding and an incoming UTF-16BE field that decode to the same name are one name,
/// so the incoming field is renamed out of the way (not silently blurred into the other).
#[test]
fn import_renames_by_decoded_name_whatever_its_encoding() {
let mut dst = doc_with_field("Text1");
let src = doc_with_field_utf16("Text1");
let out = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
assert_eq!(decoded_form_field_names(&dst), ["Text1", "Text1-1"]);
}
/// Renaming a non-ASCII name keeps it readable: the new `/T` stores the new name as text
/// (UTF-16BE again), never as mojibake of the old bytes.
#[test]
fn a_renamed_non_ascii_name_stays_readable() {
let mut dst = doc_with_field_utf16("Förm");
let src = doc_with_field_utf16("Förm");
let out = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Förm".to_string(), "Förm-1".to_string())]);
assert_eq!(decoded_form_field_names(&dst), ["Förm", "Förm-1"]);
}
/// A one-page document whose form holds one field tree: a root field `root` with one child
/// field `kid` (a merged field-and-widget, as page tools make them).
fn doc_with_tree(root: &str, kid: &str) -> Document {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), // 3
"<< /Title (form) >>".into(), // 4
format!("<< /FT /Tx /T ({root}) /Kids [6 0 R] >>"), // 5
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({kid}) /Parent 5 0 R /Rect [60 60 200 80] /P 3 0 R >>"), // 6
];
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
}
/// A field's name is its place in the tree, not just its own `/T`: an incoming field called
/// x.b collides with the child b of the destination's field x, and is renamed out of the way.
#[test]
fn import_renames_a_nested_field_whose_full_name_collides() {
let mut dst = doc_with_tree("x", "b");
let src = doc_with_field("x.b");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["x", "x.b-1"]);
}
/// The collision may sit below the top level: the destination holds a field literally named
/// a.b, and the incoming tree a ▸ b walks into it. Only the colliding child is renamed — the
/// incoming root a never collided, so it keeps its name.
#[test]
fn import_renames_the_colliding_child_not_the_whole_tree() {
let mut dst = doc_with_field("a.b");
let src = doc_with_tree("a", "b");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["a.b", "a"], "the incoming root keeps its name");
// The incoming tree is the second /Fields entry; its child was renamed.
let fields = catalog(&dst)
.get(b"AcroForm")
.map(|f| dst.resolve(f))
.and_then(|f| f.as_dict().cloned())
.unwrap()
.get(b"Fields")
.map(|l| dst.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap();
let kid = dst.resolve(&fields[1]).as_dict().unwrap().get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap();
let kid_t = dst.resolve(&kid[0]).as_dict().unwrap().get(b"T").cloned().unwrap();
let resolved = dst.resolve(&kid_t);
let name = resolved.as_string().unwrap();
assert_eq!(String::from_utf8_lossy(&name.bytes), "b-1", "the child's /T is renamed, the FQN follows the root");
}
/// A field name is taken even when it hangs only off a page's annotations: a widget that is
/// its own field, never registered in `/AcroForm /Fields`, still names a field, and an import
/// must not silently blur into it.
#[test]
fn import_renames_away_from_an_orphaned_widgets_name() {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 (no /AcroForm)
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3
"<< /Title (orphan) >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (Text1) /V (x) /Rect [60 60 200 80] /P 3 0 R >>".into(), // 5
];
let mut dst = open(build(&b, "/Root 1 0 R /Info 4 0 R"));
let src = doc_with_field("Text1");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Text1-1"], "the orphaned Text1 must count as taken");
}
/// Callers can keep the older behaviour — an incoming field of a taken name becomes one field
/// with the one already there — by asking to merge. Nothing is renamed.
#[test]
fn import_can_merge_into_same_named_fields_instead() {
let mut dst = doc_with_field("Text1");
let src = doc_with_field("Text1");
let outcome = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Merge).unwrap();
assert_eq!(form_field_names(&dst), ["Text1", "Text1"]);
assert!(outcome.renamed_fields.is_empty(), "merge renames nothing");
}
/// Duplicating a page copies the document's own fields: the copies keep their names (one field
/// shown twice, as before field-conflict renaming existed) instead of silently becoming Text1-1.
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut doc = doc_with_field("Text1");
crate::duplicate_pages(&mut doc, &[0]).unwrap();
assert_eq!(crate::page_count(&doc).unwrap(), 2);
assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed");
}
/// Replace Pages keeps the field names too.
#[test]
fn replacing_a_page_renames_no_fields() {
let mut doc = doc_with_field("Text1");
crate::replace_pages(&mut doc, &[0], &doc_with_field("Text1"), &[0]).unwrap();
assert!(form_field_names(&doc).iter().all(|n| n == "Text1"), "{:?}", form_field_names(&doc));
}
/// Extract and Split copy one document: two same-named field roots (malformed but common) stay
/// one field by name rather than being split apart as Name and Name-1.
#[test]
fn extract_and_split_keep_same_named_fields_together() {
let src = doc_with_fields(&["Text1", "Text1"]);
let out = extract_pages(&src, &[0]).unwrap();
assert_eq!(form_field_names(&out), ["Text1", "Text1"]);
for part in split(&src, &SplitBy::PageCount(1)).unwrap() {
assert_eq!(form_field_names(&part), ["Text1", "Text1"]);
}
}
/// An import says what it renamed, so callers can tell the user — and their scripts — that a
/// field they knew by one name now goes by another.
#[test]
fn import_reports_the_fields_it_renamed() {
let mut dst = doc_with_fields(&["Text1", "Text1-1"]);
let src = doc_with_field("Text1");
let outcome = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(outcome.renamed_fields, [("Text1".to_string(), "Text1-2".to_string())]);
assert_eq!(outcome.pages.len(), 1);
}
/// A renamed tree survives a save and reopen: the parent keeps its new name, the child still
/// hangs under it with its own name unchanged, and an independent parser reads the same form.
#[test]
fn a_renamed_field_tree_survives_a_save_and_reopen() {
let mut dst = doc_with_tree("a", "b");
let src = doc_with_tree("a", "b");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
let out = full_roundtrip(&dst);
assert_eq!(form_field_names(&out), ["a", "a-1"], "the incoming root was renamed");
let fields = catalog(&out)
.get(b"AcroForm")
.map(|f| out.resolve(f))
.and_then(|f| f.as_dict().cloned())
.unwrap()
.get(b"Fields")
.map(|l| out.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap();
let root = out.resolve(&fields[1]).as_dict().cloned().unwrap();
let kids = root.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap();
let kid = out.resolve(&kids[0]).as_dict().cloned().unwrap();
assert_eq!(kid.reference(b"Parent"), fields[1].as_ref(), "the child still hangs under the renamed root");
let kid_t = kid.get(b"T").cloned().unwrap();
let resolved = out.resolve(&kid_t);
assert_eq!(String::from_utf8_lossy(&resolved.as_string().unwrap().bytes), "b", "the child keeps its own name");
}
/// The pre-scan says what an import of `src` into `dst` would rename, without changing
/// anything — the Combine tab shows it before anything is combined.
#[test]
fn field_name_conflicts_previews_what_an_import_renames() {
let dst = doc_with_fields(&["Text1", "Text1-1"]);
let src = doc_with_field("Text1");
assert_eq!(field_name_conflicts(&dst, &src), [("Text1".to_string(), "Text1-2".to_string())]);
let apart = doc_with_field("Signature1");
assert!(field_name_conflicts(&dst, &apart).is_empty(), "nothing collides, nothing to preview");
}
/// The collision is not only with the destination: two incoming fields of one name would blur
/// into each other just the same, so the second is renamed too.
#[test]
fn import_renames_a_field_colliding_with_another_incoming_field() {
let mut dst = doc_with_field("Other");
let src = doc_with_fields(&["Text1", "Text1"]);
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Other", "Text1", "Text1-1"]);
}
/// A page listed twice shows its widget on both copies — also when the field was renamed: both
/// pages' widgets belong to the one renamed field, and no second Text1 appears.
#[test]
fn a_repeated_page_joins_the_renamed_field() {
let mut dst = doc_with_field("Text1");
let src = doc_with_field("Text1");
import_pages_with(&mut dst, &src, &[0, 0], 2, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1"], "one field, renamed once");
let fields = catalog(&dst)
.get(b"AcroForm")
.map(|f| dst.resolve(f))
.and_then(|f| f.as_dict().cloned())
.unwrap()
.get(b"Fields")
.map(|l| dst.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap();
let renamed = fields[1].clone();
for page in [1, 2] {
let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
assert!(list.contains(&renamed), "page {page}'s widget belongs to the renamed field");
}
}
/// Combining forms keeps same-named fields separate — the second file's colliding field is
/// renamed — and the combine says what it renamed.
#[test]
fn combine_renames_colliding_field_names_and_reports_them() {
let a = doc_with_field("Text1");
let b = doc_with_field("Text1");
let out = combine_selected_with(&[("a", &a as &Document, None), ("b", &b, None)], FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
assert_eq!(form_field_names(&out.doc), ["Text1", "Text1-1"]);
let merged = combine_selected_with(&[("a", &a, None), ("b", &b, None)], FieldConflicts::Merge).unwrap();
assert!(merged.renamed_fields.is_empty());
assert_eq!(form_field_names(&merged.doc), ["Text1", "Text1"], "merge keeps the older behaviour");
}
/// The grouped combine (a file's pages split around others') renames colliding names between
/// files just the same, while a file combined with itself once keeps its fields whole.
#[test]
fn combine_grouped_renames_colliding_field_names() {
let a = doc_with_field("Text1");
let b = doc_with_field("Text1");
let runs: Vec<Run<'_>> = vec![(0, "a", &a, None), (1, "b", &b, None)];
let out = combine_grouped_with(&runs, FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
assert_eq!(form_field_names(&out.doc), ["Text1", "Text1-1"]);
let split: Vec<Run<'_>> = vec![(0, "a", &a, Some(&[0])), (0, "a", &a, None), (1, "b", &b, None)];
let once = combine_grouped_with(&split, FieldConflicts::Rename).unwrap();
assert_eq!(
once.renamed_fields,
[("Text1".to_string(), "Text1-1".to_string())],
"the grouped file is copied once; only b's colliding field renames"
);
}
/// The pre-flight scan over a whole combine list: what each file's fields would be renamed,
/// in order, exactly as the combine would rename them — the Combine tab shows this before
/// anything is combined.
#[test]
fn combine_field_conflicts_previews_each_file_in_order() {
let a = doc_with_field("Text1");
let b = doc_with_field("Text1");
let c = doc_with_fields(&["Text1", "Other"]);
let preview = combine_field_conflicts(&[&a, &b, &c]);
assert_eq!(preview[0], [], "the first file collides with nothing");
assert_eq!(preview[1], [("Text1".to_string(), "Text1-1".to_string())]);
assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename");
}
/// #1800: thousands of shallow objects, each referring to the next, used to recurse once per
/// object and exhaust the stack. The copy is refused instead, and the destination is as it was;
/// a chain within the budget and a short cycle copy as before.
#[test]
fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alone() {
fn chained(tail: &[String]) -> Document {
let mut b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Foo 6 0 R >>".into(),
body("C1"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
];
b.extend_from_slice(tail);
open(build(&b, "/Root 1 0 R"))
}
fn chain(n: usize) -> Document {
let mut tail: Vec<String> = (0..n).map(|i| format!("<< /Next {} 0 R >>", 7 + i)).collect();
tail.push("<< /End true >>".into());
chained(&tail)
}
// On a thread with less stack than any platform's main thread, so the budget is proven.
let run = std::thread::Builder::new()
.stack_size(512 << 10)
.spawn(|| {
let mut dst = open(fixture());
let before = (labels(&dst), dst.object_numbers());
let err = import_pages(&mut dst, &chain(20_000), &[0], 0).unwrap_err();
assert!(matches!(&err, OrganizeError::Invalid(m) if m.contains("levels deep")), "{err:?}");
assert_eq!((labels(&dst), dst.object_numbers()), before, "the destination is as it was");
import_pages(&mut dst, &chain(100), &[0], 0).unwrap();
let cycle = chained(&["<< /Next 7 0 R >>".into(), "<< /Next 6 0 R >>".into()]);
import_pages(&mut dst, &cycle, &[0], 0).unwrap();
assert_eq!(labels(&dst)[..3], ["C1", "C1", "Page 1"]);
})
.unwrap()
.join();
assert!(run.is_ok(), "the copy must not overflow the stack");
}
#[test]
fn rotate_repeated_indices_accumulate_from_the_current_page_value() {
for level in 0..3 {
for indices in [vec![0, 0], vec![0, 1, 0]] {
let mut doc = open(fixture());
let rotation = doc.add(Object::Int(90));
let page = pages(&doc).unwrap()[0].obj;
let target = match level {
0 => page,
1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(),
_ => pages_root(&doc).unwrap(),
};
doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap();
let mut doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap();
let original_labels = labels(&doc);
rotate_pages(&mut doc, &indices, 90).unwrap();
assert_eq!(page_rotation(&doc, 0).unwrap(), 270, "level {level}, indices {indices:?}");
assert_eq!(*doc.get(rotation), Object::Int(90), "shared source angle is unchanged");
for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] {
let reopened = open(saved.clone());
assert_eq!(page_rotation(&reopened, 0).unwrap(), 270);
assert_eq!(page_rotation(&reopened, 1).unwrap(), if indices.len() == 3 { 180 } else { 90 });
assert_eq!(page_rotation(&reopened, 2).unwrap(), 90);
assert_eq!(labels(&reopened), original_labels);
let independent = hayro_syntax::Pdf::new(saved).unwrap();
assert_eq!(independent.pages()[0].render_dimensions(), (400.0, 300.0));
}
}
}
}