Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 94 additions & 22 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3900,7 +3900,7 @@ fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
/// at the cross-reference table — a single corrupted byte in the offset is
/// enough. lopdf trusts that pointer outright and fails to load rather than
/// searching for the real table, unlike pypdf/pdfium which both recover by
/// locating it directly. This finds the real (classic, non-stream) `xref`
/// locating it directly. This finds a recoverable (classic, non-stream) `xref`
/// table by scanning for the keyword — validating that a plausible
/// subsection header follows, not just any standalone "xref" token, since
/// this crate processes untrusted input and a coincidental match inside
Expand All @@ -3912,11 +3912,17 @@ fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
/// transparently supersedes the broken one without needing to touch
/// anything already in the file.
///
/// Linearized PDFs commonly have two classic tables: the early table carries
/// `/Root` and `/Prev`, while the final table contains only the remaining
/// object entries. Preferring the newest table whose own trailer has `/Root`
/// prevents the repair from selecting the rootless final table and reporting
/// a zero-page document.
///
/// Doesn't cover cross-reference *streams* (`N 0 obj << /Type /XRef ...`,
/// used by some PDF 1.5+ writers instead of a classic table) — recovering
/// those needs the containing object's number, not just a byte offset.
fn recover_startxref_pointer(buf: &[u8]) -> Option<Vec<u8>> {
let xref_pos = find_last_valid_xref_table_start(buf)?;
let xref_pos = find_recoverable_xref_table_start(buf)?;

let mut repaired = Vec::with_capacity(buf.len() + 32);
repaired.extend_from_slice(buf);
Expand All @@ -3927,36 +3933,102 @@ fn recover_startxref_pointer(buf: &[u8]) -> Option<Vec<u8>> {
Some(repaired)
}

/// Finds the last standalone `xref` token in `buf` that is immediately
/// followed by a plausible classic cross-reference subsection header
/// (`<start-id> <count>`, e.g. "0 6") — the shape every real classic xref
/// table starts with. A single reverse byte scan: O(n) even on a
/// pathological buffer with many non-matching or non-standalone "xref"
/// occurrences, unlike repeatedly re-searching a shrinking prefix.
fn find_last_valid_xref_table_start(buf: &[u8]) -> Option<usize> {
/// Returns classic xref starts from newest to oldest, filtered by the same
/// subsection validation as the original single-table scan.
fn valid_xref_table_starts(buf: &[u8]) -> Vec<usize> {
const KEYWORD: &[u8] = b"xref";
if buf.len() < KEYWORD.len() {
return None;
}
let mut pos = buf.len() - KEYWORD.len();
let mut starts = Vec::new();
let mut pos = buf.len().saturating_sub(KEYWORD.len());
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

loop {
if &buf[pos..pos + KEYWORD.len()] == KEYWORD {
let before_ok = pos == 0 || buf[pos - 1].is_ascii_whitespace();
let after_ok = buf
if &buf[pos..pos + KEYWORD.len()] == KEYWORD
&& (pos == 0 || buf[pos - 1].is_ascii_whitespace())
&& buf
.get(pos + KEYWORD.len())
.is_none_or(|c| c.is_ascii_whitespace());
if before_ok && after_ok && looks_like_xref_subsection_header(buf, pos + KEYWORD.len())
{
return Some(pos);
}
.is_none_or(u8::is_ascii_whitespace)
&& looks_like_xref_subsection_header(buf, pos + KEYWORD.len())
{
starts.push(pos);
}
if pos == 0 {
return None;
return starts;
}
pos -= 1;
}
}

/// Prefer the newest classic table whose trailer directly names `/Root`; fall
/// back to the newest valid table for unusual trailer layouts that the parser
/// can still traverse.
fn find_recoverable_xref_table_start(buf: &[u8]) -> Option<usize> {
let mut newest = None;
for pos in valid_xref_table_starts(buf) {
newest = newest.or(Some(pos));
if xref_trailer_has_root(buf, pos) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return Some(pos);
}
}
newest
}

/// Checks the trailer immediately following a classic xref table for a root
/// reference. Bounding the search at the following `startxref` prevents an
/// unrelated `/Root` in a later object or stream from selecting the wrong
/// table.
fn xref_trailer_has_root(buf: &[u8], xref_pos: usize) -> bool {
let Some(trailer_pos) = find_standalone_keyword(buf, xref_pos, b"trailer") else {
return false;
};
let Some(startxref_pos) = find_standalone_keyword(buf, trailer_pos, b"startxref") else {
return false;
};
if startxref_pos <= trailer_pos {
return false;
}

let trailer = &buf[trailer_pos..startxref_pos];
let mut search_from = 0;
while let Some(relative_pos) = trailer[search_from..]
.windows(b"/Root".len())
.position(|window| window == b"/Root")
{
let after_name = search_from + relative_pos + b"/Root".len();
if trailer.get(after_name).is_none_or(u8::is_ascii_whitespace) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return true;
}
search_from = search_from + relative_pos + 1;
}
false
}

fn find_standalone_keyword(buf: &[u8], start: usize, keyword: &[u8]) -> Option<usize> {
if start >= buf.len() {
return None;
}

buf[start..]
.windows(keyword.len())
.position(|window| window == keyword)
.map(|relative_pos| start + relative_pos)
.filter(|&pos| {
(pos == 0 || buf[pos - 1].is_ascii_whitespace())
&& buf
.get(pos + keyword.len())
.is_none_or(u8::is_ascii_whitespace)
})
}

/// Finds the last standalone `xref` token in `buf` that is immediately
/// followed by a plausible classic cross-reference subsection header
/// (`<start-id> <count>`, e.g. "0 6") — the shape every real classic xref
/// table starts with. A single reverse byte scan: O(n) even on a
/// pathological buffer with many non-matching or non-standalone "xref"
/// occurrences, unlike repeatedly re-searching a shrinking prefix.
#[cfg(test)]
fn find_last_valid_xref_table_start(buf: &[u8]) -> Option<usize> {
valid_xref_table_starts(buf).into_iter().next()
}

/// Checks that `buf[pos..]` starts (after whitespace) with two
/// whitespace-separated runs of ASCII digits — `<start-id> <count>`, the
/// first subsection header of a classic PDF cross-reference table.
Expand Down
80 changes: 80 additions & 0 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4388,6 +4388,86 @@ fn test_process_pdf_recovers_corrupted_startxref_pointer() {
);
}

