From 0e8ebf00e6afd61b292f05b3bebe028e1b1a170e Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:31:48 +0000 Subject: [PATCH] Issue #2366: a trailer whose /Prev is a reference still reaches the earlier sections The JHOVE PDF-HUL-157 files (tiff2pdf output) end their first trailer with `/Prev 297298 0 R`: the offset written as a reference. The chain reader took /Prev as an integer only, so the chain stopped after the first section without a word, and every object of the earlier sections (here pages 2-4 and their images) read as null. The document showed one page where Poppler and MuPDF show four, and a full save wrote the missing kids as null, so pages 2-4 were unreadable afterwards ("cycle in page tree", 1x1 renders). A reference-valued /Prev is now read as the offset it names, with a repair-log note; any other non-offset value is noted and ends the chain as before. Regression test: document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections. Verified on JA3-1998-PDF.pdf: four pages read, and after a Subject edit and a full save Poppler renders all four pages without a warning (before: "Kid object (page 2) is not an indirect reference (null)"). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh --- crates/cos/src/document.rs | 60 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 639edf582..af9997716 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1476,7 +1476,7 @@ impl Document { } } sections.push(XrefRevisionSection { primary, hybrid }); - next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64); + next = prev_offset(§_trailer, off, 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 = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64); + next = prev_offset(§_trailer, off, log); if trailer.is_none() { trailer = Some(sect_trailer); } @@ -1990,6 +1990,25 @@ 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) -> Option { + 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)) + } + Some(_) => { + log.push(format!("the trailer at {off} has a /Prev that is not an offset; the sections before it are unreachable")); + None + } + None => None, + } +} + pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option { let to = to.min(hay.len()); if from >= to || needle.is_empty() || to - from < needle.len() { @@ -2834,6 +2853,43 @@ endobj } } + /// 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()) + ); + } + #[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");