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+
66+ try {
67+ while ( remaining > 0 && Date . now ( ) <= deadline ) {
68+ const { value, done } = await reader . read ( )
69+ if ( done ) {
70+ completed = true
71+ break
72+ }
73+
74+ for ( const item of value ?. items ?? [ ] ) {
75+ if ( typeof item ?. str !== 'string' ) continue
76+
77+ const piece = item . hasEOL === true ? `${ item . str } \n` : item . str
78+ if ( piece . length > remaining ) {
79+ parts . push ( piece . slice ( 0 , remaining ) )
80+ remaining = 0
81+ break
82+ }
83+
84+ parts . push ( piece )
85+ remaining -= piece . length
86+ }
87+ }
88+ } finally {
89+ if ( ! completed ) {
90+ try {
91+ await reader . cancel ( new Error ( 'PDF text extraction budget exceeded' ) )
92+ } catch {
93+ // Cancelling a stream that already failed is not itself an error, and
94+ // throwing here would mask whatever ended the read loop.
95+ }
96+ }
97+ }
98+
99+ return { text : parts . join ( '' ) , used : budget - remaining , completed }
100+ }
101+
102+ async function extractTextWithinBudget ( pdf : PdfDocumentProxy ) : Promise < BoundedExtraction > {
103+ const deadline = Date . now ( ) + PDF_EXTRACTION_TIMEOUT_MS
104+ const totalPages = pdf . numPages
105+ const pageLimit = Math . min ( totalPages , MAX_PDF_PAGES )
106+ const pageTexts : string [ ] = [ ]
107+
108+ let remainingChars = MAX_PDF_TEXT_CHARS
109+ let truncated = totalPages > pageLimit
110+
111+ for ( let pageNumber = 1 ; pageNumber <= pageLimit ; pageNumber ++ ) {
112+ const page = await pdf . getPage ( pageNumber )
113+ const { text, used, completed } = await readPageWithinBudget ( page , remainingChars , deadline )
114+
115+ remainingChars -= used
116+ pageTexts . push ( text )
117+ page . cleanup ( )
118+
119+ if ( ! completed ) {
120+ truncated = true
121+ break
122+ }
123+ }
124+
125+ return { text : pageTexts . join ( '\n' ) . replace ( / \s + / g, ' ' ) , totalPages, truncated }
126+ }
127+
7128export class PdfParser implements FileParser {
8129 async parseFile ( filePath : string ) : Promise < FileParseResult > {
9130 try {
@@ -28,24 +149,34 @@ export class PdfParser implements FileParser {
28149 try {
29150 logger . info ( 'Starting to parse buffer, size:' , dataBuffer . length )
30151
31- const { extractText , getDocumentProxy } = await import ( 'unpdf' )
152+ const { getDocumentProxy } = await import ( 'unpdf' )
32153
33154 const uint8Array = new Uint8Array ( dataBuffer )
34155
35156 const pdf = await getDocumentProxy ( uint8Array )
36157
37- const { totalPages, text } = await extractText ( pdf , { mergePages : true } )
158+ try {
159+ const { text, totalPages, truncated } = await extractTextWithinBudget ( pdf )
38160
39- logger . info ( 'PDF parsed successfully, pages:' , totalPages , 'text length:' , text . length )
161+ logger . info ( 'PDF parsed successfully, pages:' , totalPages , 'text length:' , text . length )
40162
41- const cleanContent = text . replace ( / \u0000 / g, '' )
163+ if ( truncated ) {
164+ logger . warn ( PDF_TRUNCATION_WARNING , { totalPages, textLength : text . length } )
165+ }
42166
43- return {
44- content : cleanContent ,
45- metadata : {
46- pageCount : totalPages ,
47- source : 'unpdf' ,
48- } ,
167+ return {
168+ content : sanitizeTextForUTF8 ( text ) ,
169+ metadata : {
170+ pageCount : totalPages ,
171+ source : 'unpdf' ,
172+ truncated,
173+ warning : truncated ? PDF_TRUNCATION_WARNING : undefined ,
174+ } ,
175+ }
176+ } finally {
177+ // Releases the document-level page, font, and image caches, which the
178+ // per-page cleanup() does not touch.
179+ await pdf . destroy ( ) . catch ( ( ) => { } )
49180 }
50181 } catch ( error ) {
51182 logger . error ( 'Error parsing buffer:' , error )
0 commit comments