fn make_padded_linearized_pdf() -> Vec<u8> {
fn first_xref(main_xref_offset: usize, offsets: &[usize; 7]) -> Vec<u8> {
let mut out = b"%PDF-1.4\n".to_vec();
out.extend_from_slice(b"6 0 obj\n<< /Linearized 1 /N 1 /O 1 /T 9999 >>\nendobj\n");
out.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n");
for offset in &offsets[1..] {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size 7 /Root 1 0 R /Prev {main_xref_offset:010} >>\n\
startxref\n0\n%%EOF\n"
)
.as_bytes(),
);
out
}

let mut offsets = [0usize; 7];
offsets[6] = b"%PDF-1.4\n".len();
let prefix_len = first_xref(0, &offsets).len();

let content = b"BT /F1 12 Tf 72 720 Td (Hello World) Tj ET";
let bodies = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(),
format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content.len(),
String::from_utf8_lossy(content)
)
.into_bytes(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
];

let mut body = Vec::new();
for (index, object_body) in bodies.iter().enumerate() {
offsets[index + 1] = prefix_len + body.len();
body.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
body.extend_from_slice(object_body);
body.extend_from_slice(b"\nendobj\n");
}

let main_xref_offset = prefix_len + body.len();
let mut pdf = first_xref(main_xref_offset, &offsets);
let first_xref_offset =
b"%PDF-1.4\n".len() + b"6 0 obj\n<< /Linearized 1 /N 1 /O 1 /T 9999 >>\nendobj\n".len();
debug_assert_eq!(pdf.len(), prefix_len);
pdf.extend_from_slice(&body);

pdf.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
for offset in &offsets[1..=5] {
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!("trailer\n<< /Size 6 >>\nstartxref\n{first_xref_offset}\n%%EOF\n").as_bytes(),
);
pdf.extend(std::iter::repeat_n(0u8, 600));
pdf
}

#[test]
fn test_process_pdf_repairs_padded_linearized_xref_chain() {
let buf = make_padded_linearized_pdf();
let result = process_pdf_mem(&buf)
.expect("a padded linearized PDF should recover through its root-bearing xref");

assert_eq!(result.page_count, 1, "the root-bearing xref was not used");
assert_eq!(result.pdf_type, PdfType::TextBased);
assert!(
result
.markdown
.as_deref()
.is_some_and(|md| md.contains("Hello World")),
"the recovered page should retain its text, got {:?}",
result.markdown
);
}

/// Regression for #227: `extract_pages_markdown`'s per-page `needs_ocr`
/// must agree with `classify_pdf`/`detect_pdf_type` on the same page. The
/// fixture is a full-page raster "scan" with a single line of genuine
Expand Down