Skip to content

Commit e11be1a

Browse files
artizclaude
andcommitted
docling-pdf: trust endstream over an overstated /Length
The invoice that started this carries a second, independent defect. Its content streams declare `/Length 2826` where only 2825 bytes reach `endstream`, so lopdf reads past the data, does not find the keyword it expects, and drops the stream — the object comes back as a bare dictionary. The page then has no content at all, which is why the xref repair alone still produced zero text: the document loaded, found both pages and all four fonts, and had nothing to read. pdfium tolerates it by trusting `endstream`. fix_stream_lengths does the same, and does it without moving a byte: the corrected number is written over the old digits and padded with spaces, so every offset in the file — and therefore the cross-reference table — stays valid. It only ever shrinks a length, since a longer number would not fit. Loading now tries progressively more repair and accepts a candidate only once the pages actually carry content — "it loaded" is not evidence, precisely because a stripped-stream document loads fine. That also fixes an ordering bug in the previous cut, which returned the first candidate that merely loaded and so never reached the second repair. A well-formed file returns on the first attempt and pays for none of this. On the reporting file: 0 nodes -> 78 nodes, 1908 chars, with the umlauts the OCR fallback was mangling ("Finanzübersicht", "spätestens"). The regression test builds the defect in-process rather than committing anyone's invoice, and asserts lopdf still drops the stream unaided, so the workaround can be retired when upstream handles it. docling and docling-pdf suites stay green (152 tests); wasm32 still compiles. 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 d63ec4c commit e11be1a

2 files changed

Lines changed: 198 additions & 3 deletions

File tree

