Files
pdfcraft/crates/cos/src/document/tests.rs
T
bflatastic 9e2c8130ad Merge #6561: M1: a file whose last update has no /Prev opens with all its pages and a repair note (core.repair-broken-files)
# Conflicts:
#	parity/acrobat-features.toml
#	vendor/README.md
#	vendor/hayro-syntax/src/pdf.rs
2026-11-09 08:11:01 +07:00

2285 lines
124 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 super::*;
std::thread_local! {
/// How many times `Document::scan_for` read the file on this thread.
pub(super) static FILE_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// How many times `Document::objstm` decoded an object stream on this thread.
pub(super) static OBJSTM_DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A scan that doesn't find an object isn't made again: each reads the whole file, and a file can
/// refer to such an object from every object it has.
#[test]
fn what_a_scan_of_the_file_did_not_find_is_remembered() {
let body = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n";
let mut out = body.to_vec();
let xref = out.len();
out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
out.extend_from_slice(format!("{:010} 00000 n \n", 9).as_bytes());
out.extend_from_slice(format!("{:010} 00000 n \n", body.iter().position(|b| *b == b'\n').unwrap() + 49).as_bytes());
out.extend_from_slice(b"0000000001 00000 n \n");
out.extend_from_slice(format!("trailer << /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
let doc = Document::open(Arc::new(out)).unwrap();
FILE_SCANS.with(|scans| scans.set(0));
for _ in 0..5 {
assert!(doc.try_get(3).is_err());
}
assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1);
let clone = doc.clone();
assert!(clone.try_get(3).is_err());
assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "a clone has the same file");
}
/// An object can be nothing but a reference to another. A chain of them is followed, and one that
/// goes round ends in what isn't a dictionary or a name, without hanging.
#[test]
fn references_to_references_are_followed() {
let mut doc = Document::new_empty();
let name = doc.add(Object::name("Target"));
let one = doc.add(Object::Ref(name));
let two = doc.add(Object::Ref(one));
assert_eq!(doc.resolve(&Object::Ref(two)).as_name(), Some(b"Target".as_slice()));
let a = doc.add(Object::Null);
let b = doc.add(Object::Ref(a));
doc.set(a, Object::Ref(b));
assert_eq!(doc.resolve(&Object::Ref(a)).as_name(), None);
}
/// An object stream's `/DecodeParms` can be given by reference: without them a predictor isn't
/// undone, so its members were lost.
#[test]
fn an_object_stream_with_parameters_by_reference_is_read() {
let members = b"3 0 << /Hidden (yes) >>";
// PNG Sub, one row.
let mut rows = vec![1u8];
let mut before = 0u8;
for b in members {
rows.push(b.wrapping_sub(before));
before = *b;
}
let coded = pdfcraft_filters::encode_flate(&rows);
let mut out = b"%PDF-1.5\n".to_vec();
let mut offs = [0usize; 7];
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
offs[n] = out.len();
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms 6 0 R /Length {} >>\nstream\n", coded.len()).into_bytes();
stm.extend_from_slice(&coded);
stm.extend_from_slice(b"\nendstream");
obj(&mut out, 4, &stm);
let data = [2u8, 0, 4, 0];
let mut xs = format!("<< /Type /XRef /Size 7 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes();
xs.extend_from_slice(&data);
xs.extend_from_slice(b"\nendstream");
obj(&mut out, 5, &xs);
obj(&mut out, 6, format!("<< /Predictor 11 /Columns {} >>", members.len()).as_bytes());
let table = out.len();
out.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n");
for (n, off) in offs.iter().enumerate().skip(1) {
let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") };
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(format!("trailer << /Size 7 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
let doc = Document::open(Arc::new(out)).unwrap();
let hidden = doc.get(ObjRef::new(3, 0));
assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden")).and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"yes".to_vec()));
}
/// Each entry of an `/Encrypt` dictionary, and of a crypt filter in it, reads the same by reference.
/// Read as absent, `/StmF` or `/CFM` was `/Identity`, so a whole file's streams stayed ciphertext.
#[test]
fn encrypt_dictionary_entries_read_the_same_by_reference() {
let string = |s: &str| Object::String(crate::PdfString::text(s));
let mut filter = Dict::new();
filter.set(b"CFM".to_vec(), Object::name("AESV2"));
filter.set(b"Length".to_vec(), Object::Int(5));
let cf = |filter: Dict| {
let mut cf = Dict::new();
cf.set(b"StdCF".to_vec(), Object::Dict(filter));
cf.set(b"Other".to_vec(), Object::Dict(Dict::new()));
Object::Dict(cf)
};
let mut d = Dict::new();
let entries = [
("Filter", Object::name("Standard")),
("V", Object::Int(4)),
("R", Object::Int(4)),
("Length", Object::Int(128)),
("P", Object::Int(-44)),
("O", string("owner")),
("U", string("user")),
("EncryptMetadata", Object::Bool(false)),
("CF", cf(filter.clone())),
("StmF", Object::name("StdCF")),
("StrF", Object::name("Other")),
("EFF", Object::name("StdCF")),
];
for (key, value) in &entries {
d.set(key.as_bytes().to_vec(), value.clone());
}
let mut doc = Document::new_empty();
let direct = crate::security::encrypt_dict(&doc, &d);
assert_eq!((direct.stm_f.as_slice(), direct.str_f.as_slice(), direct.ef_f.as_slice()), (&b"StdCF"[..], &b"Other"[..], &b"StdCF"[..]));
assert_eq!(direct.length_bits, 40, "the filter's own length, in bytes");
assert!(!direct.encrypt_metadata);
assert_eq!(direct.crypt_filters.iter().filter(|(_, m)| *m == pdfcraft_crypt::Method::Aes128).count(), 1);
for (key, value) in &entries {
let mut one = d.clone();
let r = doc.add(value.clone());
one.set(key.as_bytes().to_vec(), Object::Ref(r));
assert_eq!(crate::security::encrypt_dict(&doc, &one), direct, "/{key}");
}
for key in ["CFM", "Length"] {
let mut by_reference = filter.clone();
let r = doc.add(filter.get(key.as_bytes()).unwrap().clone());
by_reference.set(key.as_bytes().to_vec(), Object::Ref(r));
let mut one = d.clone();
one.set(b"CF".to_vec(), cf(by_reference));
assert_eq!(crate::security::encrypt_dict(&doc, &one), direct, "/{key} in the filter");
}
}
/// /DecodeParms given by reference, whole, as array entries or as the numbers inside, are read
/// once made direct: here a Flate predictor, which the stream alone doesn't see. Only the entries the filters use are
/// resolved, and nothing is for a chain longer than a decoder takes.
#[test]
fn decode_parms_given_by_reference_are_read_once_direct() {
let rows = [1u8, 2, 3, 4, 5, 6, 7, 8];
let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 4, early_change: 1 };
let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &params, &rows).unwrap();
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(12));
parms.set(b"Columns".to_vec(), Object::Int(4));
let mut doc = Document::new_empty();
let r = doc.add(Object::Dict(parms.clone()));
let array = doc.add(Object::Array(vec![Object::Ref(r)]));
let stream = |filters: usize, p: Object| {
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"); filters]));
d.set(b"DecodeParms".to_vec(), p);
Stream::from_raw(d, coded.clone())
};
for p in [Object::Ref(r), Object::Array(vec![Object::Ref(r)]), Object::Ref(array)] {
let s = stream(1, p.clone());
assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is");
assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}");
}
// A number inside given by reference, which `Dict::int` doesn't follow: in the dictionary
// itself, and in an array's entry.
let twelve = doc.add(Object::Int(12));
let mut inner = parms.clone();
inner.set(b"Predictor".to_vec(), Object::Ref(twelve));
for p in [Object::Dict(inner.clone()), Object::Array(vec![Object::Dict(inner)])] {
let s = stream(1, p.clone());
assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is");
assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}");
}
// Only numbers and flags are copied: a key naming a big array, or an entry that is no
// dictionary, is left out (a file could name one big object from every key and filter).
let big = doc.add(Object::Array(vec![Object::Int(1); 100_000]));
let mut hostile = Dict::new();
hostile.set(b"Predictor".to_vec(), Object::Ref(big));
hostile.set(b"Columns".to_vec(), Object::Ref(big));
hostile.set(b"Colors".to_vec(), Object::Ref(twelve));
let entry = doc.add(Object::Dict(hostile));
let two = stream(2, Object::Array(vec![Object::Ref(entry), Object::Ref(big)]));
let made = doc.with_direct_parms(&two);
let made = made.dict.get(b"DecodeParms").and_then(Object::as_array).unwrap();
let first = made[0].as_dict().unwrap();
assert_eq!((first.contains(b"Predictor"), first.contains(b"Columns"), first.int(b"Colors")), (false, false, Some(12)));
assert!(matches!(made[1], Object::Null), "{:?}", made[1]);
let direct = stream(1, Object::Dict(parms));
assert!(matches!(doc.with_direct_parms(&direct), Cow::Borrowed(_)), "nothing to resolve");
assert_eq!(direct.decoded().unwrap(), rows);
// Entries past the filters' are left out, unresolved; so is everything past the chain limit.
let many = stream(1, Object::Array(vec![Object::Ref(r); 1000]));
let made = doc.with_direct_parms(&many);
assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(1));
let long = stream(pdfcraft_filters::MAX_CHAIN + 1, Object::Array(vec![Object::Ref(r); 9]));
assert!(matches!(doc.with_direct_parms(&long), Cow::Borrowed(_)));
// A chain as long as a decoder takes is resolved, and a `/Crypt` in it doesn't count, as
// the decoders leave it out: ASCIIHex as often as the limit allows, then the predictor's
// Flate, its parameters by reference.
let hex = |b: &[u8]| b.iter().flat_map(|x| format!("{x:02X}").into_bytes()).chain(*b">").collect::<Vec<u8>>();
let wrapped = (1..pdfcraft_filters::MAX_CHAIN).fold(coded.clone(), |data, _| hex(&data));
for crypt in [false, true] {
let mut names = vec![Object::name("ASCIIHexDecode"); pdfcraft_filters::MAX_CHAIN - 1];
names.push(Object::name("FlateDecode"));
if crypt {
names.insert(0, Object::name("Crypt"));
}
let mut entries = vec![Object::Null; names.len() - 1];
entries.push(Object::Ref(r));
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::Array(names));
d.set(b"DecodeParms".to_vec(), Object::Array(entries));
let s = Stream::from_raw(d, wrapped.clone());
assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "with /Crypt: {crypt}");
}
}
/// A CCITT fax image reads through its /DecodeParms, given directly, in an array or by
/// reference (its numbers too), with /BlackIs1, and behind other filters; one whose /Columns
/// aren't its /Width is refused, /Height rows are decoded whatever /Rows says, and other images
/// decode through their filters.
#[test]
fn fax_images_decode_with_their_parameters() {
// 10 × 3: the first five samples of each row white, the rest black.
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_g4(&bits, 10, 3).unwrap();
let mut doc = Document::new_empty();
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(10));
let indirect = doc.add(Object::Dict(parms.clone()));
let image = |doc: &Document, parms: Option<Object>, width: i64, height: i64| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(width));
d.set(b"Height".to_vec(), Object::Int(height));
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
if let Some(p) = parms {
d.set(b"DecodeParms".to_vec(), p);
}
doc.decoded_image(&Stream::from_raw(d, coded.clone()))
};
for p in [
Object::Dict(parms.clone()),
Object::Array(vec![Object::Dict(parms.clone())]),
Object::Ref(indirect),
Object::Array(vec![Object::Ref(indirect)]),
] {
assert_eq!(image(&doc, Some(p.clone()), 10, 3).unwrap(), bits, "{p:?}");
}
let mut inverted = parms.clone();
inverted.set(b"BlackIs1".to_vec(), Object::Bool(true));
assert_eq!(
image(&doc, Some(Object::Dict(inverted)), 10, 3).unwrap(),
[0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000]
);
assert_eq!(image(&doc, Some(Object::Dict(parms.clone())), 10, 2).unwrap(), bits[..4], "at most /Height rows");
// Within a bound: no more rows than it holds.
let mut bounded = Dict::new();
bounded.set(b"Width".to_vec(), Object::Int(10));
bounded.set(b"Height".to_vec(), Object::Int(3));
bounded.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
bounded.set(b"DecodeParms".to_vec(), Object::Dict(parms.clone()));
assert_eq!(doc.decoded_image_within(&Stream::from_raw(bounded, coded.clone()), 5).unwrap(), bits[..4], "two rows of two bytes in five");
let mut by_reference = Dict::new();
by_reference.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1))));
by_reference.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(10))));
assert_eq!(image(&doc, Some(Object::Dict(by_reference)), 10, 3).unwrap(), bits, "/K and /Columns by reference");
let mut understated = parms.clone();
understated.set(b"Rows".to_vec(), Object::Int(1));
assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says");
// More /Rows than /Height (PDFBOX-771: 1,084,975,104), as the renderer reads it too.
let mut overstated = parms.clone();
overstated.set(b"Rows".to_vec(), Object::Int(1_000_000_000));
assert_eq!(image(&doc, Some(Object::Dict(overstated)), 10, 3).unwrap(), bits, "/Height rows, not /Rows past it");
// The parameters as the file gives them, /Rows included, and Table 11's defaults.
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
d.set(b"DecodeParms".to_vec(), Object::Dict(understated));
let p = doc.fax_params(&Stream::from_raw(d.clone(), Vec::new())).unwrap();
assert_eq!((p.k, p.columns, p.rows, p.end_of_block, p.black_is_1), (-1, 10, 1, true, false));
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
assert!(doc.fax_params(&Stream::from_raw(d, Vec::new())).is_none(), "not a fax image");
assert!(matches!(image(&doc, Some(Object::Dict(parms.clone())), 12, 3), Err(CosError::Filter(_))), "/Columns other than /Width");
assert!(matches!(image(&doc, None, 10, 3), Err(CosError::Filter(_))), "the default 1728 /Columns");
let mut plain = Dict::new();
plain.set(b"Filter".to_vec(), Object::name("FlateDecode"));
assert_eq!(doc.decoded_image(&Stream::from_raw(plain, pdfcraft_filters::encode_flate(&bits))).unwrap(), bits);
// Behind other filters, its parameters at its own place in the array (/Crypt counts).
let behind = |filters: &[Object], parms: &[Object], data: Vec<u8>| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::Array(filters.to_vec()));
d.set(b"DecodeParms".to_vec(), Object::Array(parms.to_vec()));
doc.decoded_image(&Stream::from_raw(d, data))
};
let (fax, null, own) = (Object::name("CCITTFaxDecode"), Object::Null, Object::Dict(parms.clone()));
let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::<String>().into_bytes();
let flated = pdfcraft_filters::encode_flate(&coded);
let ahx = [Object::name("AHx"), fax.clone()];
assert_eq!(behind(&ahx, &[null.clone(), Object::Ref(indirect)], hex.clone()).unwrap(), bits, "behind ASCIIHexDecode");
assert_eq!(
behind(&[Object::name("FlateDecode"), Object::name("CCF")], &[null.clone(), own.clone()], flated.clone()).unwrap(),
bits,
"behind Flate"
);
assert_eq!(behind(&[Object::name("Crypt"), fax.clone()], &[null.clone(), own.clone()], coded.clone()).unwrap(), bits, "after /Crypt");
// A /Crypt belongs first (§7.4.10); one after the fax filter leaves it its own entry.
assert_eq!(behind(&[fax.clone(), Object::name("Crypt")], &[own.clone(), null.clone()], coded.clone()).unwrap(), bits, "before /Crypt");
assert!(behind(&ahx, &[own.clone(), null.clone()], hex).is_err(), "the first entry is the other filter's");
assert!(behind(&[fax.clone(), Object::name("FlateDecode")], &[own.clone(), null.clone()], flated).is_err(), "CCITT, then Flate");
assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name");
}
/// Images that share a `/JBIG2Globals` stream of the file decode it once a document: it is kept
/// for the next, in every clone. A globals stream edited since is read afresh (the kept one is the
/// file's), and objects added in memory aren't kept.
#[test]
fn shared_jbig2_globals_are_read_once() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let mut doc = Document::new_empty();
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
let image = |doc: &mut Document| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))
};
let (a, b) = (image(&mut doc), image(&mut doc));
let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
};
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "an object added in memory isn't kept");
// The same objects read from a file, the catalog holding on to the images (a full save may
// number them anew).
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(vec![Object::Ref(a), Object::Ref(b)]))).unwrap();
let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
let mut doc = Document::open(Arc::new(file)).unwrap();
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
let (a, b) = (images[0].as_ref().unwrap(), images[1].as_ref().unwrap());
let parms = stream(&doc, a).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().clone();
let globals = parms.reference(b"JBIG2Globals").unwrap();
let clone = doc.clone();
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
assert_eq!(doc.jbig2_globals.lock().unwrap().len(), 1, "kept");
let kept = doc.jbig2_globals.lock().unwrap()[&globals.num].clone();
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone's other image");
let shared = clone.jbig2_globals.lock().unwrap()[&globals.num].clone();
assert!(Arc::ptr_eq(&kept, &shared), "the clone uses the one kept, not a second decode");
doc.set(globals, Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec())));
assert!(doc.decoded_image(&stream(&doc, b)).is_err(), "the edited globals are read afresh");
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone keeps the file's");
}
/// A JBIG2 image's size is read as given directly, as every caller reads it first, and an image
/// with neither is an error, not an image of no rows.
#[test]
fn a_jbig2_image_needs_its_size_given_directly() {
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF, 0xFF], 8, 2).unwrap();
let mut doc = Document::new_empty();
let size = doc.add(Object::Int(2));
let image = |width: Object, height: Object| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), width);
d.set(b"Height".to_vec(), height);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
doc.decoded_image(&Stream::from_raw(d, coded.clone()))
};
assert!(image(Object::Int(8), Object::Int(2)).is_ok());
assert!(image(Object::Ref(size), Object::Int(2)).is_err(), "/Width by reference");
assert!(image(Object::Int(8), Object::Ref(size)).is_err(), "/Height by reference");
}
/// A document that kept globals of 64 MiB keeps no more, and the images past that still read:
/// three streams of 22 MiB, shared by three images, are two kept and one decoded each time.
#[test]
fn kept_jbig2_globals_are_bounded_in_total() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let length = 22 * 1024 * 1024;
let mut skipped = vec![0, 0, 0, 0, 0x3E, 0, 0];
skipped.extend_from_slice(&u32::try_from(4 + length).unwrap().to_be_bytes());
skipped.extend_from_slice(&[0, 0, 0, 1]);
skipped.resize(skipped.len() + length, 0);
let mut doc = Document::new_empty();
let mut images = Vec::new();
for _ in 0..3 {
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
images.push(Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))));
}
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(images))).unwrap();
let doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
for image in &images {
let Object::Stream(s) = &*doc.get(image.as_ref().unwrap()) else { panic!("not a stream") };
assert_eq!(doc.decoded_image(s).unwrap(), bits, "every image reads");
}
let kept = doc.jbig2_globals.lock().unwrap();
assert_eq!(kept.len(), 2, "two fit in 64 MiB, a third doesn't");
assert!(kept.values().all(|g| g.capacity() == g.len()), "what is kept holds no more than it counts");
}
/// Globals whose `/DecodeParms` are given by reference aren't kept: that's another object, so
/// editing it changes what they decode to while the stream itself, the key kept by, stays the same.
#[test]
fn jbig2_globals_with_parameters_by_reference_are_read_afresh() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 15, early_change: 1 };
let mut doc = Document::new_empty();
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(12));
parms.set(b"Columns".to_vec(), Object::Int(15));
let parms = doc.add(Object::Dict(parms));
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
d.set(b"DecodeParms".to_vec(), Object::Ref(parms));
let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &params, &skipped).unwrap();
let globals = doc.add(Object::Stream(Stream::from_raw(d, predicted)));
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut image_parms = Dict::new();
image_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(image_parms));
let image = doc.add(Object::Stream(Stream::from_raw(d, coded)));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap();
let mut doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
let stream = |doc: &Document| match &*doc.get(image) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
};
assert_eq!(doc.decoded_image(&stream(&doc)).unwrap(), bits);
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "not kept");
let globals = stream(&doc).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().reference(b"JBIG2Globals").unwrap();
let Object::Stream(g) = &*doc.get(globals) else { panic!("not a stream") };
let parms = g.dict.reference(b"DecodeParms").unwrap();
doc.set(parms, Object::Dict(Dict::new()));
assert!(doc.decoded_image(&stream(&doc)).is_err(), "without the predictor they read otherwise");
}
/// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of
/// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference
/// too): globals that can't be read are an error, and ones at another filter's place aren't its
/// own, nor are those of a lone dictionary when other filters come first. Its page has to be its
/// /Width × /Height, and fit the bound.
#[test]
fn jbig2_images_decode_with_their_globals() {
// 10 × 3: the first five samples of each row white, the rest black.
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::<String>().into_bytes();
let mut doc = Document::new_empty();
// An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips, Flate-encoded; and data
// that isn't JBIG2.
let globals = |doc: &mut Document, data: &[u8]| {
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(data))));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
Object::Dict(parms)
};
let (good, bad) = (globals(&mut doc, &[0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]), globals(&mut doc, b"not JBIG2"));
let indirect = Object::Ref(doc.add(bad.clone()));
let image = |filters: &[&str], parms: Option<Object>, data: &[u8], (width, height): (i64, i64), max: usize| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(width));
d.set(b"Height".to_vec(), Object::Int(height));
d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect()));
if let Some(p) = parms {
d.set(b"DecodeParms".to_vec(), p);
}
doc.decoded_image_within(&Stream::from_raw(d, data.to_vec()), max)
};
let jbig2 = ["JBIG2Decode"];
let full = (10, 3);
assert_eq!(image(&jbig2, None, &coded, full, MAX_DECODED).unwrap(), bits);
assert_eq!(image(&jbig2, Some(good.clone()), &coded, full, MAX_DECODED).unwrap(), bits, "globals it skips");
for parms in [bad.clone(), Object::Array(vec![bad.clone()]), indirect.clone(), Object::Array(vec![indirect])] {
let e = image(&jbig2, Some(parms.clone()), &coded, full, MAX_DECODED).unwrap_err();
assert!(e.to_string().contains("JBIG2"), "{parms:?}: {e}");
}
let behind = ["ASCIIHexDecode", "JBIG2Decode"];
assert_eq!(image(&behind, None, &hex, full, MAX_DECODED).unwrap(), bits, "behind ASCIIHexDecode");
assert!(image(&behind, Some(Object::Array(vec![Object::Null, bad.clone()])), &hex, full, MAX_DECODED).is_err(), "its own globals");
assert_eq!(image(&behind, Some(Object::Array(vec![bad.clone(), Object::Null])), &hex, full, MAX_DECODED).unwrap(), bits, "another's");
assert_eq!(image(&behind, Some(bad.clone()), &hex, full, MAX_DECODED).unwrap(), bits, "a lone dictionary, the first filter's");
let after_crypt = ["Crypt", "JBIG2Decode"];
assert!(image(&after_crypt, Some(Object::Array(vec![Object::Null, bad.clone()])), &coded, full, MAX_DECODED).is_err(), "after /Crypt");
assert_eq!(image(&after_crypt, Some(Object::Array(vec![bad, Object::Null])), &coded, full, MAX_DECODED).unwrap(), bits, "/Crypt's");
for size in [(11, 3), (9, 3), (10, 2), (10, 4)] {
assert!(image(&jbig2, None, &coded, size, MAX_DECODED).is_err(), "a page other than {size:?}");
}
assert_eq!(image(&jbig2, None, &coded, full, 6).unwrap(), bits, "the page within the bound");
assert!(image(&jbig2, None, &coded, full, 5).is_err(), "the page past the bound");
}
#[test]
fn a_missing_file_id_is_derived_from_the_encryption_seed() {
let mut doc = Document::new_empty();
let seed = [9u8; 32];
doc.set_encryption(&pdfcraft_crypt::NewEncryption {
algorithm: pdfcraft_crypt::Algorithm::Aes256,
user_password: "user",
owner_password: "owner",
permissions: -4,
encrypt_metadata: true,
seed,
})
.unwrap();
let id = doc.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone());
assert_eq!(id.as_deref(), Some(pdfcraft_crypt::file_id(&seed).as_slice()));
let mut kept = Document::new_empty();
let fixed = Object::String(crate::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true });
kept.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![fixed.clone(), fixed]));
kept.set_encryption(&pdfcraft_crypt::NewEncryption {
algorithm: pdfcraft_crypt::Algorithm::Aes256,
user_password: "user",
owner_password: "owner",
permissions: -4,
encrypt_metadata: true,
seed,
})
.unwrap();
let still = kept.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone());
assert_eq!(still.as_deref(), Some(b"0123456789abcdef".as_slice()));
}
#[test]
fn a_read_state_matches_a_clone_and_no_changed_document() {
let mut doc = Document::new_empty();
let a = doc.add(Object::Int(1));
let state = doc.read_state();
let snapshot = doc.clone();
assert!(state.matches(&doc) && state.matches(&snapshot));
// The same value set again is still a change: another allocation.
doc.set(a, Object::Int(1));
assert!(!state.matches(&doc) && state.matches(&snapshot));
let edited = doc.read_state();
doc.free(a);
assert!(!edited.matches(&doc));
let mut trailer_changed = snapshot.clone();
trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null);
assert!(!state.matches(&trailer_changed));
// Another document's state never matches, even with the same changes.
let mut other = Document::new_empty();
other.add(Object::Int(1));
assert!(!other.read_state().matches(&snapshot));
}
/// Build a classic-xref file from object bodies (object i+1 = bodies[i]).
pub(crate) fn build(bodies: &[&str], 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 assert_compact_matches_materialized(doc: &Document) {
assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map");
let (entries, _, revisions, _, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap();
assert_eq!(revisions.as_slice(), doc.revisions());
let max = entries.keys().next_back().copied().unwrap_or(0);
assert_eq!(doc.entries.max_num(), max);
for num in 0..=max {
assert_eq!(doc.xref_entry(num), entries.get(&num).copied(), "object {num}");
}
let expected: Vec<_> = entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect();
assert_eq!(doc.object_numbers(), expected);
}
#[test]
fn compact_xref_tables_streams_and_revisions_match_the_materialized_reader() {
let classic = Document::open(Arc::new(build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"],
"/Root 1 0 R",
)))
.unwrap();
assert_compact_matches_materialized(&classic);
let mut sparse = b"%PDF-1.7\n".to_vec();
let root_offset = sparse.len();
sparse.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n");
let pages_offset = sparse.len();
sparse.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
let xref = sparse.len();
sparse.extend_from_slice(
format!(
"xref\n0 1\n0000000000 65535 f \n1 1\n{root_offset:010} 00000 n \n5 1\n{pages_offset:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"
)
.as_bytes(),
);
let sparse = Document::open(Arc::new(sparse)).unwrap();
assert_eq!(sparse.object_numbers(), vec![1, 5]);
assert_compact_matches_materialized(&sparse);
let mut sparse_stream = b"%PDF-1.7\n".to_vec();
let mut offsets = [0u32; 8];
offsets[1] = sparse_stream.len() as u32;
sparse_stream.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n");
offsets[5] = sparse_stream.len() as u32;
sparse_stream.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
offsets[6] = sparse_stream.len() as u32;
sparse_stream.extend_from_slice(b"6 0 obj\n<< /Unused true >>\nendobj\n");
offsets[7] = sparse_stream.len() as u32;
let xref_row = |kind: u8, offset: u32, generation: u16| {
let mut row = Vec::with_capacity(7);
row.push(kind);
row.extend_from_slice(&offset.to_be_bytes());
row.extend_from_slice(&generation.to_be_bytes());
row
};
let mut rows = Vec::new();
rows.extend(xref_row(0, 0, u16::MAX));
rows.extend(xref_row(1, offsets[1], 0));
rows.extend(xref_row(1, offsets[5], 0));
rows.extend(xref_row(1, offsets[6], 0));
rows.extend(xref_row(1, offsets[7], 0));
sparse_stream.extend_from_slice(
format!("7 0 obj\n<< /Type /XRef /Size 8 /Root 1 0 R /W [1 4 2] /Index [0 2 5 3] /Length {} >>\nstream\n", rows.len()).as_bytes(),
);
sparse_stream.extend_from_slice(&rows);
sparse_stream.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[7]).as_bytes());
let sparse_stream = Document::open(Arc::new(sparse_stream)).unwrap();
assert_eq!(sparse_stream.object_numbers(), vec![1, 5, 6, 7]);
assert_compact_matches_materialized(&sparse_stream);
let stream_bytes = crate::write_full(&classic, &crate::SaveOptions::default()).unwrap();
let stream = Document::open(Arc::new(stream_bytes)).unwrap();
assert!(stream.revisions().last().is_some_and(|revision| revision.is_stream));
assert_compact_matches_materialized(&stream);
let mut edited = classic.clone();
edited.update_dict(ObjRef::new(1, 0), |dict| dict.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();
edited.free(ObjRef::new(3, 0));
let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
let updated = Document::open(Arc::new(incremental)).unwrap();
assert_eq!(updated.revisions().len(), 2);
assert_compact_matches_materialized(&updated);
}
#[test]
fn malformed_size_uses_the_materialized_compatibility_path() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], "/Root 1 0 R");
let marker = b"/Size 4";
let at = bytes.windows(marker.len()).position(|window| window == marker).unwrap();
bytes[at..at + marker.len()].copy_from_slice(b"/Size 2");
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(..)));
assert_eq!(doc.next_num, 4);
assert_eq!(doc.object_numbers(), vec![1, 2, 3]);
}
/// Entries the reader sets aside are remembered, so a caller comparing revisions can tell
/// that another reader may resolve the object differently.
#[test]
fn cross_reference_entries_that_are_set_aside_are_recorded() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let plain = Document::open(Arc::new(base.clone())).unwrap();
assert!(plain.xref_oddities().is_empty());
assert!((1..=3).all(|n| plain.xref_entry_is_exact(n)));
let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap();
let third = plain.xref_entry(3).unwrap();
// A later section: object 3 "in use" at offset 0, object 2 listed twice with different
// offsets, and object 0 at offset 0 (the usual producer slip, which names no object).
let mut bytes = base.clone();
let section = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]);
// What the reader resolves is what it resolved before: the first entry it kept.
assert_eq!(doc.xref_entry(3), Some(third));
assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 }));
}
/// An entry that misses its object still resolves (by searching) but is not exact.
#[test]
fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let text = String::from_utf8_lossy(&base).into_owned();
let at = text.find("3 0 obj").unwrap();
let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes();
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec()));
assert!(!doc.xref_entry_is_exact(3));
assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2));
}
/// An object that can't be read at its entry or anywhere else is searched for in the whole file
/// once: every later read, in a clone too, gives the same error without searching again. An
/// edit of that number still answers for it.
#[test]
fn an_unreadable_object_is_searched_for_once() {
let bodies = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Odd Key(x) >>"];
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R /Info 3 0 R"))).unwrap();
FILE_SCANS.with(|scans| scans.set(0));
let first = doc.try_get(3);
assert!(first.is_err(), "{first:?}");
for _ in 0..10 {
assert_eq!(doc.try_get(3), first);
assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null);
}
let mut edited = doc.clone();
assert_eq!(edited.try_get(3), first);
assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "searches for an object that isn't there");
let report = doc.unreadable_objects();
assert_eq!(report.len(), 1, "cached failures must stay listed exactly once: {report:?}");
assert_eq!(edited.unreadable_objects(), report, "clones retain the cached failure diagnostic");
edited.set(ObjRef::new(3, 0), Object::Int(7));
assert_eq!(edited.try_get(3).as_deref(), Ok(&Object::Int(7)));
assert_eq!(doc.try_get(3), first, "the edit is the clone's alone");
edited.free(ObjRef::new(3, 0));
assert_eq!(edited.try_get(3).as_deref(), Ok(&Object::Null));
}
/// A member of an object stream that can't be decoded (here, one that decodes past the document's
/// stream limit) is decoded once: every later read, in a clone too, gives the same error without
/// decoding the stream again. The stream itself loads, so no search of the file is involved.
#[test]
fn a_member_of_an_undecodable_object_stream_is_decoded_once() {
let file = object_stream_file("3 0 ", &format!("<< /Hidden ({}) >>", "x".repeat(4096)), 0);
let doc = Document::open_with_stream_limit(Arc::new(file), None, 1024).unwrap();
OBJSTM_DECODES.with(|decodes| decodes.set(0));
let first = doc.try_get(3);
assert!(matches!(first, Err(CosError::Filter(_))), "{first:?}");
for _ in 0..10 {
assert_eq!(doc.try_get(3), first);
}
assert_eq!(doc.clone().try_get(3), first);
assert_eq!(OBJSTM_DECODES.with(std::cell::Cell::get), 1, "decodes an object stream that can't be decoded");
}
/// Clones share the cache, and an edit is one clone's alone: a member of an object stream that
/// one clone freed or replaced still reads, in the others, from the file's stream.
#[test]
fn an_edited_object_stream_leaves_other_clones_members_alone() {
let file = Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0));
let doc = Document::open(file.clone()).unwrap();
let mut freed = doc.clone();
freed.free(ObjRef::new(4, 0));
assert_eq!(freed.try_get(3), Err(CosError::MissingObject(4)));
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the free reached another clone");
let doc = Document::open(file).unwrap();
let mut replaced = doc.clone();
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("ObjStm"));
dict.set(b"N".to_vec(), Object::Int(1));
dict.set(b"First".to_vec(), Object::Int(4));
replaced.set(ObjRef::new(4, 0), crate::Stream::from_raw(dict, b"3 0 << /Hidden (no) >>".to_vec()));
assert_eq!(text_of(&replaced, 3, b"Hidden").as_deref(), Some("no"));
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone");
}
/// A member that failed to load from the file's object stream is kept as a failure, but replacing
/// or freeing that stream forgets it: the member then reads from the edit.
#[test]
fn an_edited_object_stream_forgets_its_members_failures() {
let file = Arc::new(object_stream_file("3 0 ", ") broken", 0));
let mut doc = Document::open(file.clone()).unwrap();
assert!(doc.try_get(3).is_err(), "the file's member does not parse");
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("ObjStm"));
dict.set(b"N".to_vec(), Object::Int(1));
dict.set(b"First".to_vec(), Object::Int(4));
doc.set(ObjRef::new(4, 0), crate::Stream::from_raw(dict, b"3 0 << /Hidden (fixed) >>".to_vec()));
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("fixed"), "the cached failure outlived the edit");
assert!(doc.try_get(3).is_ok());
let mut doc = Document::open(file).unwrap();
assert!(matches!(doc.try_get(3), Err(CosError::Syntax { .. })), "the file's member does not parse");
doc.free(ObjRef::new(4, 0));
assert_eq!(doc.try_get(3), Err(CosError::MissingObject(4)), "the freed stream's member");
}
/// An object stream framed by one clone's edit of its indirect `/Length` is that clone's alone:
/// the others still read its members within the file's length.
#[test]
fn an_edited_object_stream_length_leaves_other_clones_members_alone() {
let file = Arc::new(object_stream_file_with("3 0 ", "<< /Hidden (cut endstream here) >>", 0, true));
let doc = Document::open(file).unwrap();
let mut edited = doc.clone();
// Ends the stream at the `endstream` inside the member's string.
let cut = "3 0 << /Hidden (cut".len();
edited.set(ObjRef::new(6, 0), Object::Int(cut as i64));
assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length was not used");
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone");
}
/// A `/Length` read through one clone's edits and reused within the same load still keeps that
/// load to the clone. Here the file's offsets are header-relative behind junk, so object 10's
/// offset first lands on object 12, whose length (object 8, in the edited object stream 9) is
/// read; object 10's own length then loads object stream 4, framed by object 8 too.
#[test]
fn a_reused_edited_length_leaves_other_clones_members_alone() {
let mut out = b"%PDF-1.5\n".to_vec();
let mut offs = [0usize; 13];
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
offs[n] = out.len();
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
let stream = |dict: &str, data: &str| format!("<< {dict} >>\nstream\n{data}\nendstream").into_bytes();
obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
let inner = "11 0 3 2 5 << /Hidden (cut endstream here) >>";
obj(&mut out, 4, &stream("/Type /ObjStm /N 2 /First 9 /Length 8 0 R", inner));
let lengths = format!("8 0 {}", inner.len());
obj(&mut out, 9, &stream(&format!("/Type /ObjStm /N 1 /First 4 /Length {}", lengths.len()), &lengths));
obj(&mut out, 12, &stream("/Length 8 0 R", &"x".repeat(40)));
obj(&mut out, 10, &stream("/Length 11 0 R", "PDATA"));
// A cross-reference stream: a hybrid file's /XRefStm is not looked for behind junk.
let compressed = [(3, 4, 1), (8, 9, 0), (11, 4, 0)];
let mut data = Vec::new();
for (n, off) in offs.iter().enumerate() {
let row = match compressed.iter().find(|c| c.0 == n) {
Some(&(_, stream, index)) => [2, 0, stream, index],
None if *off == 0 => [0, 0, 0, 0],
None => {
let [.., hi, lo] = u32::try_from(*off).unwrap().to_be_bytes();
[1, hi, lo, 0]
}
};
data.extend_from_slice(&row);
}
let table = out.len();
let dict = format!("<< /Type /XRef /Size 14 /W [1 2 1] /Index [0 13] /Root 1 0 R /Length {} >>", data.len());
out.extend_from_slice(format!("13 0 obj\n{dict}\nstream\n").as_bytes());
out.extend_from_slice(&data);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{table}\n%%EOF\n").as_bytes());
let mut file = vec![b'J'; offs[10] - offs[12] - 1];
file.push(b'\n');
file.extend_from_slice(&out);
let doc = Document::open(Arc::new(file)).unwrap();
let mut edited = doc.clone();
let cut = "11 0 3 2 5 << /Hidden (cut".len();
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("ObjStm"));
dict.set(b"N".to_vec(), Object::Int(1));
dict.set(b"First".to_vec(), Object::Int(4));
edited.set(ObjRef::new(9, 0), crate::Stream::from_raw(dict, format!("8 0 {cut}").into_bytes()));
assert!(matches!(&*edited.get(ObjRef::new(10, 0)), Object::Stream(s) if &s.raw[..] == b"PDATA"));
assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length was not used");
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone");
}
/// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered
/// `1 n` instead of `0 n` (#2676): every row is one number off.
fn misnumbered_table(start: u32) -> Vec<u8> {
let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"];
let mut out = b"%PDF-1.7
"
.to_vec();
let mut offsets = Vec::new();
for (i, body) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(
format!(
"{} 0 obj
{body}
endobj
",
i + 1
)
.as_bytes(),
);
}
let xref = out.len();
out.extend_from_slice(format!("xref\n{start} 4\n0000000000 65535 f \n").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 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
#[test]
fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() {
let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap();
let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free");
assert!(first.contains(b"Marker"));
assert_eq!(doc.root(), Some(ObjRef::new(3, 0)));
assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log());
// A correct table reads the same and logs nothing.
let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap();
assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker")));
assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1")));
}
#[test]
fn a_table_that_really_starts_at_1_keeps_its_numbers() {
// Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its
// second row locates object 2, so nothing is shifted.
let mut bytes = b"%PDF-1.7\n".to_vec();
let pages = bytes.len();
bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
let catalog = bytes.len();
bytes.extend_from_slice(b"3 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let xref = bytes.len();
bytes.extend_from_slice(
format!(
"xref\n0 1\n0000000000 65535 f \n1 3\n0000000000 65535 f \n{pages:010} 00000 n \n{catalog:010} 00000 n \ntrailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n"
)
.as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages")));
assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free");
assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1")));
}
/// A one-page scan: the page draws image 5 with content stream 4.
fn scanned_page() -> Vec<u8> {
build(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>",
"<< /Length 21 >>\nstream\n10 0 0 10 0 0 cm /Im0 Do\nendstream",
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream",
],
"/Root 1 0 R",
)
}
/// `bytes` with its cross-reference table replaced by `table` (the lines after `xref`).
fn with_table(bytes: &[u8], table: &[String], size: usize) -> Vec<u8> {
let text = String::from_utf8_lossy(bytes).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
let mut out = bytes[..at].to_vec();
out.extend_from_slice(format!("xref\n{}\ntrailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{at}\n%%EOF\n", table.join("\n")).as_bytes());
out
}
/// The table's entry lines, object 0 first.
fn table_entries(bytes: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(bytes).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
text[at..].lines().skip(2).take_while(|l| !l.starts_with("trailer")).map(str::to_string).collect()
}
/// The classic off-by-one: the subsection says it starts at 0 but lists objects 1 to 5, so
/// the last object has no entry. Viewers rebuild the table and show the page; a reader that
/// took the missing entry for a null object dropped the image when pages were copied (#1628).
#[test]
fn an_off_by_one_xref_subsection_still_finds_the_last_object() {
let base = scanned_page();
let entries = table_entries(&base);
let mut table = vec!["0 5".to_string()];
table.extend(entries[1..6].iter().cloned());
let doc = Document::open(Arc::new(with_table(&base, &table, 5))).unwrap();
let image = doc.get(ObjRef::new(5, 0));
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "{image:?}");
assert!(matches!(&*image, Object::Stream(_)));
assert!(matches!(&*doc.get(ObjRef::new(4, 0)), Object::Stream(s) if &s.raw[..] == b"10 0 0 10 0 0 cm /Im0 Do"));
assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "{:?}", doc.repair_log());
}
/// An "in use" entry at offset 0 says nothing about where the object is: the object is
/// found by its header, and the repair is recorded.
#[test]
fn an_object_listed_at_offset_zero_is_found_by_its_header() {
let base = scanned_page();
for num in [4usize, 5] {
let mut table = vec!["0 6".to_string()];
table.extend(table_entries(&base));
table[num + 1] = "0000000000 00000 n ".into();
let doc = Document::open(Arc::new(with_table(&base, &table, 6))).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(num as u32, 0)), Object::Stream(_)), "object {num}");
assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "object {num}: {:?}", doc.repair_log());
}
}
/// A valid table that simply does not list every number below `/Size` is not repaired: an
/// unlisted number is a null object (§7.3.10), even when an old body with that number is
/// still in the file.
#[test]
fn a_consistent_table_with_unlisted_numbers_is_taken_as_it_is() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let at = String::from_utf8_lossy(&base).rfind("\nxref\n").unwrap() + 1;
let mut bytes = base[..at].to_vec();
bytes.extend_from_slice(b"4 0 obj\n(stale)\nendobj\n");
let mut table = vec!["0 4".to_string()];
table.extend(table_entries(&base));
let doc = Document::open(Arc::new(with_table(&[&bytes[..], &base[at..]].concat(), &table, 6))).unwrap();
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert_eq!(*doc.get(ObjRef::new(4, 0)), Object::Null);
assert!(matches!(&*doc.get(ObjRef::new(3, 0)), Object::String(_)));
}
/// Filling the gaps of a damaged table never brings back an object that an incremental
/// update freed: its body is still in the file, but the newer section says it is gone.
#[test]
fn an_object_freed_by_an_update_stays_freed_when_the_table_is_repaired() {
let base = scanned_page();
// The original table lists the content stream at offset 0, so the gaps get filled.
let mut table = vec!["0 6".to_string()];
table.extend(table_entries(&base));
table[5] = "0000000000 00000 n ".into();
let mut bytes = with_table(&base, &table, 6);
let prev = String::from_utf8_lossy(&bytes).rfind("\nxref\n").unwrap() + 1;
let section = bytes.len();
bytes.extend_from_slice(
format!("xref\n5 1\n0000000000 00001 f \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(4, 0)), Object::Stream(_)), "the gap is filled");
assert_eq!(*doc.get(ObjRef::new(5, 0)), Object::Null, "the freed image stays freed");
assert_eq!(doc.xref_entry(5), Some(XrefEntry::Free { next_generation: 1 }));
}
/// Repairing a damaged table fills only the numbers the damage leaves out: an old body of a
/// number no section lists, under an oversized `/Size`, stays a null object.
#[test]
fn a_repaired_table_leaves_an_unlisted_number_null() {
let base = scanned_page();
let at = String::from_utf8_lossy(&base).rfind("\nxref\n").unwrap() + 1;
let entries = table_entries(&base);
let mut offset_zero = vec!["0 6".to_string()];
offset_zero.extend(entries.iter().cloned());
offset_zero[5] = "0000000000 00000 n ".into();
let mut off_by_one = vec!["0 5".to_string()];
off_by_one.extend(entries[1..6].iter().cloned());
for (name, table, found) in [("offset 0", offset_zero, 4), ("off by one", off_by_one, 5)] {
let bytes = [&base[..at], b"7 0 obj\n(stale)\nendobj\n", &base[at..]].concat();
let doc = Document::open(Arc::new(with_table(&bytes, &table, 9))).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(found, 0)), Object::Stream(_)), "{name}: the gap is filled");
assert_eq!(*doc.get(ObjRef::new(7, 0)), Object::Null, "{name}: {:?}", doc.repair_log());
assert_eq!(doc.xref_entry(7), None, "{name}");
}
}
/// The last object of a subsection numbered one too low is filled from that subsection,
/// whatever later happens to the row before it: an incremental save that rewrites object 4,
/// or object 4's own row at offset 0.
#[test]
fn the_last_object_of_a_shifted_subsection_is_filled_whatever_its_neighbour_says() {
let base = scanned_page();
let entries = table_entries(&base);
let mut off_by_one = vec!["0 5".to_string()];
off_by_one.extend(entries[1..6].iter().cloned());
let shifted = with_table(&base, &off_by_one, 5);
let prev = String::from_utf8_lossy(&shifted).rfind("\nxref\n").unwrap() + 1;
let mut updated = shifted.clone();
let body = updated.len();
updated.extend_from_slice(b"4 0 obj\n<< /Length 21 >>\nstream\n10 0 0 10 0 0 cm /Im0 Do\nendstream\nendobj\n");
let section = updated.len();
updated.extend_from_slice(
format!("xref\n4 1\n{body:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let mut neighbour_at_zero = off_by_one.clone();
neighbour_at_zero[5] = "0000000000 00000 n ".into();
for (name, bytes) in [("updated", updated), ("row 4 at offset 0", with_table(&base, &neighbour_at_zero, 5))] {
let doc = Document::open(Arc::new(bytes)).unwrap();
let image = doc.get(ObjRef::new(5, 0));
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "{name}: {:?}", doc.repair_log());
}
}
/// The image the first page draws as `/Im1`, followed through the page tree.
fn first_page_image(doc: &Document) -> Arc<Object> {
let catalog = doc.get(doc.root().unwrap());
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]);
let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap());
let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
doc.resolve(xobjects.as_dict().unwrap().get(b"Im1").unwrap())
}
/// The opposite off-by-one (#1628): the subsection says it starts at 1, but its first entry
/// is object 0's free-list head, so every entry lands one number too high and object 1, the
/// image, reads as free. The offset the table gives object 2 holds `1 0 obj`, so the
/// subsection is read from 0, and the image survives a full save.
#[test]
fn a_subsection_from_one_that_opens_with_the_free_list_head_is_read_from_zero() {
let base = build(
&[
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\nABCDEFGHIJKL\nendstream",
"<< /Length 29 >>\nstream\nq 72 0 0 72 0 0 cm /Im1 Do Q\nendstream",
"<< /Type /Page /Parent 4 0 R /MediaBox [0 0 72 72] /Resources << /XObject << /Im1 1 0 R >> >> /Contents 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Catalog /Pages 4 0 R >>",
"<< /Subject (synthetic) >>",
],
"/Root 5 0 R /Info 6 0 R",
);
// /Size 7 is one short of the shifted numbers; /Size 8 covers them all. Junk before the
// header moves every object past the offsets the table gives; it is not a comment, which
// the lexer would skip on its way to `1 0 obj` anyway.
for (size, junk) in [(7, ""), (8, ""), (7, "Content-Type: application/pdf\n\n")] {
let text = String::from_utf8_lossy(&base).replace("\nxref\n0 7\n", "\nxref\n1 7\n").replace("/Size 7 ", &format!("/Size {size} "));
let doc = Document::open(Arc::new(format!("{junk}{text}").into_bytes())).unwrap();
let image = first_page_image(&doc);
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "/Size {size} {junk:?}: {image:?}");
assert_eq!(doc.xref_entry(0), Some(XrefEntry::Free { next_generation: 65535 }), "/Size {size} {junk:?}");
assert!(doc.repair_log().iter().any(|l| l.contains("from 0")), "/Size {size} {junk:?}: {:?}", doc.repair_log());
let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let image = first_page_image(&saved);
assert!(matches!(&*image, Object::Stream(s) if &s.raw[..] == b"ABCDEFGHIJKL"), "/Size {size} {junk:?}: {image:?}");
}
}
/// Object 1 really freed: the subsection from 1 opens with a free entry, but the next entry's
/// offset holds object 2, not object 1. The table is taken as it is, and the old body of
/// object 1 still in the file stays freed.
#[test]
fn a_subsection_from_one_whose_next_offset_holds_object_two_is_taken_as_it_is() {
let base = build(&["(freed)", "<< /Type /Catalog /Pages 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 2 0 R");
let entries = table_entries(&base);
let mut table = vec!["1 3".to_string(), "0000000000 65535 f ".to_string()];
table.extend(entries[2..].iter().cloned());
let text = String::from_utf8_lossy(&base).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
for junk in ["", "Content-Type: application/pdf\n\n"] {
let mut bytes = junk.as_bytes().to_vec();
bytes.extend_from_slice(&base[..at]);
bytes.extend_from_slice(format!("xref\n{}\ntrailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n{at}\n%%EOF\n", table.join("\n")).as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.xref_entry(1), Some(XrefEntry::Free { next_generation: 65535 }), "{junk:?}");
assert_eq!(*doc.get(ObjRef::new(1, 0)), Object::Null, "{junk:?}: the freed object stays freed");
assert!(matches!(&*doc.get(ObjRef::new(2, 0)), Object::Dict(d) if d.name(b"Type") == Some(&b"Catalog"[..])), "{junk:?}");
assert!(!doc.repair_log().iter().any(|l| l.contains("from 0")), "{junk:?}: {:?}", doc.repair_log());
assert!(!junk.is_empty() || doc.repair_log().is_empty(), "{:?}", doc.repair_log());
}
}
#[test]
fn opening_an_exhausted_classic_xref_repairs_and_allocates() {
// The extra in-use row need not be loaded to exhaust the next object number.
let bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n4294967295 1\n0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n";
assert_eq!(bytes.len(), 266);
let mut doc = Document::open(Arc::new(bytes.to_vec())).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref")));
assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0));
}
#[test]
fn opening_an_exhausted_xref_stream_repairs_and_allocates() {
let mut bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec();
let offset = bytes.len();
bytes.extend_from_slice(b"3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 2] /Index [0 3 4294967295 1] /Length 28 >>\nstream\n");
for (kind, position, generation) in [(0_u8, 0_u32, 65535_u16), (1, 9, 0), (1, 58, 0), (1, 58, 0)] {
bytes.push(kind);
bytes.extend_from_slice(&position.to_be_bytes());
bytes.extend_from_slice(&generation.to_be_bytes());
}
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
let mut doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref")));
assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0));
}
#[test]
fn exhausted_size_and_high_edits_allocate_without_overwriting_objects() {
let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
for size in ["4294967295", "4294967296", "9223372036854775807"] {
let bytes = String::from_utf8(ordinary.clone()).unwrap().replace("/Size 3", &format!("/Size {size}"));
let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap();
assert!(doc.repair_log().iter().any(|line| line.contains("exhausted trailer")));
assert_eq!(doc.add(Object::Int(42)), ObjRef::new(3, 0));
doc.set(ObjRef::new(u32::MAX, 0), Object::Int(99));
doc.set(ObjRef::new(u32::MAX - 1, 0), Object::Int(98));
assert_eq!(doc.add(Object::Int(43)), ObjRef::new(4, 0));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_int(), Some(42));
assert_eq!(doc.get(ObjRef::new(u32::MAX, 0)).as_int(), Some(99));
assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice()));
}
let mut doc = Document::new_empty();
doc.set(ObjRef::new(u32::MAX - 2, 0), Object::Null);
assert_eq!(doc.add(Object::Null), ObjRef::new(u32::MAX - 1, 0));
assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0));
assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0));
}
#[test]
fn exhausted_xref_controls_preserve_ordinary_ignored_and_repaired_entries() {
let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
for (extra, repaired) in
[("", false), ("4294967295 1\n0000000000 00000 n \n", false), ("4294967295 2\n0000000058 00000 n \n0000000058 00000 n \n", true)]
{
let bytes = String::from_utf8(ordinary.clone()).unwrap().replacen("trailer\n", &format!("{extra}trailer\n"), 1);
let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice()));
assert_eq!(!doc.repair_log().is_empty(), repaired);
if repaired {
assert!(doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range")));
}
assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0));
}
}
#[test]
fn invalid_xref_object_ranges_are_reconstructed() {
// Original in-memory PDF: an intact catalog followed by a damaged xref section.
for stream in [false, true] {
for (start, count) in [(i64::MAX, 2), (-1, 2), (4_294_967_296, 1), (4_294_967_295, 2)] {
let mut bytes =
b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec();
let offset = bytes.len();
if stream {
bytes.extend_from_slice(
format!("3 0 obj\n<< /Type /XRef /Root 1 0 R /Size 4 /W [1 1 1] /Index [{start} {count}] /Length 6 >>\nstream\n").as_bytes(),
);
bytes.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
bytes.extend_from_slice(b"\nendstream\nendobj\n");
} else {
bytes.extend_from_slice(
format!("xref\n{start} {count}\n0000000000 65535 f \n0000000000 65535 f \ntrailer\n<< /Root 1 0 R /Size 3 >>\n").as_bytes(),
);
}
bytes.extend_from_slice(format!("startxref\n{offset}\n%%EOF\n").as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(
doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range")),
"stream={stream}, start={start}, count={count}: {:?}",
doc.repair_log()
);
assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice()));
}
}
}
#[test]
fn objects_only_an_unreadable_older_section_locates_are_found_by_scanning() {
// The newest section lists the catalog only; its /Prev points past the end of the file,
// so the page tree is reachable only by scanning object headers, as readers do (#1656).
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (num, body) in
[(1, "<< /Type /Catalog /Pages 2 0 R >>"), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), (3, "<< /Type /Page /Parent 2 0 R >>")]
{
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(
format!(
"xref\n0 2\n0000000000 65535 f \n{:010} 00000 n \ntrailer\n<< /Root 1 0 R /Size 4 /Prev 999999 >>\nstartxref\n{xref}\n%%EOF\n",
offsets[0]
)
.as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.get(ObjRef::new(2, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Pages".as_slice()));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Page".as_slice()));
assert!(doc.repair_log().iter().any(|l| l.contains("999999")), "{:?}", doc.repair_log());
}
#[test]
fn self_referential_length_does_not_overflow() {
let bytes = build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Length 3 0 R >>\nstream\nabc\nendstream"],
"/Root 1 0 R",
);
let doc = Document::open(Arc::new(bytes)).unwrap();
// Either recovered via the endstream search or reported — but never a stack overflow.
let o = doc.get(ObjRef::new(3, 0));
if let Object::Stream(s) = o.as_ref() {
assert_eq!(&s.raw[..], b"abc");
}
}
#[test]
fn mutually_referential_lengths_do_not_overflow() {
let bytes = build(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [] /Count 0 >>",
"<< /Length 4 0 R >>\nstream\nabc\nendstream",
"<< /Length 3 0 R >>\nstream\nxyz\nendstream",
],
"/Root 1 0 R",
);
let doc = Document::open(Arc::new(bytes)).unwrap();
let _ = doc.get(ObjRef::new(3, 0));
let _ = doc.get(ObjRef::new(4, 0));
}
#[test]
fn reads_classic_revision_and_edits_are_copy_on_write() {
let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.revisions().len(), 1);
assert!(doc.repair_log().is_empty());
let mut edited = doc.clone();
edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();
assert!(edited.is_modified() && !doc.is_modified(), "clone is an independent snapshot");
assert!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
}
#[test]
fn object_numbers_merge_source_and_overlay_in_order() {
let mut doc = Document::new_empty();
doc.overlay.clear();
doc.entries = Arc::new(XrefIndex::Materialized(
Arc::new(BTreeMap::from([
(0, XrefEntry::Free { next_generation: 65535 }),
(2, XrefEntry::InFile { offset: 10, generation: 7 }),
(4, XrefEntry::Free { next_generation: 9 }),
(6, XrefEntry::InStream { stream: 80, index: 1 }),
(8, XrefEntry::InFile { offset: 20, generation: 5 }),
(10, XrefEntry::InFile { offset: 30, generation: 3 }),
(u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }),
])),
Arc::default(),
));
doc.set(ObjRef::new(1, 2), Object::Int(1)); // before the first live source slot
doc.set(ObjRef::new(2, 8), Object::Int(2)); // override, without duplication
doc.set(ObjRef::new(4, 9), Object::Int(4)); // reuse a source free slot
doc.free(ObjRef::new(6, 0)); // remove a compressed source object
doc.free(ObjRef::new(7, 2)); // free a number absent from the source
doc.free(ObjRef::new(10, 3));
doc.set(ObjRef::new(11, 4), Object::Int(11));
assert_eq!(doc.object_numbers(), vec![1, 2, 4, 8, 11, u32::MAX]);
assert_eq!(doc.generation(2), 8);
assert_eq!(doc.generation(4), 9);
assert_eq!(doc.generation(6), 1);
assert_eq!(doc.generation(8), 5);
assert_eq!(doc.generation(10), 4);
doc.overlay.clear();
assert_eq!(doc.object_numbers(), vec![2, 6, 8, 10, u32::MAX]);
doc.entries = Arc::new(XrefIndex::empty());
assert!(doc.object_numbers().is_empty());
doc.set(ObjRef::new(5, 7), Object::Int(5));
assert_eq!(doc.object_numbers(), vec![5]);
}
#[test]
fn full_object_scans_preserve_edits_without_retaining_the_scan() {
let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), "<< /Type /Pages /Kids [] /Count 0 >>".to_owned()];
bodies.extend((0..400).map(|i| format!("<< /Value {i} >>")));
let refs: Vec<_> = bodies.iter().map(String::as_str).collect();
let mut doc = Document::open(Arc::new(build(&refs, "/Root 1 0 R"))).unwrap();
doc.set(ObjRef::new(3, 0), Object::Int(900));
doc.free(ObjRef::new(4, 0));
let added = doc.add(Object::Int(901));
let cached = doc.cache.lock().unwrap().len();
let held: Vec<_> = doc.scan_objects().collect();
assert_eq!(held.len(), 402);
assert_eq!(doc.cache.lock().unwrap().len(), cached, "scanning must not populate the editor cache");
assert!(doc.objstms.lock().unwrap().is_empty());
assert!(!held.iter().any(|(r, _)| r.num == 4));
assert_eq!(held.iter().find(|(r, _)| r.num == 3).unwrap().1.as_int(), Some(900));
assert_eq!(held.iter().find(|(r, _)| *r == added).unwrap().1.as_int(), Some(901));
assert_eq!(held.iter().find(|(r, _)| r.num == 400).unwrap().1.as_dict().unwrap().int(b"Value"), Some(397));
// Previously returned objects survive cache eviction and dropping the source.
drop(doc);
assert_eq!(held.iter().find(|(r, _)| r.num == 128).unwrap().1.as_dict().unwrap().int(b"Value"), Some(125));
}
#[test]
fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() {
// A stream read from the file is a view into the input: caching it costs no copy, so
// even a large one stays cached (its /Length object too) instead of being re-parsed.
let body = "x".repeat(ObjectReader::STREAM_BYTES + 1);
let stream = format!("<< /Length 4 0 R >>\nstream\n{body}\nendstream");
let length = body.len().to_string();
let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream, &length], "/Root 1 0 R");
let doc = Document::open(Arc::new(bytes)).unwrap();
let mut reader = doc.object_reader();
let read = reader.get(ObjRef::new(3, 0));
let Object::Stream(s) = &*read else { panic!("stream") };
assert!(s.raw.shares(&reader.document.data), "no copy of the file's bytes");
assert!(reader.document.cache.lock().unwrap().contains_key(&3), "a view costs nothing to keep");
// Data of its own (decrypted streams, strings) counts: past the budget the cache is
// cleared after the next read, including a failed one.
let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1]));
let size = held_bytes(&owned, &reader.document.data, 0);
reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned)), size);
assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null);
assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted");
}
#[test]
fn temporary_reader_and_full_save_do_not_fill_snapshot_caches() {
let mut source = Document::new_empty();
let objects: Vec<_> = (0..260)
.map(|i| {
let mut value = vec![b'x'; 48 * 1024];
value[..4].copy_from_slice(&(i as u32).to_le_bytes());
Object::Ref(source.add(Object::String(crate::PdfString::literal(value))))
})
.collect();
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
let doc = Document::open(bytes).unwrap();
let root = doc.get(doc.root().unwrap());
let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap();
let snapshot = doc.clone();
let cached = doc.cache.lock().unwrap().len();
let streams = doc.objstms.lock().unwrap().len();
let mut reader = doc.object_reader();
let mut held = None;
for (i, reference) in references.iter().enumerate() {
let Object::Ref(reference) = reference else { panic!("indirect object") };
let object = reader.get(*reference);
assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes());
if i == 0 {
held = Some(object);
}
assert!(reader.document.cache.lock().unwrap().len() < ObjectReader::OBJECTS);
let decoded = reader.document.objstms.lock().unwrap();
assert!(decoded.len() < ObjectReader::OBJECTS);
let bytes: usize = decoded.values().map(|s| s.data.capacity() + s.index.capacity() * std::mem::size_of::<Option<(u32, usize)>>()).sum();
assert!(bytes <= ObjectReader::STREAM_BYTES || decoded.len() == 1, "only one oversized stream may remain");
}
drop(reader);
assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes(), "owned results survive evictions");
for object_streams in [false, true] {
let saved = crate::write_full(&doc, &crate::SaveOptions { object_streams, ..Default::default() }).unwrap();
assert!(Document::open(Arc::new(saved)).unwrap().repair_log().is_empty());
assert_eq!(doc.cache.lock().unwrap().len(), cached, "full saves must not fill the editor cache");
assert_eq!(doc.objstms.lock().unwrap().len(), streams);
assert!(Arc::ptr_eq(&doc.cache, &snapshot.cache), "do not detach or clear caller snapshots");
}
}
#[test]
fn streams_without_length_share_one_endstream_search() {
let n = 2_000;
let mut file = b"%PDF-1.7\n".to_vec();
for k in 1..=n {
file.extend_from_slice(format!("{k} 0 obj\n<< >>\nstream\nxx\n").as_bytes());
}
file.extend_from_slice(b"endstream\nendobj\n");
file.extend_from_slice(
format!("{0} 0 obj\n<< /Type /Catalog /Pages {1} 0 R >>\nendobj\n{1} 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n", n + 1, n + 2)
.as_bytes(),
);
file.extend_from_slice(format!("trailer\n<< /Root {} 0 R /Size {} >>\n%%EOF\n", n + 1, n + 3).as_bytes());
let len = file.len();
crate::parser::ENDSTREAM_SCAN.with(|c| c.set(0));
let doc = Document::open(Arc::new(file)).unwrap();
for k in [1, n / 2, n] {
assert!(matches!(doc.try_get(k).as_deref(), Ok(Object::Stream(_))), "object {k}");
}
let scanned = crate::parser::ENDSTREAM_SCAN.with(|c| c.get());
assert!(scanned <= 4 * len, "{scanned} bytes searched in a {len}-byte file");
}
#[test]
fn editor_cache_counts_deeply_nested_strings() {
let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20]));
for _ in 0..8 {
nested = Object::Array(vec![nested]);
}
let mut source = Document::new_empty();
let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect();
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
let doc = Document::open(bytes).unwrap();
let held = held_bytes(&nested, &doc.data, 0);
assert!((1 << 20..(1 << 20) + 4096).contains(&held), "the string and a little tree: {held}");
let root = doc.get(doc.root().unwrap());
for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() {
let Object::Ref(reference) = reference else { panic!("indirect object") };
drop(doc.get(*reference));
let cache = doc.cache.lock().unwrap();
let owned = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0)));
let strings = cache.values().filter(|o| o.as_array().is_some()).count();
assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted");
}
}
#[test]
fn editor_cache_stays_bounded() {
let mut source = Document::new_empty();
let objects: Vec<_> = (0..(Document::EDITOR_CACHE_OBJECTS + 64))
.map(|i| {
let mut value = vec![b'x'; 1024];
value[..4].copy_from_slice(&(i as u32).to_le_bytes());
Object::Ref(source.add(Object::String(crate::PdfString::literal(value))))
})
.collect();
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
let doc = Document::open(bytes).unwrap();
let root = doc.get(doc.root().unwrap());
let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone();
let mut held = None;
for (i, reference) in references.iter().enumerate() {
let Object::Ref(reference) = reference else { panic!("indirect object") };
let object = doc.get(*reference);
assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes());
if i == 0 {
held = Some(object);
}
assert!(doc.cache.lock().unwrap().len() <= Document::EDITOR_CACHE_OBJECTS, "editing must not pin the whole object graph");
// Eviction keeps the newcomer.
assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer");
}
// Returned objects survive eviction, through both handles of the shared cache.
assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
let snapshot = doc.clone();
let first = references[0].as_ref().unwrap();
assert_eq!(&snapshot.get(first).as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
}
#[test]
fn editor_cache_byte_bound_fires() {
// 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget, which is
// checked after every insertion.
let mut source = Document::new_empty();
let objects: Vec<_> = (0..40)
.map(|i| {
let mut value = vec![b'y'; 1024 * 1024];
value[..4].copy_from_slice(&(i as u32).to_le_bytes());
Object::Ref(source.add(Object::String(crate::PdfString::literal(value))))
})
.collect();
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
let doc = Document::open(bytes).unwrap();
let root = doc.get(doc.root().unwrap());
let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone();
let mut held = None;
for (i, reference) in references.iter().enumerate() {
let Object::Ref(reference) = reference else { panic!("indirect object") };
let object = doc.get(*reference);
assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes());
if i == 0 {
held = Some(object);
}
// Eviction keeps the newcomer.
assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer");
}
assert!(doc.cache.lock().unwrap().len() < references.len(), "the byte budget must evict even below the count cap");
let last = references[references.len() - 1].as_ref().unwrap();
assert!(doc.cache.lock().unwrap().contains_key(&last.num), "eviction must keep the newcomer");
assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
}
#[test]
fn a_stream_limited_document_cannot_be_saved() {
let bytes = Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap());
let doc = Document::open_with_stream_limit(bytes.clone(), None, 1 << 20).unwrap();
for saved in [crate::write_full(&doc, &crate::SaveOptions::default()), crate::write_incremental(&doc, &crate::SaveOptions::default())] {
assert!(matches!(saved, Err(CosError::ReadOnlyLimit)), "{saved:?}");
}
let doc = Document::open(bytes).unwrap();
assert!(crate::write_full(&doc, &crate::SaveOptions::default()).is_ok());
}
#[test]
fn inspection_stream_limit_applies_before_loading_compressed_objects() {
let mut doc = Document::new_empty();
let large = doc.add(Object::String(crate::PdfString::literal(vec![b'x'; 4096])));
doc.update_dict(doc.root().unwrap(), |catalog| catalog.set(b"Extension".to_vec(), Object::Ref(large))).unwrap();
let bytes = Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap());
assert!(Document::open(bytes.clone()).is_ok());
assert!(Document::open_with_stream_limit(bytes, None, 512).is_err());
}
/// A /Prev chain that loops back on itself stops at the loop (and says so).
#[test]
fn looping_revision_chains_stop() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
// An update whose /Prev points at itself.
let at = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 f \ntrailer << /Size 3 /Root 1 0 R /Prev {at} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|l| l.contains("loops")), "{:?}", doc.repair_log());
}
/// A hybrid-reference file: the classic table leaves object 3 out; the `/XRefStm` stream
/// says it lives in object stream 4.
#[test]
fn hybrid_reference_files_read_the_xref_stream_too() {
let doc = Document::open(Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0))).unwrap();
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert_compact_matches_materialized(&doc);
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
}
/// A hybrid-reference file whose classic table leaves object 3 out, and whose `/XRefStm`
/// stream puts it at position `member` of object stream 4, which holds `header` (the number
/// and offset pairs) and then `members`.
fn object_stream_file(header: &str, members: &str, member: u8) -> Vec<u8> {
object_stream_file_with(header, members, member, false)
}
/// [`object_stream_file`], with the stream's `/Length` in object 6 when `indirect_length`.
fn object_stream_file_with(header: &str, members: &str, member: u8, indirect_length: bool) -> Vec<u8> {
let mut out = b"%PDF-1.5\n".to_vec();
let size = if indirect_length { 7 } else { 6 };
let mut offs = vec![0usize; size];
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
offs[n] = out.len();
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
let count = header.split_whitespace().count() / 2;
let inner = format!("{header}{members}");
let length = if indirect_length { "6 0 R".to_string() } else { inner.len().to_string() };
let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {length} >>\nstream\n", header.len()).into_bytes();
stm.extend_from_slice(inner.as_bytes());
stm.extend_from_slice(b"\nendstream");
obj(&mut out, 4, &stm);
let data = [2u8, 0, 4, member];
let mut xs = format!("<< /Type /XRef /Size 6 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes();
xs.extend_from_slice(&data);
xs.extend_from_slice(b"\nendstream");
obj(&mut out, 5, &xs);
if indirect_length {
obj(&mut out, 6, inner.len().to_string().as_bytes());
}
let table = out.len();
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for (n, off) in offs.iter().enumerate().skip(1) {
let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") };
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
out
}
/// Many revisions naming one hybrid /XRefStm read it once.
#[test]
fn a_hybrid_stream_named_by_many_revisions_is_read_once() {
let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0);
let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap();
let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1;
let revisions = 200;
for _ in 0..revisions {
let at = out.len();
out.extend_from_slice(format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes());
prev = at;
}
let doc = Document::open(Arc::new(out)).unwrap();
SECTION_READS.with(|c| c.set(0));
let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap();
let reads = SECTION_READS.with(|c| c.get());
assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1);
assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3));
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
assert_compact_matches_materialized(&doc);
}
/// An older section that names an /XRefStm a newer section already read still has its own
/// table compared with that stream, so an entry it sets aside is recorded for it.
#[test]
fn a_repeated_hybrid_stream_records_each_sections_conflicts() {
let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0);
let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap();
let catalog = find(&out, b"1 0 obj", 0, out.len()).unwrap();
let base = rfind(&out, b"\nxref\n", 0).unwrap() + 1;
// This section's table places object 3 at the catalog; the stream puts it in object stream 4.
let older = out.len();
out.extend_from_slice(
format!("xref\n3 1\n{catalog:010} 00000 n \ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {base} >>\nstartxref\n{older}\n%%EOF\n")
.as_bytes(),
);
let newer = out.len();
out.extend_from_slice(format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {older} >>\nstartxref\n{newer}\n%%EOF\n").as_bytes());
let doc = Document::open(Arc::new(out)).unwrap();
assert_eq!(doc.xref_oddities(), [(older as u64, 3)]);
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
}
fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option<String> {
doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text())))
}
/// An object stream member numbered 2^32 + 3 is not object 3 (#1815): the number used to be
/// cast to `u32`, which dropped the high bits.
#[test]
fn object_stream_numbers_beyond_u32_are_not_truncated() {
let control = Document::open(Arc::new(object_stream_file("3 0 ", "<< /Member (yes) >>", 0))).unwrap();
assert_eq!(text_of(&control, 3, b"Member").as_deref(), Some("yes"));
let doc = Document::open(Arc::new(object_stream_file("4294967299 0 ", "<< /Member (yes) >>", 0))).unwrap();
assert!(matches!(*doc.get(ObjRef::new(3, 0)), Object::Null), "{:?}", doc.get(ObjRef::new(3, 0)));
assert!(!doc.xref_entry_is_exact(3));
}
/// Member starts are checked rather than cast, so they cannot wrap on any target (#1815).
#[test]
fn object_stream_member_starts_are_checked() {
assert_eq!(objstm_member(3, 4, Some(10), 20), Some((3, 14)));
assert_eq!(objstm_member(3, 9, Some(10), 20), Some((3, 19)), "the last byte");
assert_eq!(objstm_member(3, 10, Some(10), 20), None, "at the end of the data");
assert_eq!(objstm_member(3, 1, Some(usize::MAX), 20), None, "/First plus the offset overflows");
assert_eq!(objstm_member(3, i64::MAX, Some(10), 20), None);
assert_eq!(objstm_member(3, -1, Some(10), 20), None);
assert_eq!(objstm_member(3, 0, None, 20), None, "/First does not fit in usize");
assert_eq!(objstm_member(i64::from(u32::MAX), 0, Some(0), 1), Some((u32::MAX, 0)));
assert_eq!(objstm_member(i64::from(u32::MAX) + 1, 0, Some(0), 1), None);
assert_eq!(objstm_member(-1, 0, Some(0), 1), None);
}
/// A member at a negative offset is unusable, not the stream's first member (#1815): the
/// offset used to be clamped to zero, which read the object at `/First`.
#[test]
fn object_stream_negative_offsets_are_refused() {
let doc = Document::open(Arc::new(object_stream_file("7 0 3 -1 ", "<< /First (yes) >>", 1))).unwrap();
assert!(matches!(*doc.get(ObjRef::new(3, 0)), Object::Null), "{:?}", doc.get(ObjRef::new(3, 0)));
let control = Document::open(Arc::new(object_stream_file("7 0 3 19 ", "<< /First (yes) >> << /Second (yes) >>", 1))).unwrap();
assert_eq!(text_of(&control, 3, b"Second").as_deref(), Some("yes"));
}
#[test]
fn revision_ends_split_incremental_updates() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
let first = bytes.len();
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert_eq!(doc.revision_ends(), vec![first]);
let mut edited = doc.clone();
edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();
bytes = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
let ends = doc.revision_ends();
assert_eq!(ends.len(), 2, "{ends:?}");
assert!(ends[0] >= first && ends[0] < bytes.len() && ends[1] == bytes.len(), "{ends:?} of {}", bytes.len());
// The first revision opens on its own, without the update.
let old = Document::open(Arc::new(bytes[..ends[0]].to_vec())).unwrap();
assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
}
/// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream
/// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99.
fn compressed_in_compressed_fixture() -> Document {
let mut out = b"%PDF-1.5\n".to_vec();
let at = |out: &mut Vec<u8>, num: u32, body: &str| {
let off = out.len();
out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
off as u16
};
let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>");
let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>");
let inner = "<< /Type /Outlines /Count 0 >>";
at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len()));
// /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3.
let rows: Vec<[u8; 4]> =
vec![[0, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [2, 0, 99, 0], [2, 0, 3, 0], [1, (out.len() >> 8) as u8, out.len() as u8, 0]];
let rows: Vec<u8> = rows.concat();
let xref = out.len();
out.extend_from_slice(
format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(),
);
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// An object stream whose own cross-reference entry says it is compressed inside another
/// stream (which the format forbids, and which does not exist here: qpdf's
/// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it
/// read as null and a full save wrote `/Outlines null`. The stream is now found by its own
/// header, as a plain object with a wrong offset is.
#[test]
fn an_object_stream_marked_as_compressed_is_found_by_its_header() {
let doc = compressed_in_compressed_fixture();
assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log());
let outlines = doc.get(ObjRef::new(4, 0));
assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}");
let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
let catalog = back.get(back.root().unwrap());
let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap());
assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}");
}
/// The header fallback is for file entries only: once the stream is freed (or replaced) in
/// the overlay, its objects must not come back from the file's copy of it.
#[test]
fn a_freed_object_stream_is_not_found_by_its_header() {
let doc = compressed_in_compressed_fixture();
assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some());
let mut edited = doc.clone();
edited.free(ObjRef::new(3, 0));
let fresh = edited.object_reader().document;
assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
let mut replaced = doc.clone();
replaced.set(ObjRef::new(3, 0), Object::Int(0));
let fresh = replaced.object_reader().document;
assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
}
/// The same once the free is saved: the revision's table says the stream is free, and
/// neither reading the file back nor rewriting it in full brings its objects back.
#[test]
fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() {
let mut edited = compressed_in_compressed_fixture();
edited.free(ObjRef::new(3, 0));
let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(incremental)).unwrap();
assert_eq!(reopened.revisions().len(), 2);
assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. })));
assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0)));
for object_streams in [true, false] {
let opts = crate::SaveOptions { object_streams, ..Default::default() };
let full = crate::write_full(&reopened, &opts).unwrap();
let back = Document::open(Arc::new(full)).unwrap();
let catalog = back.get(back.root().unwrap());
let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o));
assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}");
}
}
/// A trailer whose /Prev is written as the reference `N 0 R` (tiff2pdf did this; the files
/// are JHOVE's PDF-HUL-157 cases, #2366) used to end the cross-reference chain silently, so
/// every object of the earlier sections read as null: a four-page file showed one page, and
/// a full save wrote the three missing kids as null. The offset is read from the reference.
#[test]
fn a_prev_written_as_a_reference_still_reaches_the_earlier_sections() {
let base =
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"], "/Root 1 0 R");
let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap();
let update = |prev_entry: &str| {
let mut bytes = base.clone();
let at = bytes.len();
bytes.extend_from_slice(b"4 0 obj\n<< /Title (Update) >>\nendobj\n");
let xref = bytes.len();
bytes.extend_from_slice(
format!("xref\n4 1\n{at:010} 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R /Info 4 0 R /Prev {prev_entry} >>\nstartxref\n{xref}\n%%EOF\n")
.as_bytes(),
);
Document::open(Arc::new(bytes)).unwrap()
};
let good = update(&prev.to_string());
let bad = update(&format!("{prev} 0 R"));
assert!(good.repair_log().is_empty(), "{:?}", good.repair_log());
assert_eq!(bad.revisions().len(), 2, "{:?}", bad.repair_log());
assert_eq!(bad.repair_log().len(), 1, "a note about the /Prev: {:?}", bad.repair_log());
assert_eq!(bad.xref_entry(3), good.xref_entry(3), "the page of the first section is reached");
assert_eq!(bad.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..]));
assert_eq!(
bad.get(ObjRef::new(4, 0)).as_dict().and_then(|d| d.get(b"Title").and_then(|t| t.as_string().map(|s| s.to_text()))),
Some("Update".into())
);
}
/// iText's `indirectPrev.pdf` in miniature: the update's trailer gives `/Prev 4 0 R`, and
/// object 4, listed in that update, holds the offset of the first section. Read as offset 4
/// (inside the header), the chain broke and the file was rebuilt without its revisions.
#[test]
fn a_prev_reference_to_an_object_holding_the_offset_reads_that_offset() {
let mut bytes =
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"], "/Root 1 0 R");
let prev = String::from_utf8_lossy(&bytes).rfind("xref\n0 4").unwrap();
let mut at = Vec::new();
for (num, body) in
[(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".to_string()), (4, prev.to_string()), (5, "<< /Type /Page /Parent 2 0 R >>".into())]
{
at.push(bytes.len());
bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(
format!(
"xref\n2 1\n{:010} 00000 n \n4 2\n{:010} 00000 n \n{:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R /Prev 4 0 R >>\nstartxref\n{xref}\n%%EOF\n",
at[0], at[1], at[2]
)
.as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.revisions().len(), 2, "{:?}", doc.repair_log());
assert_eq!(doc.repair_log().len(), 1, "a note about the /Prev, and no rebuild: {:?}", doc.repair_log());
assert!(doc.repair_log()[0].contains(&format!("the offset {prev}")), "{:?}", doc.repair_log());
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..]));
assert_compact_matches_materialized(&doc);
}
/// A file whose only trailers are cross-reference stream dictionaries, with its `startxref`
/// out of reach (here a second file appended after it, cut off before its own
/// cross-reference data, as in #2365), is rebuilt from its object headers. The rebuild used
/// to look only for the `trailer` keyword, so /Info and /ID were lost, and a full save after
/// editing one Info field dropped every other.
#[test]
fn reconstruction_takes_the_trailer_from_a_cross_reference_stream() {
let table = build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) /Creator (Me) >>"],
"/Root 1 0 R /Info 3 0 R",
);
let packed = crate::write_full(&Document::open(Arc::new(table)).unwrap(), &crate::SaveOptions::default()).unwrap();
let text = String::from_utf8_lossy(&packed);
assert!(text.contains("/XRef") && !text.contains("trailer"), "a cross-reference stream is the only trailer");
let mut bytes = packed.clone();
let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000));
bytes.extend_from_slice(appended.as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(doc.revisions().is_empty(), "rebuilt: {:?}", doc.repair_log());
let info = doc.trailer().reference(b"Info").expect("the cross-reference stream's /Info");
let field = |d: &Document, key: &[u8]| d.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone();
assert_eq!(field(&doc, b"Title"), b"Kept");
assert!(doc.trailer().get(b"ID").is_some(), "and its /ID");
let mut edited = doc.clone();
edited.update_dict(info, |d| d.set(b"Subject".to_vec(), Object::String(crate::PdfString::literal("new")))).unwrap();
let saved = crate::write_full(&edited, &crate::SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
let info = back.trailer().reference(b"Info").expect("the full save keeps /Info");
let field = |key: &[u8]| back.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone();
assert_eq!((field(b"Title"), field(b"Creator"), field(b"Subject")), (b"Kept".to_vec(), b"Me".to_vec(), b"new".to_vec()));
}
/// A rebuild prefers a trailer whose /Root is the catalog (as `root_is_catalog` judges it): a
/// later cross-reference stream whose /Root points at the Info dictionary does not replace an
/// earlier good trailer, or a full save would write the Info dictionary as /Root.
#[test]
fn reconstruction_skips_a_trailer_whose_root_is_not_the_catalog() {
let mut bytes =
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) >>"], "/Root 1 0 R /Info 3 0 R");
bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 1 1] /Root 3 0 R /Length 3 >>\nstream\nabc\nendstream\nendobj\n");
let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000));
bytes.extend_from_slice(appended.as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt")), "{:?}", doc.repair_log());
assert_eq!(doc.trailer().reference(b"Root").map(|r| r.num), Some(1), "the catalog, not the Info dictionary");
assert_eq!(doc.trailer().reference(b"Info").map(|r| r.num), Some(3));
}
#[test]
fn damaged_xref_is_reconstructed() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
let n = bytes.len();
bytes[n - 12..n - 6].copy_from_slice(b"999999"); // point startxref into nowhere
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(doc.revisions().is_empty());
assert!(!doc.repair_log().is_empty());
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
}
#[test]
fn a_decompression_bomb_in_an_xref_stream_is_refused_and_the_file_reconstructed() {
// 32 MiB of rows behind two FlateDecode filters (a few hundred bytes in the file) for
// /W [1 1 1]: at 3 bytes a row that is more than 8,388,607 objects' worth, so it is not
// decoded (in full: the limit applies while inflating) and the objects are found by
// reconstruction instead.
let bomb = pdfcraft_filters::encode_flate(&pdfcraft_filters::encode_flate(&vec![0u8; 32 << 20]));
let mut bytes = b"%PDF-1.7\n".to_vec();
let o1 = bytes.len();
bytes.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let o2 = bytes.len();
bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
let x = bytes.len();
bytes.extend_from_slice(
format!("3 0 obj\n<< /Type /XRef /Size 4 /W [1 1 1] /Root 1 0 R /Filter [/FlateDecode /FlateDecode] /Length {} >>\nstream\n", bomb.len())
.as_bytes(),
);
bytes.extend_from_slice(&bomb);
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes());
assert!(o1 < o2 && o2 < x);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log());
}
#[test]
fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
// From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream`
// after it. Every parse attempt failed, and each attempt resolved the length by loading
// object 6 again, retrying the same ways down to the nesting limit: about 2^32 parses.
// Objects being loaded are now a seen-set, lengths are memoised, and the second parse
// only runs when the header is shifted.
let mut bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n".to_vec();
bytes.extend_from_slice(b"6 0 obj\n<< /Length 6 0 R >>\nstream\nno end marker follows\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
if let Ok(doc) = Document::open(Arc::new(bytes)) {
let _ = doc.try_get(6);
}
let _ = tx.send(());
});
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
}
/// A file whose object 3 (its `/Info`) can't be parsed: the rest reads.
fn with_unreadable_info() -> Vec<u8> {
build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Report) /Author ) >>"],
"/Root 1 0 R /Info 3 0 R",
)
}
/// An object that can't be parsed reads as null and is listed once, by number and generation,
/// with why. Clones and scans share the list, an edit takes the object off it (in that document
/// only), and a sound file lists nothing.
#[test]
fn unreadable_objects_are_listed_with_the_reason() {
let doc = Document::open(Arc::new(with_unreadable_info())).unwrap();
assert!(doc.unreadable_objects().is_empty(), "nothing has read object 3 yet");
assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null);
assert!(doc.try_get(3).is_err());
let lines = doc.unreadable_objects();
assert_eq!(lines.len(), 1, "read twice, listed once: {lines:?}");
assert!(lines[0].starts_with("object 3 0 could not be read: syntax error"), "{lines:?}");
let scanned = doc.clone();
assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1);
assert_eq!(doc.unreadable_objects(), lines, "the scan found the same object");
let unread = Document::open(Arc::new(with_unreadable_info())).unwrap();
let scanned = unread.clone();
assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1);
assert_eq!(unread.unreadable_objects(), lines, "a clone's scan reaches the original");
let mut edited = doc.clone();
edited.set(ObjRef::new(3, 0), Object::Dict(Dict::new()));
assert!(edited.unreadable_objects().is_empty(), "the edit replaced it");
assert_eq!(doc.unreadable_objects(), lines);
let sound =
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Report) >>"], "/Root 1 0 R /Info 3 0 R");
let sound = Document::open(Arc::new(sound)).unwrap();
assert_eq!(sound.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 0);
assert!(sound.unreadable_objects().is_empty());
}
/// A file rebuilt from its object headers still lists an object the scan could not read.
#[test]
fn a_rebuilt_file_lists_the_objects_its_scan_could_not_read() {
let text = "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n3 0 obj\n<< /A ) >>\nendobj\n%%EOF\n";
let doc = Document::open(Arc::new(text.as_bytes().to_vec())).unwrap();
assert!(doc.repair_log().iter().any(|l| l.starts_with("rebuilt cross-reference index")), "{:?}", doc.repair_log());
let lines = doc.unreadable_objects();
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].starts_with("object 3 0 could not be read: syntax error"), "{lines:?}");
}
/// The list keeps at most 100 objects and then says there were more; an object whose
/// cross-reference offset holds no object is listed with that as its reason.
#[test]
fn the_unreadable_object_list_is_bounded() {
let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
bodies.extend(std::iter::repeat_n("<< /A ) >>", 120));
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap();
assert_eq!(doc.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 120);
let lines = doc.unreadable_objects();
assert_eq!(lines.len(), 101, "{:?}", lines.last());
assert!(lines[0].starts_with("object 3 0 could not be read"), "{}", lines[0]);
assert_eq!(lines[100], "more objects could not be read; only the first 100 are listed");
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let text = String::from_utf8_lossy(&base).into_owned();
let gone = text.replacen("3 0 obj\n(three)", "9 0 obj\n(three)", 1);
let doc = Document::open(Arc::new(gone.into_bytes())).unwrap();
assert!(doc.try_get(3).is_err());
assert_eq!(
doc.unreadable_objects(),
["object 3 0 could not be read: no readable copy was found at its cross-reference offset or elsewhere in the file"]
);
}
/// The unreadable list is shared by clones, and each hides what it has edited. A clone that edited
/// every listed object still lists its own failures: hidden entries don't use up its allowance.
#[test]
fn objects_a_clone_edited_do_not_use_up_its_unreadable_allowance() {
let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"];
bodies.extend(std::iter::repeat_n("<< /A ) >>", 110));
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap();
for num in 3..103 {
assert!(doc.try_get(num).is_err());
}
assert_eq!(doc.unreadable_objects().len(), 100);
let mut edited = doc.clone();
for num in 3..103 {
edited.set(ObjRef::new(num, 0), Object::Null);
}
for num in 103..113 {
assert!(edited.try_get(num).is_err());
}
let lines = edited.unreadable_objects();
assert_eq!(lines.len(), 10, "{lines:?}");
assert!(lines[0].starts_with("object 103 0 could not be read"), "{}", lines[0]);
let original = doc.unreadable_objects();
assert_eq!(original.len(), 101, "the original still shows at most 100, then says there were more");
assert_eq!(original[100], "more objects could not be read; only the first 100 are listed");
}
#[test]
fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() {
// The earlier revision: an empty document read back from a file whose catalog and pages
// sit in an object stream.
let earlier = Document::open(Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap())).unwrap();
// The saved revision adds an object the catalog links to, so a full save numbers its objects
// anew; an undo then returns to the earlier graph.
let mut later = earlier.clone();
let extra = later.add(Object::Int(7));
let root = later.root().unwrap();
later.update_dict(root, |c| c.set(b"Extra".to_vec(), Object::Ref(extra))).unwrap();
let saved = crate::write_full(&later, &crate::SaveOptions::default()).unwrap();
let mut base = Document::open(Arc::new(saved)).unwrap();
assert!(
base.object_numbers().iter().any(|&n| matches!(base.xref_entry(n), Some(XrefEntry::InStream { .. }))),
"the saved file holds objects in a stream"
);
base.overlay_changes_from(&earlier).unwrap();
let file = crate::write_incremental(&base, &crate::SaveOptions::default()).unwrap();
let reread = Document::open(Arc::new(file)).unwrap();
let catalog = reread.get(reread.root().unwrap()).as_dict().unwrap().clone();
assert!(catalog.get(b"Extra").is_none(), "the restored catalog");
let pages = reread.resolve(catalog.get(b"Pages").unwrap());
assert_eq!(pages.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Pages"[..]), "the pages survive: {pages:?}");
}
/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has
/// to measure the cache again on every read (#5873).
#[test]
fn the_object_cache_keeps_its_total_as_objects_come_and_go() {
let data = Arc::new(Vec::new());
let one = Arc::new(Object::String(crate::PdfString::literal(vec![b'x'; 100])));
let two = Arc::new(Object::Array(vec![Object::Int(1); 10]));
let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0));
let mut cache = super::state::ObjectCache::default();
cache.insert(1, Ok(one.clone()), a);
cache.insert(2, Ok(two), b);
assert_eq!(cache.owned(), a + b);
cache.insert(2, Ok(one), a);
assert_eq!(cache.owned(), a + a, "a replaced object no longer counts");
cache.retain(|n| *n == 1);
assert_eq!((cache.len(), cache.owned()), (1, a));
cache.clear();
assert_eq!((cache.len(), cache.owned()), (0, 0));
}
/// A dictionary of numbers owns no string or stream bytes, but parsed it is tens of thousands
/// of values: InDesign's per-page `/PieceInfo`. Counting only strings and streams, the editing
/// cache kept a thousand such pages and a 35 MB book held 1.5 GB (#5873). What the parsed tree
/// holds counts now, so the byte budget bounds them.
#[test]
fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
let entries: String = (0..2000).map(|i| format!("/{i}[{i} 141 3 116.22 -375.993 1.0]")).collect();
let dict = format!("<< /PieceInfo << /InDesign << /Map << {entries} >> >> >> >>");
let count = 300;
let refs: String = (0..count).map(|i| format!("{} 0 R ", i + 2)).collect();
let mut bodies = vec![format!("<< /Type /Catalog /Extension [{refs}] >>")];
bodies.extend((0..count).map(|_| dict.clone()));
let bodies: Vec<&str> = bodies.iter().map(String::as_str).collect();
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap();
let one = doc.get(crate::ObjRef::new(2, 0));
let held = held_bytes(&one, &doc.data, 0);
assert!(held > 2000 * std::mem::size_of::<Object>(), "the parsed tree counts: {held} bytes");
for i in 0..count {
drop(doc.get(crate::ObjRef::new(i as u32 + 2, 0)));
}
let cache = doc.cache.lock().unwrap();
assert!(cache.owned() <= Document::EDITOR_CACHE_BYTES, "{} bytes held", cache.owned());
assert!(cache.len() < count, "the byte budget evicted some, below the count cap: {} kept", cache.len());
let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0)));
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
}
/// The width (`/MediaBox` x1) of each of `pages`, in order.
fn page_widths(pages: &[Dict]) -> Vec<Option<i64>> {
pages.iter().map(|p| p.get(b"MediaBox").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_int)).collect()
}
/// The pages of `doc` in page-tree order, as the dictionaries the document holds.
fn page_tree_pages(doc: &Document) -> Vec<Dict> {
let catalog = doc.get(doc.root().expect("root"));
let pages = doc.resolve(catalog.as_dict().and_then(|c| c.get(b"Pages")).expect("/Pages"));
let kids = pages.as_dict().and_then(|p| p.get(b"Kids")).and_then(Object::as_array).cloned().unwrap_or_default();
kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).collect()
}
/// ISO 32000-2 §7.5.6 and §7.5.8.2: an update reaches the revisions before it through `/Prev`.
/// One without it cuts them off, page tree and all. The file is rebuilt from its object headers
/// as a damaged one is, so its page tree is found, the newest definition of each object wins, and
/// the repair log says why; a save rewrites it in full, with every page.
#[test]
fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
let doc = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.reconstructed() && doc.rebuilt_unlinked_update());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let saved = crate::write_incremental(&doc, &crate::SaveOptions::default()).expect("saves");
let reopened = Document::open(Arc::new(saved)).expect("the saved file opens");
let pages = page_tree_pages(&reopened);
assert_eq!(page_widths(&pages), [Some(200), Some(300), Some(400)], "every page is saved, in order");
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page is saved: {pages:?}");
let catalog = reopened.get(reopened.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog is saved");
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
// The same file with `/Prev` reads through its chain, as before.
let linked = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.reconstructed() && !linked.rebuilt_unlinked_update());
assert_eq!(linked.revisions().len(), 2);
assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log());
}
/// A hybrid update (ISO 32000-2 §7.5.8.4) without `/Prev`: its `/XRefStm` stream belongs to the
/// update, so the earlier revision is cut off just the same, and the file is rebuilt.
#[test]
fn a_hybrid_update_without_prev_is_rebuilt_too() {
let doc = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let linked = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.rebuilt_unlinked_update() && linked.repair_log().is_empty(), "{:?}", linked.repair_log());
assert_eq!(linked.revisions().len(), 2);
}
#[test]
fn find_agrees_with_a_plain_window_search() {
let naive = |hay: &[u8], needle: &[u8], from: usize, to: usize| {
if from >= to || needle.is_empty() || to - from < needle.len() {
return None;
}
hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
};
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let alphabet = b"/XRef t\n<>0";
for _ in 0..20_000 {
let hay: Vec<u8> = (0..next() % 200).map(|_| alphabet[(next() % alphabet.len() as u64) as usize]).collect();
let needles: [&[u8]; 5] = [b"/XRef", b"trailer", b"R", b"ff", b"<<"];
let needle = needles[(next() % 5) as usize];
let from = (next() % (hay.len() as u64 + 1)) as usize;
let to = from + (next() % (hay.len() as u64 + 1 - from as u64)) as usize;
assert_eq!(find(&hay, needle, from, to), naive(&hay, needle, from, to), "{hay:?} {needle:?} {from} {to}");
}
}