@@ -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.
667700fn 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