Merge PR #2397: Issue #2365: a rebuilt file keeps the /Info and /ID of its cross-reference stream

crates/cos/src/document.rs: #2220 moved the header scan into object_headers; it also returns the position after `obj`, and reconstruct applies this PR's /XRef candidate check (same 1024-byte window) to each header there. Tests: kept both sides.
This commit is contained in:
aiongg
2026-10-22 01:47:39 +00:00
4 changed files with 238 additions and 20 deletions
+95 -11
View File
@@ -1952,7 +1952,7 @@ impl Document {
return;
}
let mut located = BTreeMap::new();
for (num, offset, generation) in object_headers(&self.data) {
for (num, offset, generation, _) in object_headers(&self.data) {
located.insert(num, (offset, generation));
}
let mut filled = entries.to_map(&self.data);
@@ -1989,9 +1989,17 @@ impl Document {
let data = self.data.clone();
let mut entries: BTreeMap<u32, XrefEntry> = BTreeMap::new();
let mut objstms = Vec::new();
for (num, offset, generation) in object_headers(&data) {
// 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();
for (num, offset, generation, body) in object_headers(&data) {
entries.insert(num, XrefEntry::InFile { offset, generation });
objstms.push(num);
if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
&& let Ok(at) = usize::try_from(offset)
{
xref_streams.push(at);
}
}
if entries.is_empty() {
return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
@@ -2015,20 +2023,44 @@ 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. One whose
// /Root is a catalog (as `root_is_catalog` judges it) beats one whose /Root is another
// dictionary, such as /Info, wherever it is; an FDF's /Root (no /Type, no /Pages) still
// counts when nothing better is there.
// 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 rank = |at: usize, d: &Dict| {
let root = self.try_get(d.reference(b"Root")?.num).ok()?;
let Object::Dict(c) = &*root else { return None };
Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at))
};
let mut trailer: Option<((bool, 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(_))))
&& let Some(r) = rank(p, &d)
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
{
trailer = Some(d);
trailer = Some((r, d));
}
}
for at in xref_streams {
if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) {
continue;
}
if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None)
&& s.dict.name(b"Type") == Some(b"XRef")
&& let Some(r) = rank(at, &s.dict)
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
{
trailer = Some((r, s.dict));
}
}
let trailer = match trailer {
Some(t) => t,
Some((_, t)) => t,
None => {
let catalog = self
.entries
@@ -2042,7 +2074,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;
@@ -2071,8 +2105,9 @@ fn shifted_subsection_end(data: &[u8], at: usize) -> Option<u32> {
}
}
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation).
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16)> {
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position
/// after `obj`).
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16, usize)> {
let mut headers = Vec::new();
let mut i = 0;
while i < data.len() {
@@ -2087,7 +2122,7 @@ fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16)> {
let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::<u32>().ok());
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
if let Some(num) = num {
headers.push((num, i as u64, generation));
headers.push((num, i as u64, generation, lx.pos));
}
i = lx.pos;
continue;
@@ -3321,6 +3356,55 @@ endobj
assert_compact_matches_materialized(&doc);
}
/// 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()));
}
/// A rebuild prefers a trailer whose /Root is the catalog (as `root_is_catalog` judges it): a
/// later cross-reference stream whose /Root points at the Info dictionary does not replace an
/// earlier good trailer, or a full save would write the Info dictionary as /Root.
#[test]
fn reconstruction_skips_a_trailer_whose_root_is_not_the_catalog() {
let mut bytes =
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) >>"], "/Root 1 0 R /Info 3 0 R");
bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 1 1] /Root 3 0 R /Length 3 >>\nstream\nabc\nendstream\nendobj\n");
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.repair_log().iter().any(|l| l.contains("rebuilt")), "{:?}", doc.repair_log());
assert_eq!(doc.trailer().reference(b"Root").map(|r| r.num), Some(1), "the catalog, not the Info dictionary");
assert_eq!(doc.trailer().reference(b"Info").map(|r| r.num), Some(3));
}
#[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");
+65
View File
@@ -2439,6 +2439,71 @@ trailer << /Root 1 0 R >>
assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads");
}
/// #2365: the issue's fixture is two PDFs concatenated, the second cut off before its
/// cross-reference data, and the first's only trailer is a cross-reference stream. The
/// lazy reader refuses a file that needed repair, and lopdf's rebuild found no `trailer`
/// keyword and failed, so Document Properties showed every field empty. The rebuild now
/// takes the cross-reference stream's dictionary as the trailer.
#[test]
fn rebuilt_file_keeps_the_info_of_its_cross_reference_stream() {
let header = "%PDF-1.5\n";
let obj1 = "1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n";
let obj2 = "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n";
let obj3 = "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>endobj\n";
let obj4 = "4 0 obj<</Title(Kept)/Creator(Me)>>endobj\n";
let o1 = header.len();
let o2 = o1 + obj1.len();
let o3 = o2 + obj2.len();
let o4 = o3 + obj3.len();
let o5 = o4 + obj4.len();
let rows: Vec<u8> =
[[0u8, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [1, 0, o3 as u8, 0], [1, 0, o4 as u8, 0], [1, (o5 >> 8) as u8, o5 as u8, 0]]
.concat();
let mut bytes = format!("{header}{obj1}{obj2}{obj3}{obj4}").into_bytes();
bytes.extend_from_slice(format!("5 0 obj<</Type/XRef/Root 1 0 R/Info 4 0 R/Size 6/W[1 2 1]/Length {}>>stream\n", rows.len()).as_bytes());
bytes.extend_from_slice(&rows);
// A wrong startxref, and a second file after %%EOF: every reader rebuilds the table.
bytes.extend_from_slice(b"\nendstream\nendobj\nstartxref\n999999\n%%EOF\n%PDF-1.3\n");
bytes.extend_from_slice(&[b'x'; 1024]);
let info = inspect(Arc::new(bytes), None).unwrap();
assert_eq!(info.pages.len(), 1);
assert_eq!(info.title.as_deref(), Some("Kept"));
assert_eq!(info.creator.as_deref(), Some("Me"));
}
/// #2365 review: the rebuild prefers a trailer whose /Root is the catalog. A later
/// cross-reference stream whose /Root points at the Info dictionary used to replace the
/// earlier `trailer`, so Document Properties lost the catalog it should have read.
#[test]
fn rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog() {
let mut bytes = b"%PDF-1.5\n".to_vec();
for body in [
"<</Type/Catalog/Pages 2 0 R>>",
"<</Type/Pages/Kids[3 0 R]/Count 1>>",
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>",
"<</Title(Kept)/Creator(Me)>>",
] {
let n = bytes.iter().filter(|b| **b == b'\n').count();
bytes.extend_from_slice(format!("{n} 0 obj{body}endobj\n").as_bytes());
}
bytes.extend_from_slice(b"trailer\n<</Size 5/Root 1 0 R/Info 4 0 R>>\n");
bytes.extend_from_slice(b"5 0 obj<</Type/XRef/Root 4 0 R/Size 6/W[1 2 1]/Length 4>>stream\n\x01\x00\x09\x00\nendstream\nendobj\n");
// A wrong startxref, and a second file after %%EOF: every reader rebuilds the table.
bytes.extend_from_slice(b"startxref\n999999\n%%EOF\n%PDF-1.3\n");
bytes.extend_from_slice(&[b'x'; 1024]);
let info = inspect(Arc::new(bytes.clone()), None).unwrap();
assert_eq!(info.pages.len(), 1);
assert_eq!(info.title.as_deref(), Some("Kept"));
// A later stream whose /Root names the catalog's number with another generation does
// not count either: loading `1 1 R` finds no such object.
let at = bytes.windows(10).position(|w| w == b"/Root 4 0 ").unwrap();
bytes[at..at + 10].copy_from_slice(b"/Root 1 1 ");
let info = inspect(Arc::new(bytes), None).unwrap();
assert_eq!((info.pages.len(), info.title.as_deref()), (1, Some("Kept")));
}
#[test]
fn lazy_structure_matches_the_compatibility_inspector() {
// Each fixture as lopdf writes it, and with its objects in object streams.
+1 -1
View File
File diff suppressed because one or more lines are too long
+77 -8
View File
@@ -1627,8 +1627,38 @@ impl Reader<'_> {
Some((number, generation))
}
/// Newest backwards-scanned `trailer` whose `/Root` references a scanned object.
/// Newest backwards-scanned `trailer` whose `/Root` references a scanned object, one whose
/// `/Root` is the catalog first.
fn find_latest_trailer(&self, xref: &Xref) -> Option<(usize, Dictionary)> {
// PdfCraft patch: (#2365) an in-file /Root must also match its generation, as
// `get_offset` requires when the root is loaded.
let usable = |dict: &Dictionary| {
dict.get(b"Root")
.and_then(Object::as_reference)
.is_ok_and(|root| match xref.entries.get(&root.0) {
Some(XrefEntry::Normal { generation, .. }) => *generation == root.1,
entry => entry.is_some(),
})
};
// PdfCraft patch: (#2365) a candidate whose /Root is the catalog (`/Type /Catalog` or
// `/Pages`) beats one whose /Root is another dictionary, such as /Info, wherever it is;
// the other kind is still taken when nothing better is there (an FDF's /Root).
let is_catalog = |dict: &Dictionary| {
let Ok(root) = dict.get(b"Root").and_then(Object::as_reference) else {
return false;
};
let Some(XrefEntry::Normal { offset, generation }) = xref.entries.get(&root.0) else {
return false;
};
if *generation != root.1 {
return false;
}
parser::indirect_object(self.buffer, *offset as usize, None, self, &mut HashSet::new(), None)
.ok()
.and_then(|(_, object)| object.as_dict().map(|d| d.has_type(b"Catalog") || d.has(b"Pages")).ok())
.unwrap_or(false)
};
let mut latest: Option<(bool, usize, Dictionary)> = None;
let mut bound = self.buffer.len();
for _ in 0..MAX_TRAILER_CANDIDATES {
let Some(pos) = Reader::search_substring(&self.buffer[..bound], b"trailer", 0) else {
@@ -1643,15 +1673,54 @@ impl Reader<'_> {
let Ok((_, dict)) = parser::dictionary(&after_keyword[dict_start..]) else {
continue;
};
if dict
.get(b"Root")
.and_then(Object::as_reference)
.is_ok_and(|root| xref.entries.contains_key(&root.0))
{
return Some((pos, dict));
if usable(&dict) {
let catalog = is_catalog(&dict);
if latest.is_none() || catalog {
latest = Some((catalog, pos, dict));
}
if catalog {
break;
}
}
}
None
// PdfCraft patch: (#2365) a file whose only trailer is a cross-reference stream has
// no `trailer` keyword, so the scan above found nothing and the rebuild failed; its
// /Info and /ID were then lost. Such a stream's dictionary is the trailer, and the
// latest one in the file wins, as a later `trailer` keyword would.
const HEADER_WINDOW: usize = 32;
const DICT_WINDOW: usize = 4096;
for entry in xref.entries.values() {
let XrefEntry::Normal { offset, .. } = entry else {
continue;
};
let offset = *offset as usize;
if latest.as_ref().is_some_and(|(catalog, pos, _)| *catalog && *pos >= offset) {
continue;
}
let Some(head) = self.buffer.get(offset..) else {
continue;
};
let Some(dict_start) = head[..cmp::min(head.len(), HEADER_WINDOW)]
.windows(2)
.position(|window| window == b"<<")
else {
continue;
};
let dict_bytes = &head[dict_start..cmp::min(head.len(), dict_start + DICT_WINDOW)];
if !dict_bytes.windows(5).any(|window| window == b"/XRef") {
continue;
}
let Ok((_, dict)) = parser::dictionary(&head[dict_start..]) else {
continue;
};
if dict.has_type(b"XRef") && usable(&dict) {
let rank = (is_catalog(&dict), offset);
if latest.as_ref().is_none_or(|(catalog, pos, _)| rank > (*catalog, *pos)) {
latest = Some((rank.0, offset, dict));
}
}
}
latest.map(|(_, pos, dict)| (pos, dict))
}
/// Some generators write `startxref` (or trailer `Prev`) values that are