Skip to content

Commit d63ec4c

Browse files
artizclaude
andcommitted
docling-pdf: say why the xref repair declined
The repair either fires or it does not, and a decline looks exactly like a scan from the outside — so a report of "still going to OCR" cannot be told apart from "the wasm build is stale". Carry a reason out of pad_short_xref_entries and surface it through xref_repair_status, which the text_layer example now prints: repaired, declined (and why: an xref stream, several xref sections, an object after the table, a malformed entry), or padded-but-still-unloadable. Refs #157 Signed-off-by: artiz <artem.kustikov@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY5KAiquN4YpVf2PXEQkVT
1 parent 4b0b172 commit d63ec4c

2 files changed

Lines changed: 52 additions & 15 deletions

File tree

crates/docling-pdf/examples/text_layer.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ fn main() {
3535
Err(e) => {
3636
eprintln!("1. lopdf load: FAILED — {e}");
3737
eprintln!(" (the browser reports this as \"no embedded text layer\")");
38+
eprintln!(
39+
" xref repair: {}",
40+
docling_pdf::textparse::xref_repair_status(&bytes)
41+
);
3842
probe_tail(&bytes);
3943
}
4044
Ok(doc) => {

crates/docling-pdf/src/textparse.rs

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,22 @@ fn page_size(doc: &Document, page_id: lopdf::ObjectId) -> (f32, f32) {
634634
(612.0, 792.0)
635635
}
636636

637+
/// Why the cross-reference repair did or did not fire, for the `text_layer`
638+
/// diagnostic. A PDF that will not load is indistinguishable from a scan in
639+
/// production (both convert to nothing), so the reason has to be askable.
640+
pub fn xref_repair_status(bytes: &[u8]) -> String {
641+
if Document::load_mem(bytes).is_ok() {
642+
return "loads unaided; no repair needed".into();
643+
}
644+
match pad_short_xref_entries(bytes) {
645+
Ok(fixed) => match Document::load_mem(&fixed) {
646+
Ok(_) => "repaired: cross-reference entries padded to 20 bytes".into(),
647+
Err(e) => format!("padded the entries, but it still will not load: {e}"),
648+
},
649+
Err(why) => format!("repair declined — {why}"),
650+
}
651+
}
652+
637653
/// Load a PDF, repairing the one malformation that otherwise costs us the whole
638654
/// document: **19-byte cross-reference entries**.
639655
///
@@ -651,24 +667,30 @@ fn page_size(doc: &Document, page_id: lopdf::ObjectId) -> (f32, f32) {
651667
fn load_document(bytes: &[u8]) -> Option<Document> {
652668
match Document::load_mem(bytes) {
653669
Ok(doc) => Some(doc),
654-
Err(_) => Document::load_mem(&pad_short_xref_entries(bytes)?).ok(),
670+
Err(_) => Document::load_mem(&pad_short_xref_entries(bytes).ok()?).ok(),
655671
}
656672
}
657673

658674
/// Rewrite a classic cross-reference table's entries to the spec's 20 bytes,
659675
/// or `None` when the file's shape makes that unsafe (see [`load_document`]).
660-
fn pad_short_xref_entries(bytes: &[u8]) -> Option<Vec<u8>> {
676+
fn pad_short_xref_entries(bytes: &[u8]) -> Result<Vec<u8>, &'static str> {
661677
// Exactly one xref section, and it must start after every object, so that
662678
// growing it shifts nothing the table's offsets refer to.
663679
let is_boundary = |i: usize| i == 0 || matches!(bytes[i - 1], b'\n' | b'\r');
664680
let mut starts = (0..bytes.len().saturating_sub(4))
665681
.filter(|&i| &bytes[i..i + 4] == b"xref" && is_boundary(i));
666-
let xref_at = starts.next()?;
682+
let xref_at = starts
683+
.next()
684+
.ok_or("no classic `xref` section (an xref stream?)")?;
667685
if starts.next().is_some() {
668-
return None;
686+
return Err("more than one xref section (incremental update)");
669687
}
670-
if bytes.windows(3).rposition(|w| w == b"obj")? > xref_at {
671-
return None;
688+
let last_obj = bytes
689+
.windows(3)
690+
.rposition(|w| w == b"obj")
691+
.ok_or("no objects found")?;
692+
if last_obj > xref_at {
693+
return Err("an object follows the xref — padding would move it");
672694
}
673695

674696
let mut out = bytes[..xref_at].to_vec();
@@ -684,30 +706,37 @@ fn pad_short_xref_entries(bytes: &[u8]) -> Option<Vec<u8>> {
684706
// Either the next subsection header ("first count") or the trailer.
685707
if bytes[i..].starts_with(b"trailer") {
686708
out.extend_from_slice(&bytes[i..]);
687-
return Some(out);
709+
return Ok(out);
688710
}
689-
let header_end = i + bytes[i..].iter().position(|c| matches!(c, b'\n' | b'\r'))?;
690-
let header = std::str::from_utf8(&bytes[i..header_end]).ok()?.trim();
711+
let header_end = i + bytes[i..]
712+
.iter()
713+
.position(|c| matches!(c, b'\n' | b'\r'))
714+
.ok_or("subsection header runs off the end")?;
715+
let header = std::str::from_utf8(&bytes[i..header_end])
716+
.map_err(|_| "subsection header is not text")?
717+
.trim();
691718
let mut parts = header.split_whitespace();
692-
let _first: u64 = parts.next()?.parse().ok()?;
693-
let count: usize = parts.next()?.parse().ok()?;
719+
let count: usize = parts
720+
.nth(1)
721+
.and_then(|c| c.parse().ok())
722+
.ok_or("unparseable subsection header")?;
694723
if parts.next().is_some() || count == 0 {
695-
return None;
724+
return Err("unexpected subsection header shape");
696725
}
697726
out.extend_from_slice(header.as_bytes());
698727
out.push(b'\n');
699728
i = header_end;
700729
for _ in 0..count {
701730
skip_ws(&mut i);
702731
// `nnnnnnnnnn ggggg n` — the 18 bytes before whatever EOL follows.
703-
let entry = bytes.get(i..i + 18)?;
732+
let entry = bytes.get(i..i + 18).ok_or("xref entry runs off the end")?;
704733
let well_formed = entry[..10].iter().all(u8::is_ascii_digit)
705734
&& entry[10] == b' '
706735
&& entry[11..16].iter().all(u8::is_ascii_digit)
707736
&& entry[16] == b' '
708737
&& matches!(entry[17], b'n' | b'f');
709738
if !well_formed {
710-
return None;
739+
return Err("xref entry is not `nnnnnnnnnn ggggg n`");
711740
}
712741
out.extend_from_slice(entry);
713742
out.extend_from_slice(b" \n"); // the spec's 2-byte EOL -> 20 bytes
@@ -1661,6 +1690,10 @@ mod xref_repair {
16611690
fn repair_declines_when_padding_would_move_objects() {
16621691
let mut incremental = pdf_with_xref(false);
16631692
incremental.extend_from_slice(b"6 0 obj<</Type/Whatever>>endobj\n");
1664-
assert!(super::pad_short_xref_entries(&incremental).is_none());
1693+
let declined = super::pad_short_xref_entries(&incremental).unwrap_err();
1694+
assert!(
1695+
declined.contains("object follows the xref"),
1696+
"reason: {declined}"
1697+
);
16651698
}
16661699
}

0 commit comments

Comments
 (0)