@@ -36,17 +36,18 @@ export const MAX_PDF_TEXT_CHARS = 10_000_000
3636/** Complete extraction shares the ingestion pipeline's bounded text-output envelope. */
3737export const MAX_COMPLETE_PDF_TEXT_BYTES = 20 * 1024 * 1024
3838
39+ /** Independent retained-text ceiling, including conservative layout overhead. */
40+ const MAX_COMPLETE_PDF_RETAINED_TEXT_BYTES = MAX_COMPLETE_PDF_TEXT_BYTES
41+
3942/** Bounds expansion on one page independently of a long document's output budget. */
4043export const MAX_COMPLETE_PDF_PAGE_CHARS = 250_000
4144
4245/** Wall-clock ceiling for extracting text from a whole document. */
4346const PDF_EXTRACTION_TIMEOUT_MS = 60_000
4447
4548/**
46- * Upper bound on what line reconstruction adds per line after the budget is
47- * spent: a two-character paragraph break plus a three-character heading marker.
48- * The complete-mode byte ceiling therefore trips slightly earlier than it did
49- * when pages were flattened to one line, by at most this many bytes per line.
49+ * Conservative retained-text allowance for a paragraph break and heading marker.
50+ * This protects parser state independently of the caller's rendered-output budget.
5051 */
5152const MAX_LINE_DECORATION_BYTES = 5
5253
@@ -249,8 +250,8 @@ function readPageHeight(page: PDFPageProxy): number | undefined {
249250 }
250251}
251252
252- /** Bytes a page's lines can occupy in the output once joined and decorated . */
253- function estimatePageBytes ( lines : readonly PdfLine [ ] ) : number {
253+ /** Conservative text storage for retained lines and their possible decorations . */
254+ function estimateRetainedPageBytes ( lines : readonly PdfLine [ ] ) : number {
254255 let bytes = 0
255256 for ( const line of lines ) {
256257 bytes += Buffer . byteLength ( line . text , 'utf8' ) + MAX_LINE_DECORATION_BYTES
@@ -267,7 +268,8 @@ function estimatePageBytes(lines: readonly PdfLine[]): number {
267268async function assemblePages (
268269 pages : readonly PdfPageLines [ ] ,
269270 complete : boolean ,
270- signal : AbortSignal | undefined
271+ signal : AbortSignal | undefined ,
272+ maxTextBytes : number
271273) : Promise < string > {
272274 signal ?. throwIfAborted ( )
273275 const filteredPages = suppressFurniture ( pages )
@@ -280,14 +282,25 @@ async function assemblePages(
280282 headingMarkers : PDF_HEADING_MARKERS_ENABLED && headingMarkersViable ( allLines , bodyHeight ) ,
281283 }
282284 const pageTexts : string [ ] = [ ]
285+ let outputBytes = 0
283286 for ( const [ index , lines ] of filteredPages . entries ( ) ) {
284287 if ( index > 0 && index % ASSEMBLY_YIELD_EVERY_PAGES === 0 ) {
285288 await sleep ( 0 )
286289 signal ?. throwIfAborted ( )
287290 }
288291 const joined = joinLines ( lines , options )
289292 const text = complete ? normalizePdfWhitespace ( sanitizeTextForUTF8 ( joined ) ) . trim ( ) : joined
290- if ( text . length > 0 ) pageTexts . push ( text )
293+ if ( text . length > 0 ) {
294+ if ( complete ) {
295+ outputBytes +=
296+ Buffer . byteLength ( text , 'utf8' ) + ( pageTexts . length > 0 ? PAGE_SEPARATOR . length : 0 )
297+ if ( outputBytes > maxTextBytes )
298+ throw completeExtractionLimit (
299+ `PDF text exceeds the safe ${ maxTextBytes . toLocaleString ( ) } -byte output limit.`
300+ )
301+ }
302+ pageTexts . push ( text )
303+ }
291304 }
292305 const text = pageTexts . join ( PAGE_SEPARATOR )
293306 return complete ? text : normalizePdfWhitespace ( text ) . trim ( )
@@ -297,6 +310,13 @@ function completeExtractionLimit(message: string): FileParserError {
297310 return new FileParserError ( 'complexity_limit' , `${ message } Split or simplify the PDF and retry.` )
298311}
299312
313+ function completeBudget ( value : number | undefined , ceiling : number ) : number {
314+ if ( value === undefined ) return ceiling
315+ if ( ! Number . isSafeInteger ( value ) || value < 1 )
316+ throw completeExtractionLimit ( 'PDF extraction limits must be positive safe integers.' )
317+ return Math . min ( value , ceiling )
318+ }
319+
300320async function extractTextWithinBudget (
301321 pdf : PDFDocumentProxy ,
302322 options : FileParseOptions ,
@@ -305,17 +325,21 @@ async function extractTextWithinBudget(
305325 const { signal } = options
306326 const complete = options . pdfTextMode === 'complete'
307327 const totalPages = pdf . numPages
308- const pageLimit = Math . min ( totalPages , MAX_PDF_PAGES )
328+ const maxPages = complete ? completeBudget ( options . pdfMaxPages , MAX_PDF_PAGES ) : MAX_PDF_PAGES
329+ const maxTextBytes = complete
330+ ? completeBudget ( options . maxTextBytes , MAX_COMPLETE_PDF_TEXT_BYTES )
331+ : MAX_COMPLETE_PDF_TEXT_BYTES
332+ const pageLimit = Math . min ( totalPages , maxPages )
309333 const pages : PdfPageLines [ ] = [ ]
310334
311335 let remainingChars = MAX_PDF_TEXT_CHARS
312- let outputBytes = 0
336+ let retainedTextBytes = 0
313337 let pagesRead = 0
314338 let truncated = totalPages > pageLimit
315339
316340 if ( complete && truncated ) {
317341 throw completeExtractionLimit (
318- `PDF exceeds the safe limit of ${ MAX_PDF_PAGES . toLocaleString ( ) } pages.`
342+ `PDF exceeds the safe limit of ${ maxPages . toLocaleString ( ) } pages.`
319343 )
320344 }
321345
@@ -341,13 +365,9 @@ async function extractTextWithinBudget(
341365 const page = pageResult
342366 const pageHeight = readPageHeight ( page )
343367 let extraction : PageExtraction
368+ const pageCharLimit = complete ? MAX_COMPLETE_PDF_PAGE_CHARS : remainingChars
344369 try {
345- extraction = await readPageWithinBudget (
346- page ,
347- complete ? MAX_COMPLETE_PDF_PAGE_CHARS : remainingChars ,
348- deadline ,
349- signal
350- )
370+ extraction = await readPageWithinBudget ( page , pageCharLimit , deadline , signal )
351371 } finally {
352372 page . cleanup ( )
353373 }
@@ -361,10 +381,11 @@ async function extractTextWithinBudget(
361381 pagesRead ++
362382 if ( complete ) {
363383 if ( lines . length > 0 ) {
364- outputBytes += estimatePageBytes ( lines ) + ( pages . length > 0 ? PAGE_SEPARATOR . length : 0 )
365- if ( outputBytes > MAX_COMPLETE_PDF_TEXT_BYTES ) {
384+ retainedTextBytes +=
385+ estimateRetainedPageBytes ( lines ) + ( pages . length > 0 ? PAGE_SEPARATOR . length : 0 )
386+ if ( retainedTextBytes > MAX_COMPLETE_PDF_RETAINED_TEXT_BYTES ) {
366387 throw completeExtractionLimit (
367- `PDF text exceeds the safe ${ MAX_COMPLETE_PDF_TEXT_BYTES . toLocaleString ( ) } -byte output limit.`
388+ `PDF text exceeds the safe ${ MAX_COMPLETE_PDF_RETAINED_TEXT_BYTES . toLocaleString ( ) } -byte output limit.`
368389 )
369390 }
370391 pages . push ( { lines, pageHeight } )
@@ -387,7 +408,7 @@ async function extractTextWithinBudget(
387408 }
388409 }
389410
390- let text = await assemblePages ( pages , complete , signal )
411+ let text = await assemblePages ( pages , complete , signal , maxTextBytes )
391412
392413 /** Paragraph breaks land after the budget is spent; trimming that overflow is a truncation too. */
393414 if ( ! complete && text . length > MAX_PDF_TEXT_CHARS ) {
0 commit comments