Merge PR #2220: fix(cos): find objects a broken cross-reference table leaves out

This commit is contained in:
aiongg
2026-10-22 01:45:53 +00:00
2 changed files with 377 additions and 32 deletions
+350 -32
View File
@@ -268,6 +268,20 @@ impl XrefIndex {
}
}
/// Every entry, newest section first, as the materialized reader keeps them.
fn to_map(&self, file: &[u8]) -> BTreeMap<u32, XrefEntry> {
match self {
Self::Materialized(entries) => entries.as_ref().clone(),
Self::Compact { revisions, .. } => {
let mut numbers = Vec::new();
for revision in revisions {
revision.extend_numbers(&mut numbers);
}
numbers.into_iter().filter_map(|num| self.lookup(num, file).map(|entry| (num, entry))).collect()
}
}
}
fn max_num(&self) -> u32 {
match self {
Self::Compact { max_num, .. } => *max_num,
@@ -397,6 +411,10 @@ pub struct Document {
revisions: Arc<Vec<Revision>>,
/// See [`Self::xref_oddities`].
xref_oddities: Arc<Vec<(u64, u32)>>,
/// Where the file's object headers are, as (offset, generation) of the last header of each
/// number. Filled only when the cross-reference data proved inconsistent; see
/// `fill_xref_gaps`.
located: Arc<BTreeMap<u32, (u64, u16)>>,
repair_log: Arc<Vec<String>>,
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
@@ -519,6 +537,7 @@ impl Document {
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
@@ -600,6 +619,7 @@ impl Document {
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
@@ -627,6 +647,7 @@ impl Document {
doc.trailer = trailer;
doc.revisions = Arc::new(revisions);
doc.xref_oddities = Arc::new(oddities);
doc.fill_xref_gaps(&mut log);
// Authenticate before checking the catalog: it may sit in an encrypted object
// stream, which reads as garbage until the key is known.
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
@@ -1162,6 +1183,7 @@ impl Document {
// Offsets relative to a shifted header, or simply wrong: try both fixes.
at(off)
.or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) })
.or_else(|| self.located.get(&num).and_then(|(o, _)| usize::try_from(*o).ok()).and_then(at))
.or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o)))
.ok_or(CosError::MissingObject(num))
}
@@ -1532,7 +1554,7 @@ impl Document {
if !valid_xref_range(start, count) || count > 10_000_000 {
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
}
let shifted = subsection_start(data, lx.pos, start, count);
let shifted = subsection_start(data, self.header_offset, lx.pos, start, count);
renumbered |= shifted != start;
let start = shifted;
let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) };
@@ -1808,7 +1830,7 @@ impl Document {
if !valid_xref_range(start, count) || count > 10_000_000 {
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
}
let shifted = subsection_start(data, lx.pos, start, count);
let shifted = subsection_start(data, self.header_offset, lx.pos, start, count);
if shifted != start {
log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0"));
}
@@ -1912,32 +1934,63 @@ impl Document {
Ok((s.dict.clone(), true))
}
/// Cross-reference data that leaves out objects the file contains: a subsection numbered one
/// too low (object 0 in use, the last object without an entry), or an in-use entry at offset 0
/// that nothing else replaces. (A number no section lists is not evidence: it is a null
/// object, §7.3.10, and valid files have them below an oversized `/Size`.) Viewers rebuild
/// the table and show these objects; reading them as null would drop them from every copy.
/// When one of those shows, the file is scanned once for object headers, and wrong offsets
/// are looked up there (`load`). Only the numbers the damage leaves out take the last
/// header's location: one whose entry was set aside, or the last object of a subsection
/// numbered one too low. Any other unlisted number stays null, and existing
/// entries are never replaced: an object freed by an update stays freed.
fn fill_xref_gaps(&mut self, log: &mut Vec<String>) {
let entries = &self.entries;
let shifted = matches!(entries.lookup(0, &self.data), Some(XrefEntry::InFile { .. }));
let set_aside = self.xref_oddities.iter().any(|(_, num)| entries.lookup(*num, &self.data).is_none());
if !(shifted || set_aside) {
return;
}
let mut located = BTreeMap::new();
for (num, offset, generation) in object_headers(&self.data) {
located.insert(num, (offset, generation));
}
let mut filled = entries.to_map(&self.data);
// A subsection `0 N` whose rows are those of objects 1 to N leaves out object N, whatever
// newer sections say about its neighbours.
let past_shift: HashSet<u32> = if shifted {
self.revisions
.iter()
.filter(|r| !r.is_stream)
.filter_map(|r| usize::try_from(r.xref_offset).ok().map(|o| if r.header_relative { o.saturating_add(self.header_offset) } else { o }))
.filter_map(|at| shifted_subsection_end(&self.data, at))
.collect()
} else {
HashSet::new()
};
let mut found = 0usize;
for (&num, &(offset, generation)) in &located {
let left_out = past_shift.contains(&num) || self.xref_oddities.iter().any(|(_, n)| *n == num);
if num != 0 && !filled.contains_key(&num) && left_out {
filled.insert(num, XrefEntry::InFile { offset, generation });
found += 1;
}
}
if found > 0 {
log.push(format!("the cross-reference data leaves out {found} objects the file contains; located them by their object headers"));
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled)));
}
self.located = Arc::new(located);
}
/// Rebuild the cross-reference index by scanning for `N G obj` headers (§7.5.4 recovery).
fn reconstruct(&mut self, log: &mut Vec<String>) -> Result<(), CosError> {
let data = self.data.clone();
let mut entries: BTreeMap<u32, XrefEntry> = BTreeMap::new();
let mut i = 0;
let mut objstms = 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'>';
if at_start && data[i].is_ascii_digit() {
let mut lx = Lexer::new(&data, i);
let n = lx.token();
lx.skip_ws();
let g = lx.token();
if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") {
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 {
entries.insert(num, XrefEntry::InFile { offset: i as u64, generation });
objstms.push(num);
}
i = lx.pos;
continue;
}
}
i += 1;
for (num, offset, generation) in object_headers(&data) {
entries.insert(num, XrefEntry::InFile { offset, generation });
objstms.push(num);
}
if entries.is_empty() {
return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
@@ -1998,6 +2051,52 @@ impl Document {
}
}
/// For a classic table at `at` whose first subsection is `0 N` and opens with an in-use row
/// (the rows of objects 1 to N), the number N its rows really end at.
fn shifted_subsection_end(data: &[u8], at: usize) -> Option<u32> {
let mut lx = Lexer::new(data, at);
if !lx.eat_keyword(b"xref") {
return None;
}
let (Ok(Object::Int(0)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { return None };
lx.skip_ws();
let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::<u64>().ok());
lx.skip_ws();
lx.token();
lx.skip_ws();
match (offset, lx.token()) {
(Some(offset), b"n") if offset != 0 => u32::try_from(count).ok().filter(|n| *n > 0),
_ => None,
}
}
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation).
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16)> {
let mut headers = Vec::new();
let mut i = 0;
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'>';
if at_start && data[i].is_ascii_digit() {
let mut lx = Lexer::new(data, i);
let n = lx.token();
lx.skip_ws();
let g = lx.token();
if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") {
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));
}
i = lx.pos;
continue;
}
}
i += 1;
}
headers
}
pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<usize> {
let to = to.min(hay.len());
if from >= to || needle.is_empty() || to - from < needle.len() {
@@ -2028,9 +2127,10 @@ fn generated_id(seed: &[u8; 32]) -> Vec<u8> {
/// The object number a classic xref subsection really starts at. A common producer bug heads the
/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so
/// every row would be read one number too high and object 1 as free. Such a subsection is read
/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows`
/// is the position just after the subsection header.
fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 {
/// from 0 when its second row, read that way, locates an object that is indeed numbered 1, at
/// that offset or, as `load` reads offsets, relative to a header shifted by `header_offset`.
/// `rows` is the position just after the subsection header.
fn subsection_start(data: &[u8], header_offset: usize, rows: usize, start: i64, count: i64) -> i64 {
if start != 1 || count < 2 {
return start;
}
@@ -2045,12 +2145,15 @@ fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 {
};
let (Some(0), Some(65535), true) = row() else { return start };
let (Some(second), _, false) = row() else { return start };
let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start };
let mut obj = Lexer::new(data, at);
match (obj.object(), obj.object()) {
(Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0,
_ => start,
}
let object_one = |at: usize| {
if at >= data.len() {
return false;
}
let mut obj = Lexer::new(data, at);
matches!((obj.object(), obj.object()), (Ok(Object::Int(1)), Ok(Object::Int(_)))) && obj.eat_keyword(b"obj")
};
let Ok(second) = usize::try_from(second) else { return start };
if object_one(second) || (header_offset != 0 && object_one(second.saturating_add(header_offset))) { 0 } else { start }
}
/// Object numbers must fit the representation used by references and the xref map.
@@ -2305,6 +2408,221 @@ endobj
assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1")));
}
/// A one-page scan: the page draws image 5 with content stream 4.
fn scanned_page() -> Vec<u8> {
build(
&[
"<< /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] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>",
"<< /Length 21 >>\nstream\n10 0 0 10 0 0 cm /Im0 Do\nendstream",
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream",
],
"/Root 1 0 R",
)
}
/// `bytes` with its cross-reference table replaced by `table` (the lines after `xref`).
fn with_table(bytes: &[u8], table: &[String], size: usize) -> Vec<u8> {
let text = String::from_utf8_lossy(bytes).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
let mut out = bytes[..at].to_vec();
out.extend_from_slice(format!("xref\n{}\ntrailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{at}\n%%EOF\n", table.join("\n")).as_bytes());
out
}
/// The table's entry lines, object 0 first.
fn table_entries(bytes: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(bytes).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
text[at..].lines().skip(2).take_while(|l| !l.starts_with("trailer")).map(str::to_string).collect()
}
/// The classic off-by-one: the subsection says it starts at 0 but lists objects 1 to 5, so
/// the last object has no entry. Viewers rebuild the table and show the page; a reader that
/// took the missing entry for a null object dropped the image when pages were copied (#1628).
#[test]
fn an_off_by_one_xref_subsection_still_finds_the_last_object() {
let base = scanned_page();
let entries = table_entries(&base);
let mut table = vec!["0 5".to_string()];
table.extend(entries[1..6].iter().cloned());
let doc = Document::open(Arc::new(with_table(&base, &table, 5))).unwrap();
let image = doc.get(ObjRef::new(5, 0));
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "{image:?}");
assert!(matches!(&*image, Object::Stream(_)));
assert!(matches!(&*doc.get(ObjRef::new(4, 0)), Object::Stream(s) if &s.raw[..] == b"10 0 0 10 0 0 cm /Im0 Do"));
assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "{:?}", doc.repair_log());
}
/// An "in use" entry at offset 0 says nothing about where the object is: the object is
/// found by its header, and the repair is recorded.
#[test]
fn an_object_listed_at_offset_zero_is_found_by_its_header() {
let base = scanned_page();
for num in [4usize, 5] {
let mut table = vec!["0 6".to_string()];
table.extend(table_entries(&base));
table[num + 1] = "0000000000 00000 n ".into();
let doc = Document::open(Arc::new(with_table(&base, &table, 6))).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(num as u32, 0)), Object::Stream(_)), "object {num}");
assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "object {num}: {:?}", doc.repair_log());
}
}
/// A valid table that simply does not list every number below `/Size` is not repaired: an
/// unlisted number is a null object (§7.3.10), even when an old body with that number is
/// still in the file.
#[test]
fn a_consistent_table_with_unlisted_numbers_is_taken_as_it_is() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let at = String::from_utf8_lossy(&base).rfind("\nxref\n").unwrap() + 1;
let mut bytes = base[..at].to_vec();
bytes.extend_from_slice(b"4 0 obj\n(stale)\nendobj\n");
let mut table = vec!["0 4".to_string()];
table.extend(table_entries(&base));
let doc = Document::open(Arc::new(with_table(&[&bytes[..], &base[at..]].concat(), &table, 6))).unwrap();
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert_eq!(*doc.get(ObjRef::new(4, 0)), Object::Null);
assert!(matches!(&*doc.get(ObjRef::new(3, 0)), Object::String(_)));
}
/// Filling the gaps of a damaged table never brings back an object that an incremental
/// update freed: its body is still in the file, but the newer section says it is gone.
#[test]
fn an_object_freed_by_an_update_stays_freed_when_the_table_is_repaired() {
let base = scanned_page();
// The original table lists the content stream at offset 0, so the gaps get filled.
let mut table = vec!["0 6".to_string()];
table.extend(table_entries(&base));
table[5] = "0000000000 00000 n ".into();
let mut bytes = with_table(&base, &table, 6);
let prev = String::from_utf8_lossy(&bytes).rfind("\nxref\n").unwrap() + 1;
let section = bytes.len();
bytes.extend_from_slice(
format!("xref\n5 1\n0000000000 00001 f \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(4, 0)), Object::Stream(_)), "the gap is filled");
assert_eq!(*doc.get(ObjRef::new(5, 0)), Object::Null, "the freed image stays freed");
assert_eq!(doc.xref_entry(5), Some(XrefEntry::Free { next_generation: 1 }));
}
/// Repairing a damaged table fills only the numbers the damage leaves out: an old body of a
/// number no section lists, under an oversized `/Size`, stays a null object.
#[test]
fn a_repaired_table_leaves_an_unlisted_number_null() {
let base = scanned_page();
let at = String::from_utf8_lossy(&base).rfind("\nxref\n").unwrap() + 1;
let entries = table_entries(&base);
let mut offset_zero = vec!["0 6".to_string()];
offset_zero.extend(entries.iter().cloned());
offset_zero[5] = "0000000000 00000 n ".into();
let mut off_by_one = vec!["0 5".to_string()];
off_by_one.extend(entries[1..6].iter().cloned());
for (name, table, found) in [("offset 0", offset_zero, 4), ("off by one", off_by_one, 5)] {
let bytes = [&base[..at], b"7 0 obj\n(stale)\nendobj\n", &base[at..]].concat();
let doc = Document::open(Arc::new(with_table(&bytes, &table, 9))).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(found, 0)), Object::Stream(_)), "{name}: the gap is filled");
assert_eq!(*doc.get(ObjRef::new(7, 0)), Object::Null, "{name}: {:?}", doc.repair_log());
assert_eq!(doc.xref_entry(7), None, "{name}");
}
}
/// The last object of a subsection numbered one too low is filled from that subsection,
/// whatever later happens to the row before it: an incremental save that rewrites object 4,
/// or object 4's own row at offset 0.
#[test]
fn the_last_object_of_a_shifted_subsection_is_filled_whatever_its_neighbour_says() {
let base = scanned_page();
let entries = table_entries(&base);
let mut off_by_one = vec!["0 5".to_string()];
off_by_one.extend(entries[1..6].iter().cloned());
let shifted = with_table(&base, &off_by_one, 5);
let prev = String::from_utf8_lossy(&shifted).rfind("\nxref\n").unwrap() + 1;
let mut updated = shifted.clone();
let body = updated.len();
updated.extend_from_slice(b"4 0 obj\n<< /Length 21 >>\nstream\n10 0 0 10 0 0 cm /Im0 Do\nendstream\nendobj\n");
let section = updated.len();
updated.extend_from_slice(
format!("xref\n4 1\n{body:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let mut neighbour_at_zero = off_by_one.clone();
neighbour_at_zero[5] = "0000000000 00000 n ".into();
for (name, bytes) in [("updated", updated), ("row 4 at offset 0", with_table(&base, &neighbour_at_zero, 5))] {
let doc = Document::open(Arc::new(bytes)).unwrap();
let image = doc.get(ObjRef::new(5, 0));
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "{name}: {:?}", doc.repair_log());
}
}
/// The image the first page draws as `/Im1`, followed through the page tree.
fn first_page_image(doc: &Document) -> Arc<Object> {
let catalog = doc.get(doc.root().unwrap());
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]);
let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap());
let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
doc.resolve(xobjects.as_dict().unwrap().get(b"Im1").unwrap())
}
/// The opposite off-by-one (#1628): the subsection says it starts at 1, but its first entry
/// is object 0's free-list head, so every entry lands one number too high and object 1, the
/// image, reads as free. The offset the table gives object 2 holds `1 0 obj`, so the
/// subsection is read from 0, and the image survives a full save.
#[test]
fn a_subsection_from_one_that_opens_with_the_free_list_head_is_read_from_zero() {
let base = build(
&[
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\nABCDEFGHIJKL\nendstream",
"<< /Length 29 >>\nstream\nq 72 0 0 72 0 0 cm /Im1 Do Q\nendstream",
"<< /Type /Page /Parent 4 0 R /MediaBox [0 0 72 72] /Resources << /XObject << /Im1 1 0 R >> >> /Contents 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Catalog /Pages 4 0 R >>",
"<< /Subject (synthetic) >>",
],
"/Root 5 0 R /Info 6 0 R",
);
// /Size 7 is one short of the shifted numbers; /Size 8 covers them all. Junk before the
// header moves every object past the offsets the table gives; it is not a comment, which
// the lexer would skip on its way to `1 0 obj` anyway.
for (size, junk) in [(7, ""), (8, ""), (7, "Content-Type: application/pdf\n\n")] {
let text = String::from_utf8_lossy(&base).replace("\nxref\n0 7\n", "\nxref\n1 7\n").replace("/Size 7 ", &format!("/Size {size} "));
let doc = Document::open(Arc::new(format!("{junk}{text}").into_bytes())).unwrap();
let image = first_page_image(&doc);
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "/Size {size} {junk:?}: {image:?}");
assert_eq!(doc.xref_entry(0), Some(XrefEntry::Free { next_generation: 65535 }), "/Size {size} {junk:?}");
assert!(doc.repair_log().iter().any(|l| l.contains("from 0")), "/Size {size} {junk:?}: {:?}", doc.repair_log());
let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let image = first_page_image(&saved);
assert!(matches!(&*image, Object::Stream(s) if &s.raw[..] == b"ABCDEFGHIJKL"), "/Size {size} {junk:?}: {image:?}");
}
}
/// Object 1 really freed: the subsection from 1 opens with a free entry, but the next entry's
/// offset holds object 2, not object 1. The table is taken as it is, and the old body of
/// object 1 still in the file stays freed.
#[test]
fn a_subsection_from_one_whose_next_offset_holds_object_two_is_taken_as_it_is() {
let base = build(&["(freed)", "<< /Type /Catalog /Pages 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 2 0 R");
let entries = table_entries(&base);
let mut table = vec!["1 3".to_string(), "0000000000 65535 f ".to_string()];
table.extend(entries[2..].iter().cloned());
let text = String::from_utf8_lossy(&base).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
for junk in ["", "Content-Type: application/pdf\n\n"] {
let mut bytes = junk.as_bytes().to_vec();
bytes.extend_from_slice(&base[..at]);
bytes.extend_from_slice(format!("xref\n{}\ntrailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n{at}\n%%EOF\n", table.join("\n")).as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.xref_entry(1), Some(XrefEntry::Free { next_generation: 65535 }), "{junk:?}");
assert_eq!(*doc.get(ObjRef::new(1, 0)), Object::Null, "{junk:?}: the freed object stays freed");
assert!(matches!(&*doc.get(ObjRef::new(2, 0)), Object::Dict(d) if d.name(b"Type") == Some(&b"Catalog"[..])), "{junk:?}");
assert!(!doc.repair_log().iter().any(|l| l.contains("from 0")), "{junk:?}: {:?}", doc.repair_log());
assert!(!junk.is_empty() || doc.repair_log().is_empty(), "{:?}", doc.repair_log());
}
}
#[test]
fn opening_an_exhausted_classic_xref_repairs_and_allocates() {
// The extra in-use row need not be loaded to exhaust the next object number.
+27
View File
@@ -774,6 +774,33 @@ fn resources_split_between_page_and_tree_travel_together() {
}
}
#[test]
fn a_scan_whose_xref_misses_the_image_combines_with_the_image() {
// A cheap image-to-PDF converter's table: "0 5" followed by the entries of objects 1 to 5,
// so the image (object 5) has none. Viewers rebuild the table and show the scan; the
// combined copy got a null image and came out blank (#1628).
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>".into(),
"<< /Length 26 >>\nstream\n100 0 0 100 0 0 cm /Im0 Do\nendstream".into(),
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream".into(),
];
let good = build(&b, "/Root 1 0 R");
let text = String::from_utf8_lossy(&good).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
let entries: Vec<&str> = text[at..].lines().skip(3).take(5).collect();
let mut bytes = good[..at].to_vec();
bytes.extend_from_slice(format!("xref\n0 5\n{}\ntrailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{at}\n%%EOF\n", entries.join("\n")).as_bytes());
let src = open(bytes);
let out = full_roundtrip(&combine(&[("scan", &src)]).unwrap());
let res = out.dict(page_dict(&out, 0).get(b"Resources").unwrap()).unwrap();
let image = res.get(b"XObject").and_then(|x| out.dict(x)).and_then(|d| d.get(b"Im0").map(|v| out.resolve(v))).expect("the page lists its image");
let Object::Stream(image) = &*image else { panic!("the image is a stream, not {image:?}") };
assert_eq!(image.dict.name(b"Subtype"), Some(&b"Image"[..]));
assert_eq!(image.decoded().unwrap(), b"A");
}
#[test]
fn links_are_rewired_to_copied_pages_or_dropped() {
let a = doc_a();