Issue #2366: a /Prev reference to an object holding the offset reads that offset

iText's indirectPrev.pdf writes /Prev 6 0 R, and object 6, listed in the
same update, holds 470, the offset of the first section. Both readers took
the object number as the offset, so cos found no section at 6, rebuilt the
file from object headers and lost its revision list, and hayro's chain
stopped there.

When /Prev is a reference, the sections read so far (cos) or the section's
own entries (hayro, which now reads them before following /Prev) are asked
for that object first. If it holds a non-negative integer, that is the
offset; otherwise the object number still is, as the tiff2pdf files need.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH
This commit is contained in:
wstock
2026-10-20 11:32:17 +00:00
co-authored by Claude Opus 5.5
parent c351febe85
commit 3af350026f
3 changed files with 152 additions and 43 deletions
+62 -8
View File
@@ -1476,7 +1476,7 @@ impl Document {
}
}
sections.push(XrefRevisionSection { primary, hybrid });
next = prev_offset(&sect_trailer, off, log);
next = prev_offset(data, &sect_trailer, off, |num| sections.iter().find_map(|s| s.lookup(num, data)), log);
if trailer.is_none() {
trailer = Some(sect_trailer);
}
@@ -1764,7 +1764,7 @@ impl Document {
for (num, e) in sect {
entries.entry(num).or_insert(e);
}
next = prev_offset(&sect_trailer, off, log);
next = prev_offset(data, &sect_trailer, off, |num| entries.get(&num).copied(), log);
if trailer.is_none() {
trailer = Some(sect_trailer);
}
@@ -1991,15 +1991,36 @@ impl Document {
}
/// The offset of the section before the one whose trailer this is (`/Prev`), if any. Some writers
/// (tiff2pdf; JHOVE's PDF-HUL-157 files) give it as a reference, `N 0 R`: other readers rebuild
/// such a file, and the sections before are at N, so it is read as that, with a note. Any other
/// value that is not an offset is noted and ends the chain.
fn prev_offset(trailer: &Dict, off: u64, log: &mut Vec<String>) -> Option<u64> {
/// give it as a reference, `N 0 R`. When the sections read so far (`lookup`) place that object
/// and it holds an offset (iText's `indirectPrev.pdf`), that is the offset; otherwise, as the
/// tiff2pdf files in JHOVE's PDF-HUL-157 cases need, the sections before are at N. Either way the
/// reference is noted. Any other value that is not an offset is noted and ends the chain.
fn prev_offset(data: &[u8], trailer: &Dict, off: u64, lookup: impl Fn(u32) -> Option<XrefEntry>, log: &mut Vec<String>) -> Option<u64> {
match trailer.get(b"Prev") {
Some(Object::Int(p)) if *p >= 0 => Some(*p as u64),
Some(Object::Ref(r)) => {
log.push(format!("the trailer at {off} gives /Prev as the reference {} 0 R; reading it as offset {}", r.num, r.num));
Some(u64::from(r.num))
let held = match lookup(r.num) {
Some(XrefEntry::InFile { offset, generation }) if generation == r.generation => {
usize::try_from(offset).ok().and_then(|at| parse_indirect(data, at, &|_| None).ok()).and_then(|(at, value)| match value {
Object::Int(p) if at == *r && p >= 0 => Some(p as u64),
_ => None,
})
}
_ => None,
};
match held {
Some(p) => {
log.push(format!(
"the trailer at {off} gives /Prev as the reference {} {} R; reading the offset {p} it holds",
r.num, r.generation
));
Some(p)
}
None => {
log.push(format!("the trailer at {off} gives /Prev as the reference {} 0 R; reading it as offset {}", r.num, r.num));
Some(u64::from(r.num))
}
}
}
Some(_) => {
log.push(format!("the trailer at {off} has a /Prev that is not an offset; the sections before it are unreachable"));
@@ -2890,6 +2911,39 @@ endobj
);
}
/// 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);
}
#[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");