@@ -10,7 +10,7 @@ export function hasNativeInspector(platform = process.platform, arch = process.a
1010 : platform === 'linux' && [ 'x64' , 'arm64' ] . includes ( arch ) ;
1111}
1212
13- let native , pdfjs ;
13+ let native , pdfjs , recoveryWarned ;
1414export async function extractPdfJsPositions ( bytes , pages ) {
1515 pdfjs ??= ( async ( ) => {
1616 if ( ! globalThis . DOMMatrix ) globalThis . DOMMatrix = ( await import ( '@thednp/dommatrix' ) ) . default ;
@@ -66,8 +66,90 @@ export async function extractPdfJsPositions(bytes, pages) {
6666 }
6767}
6868
69- export async function extractTextWithPositionsAsync ( ...args ) {
70- if ( ! hasNativeInspector ( ) ) return extractPdfJsPositions ( ...args ) ;
69+ const textLength = ( items ) =>
70+ items . reduce (
71+ ( sum , item ) => sum + ( item . itemType === 'Image' ? 0 : item . text ?. trim ( ) . length || 0 ) ,
72+ 0 ,
73+ ) ;
74+
75+ // OCR words jitter in baseline, size and spacing. Merge each baseline into runs and
76+ // snap near-body sizes to the page's body size so layout grouping sees whole lines.
77+ export function normalizeOcrItems ( items ) {
78+ const groups = [ ] ;
79+ for ( const item of [ ...items ] . sort ( ( a , b ) => b . y - a . y ) ) {
80+ const group = groups . at ( - 1 ) ;
81+ if ( group && Math . abs ( group [ 0 ] . y - item . y ) < Math . min ( group [ 0 ] . fontSize , item . fontSize ) * 0.3 )
82+ group . push ( item ) ;
83+ else groups . push ( [ item ] ) ;
84+ }
85+ const runs = groups . flatMap ( ( group ) => {
86+ const size = group . map ( ( item ) => item . fontSize ) . sort ( ( a , b ) => a - b ) [ group . length >> 1 ] ,
87+ merged = [ ] ;
88+ for ( const item of group . sort ( ( a , b ) => a . x - b . x ) ) {
89+ const run = merged . at ( - 1 ) ;
90+ if ( run && item . x - ( run . x + run . width ) < size * 1.6 ) {
91+ run . text += ' ' + item . text ;
92+ run . width = Math . max ( run . width , item . x + item . width - run . x ) ;
93+ run . isBold ||= item . isBold ;
94+ } else merged . push ( { ...item , y : group [ 0 ] . y , fontSize : size , height : size } ) ;
95+ }
96+ return merged ;
97+ } ) ;
98+ const sizes = runs
99+ . flatMap ( ( run ) => Array ( run . text . length ) . fill ( run . fontSize ) )
100+ . sort ( ( a , b ) => a - b ) ,
101+ body = sizes [ sizes . length >> 1 ] ;
102+ for ( const run of runs )
103+ if ( run . fontSize >= body * 0.8 && run . fontSize <= body * 1.3 ) run . fontSize = run . height = body ;
104+ return runs ;
105+ }
106+
107+ // The native inspector drops invisible OCR text layers on scanned pages, leaving only
108+ // the page image plus any real overlay text (e.g. a JSTOR footer). Re-read those pages
109+ // with pdf.js and keep its text when it finds substantially more.
110+ export async function recoverScannedPages ( bytes , items , pdfJs = extractPdfJsPositions ) {
111+ const byPage = new Map ( ) ;
112+ for ( const item of items ) {
113+ if ( ! byPage . has ( item . page ) ) byPage . set ( item . page , [ ] ) ;
114+ byPage . get ( item . page ) . push ( item ) ;
115+ }
116+ const candidates = [ ...byPage ]
117+ . filter ( ( [ , pageItems ] ) => pageItems . some ( ( item ) => item . itemType === 'Image' ) )
118+ . map ( ( [ page ] ) => page ) ;
119+ if ( ! candidates . length ) return items ;
120+ const recovered = new Map ( ) ;
121+ for ( const item of await pdfJs ( bytes , candidates ) ) {
122+ if ( ! recovered . has ( item . page ) ) recovered . set ( item . page , [ ] ) ;
123+ recovered . get ( item . page ) . push ( item ) ;
124+ }
125+ for ( const [ page , pageItems ] of recovered ) recovered . set ( page , normalizeOcrItems ( pageItems ) ) ;
126+ const replaced = new Set (
127+ candidates . filter ( ( page ) => {
128+ const before = textLength ( byPage . get ( page ) ) ,
129+ after = textLength ( recovered . get ( page ) || [ ] ) ;
130+ return after >= 200 && after > before * 2 ;
131+ } ) ,
132+ ) ;
133+ if ( ! replaced . size ) return items ;
134+ const result = [ ] ;
135+ for ( const [ page , pageItems ] of byPage )
136+ result . push (
137+ ...( replaced . has ( page )
138+ ? [ ...pageItems . filter ( ( item ) => item . itemType === 'Image' ) , ...recovered . get ( page ) ]
139+ : pageItems ) ,
140+ ) ;
141+ return result ;
142+ }
143+
144+ export async function extractTextWithPositionsAsync ( bytes , ...args ) {
145+ if ( ! hasNativeInspector ( ) ) return extractPdfJsPositions ( bytes , ...args ) ;
71146 native ??= import ( '@firecrawl/pdf-inspector' ) ;
72- return ( await native ) . extractTextWithPositionsAsync ( ...args ) ;
147+ const items = await ( await native ) . extractTextWithPositionsAsync ( bytes , ...args ) ;
148+ try {
149+ return await recoverScannedPages ( bytes , items ) ;
150+ } catch ( error ) {
151+ if ( ! recoveryWarned ) console . warn ( 'Scanned-page text recovery unavailable:' , error ?. message ) ;
152+ recoveryWarned = true ;
153+ return items ;
154+ }
73155}
0 commit comments