From 227b4bfcf093bf171390e345a0d352843ab2e2ed Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:24:22 +0000 Subject: [PATCH] Issue #2365: a rebuilt file keeps the /Info and /ID of its cross-reference stream When the cross-reference chain is unusable (here: a second, truncated PDF appended after the first, so no startxref is in reach), the index is rebuilt from object headers and the trailer taken from the last `trailer` dictionary with a usable /Root. A file written with cross-reference streams has no `trailer` keyword, so the rebuild fell through to the catalog alone and /Info (and /ID, /Encrypt) were lost: Document Properties showed nothing, and a full save after editing one Info field wrote only that field and /ModDate. reconstruct() now also considers every /Type /XRef stream dictionary with a usable /Root, taking whichever trailer comes last in the file. Regression test: document::tests::reconstruction_takes_the_trailer_from_a_cross_reference_stream. Verified on the issue's fixture (openpreserve PDF-HUL-138/6.2017-0960.pdf): Creator, Producer, CreationDate and ModDate are read and kept by a full save. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh --- crates/cos/src/document.rs | 68 ++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e94355590..51358f525 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1867,6 +1867,9 @@ impl Document { let mut entries: BTreeMap = BTreeMap::new(); let mut i = 0; let mut objstms = Vec::new(); + // Offsets of objects that may be cross-reference streams: their dictionaries are the + // trailers of files that have no `trailer` keyword (ยง7.5.8). + let mut xref_streams = Vec::new(); while i < data.len() { // Object headers start at a line start (or after whitespace). let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>'; @@ -1882,6 +1885,9 @@ impl Document { entries.insert(num, XrefEntry::InFile { offset: i as u64, generation }); objstms.push(num); } + if find(&data, b"/XRef", lx.pos, lx.pos + 1024).is_some() { + xref_streams.push(i); + } i = lx.pos; continue; } @@ -1908,20 +1914,35 @@ impl Document { let object_count = entries.len(); self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries))); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); - // Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself. - let mut trailer = None; + // Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a + // usable /Root (last in the file, whichever kind), else the catalog itself. A file whose + // only trailers are cross-reference streams (and whose `startxref` is gone or points + // nowhere) keeps its /Info, /ID and /Encrypt this way. + let usable = |d: &Dict| d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_)))); + let mut trailer: Option<(usize, Dict)> = None; let mut from = 0; while let Some(p) = find(&data, b"trailer", from, data.len()) { from = p + 7; let mut lx = Lexer::new(&data, from); if let Ok(Object::Dict(d)) = lx.object() - && d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_)))) + && usable(&d) { - trailer = Some(d); + trailer = Some((p, d)); + } + } + for at in xref_streams { + if trailer.as_ref().is_some_and(|(p, _)| *p > at) { + continue; + } + if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None) + && s.dict.name(b"Type") == Some(b"XRef") + && usable(&s.dict) + { + trailer = Some((at, s.dict)); } } let trailer = match trailer { - Some(t) => t, + Some((_, t)) => t, None => { let catalog = self .entries @@ -1935,7 +1956,9 @@ impl Document { } }; let mut trailer = trailer; - for k in [&b"Prev"[..], b"XRefStm"] { + // What a cross-reference stream's dictionary carries for itself goes, as the chain + // readers drop it: a save writes its own. + for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { trailer.remove(k); } self.trailer = trailer; @@ -2696,6 +2719,39 @@ mod tests { } } + /// 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())); + } + #[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");