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
+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.