use std::fmt::Write as _;
use std::sync::Arc;
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::Resolve as _;
use pdfrum_parser::{Entry, LoadError, LoadOptions, Xref, load, read_xref};
fn document(dict_body: &str, entries: &[&str]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"%PDF1-7\n%\xa0\xf2\xa4\xf4\n");
out.extend_from_slice(b"7 0 obj <<\n");
out.extend_from_slice(b" /Filter /ASCIIHexDecode\n");
out.extend_from_slice(dict_body.as_bytes());
out.extend_from_slice(b">>\nstream\n");
for entry in entries {
out.extend_from_slice(entry.as_bytes());
out.push(b'\n');
}
out.extend_from_slice(b"endstream\nendobj\nstartxref\n14\n%%EOF\n");
out
}
fn read(file: &[u8]) -> Option<Xref> {
let mut diags = Diagnostics::default();
read_xref(file, &Limits::default(), &mut diags)
.ok()
.map(|(xref, _)| xref)
}
fn offsets(xref: &Xref) -> Vec<(u32, u64)> {
xref.object_numbers()
.filter_map(|num| match xref.entry(num) {
Some(Entry::Offset(pos)) => Some((num, pos)),
_ => None,
})
.collect()
}
fn opens(file: &[u8]) -> bool {
read(file).is_some()
}
#[test]
fn the_highest_legal_object_number_is_accepted() {
let limits = Limits::default();
let file = document(
&format!(
" /Index [{} 1]\n /Root 1 0 R\n /Size {}\n /W [1 1 1]\n",
limits.max_object_number, limits.max_xref_size
),
&["01 00 00"],
);
let xref = read(&file).expect("cross-reference stream");
assert_eq!(xref.entry(limits.max_object_number), Some(Entry::Offset(0)));
}
#[test]
fn object_numbers_past_the_cap_are_refused() {
let limits = Limits::default();
let file = document(
&format!(
" /Index [{} 2]\n /Root 1 0 R\n /Size {}\n /W [1 1 1]\n",
limits.max_object_number,
u64::from(limits.max_xref_size) + 1
),
&["01 00 00", "01 0F 00", "01 12 00"],
);
assert!(!opens(&file));
}
#[test]
fn an_archive_number_past_the_table_skips_only_that_entry() {
let file = document(
" /Root 1 0 R\n /Size 3\n /W [1 1 1]\n",
&["02 FF 00", "01 0F 00", "01 12 00"],
);
let xref = read(&file).expect("cross-reference stream");
assert_eq!(offsets(&xref), vec![(1, 15), (2, 18)]);
}
#[test]
fn a_dictionary_that_is_not_a_stream_is_not_a_section() {
let mut file = Vec::new();
file.extend_from_slice(b"%PDF1-7\n%\xa0\xf2\xa4\xf4\n");
file.extend_from_slice(
b"7 0 obj <<\n /Filter /ASCIIHexDecode\n /Root 1 0 R\n /Size 3\n /W [1 1 1]\n>>\n",
);
file.extend_from_slice(b"endobj\nstartxref\n14\n%%EOF\n");
assert!(!opens(&file));
}
#[test]
fn a_negative_previous_offset_is_refused() {
let file = document(
" /Root 1 0 R\n /Size 3\n /W [1 1 1]\n /Prev -1\n",
&["02 FF 00", "01 0F 00", "01 12 00"],
);
assert!(!opens(&file));
}
#[test]
fn a_negative_size_is_refused() {
let file = document(
" /Root 1 0 R\n /Size 3\n /W [1 1 1]\n /Size -1\n",
&["02 FF 00", "01 0F 00", "01 12 00"],
);
assert!(!opens(&file));
}
#[test]
fn a_zero_size_reads_an_empty_table() {
let file = document(
" /Root 1 0 R\n /Size 0\n /W [1 1 1]\n /Size 0\n",
&["02 FF 00", "01 0F 00", "01 12 00"],
);
let xref = read(&file).expect("cross-reference stream");
assert!(xref.is_empty());
}
#[test]
fn a_width_array_of_two_is_not_a_cross_reference_stream() {
let file = document(
" /Type /XRef\n /Root 1 0 R\n /Size 3\n /W [1 1]\n",
&["02 FF 00", "01 0F 00", "01 12 00"],
);
let xref = read(&file).expect("recovered by scanning");
assert!(xref.entry(7).is_some());
let hopeless = document(
" /Root 1 0 R\n /Size 3\n /W [1 1]\n",
&["02 FF 00", "01 0F 00", "01 12 00"],
);
assert!(!opens(&hopeless));
}
#[test]
fn a_zero_width_type_field_means_every_entry_is_in_use() {
let file = document(
" /Root 1 0 R\n /Size 2\n /W [0 1 1]\n",
&["0F 00", "12 00"],
);
let xref = read(&file).expect("cross-reference stream");
assert_eq!(offsets(&xref), vec![(0, 15), (1, 18)]);
}
#[test]
fn an_index_names_which_objects_the_entries_describe() {
let file = document(
" /Root 1 0 R\n /Size 83\n /Index [2 1 4 2 80 3]\n /W [1 1 1]\n",
&[
"01 00 00", "01 0F 00", "01 12 00", "01 20 00", "01 22 00", "01 25 00",
],
);
let xref = read(&file).expect("cross-reference stream");
assert_eq!(
offsets(&xref),
vec![(2, 0), (4, 15), (5, 18), (80, 32), (81, 34), (82, 37)]
);
}
#[test]
fn overlapping_index_ranges_let_the_later_entry_win() {
let file = document(
" /Root 1 0 R\n /Size 4\n /Index [2 2 3 1]\n /W [1 1 1]\n",
&["01 00 00", "01 0F 00", "01 12 00"],
);
let xref = read(&file).expect("cross-reference stream");
assert_eq!(offsets(&xref), vec![(2, 0), (3, 18)]);
}
#[test]
fn index_ranges_need_not_be_in_order() {
let file = document(
" /Root 1 0 R\n /Size 5\n /Index [3 2 2 1]\n /W [1 1 1]\n",
&["01 00 00", "01 0F 00", "01 12 00"],
);
let xref = read(&file).expect("cross-reference stream");
assert_eq!(offsets(&xref), vec![(2, 18), (3, 0), (4, 15)]);
}
#[test]
fn an_index_reaching_past_the_declared_size_still_loads() {
let file = document(
" /Root 1 0 R\n /Size 81\n /Index [2 1 80 2]\n /W [1 1 1]\n",
&["01 00 00", "01 0F 00", "01 12 00"],
);
let xref = read(&file).expect("cross-reference stream");
assert_eq!(offsets(&xref), vec![(2, 0), (80, 15), (81, 18)]);
}
#[test]
fn a_start_xref_pointing_at_a_stream_body_builds_no_table() {
let file: &[u8] = b"%PDF1-7 0 obj <</Size 2 /W [0 0 0]\n>>\n\
stream\n\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
endstream\n\
endobj\n\
startxref\n\
6\n\
%%EOF\n\0";
let bytes: Arc<[u8]> = Arc::from(file);
assert!(matches!(
load(bytes, &LoadOptions::default()),
Err(LoadError::Broken(_))
));
}
fn chained(base_dict: &str, base: &[&str], update_dict: &str, update: &[&str]) -> Vec<u8> {
let section = |num: u32, dict: &str, entries: &[&str]| {
let mut out = format!("{num} 0 obj <<\n /Filter /ASCIIHexDecode\n{dict}>>\nstream\n");
for entry in entries {
out.push_str(entry);
out.push('\n');
}
out.push_str("endstream\nendobj\n");
out
};
let mut out = String::from("%PDF-1.7\n%\u{a0}\u{f2}\u{a4}\u{f4}\n");
let base_at = out.len();
out.push_str(§ion(7, base_dict, base));
let update_at = out.len();
out.push_str(§ion(
8,
&format!("{update_dict} /Prev {base_at}\n"),
update,
));
let _ = write!(out, "startxref\n{update_at}\n%%EOF\n");
out.into_bytes()
}
#[test]
fn an_update_section_wins_over_the_base_for_an_object_both_describe() {
let file = chained(
" /Root 1 0 R\n /Size 6\n /Index [5 1]\n /W [1 1 1]\n",
&["01 AA 00"],
" /Root 1 0 R\n /Size 6\n /Index [5 1]\n /W [1 1 1]\n",
&["01 BB 00"],
);
let xref = read(&file).expect("a chain of cross-reference streams");
assert_eq!(xref.entry(5), Some(Entry::Offset(0xBB)));
}
#[test]
fn a_base_entry_survives_a_newer_sections_size_phantom() {
let file = chained(
" /Root 1 0 R\n /Size 6\n /W [1 1 1]\n",
&[
"00 00 00", "01 11 00", "01 22 00", "01 33 00", "01 44 00", "01 55 00",
],
" /Root 1 0 R\n /Size 6\n /Index [2 1]\n /W [1 1 1]\n",
&["01 99 00"],
);
let xref = read(&file).expect("a chain of cross-reference streams");
assert_eq!(xref.entry(2), Some(Entry::Offset(0x99)), "the update wins");
assert_eq!(
xref.entry(5),
Some(Entry::Offset(0x55)),
"the base's last object survives the update's phantom"
);
}
fn hybrid(update_table_names_stale_six: bool) -> (Vec<u8>, [usize; 4]) {
let mut out = String::from("%PDF-1.7\n%\u{a0}\u{f2}\u{a4}\u{f4}\n");
let obj = |out: &mut String, num: u32, body: &str| {
let at = out.len();
let _ = write!(out, "{num} 0 obj << {body} >>\nendobj\n");
at
};
let old_five = obj(&mut out, 5, "/Version 1");
let old_six = obj(&mut out, 6, "/Version 1");
let table = |entries: &[(u32, usize)]| {
let mut t = String::from("xref\n");
for (num, offset) in entries {
let _ = write!(t, "{num} 1\n{offset:010} 00000 n \n");
}
t
};
let base_at = out.len();
out.push_str(&table(&[(5, old_five), (6, old_six)]));
out.push_str("trailer << /Root 1 0 R /Size 9 >>\n");
let new_five = obj(&mut out, 5, "/Version 2");
let new_six = obj(&mut out, 6, "/Version 2");
let stream_at = out.len();
let _ = write!(
out,
"8 0 obj <<\n /Type /XRef\n /Filter /ASCIIHexDecode\n /Root 1 0 R\n \
/Size 9\n /Index [5 2]\n /W [1 2 1]\n>>\nstream\n\
01 {new_five:04X} 00\n01 {new_six:04X} 00\nendstream\nendobj\n"
);
let update_at = out.len();
let six = if update_table_names_stale_six {
old_six
} else {
new_six
};
out.push_str(&table(&[(6, six)]));
let _ = write!(
out,
"trailer << /Root 1 0 R /Size 9 /Prev {base_at} /XRefStm {stream_at} >>\n\
startxref\n{update_at}\n%%EOF\n"
);
(out.into_bytes(), [old_five, old_six, new_five, new_six])
}
#[test]
fn the_newest_sections_xref_stm_is_honoured() {
let (file, [_, _, new_five, new_six]) = hybrid(false);
let xref = read(&file).expect("a hybrid chain");
assert_eq!(
xref.entry(5),
Some(Entry::Offset(new_five as u64)),
"the newest /XRefStm must revise object 5, which no plain table names"
);
assert_eq!(
xref.entry(6),
Some(Entry::Offset(new_six as u64)),
"and object 6, which the update's plain table names identically"
);
}
#[test]
fn a_plain_table_still_beats_its_own_sections_xref_stm() {
let (file, [_, old_six, new_five, _]) = hybrid(true);
let xref = read(&file).expect("a hybrid chain");
assert_eq!(
xref.entry(6),
Some(Entry::Offset(old_six as u64)),
"the update's plain table beats its own /XRefStm"
);
assert_eq!(
xref.entry(5),
Some(Entry::Offset(new_five as u64)),
"an object only the stream names is unaffected"
);
}
fn hybrid_freeing_its_compressed_objects() -> (Vec<u8>, usize) {
let mut out = String::from("%PDF-1.5\n%\u{a0}\u{f2}\u{a4}\u{f4}\n");
let plain = {
let at = out.len();
let _ = write!(out, "1 0 obj << /Type /Catalog /Pages 6 0 R >>\nendobj\n");
at
};
let pages = out.len();
let _ = write!(
out,
"6 0 obj << /Type /Pages /Count 1 /Kids [7 0 R] >>\nendobj\n"
);
let page = out.len();
let _ = write!(
out,
"7 0 obj << /Type /Page /Parent 6 0 R /MediaBox [0 0 10 10] >>\nendobj\n"
);
let two = "<< /Kind /Two >> ";
let three = "<< /Kind /Three >>";
let header = format!("2 0 3 {} ", two.len());
let first = header.len();
let payload = format!("{header}{two}{three}");
let archive = out.len();
let _ = write!(
out,
"4 0 obj << /Type /ObjStm /N 2 /First {first} /Length {} >>\nstream\n{payload}\n\
endstream\nendobj\n",
payload.len()
);
let stream_at = out.len();
let _ = write!(
out,
"5 0 obj <<\n /Type /XRef\n /Filter /ASCIIHexDecode\n /Root 1 0 R\n \
/Size 8\n /Index [1 4]\n /W [1 2 1]\n>>\nstream\n\
01 {plain:04X} 00\n02 0004 00\n02 0004 01\n01 {archive:04X} 00\n\
endstream\nendobj\n"
);
let base_at = out.len();
let _ = write!(
out,
"xref\n0 2\n\
0000000000 65535 f \n\
{plain:010} 00000 n \n\
4 1\n\
{archive:010} 00000 n \n\
6 2\n\
{pages:010} 00000 n \n\
{page:010} 00000 n \n\
trailer << /Root 1 0 R /Size 8 >>\n\
startxref\n{base_at}\n%%EOF\n"
);
let table_at = out.len();
let _ = write!(
out,
"xref\n0 8\n\
0000000002 65535 f \n\
{plain:010} 00000 n \n\
0000000003 65535 f \n\
0000000000 65535 f \n\
{archive:010} 00000 n \n\
0000000000 65535 f \n\
{pages:010} 00000 n \n\
{page:010} 00000 n \n\
trailer << /Root 1 0 R /Size 8 /Prev {base_at} /XRefStm {stream_at} >>\n\
startxref\n{table_at}\n%%EOF\n"
);
(out.into_bytes(), archive)
}
#[test]
fn a_classic_tables_free_entry_does_not_undo_the_xref_stms_compressed_one() {
let (file, archive) = hybrid_freeing_its_compressed_objects();
let xref = read(&file).expect("a hybrid chain");
for (num, index) in [(2u32, 0u32), (3, 1)] {
assert_eq!(
xref.entry(num),
Some(Entry::InObjStream {
stream: pdfrum_object::ObjRef::new(4, 0),
index,
}),
"object {num} must stay compressed, not be freed by the table"
);
}
assert_eq!(xref.entry(4), Some(Entry::Offset(archive as u64)));
}
#[test]
fn the_objects_such_a_file_hides_in_its_stream_actually_resolve() {
let (file, _) = hybrid_freeing_its_compressed_objects();
let doc = load(Arc::from(file), &LoadOptions::default()).expect("the document loads");
assert!(
!doc.xref_was_rebuilt(),
"the chain must carry this file, or the test proves nothing"
);
for (num, kind) in [(2u32, "Two"), (3, "Three")] {
let object = doc
.fetch(pdfrum_object::ObjRef::new(num, 0))
.expect("the object resolves");
let name = object
.as_dict()
.and_then(|d| d.get(&pdfrum_object::Name::new(b"Kind"), &doc))
.and_then(|v| v.as_name().map(|n| n.as_bytes().to_vec()));
assert_eq!(
name.as_deref(),
Some(kind.as_bytes()),
"object {num} must resolve out of the object stream"
);
}
}