11import { readFile } from 'fs/promises'
22import { createLogger } from '@sim/logger'
33import type { FileParseResult , FileParser } from '@/lib/file-parsers/types'
4+ import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
45
56const logger = createLogger ( 'PdfParser' )
67
8+ /** Highest page number visited, bounding documents that declare huge page counts. */
9+ const MAX_PDF_PAGES = 10_000
10+
11+ /** Ceiling on extracted characters — roughly 3,000 pages of dense text. */
12+ export const MAX_PDF_TEXT_CHARS = 10_000_000
13+
14+ /** Wall-clock ceiling for extracting text from a whole document. */
15+ const PDF_EXTRACTION_TIMEOUT_MS = 60_000
16+
17+ const PDF_TRUNCATION_WARNING = 'PDF text extraction stopped at a parser limit and is incomplete'
18+
19+ type PdfDocumentProxy = Awaited < ReturnType < typeof import ( 'unpdf' ) [ 'getDocumentProxy' ] > >
20+ type PdfPageProxy = Awaited < ReturnType < PdfDocumentProxy [ 'getPage' ] > >
21+
22+ interface TextContentChunk {
23+ items ?: Array < { str ?: unknown ; hasEOL ?: unknown } >
24+ }
25+
26+ interface PageExtraction {
27+ text : string
28+ /** Characters consumed from the caller's budget. */
29+ used : number
30+ /** False when a budget stopped the read before the page was exhausted. */
31+ completed : boolean
32+ }
33+
34+ interface BoundedExtraction {
35+ text : string
36+ /** Page count the document declares, however many pages were actually read. */
37+ totalPages : number
38+ /** True when a budget stopped extraction before the document was exhausted. */
39+ truncated : boolean
40+ }
41+
42+ /**
43+ * Reads one page's text through pdf.js's streaming API, stopping once the
44+ * character budget or the deadline is spent.
45+ *
46+ * `extractText`/`getTextContent` buffer a page's entire text content before
47+ * resolving, so a page whose compressed content stream expands to hundreds of
48+ * megabytes reaches the V8 heap limit and aborts the process — a fatal error no
49+ * `try/catch` can intercept, taking every other in-flight request with it.
50+ * `streamTextContent` applies backpressure, so cancelling the reader stops the
51+ * evaluator rather than letting it run the expansion to completion.
52+ */
53+ async function readPageWithinBudget (
54+ page : PdfPageProxy ,
55+ budget : number ,
56+ deadline : number
57+ ) : Promise < PageExtraction > {
58+ const reader = page
59+ . streamTextContent ( )
60+ . getReader ( ) as ReadableStreamDefaultReader < TextContentChunk >
61+
62+ const parts : string [ ] = [ ]
63+ let remaining = budget
64+ let completed = false
65+ let dropped = false
66+
67+ try {
68+ /**
69+ * Loops until content is actually dropped rather than until the budget hits
70+ * zero: text that ends exactly on the budget is complete, not truncated.
71+ */
72+ while ( ! dropped && Date . now ( ) <= deadline ) {
73+ const { value, done } = await reader . read ( )
74+ if ( done ) {
75+ completed = true
76+ break
77+ }
78+
79+ for ( const item of value ?. items ?? [ ] ) {
80+ if ( typeof item ?. str !== 'string' ) continue
81+
82+ const piece = item . hasEOL === true ? `${ item . str } \n` : item . str
83+ if ( piece . length > remaining ) {
84+ parts . push ( piece . slice ( 0 , remaining ) )
85+ remaining = 0
86+ dropped = true
87+ break
88+ }
89+
90+ parts . push ( piece )
91+ remaining -= piece . length
92+ }
93+ }
94+ } finally {
95+ if ( ! completed ) {
96+ try {
97+ await reader . cancel ( new Error ( 'PDF text extraction budget exceeded' ) )
98+ } catch {
99+ // Cancelling a stream that already failed is not itself an error, and
100+ // throwing here would mask whatever ended the read loop.
101+ }
102+ }
103+ }
104+
105+ return { text : parts . join ( '' ) , used : budget - remaining , completed }
106+ }
107+
108+ async function extractTextWithinBudget ( pdf : PdfDocumentProxy ) : Promise < BoundedExtraction > {
109+ const deadline = Date . now ( ) + PDF_EXTRACTION_TIMEOUT_MS
110+ const totalPages = pdf . numPages
111+ const pageLimit = Math . min ( totalPages , MAX_PDF_PAGES )
112+ const pageTexts : string [ ] = [ ]
113+
114+ let remainingChars = MAX_PDF_TEXT_CHARS
115+ let truncated = totalPages > pageLimit
116+
117+ for ( let pageNumber = 1 ; pageNumber <= pageLimit ; pageNumber ++ ) {
118+ const page = await pdf . getPage ( pageNumber )
119+ const { text, used, completed } = await readPageWithinBudget ( page , remainingChars , deadline )
120+
121+ remainingChars -= used
122+ pageTexts . push ( text )
123+ page . cleanup ( )
124+
125+ if ( ! completed ) {
126+ truncated = true
127+ break
128+ }
129+ }
130+
131+ return { text : pageTexts . join ( '\n' ) . replace ( / \s + / g, ' ' ) , totalPages, truncated }
132+ }
133+
7134export class PdfParser implements FileParser {
8135 async parseFile ( filePath : string ) : Promise < FileParseResult > {
9136 try {
@@ -28,24 +155,34 @@ export class PdfParser implements FileParser {
28155 try {
29156 logger . info ( 'Starting to parse buffer, size:' , dataBuffer . length )
30157
31- const { extractText , getDocumentProxy } = await import ( 'unpdf' )
158+ const { getDocumentProxy } = await import ( 'unpdf' )
32159
33160 const uint8Array = new Uint8Array ( dataBuffer )
34161
35162 const pdf = await getDocumentProxy ( uint8Array )
36163
37- const { totalPages, text } = await extractText ( pdf , { mergePages : true } )
164+ try {
165+ const { text, totalPages, truncated } = await extractTextWithinBudget ( pdf )
38166
39- logger . info ( 'PDF parsed successfully, pages:' , totalPages , 'text length:' , text . length )
167+ logger . info ( 'PDF parsed successfully, pages:' , totalPages , 'text length:' , text . length )
40168
41- const cleanContent = text . replace ( / \u0000 / g, '' )
169+ if ( truncated ) {
170+ logger . warn ( PDF_TRUNCATION_WARNING , { totalPages, textLength : text . length } )
171+ }
42172
43- return {
44- content : cleanContent ,
45- metadata : {
46- pageCount : totalPages ,
47- source : 'unpdf' ,
48- } ,
173+ return {
174+ content : sanitizeTextForUTF8 ( text ) ,
175+ metadata : {
176+ pageCount : totalPages ,
177+ source : 'unpdf' ,
178+ truncated,
179+ warning : truncated ? PDF_TRUNCATION_WARNING : undefined ,
180+ } ,
181+ }
182+ } finally {
183+ // Releases the document-level page, font, and image caches, which the
184+ // per-page cleanup() does not touch.
185+ await pdf . destroy ( ) . catch ( ( ) => { } )
49186 }
50187 } catch ( error ) {
51188 logger . error ( 'Error parsing buffer:' , error )
0 commit comments