crates/docling-pdf/examples/text_layer.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ fn main() {
5757
let pages = docling_pdf::textparse::pdf_textlines(&bytes);
5858
let cells: usize = pages.iter().map(|(_, _, c)| c.len()).sum();
5959
eprintln!("2. text lines: {cells} across {} page(s)", pages.len());
60+
if cells == 0 {
61+
eprintln!(
62+
" where it is lost:{}",
63+
docling_pdf::textparse::content_diagnosis(&bytes)
64+
);
65+
}
6066
for (i, (_, _, c)) in pages.iter().enumerate().take(3) {
6167
if let Some(first) = c.iter().find(|c| !c.text.trim().is_empty()) {
6268
eprintln!(" page {}: first line {:?}", i + 1, first.text);

crates/docling-pdf/src/textparse.rs

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

637+
/// Localize where a page's text is lost, for the `text_layer` diagnostic.
638+
/// Extraction can come up empty at three different points — no content stream
639+
/// reached the parser, the stream did not decode into operators, or it ran but
640+
/// produced no glyphs (fonts/encodings) — and from the outside all three look
641+
/// the same. Report them per page.
642+
pub fn content_diagnosis(bytes: &[u8]) -> String {
643+
let Some(doc) = load_document(bytes) else {
644+
return "document does not load".into();
645+
};
646+
let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
647+
pages.sort_by_key(|(n, _)| *n);
648+
let mut out = String::new();
649+
let mut caches = DocCaches::default();
650+
for (n, pid) in pages.into_iter().take(4) {
651+
let content_bytes = doc.get_page_content(pid);
652+
let ops = lopdf::content::Content::decode(&content_bytes)
653+
.map(|c| c.operations.len())
654+
.ok();
655+
let res = page_res(&doc, pid);
656+
let fonts = res.map(|r| fonts_from_res(&doc, r, &mut caches).len());
657+
let glyphs = page_glyphs_cached(&doc, pid, &mut caches).len();
658+
out.push_str(&format!(
659+
"\n page {n}: content {} B, ops {}, resources {}, fonts {}, glyphs {}",
660+
content_bytes.len(),
661+
ops.map_or("UNDECODABLE".to_string(), |n| n.to_string()),
662+
if res.is_some() { "ok" } else { "MISSING" },
663+
fonts.map_or("-".to_string(), |n| n.to_string()),
664+
glyphs,
665+
));
666+
}
667+
out
668+
}
669+
637670
/// Why the cross-reference repair did or did not fire, for the `text_layer`
638671
/// diagnostic. A PDF that will not load is indistinguishable from a scan in
639672
/// production (both convert to nothing), so the reason has to be askable.
@@ -665,10 +698,119 @@ pub fn xref_repair_status(bytes: &[u8]) -> String {
665698
/// has to prove itself — the padded bytes are used only if they load — so a
666699
/// mis-repair degrades to today's behaviour rather than to silent garbage.
667700
fn load_document(bytes: &[u8]) -> Option<Document> {
668-
match Document::load_mem(bytes) {
669-
Ok(doc) => Some(doc),
670-
Err(_) => Document::load_mem(&pad_short_xref_entries(bytes).ok()?).ok(),
701+
// Try progressively more repair, and accept a candidate only once the pages
702+
// actually carry content — a document whose streams were dropped still
703+
// "loads", so loading alone is not evidence the repair helped. A
704+
// well-formed file returns on the first attempt and pays for nothing.
705+
let mut fallback = None;
706+
if let Some(doc) = best_effort_load(bytes, &mut fallback) {
707+
return Some(doc);
708+
}
709+
let xref_fixed = pad_short_xref_entries(bytes).ok();
710+
if let Some(fixed) = &xref_fixed {
711+
if let Some(doc) = best_effort_load(fixed, &mut fallback) {
712+
return Some(doc);
713+
}
714+
}
715+
// Both defects can coexist, and the second only becomes visible once the
716+
// first is repaired, so build on whatever the previous step produced.
717+
let lengths_fixed = fix_stream_lengths(xref_fixed.as_deref().unwrap_or(bytes));
718+
if let Some(doc) = best_effort_load(&lengths_fixed, &mut fallback) {
719+
return Some(doc);
720+
}
721+
fallback
722+
}
723+
724+
/// Load `data`, returning it only when its pages carry content; a document that
725+
/// merely parses is remembered as the fallback for when nothing does better.
726+
fn best_effort_load(data: &[u8], fallback: &mut Option<Document>) -> Option<Document> {
727+
match Document::load_mem(data) {
728+
Ok(doc) if has_page_content(&doc) => Some(doc),
729+
Ok(doc) => {
730+
fallback.get_or_insert(doc);
731+
None
732+
}
733+
Err(_) => None,
734+
}
735+
}
736+
737+
/// Does any page actually hand us a content stream? A document whose streams
738+
/// were dropped still parses — it simply has nothing to read — so this is what
739+
/// tells a successful repair from a pointless one.
740+
fn has_page_content(doc: &Document) -> bool {
741+
doc.get_pages()
742+
.into_values()
743+
.take(4)
744+
.any(|pid| !doc.get_page_content(pid).is_empty())
745+
}
746+
747+
/// Correct `/Length` values that disagree with where `endstream` actually is.
748+
///
749+
/// The same generator that writes short xref entries also overstates its
750+
/// content-stream lengths by a byte or two. lopdf trusts `/Length`, reads past
751+
/// the data, fails to find `endstream` there and drops the stream — the object
752+
/// comes back as a bare dictionary, so the page has no content at all and the
753+
/// document looks like a scan. pdfium instead trusts `endstream`, which is what
754+
/// this does.
755+
///
756+
/// The rewrite is length-preserving: the corrected number is written over the
757+
/// old digits and padded with spaces, so every byte offset in the file — and
758+
/// therefore the whole cross-reference table — stays valid.
759+
fn fix_stream_lengths(bytes: &[u8]) -> Vec<u8> {
760+
let mut out = bytes.to_vec();
761+
let mut i = 0;
762+
while let Some(rel) = find(&out[i..], b"stream") {
763+
let kw = i + rel;
764+
i = kw + 6;
765+
// Skip `endstream` (the keyword we are measuring *to*).
766+
if kw >= 3 && &out[kw - 3..kw] == b"end" {
767+
continue;
768+
}
769+
// The stream data starts after the EOL that follows the keyword.
770+
let mut data = kw + 6;
771+
if out.get(data..data + 2) == Some(b"\r\n".as_slice()) {
772+
data += 2;
773+
} else if matches!(out.get(data), Some(b'\n' | b'\r')) {
774+
data += 1;
775+
}
776+
let Some(end) = find(&out[data..], b"endstream").map(|r| data + r) else {
777+
continue;
778+
};
779+
// `/Length <digits>` in the dictionary just before the keyword.
780+
let dict_start = out[..kw].iter().rposition(|&c| c == b'<').unwrap_or(0);
781+
let Some(lrel) = find(&out[dict_start..kw], b"/Length") else {
782+
continue;
783+
};
784+
let mut d = dict_start + lrel + 7;
785+
while matches!(out.get(d), Some(b' ')) {
786+
d += 1;
787+
}
788+
let digits = out[d..].iter().take_while(|c| c.is_ascii_digit()).count();
789+
if digits == 0 {
790+
continue;
791+
}
792+
let declared: usize = match std::str::from_utf8(&out[d..d + digits])
793+
.ok()
794+
.and_then(|s| s.parse().ok())
795+
{
796+
Some(v) => v,
797+
None => continue,
798+
};
799+
let actual = end - data;
800+
// Only shrink, and only when the new value fits the space the old one
801+
// occupied — growing the number would move every following byte.
802+
let replacement = actual.to_string();
803+
if actual == declared || replacement.len() > digits {
804+
continue;
805+
}
806+
out[d..d + digits].fill(b' ');
807+
out[d..d + replacement.len()].copy_from_slice(replacement.as_bytes());
671808
}
809+
out
810+
}
811+
812+
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
813+
haystack.windows(needle.len()).position(|w| w == needle)
672814
}
673815

674816
/// Rewrite a classic cross-reference table's entries to the spec's 20 bytes,
@@ -1683,6 +1825,53 @@ mod xref_repair {
16831825
);
16841826
}
16851827

1828+
/// The same generator overstates `/Length`, so lopdf reads past the data,
1829+
/// misses `endstream` and drops the stream — the object comes back as a
1830+
/// bare dictionary and the page has no content at all. Trust `endstream`
1831+
/// instead, and do it without moving a single byte.
1832+
#[test]
1833+
fn overstated_stream_length_still_yields_content() {
1834+
let good = pdf_with_xref(true);
1835+
// Inflate the content stream's /Length by one, exactly as the invoice
1836+
// that prompted this does.
1837+
let broken = {
1838+
let at = good
1839+
.windows(8)
1840+
.position(|w| w == b"/Length ")
1841+
.expect("a /Length")
1842+
+ 8;
1843+
let digits = good[at..].iter().take_while(|c| c.is_ascii_digit()).count();
1844+
let n: usize = std::str::from_utf8(&good[at..at + digits])
1845+
.unwrap()
1846+
.parse()
1847+
.unwrap();
1848+
let inflated = (n + 1).to_string();
1849+
assert_eq!(inflated.len(), digits, "keep the digit count");
1850+
let mut b = good.clone();
1851+
b[at..at + digits].copy_from_slice(inflated.as_bytes());
1852+
b
1853+
};
1854+
assert_eq!(broken.len(), good.len(), "the defect must not move bytes");
1855+
// lopdf alone loses the stream: the page parses but carries no content.
1856+
let raw = lopdf::Document::load_mem(&broken).expect("still loads");
1857+
assert!(
1858+
raw.get_pages()
1859+
.into_values()
1860+
.all(|p| raw.get_page_content(p).is_empty()),
1861+
"lopdf should drop the stream — if it stops, drop this repair"
1862+
);
1863+
// Ours recovers the same text the well-formed file gives.
1864+
let text = |b: &[u8]| -> Vec<String> {
1865+
super::pdf_textlines(b)
1866+
.into_iter()
1867+
.flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
1868+
.collect()
1869+
};
1870+
let expected = text(&good);
1871+
assert!(!expected.is_empty(), "control must produce text");
1872+
assert_eq!(text(&broken), expected);
1873+
}
1874+
16861875
/// The repair only fires where padding cannot move an object: it declines a
16871876
/// file whose xref precedes an object (an incremental update), rather than
16881877
/// shifting every offset the table records.

0 commit comments

Comments
 (0)