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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
This commit is contained in:
wstock
2026-10-18 11:25:48 +00:00
co-authored by Claude Fable 5.1
parent 50f261d70c
commit 227b4bfcf0
+62 -6
View File
@@ -1867,6 +1867,9 @@ impl Document {
let mut entries: BTreeMap<u32, XrefEntry> = 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");