mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 19:19:10 -06:00
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:
co-authored by
Claude Fable 5.1
parent
50f261d70c
commit
227b4bfcf0
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user