Skip to content

Commit 441004a

Browse files
authored
improvement(file-parsers): bound PDF text extraction (#6425)
* improvement(file-parsers): bound PDF text extraction Extract page text through pdf.js's streaming API with page, character, and wall-clock budgets instead of buffering the whole document, so extraction memory stays bounded regardless of input. Release the document proxy when done, and route output through sanitizeTextForUTF8 like the other parsers. * fix(file-parsers): only flag truncation when PDF text is actually dropped
1 parent 52be28e commit 441004a

3 files changed

Lines changed: 233 additions & 10 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { deflateSync } from 'zlib'
5+
import { describe, expect, it } from 'vitest'
6+
import { MAX_PDF_TEXT_CHARS, PdfParser } from '@/lib/file-parsers/pdf-parser'
7+
8+
/**
9+
* Builds a single-page PDF that draws 64 characters per repeat from a
10+
* FlateDecode content stream, so a few dozen kilobytes of input yields millions
11+
* of extracted characters — what made the unbounded extractor exhaust the heap
12+
* and abort the process.
13+
*
14+
* Hand-assembled rather than built with `pdf-lib` because the fixture's whole
15+
* point is the compression ratio of the content stream, which `pdf-lib` gives
16+
* no way to control.
17+
*/
18+
function buildTextBombPdf(repeats: number): Buffer {
19+
const unit = `BT /F1 12 Tf 10 700 Td (${'A'.repeat(64)}) Tj ET\n`
20+
const compressed = deflateSync(Buffer.from(unit.repeat(repeats)))
21+
22+
const objects = [
23+
Buffer.from('<< /Type /Catalog /Pages 2 0 R >>'),
24+
Buffer.from('<< /Type /Pages /Kids [3 0 R] /Count 1 >>'),
25+
Buffer.from(
26+
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>'
27+
),
28+
Buffer.concat([
29+
Buffer.from(`<< /Length ${compressed.length} /Filter /FlateDecode >>\nstream\n`),
30+
compressed,
31+
Buffer.from('\nendstream'),
32+
]),
33+
Buffer.from('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'),
34+
]
35+
36+
const chunks: Buffer[] = [Buffer.from('%PDF-1.4\n')]
37+
const offsets: number[] = []
38+
let offset = chunks[0].length
39+
40+
objects.forEach((object, index) => {
41+
offsets.push(offset)
42+
const chunk = Buffer.concat([
43+
Buffer.from(`${index + 1} 0 obj\n`),
44+
object,
45+
Buffer.from('\nendobj\n'),
46+
])
47+
chunks.push(chunk)
48+
offset += chunk.length
49+
})
50+
51+
const xrefRows = offsets
52+
.map((value) => `${value.toString().padStart(10, '0')} 00000 n \n`)
53+
.join('')
54+
chunks.push(
55+
Buffer.from(
56+
`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${xrefRows}` +
57+
`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${offset}\n%%EOF\n`
58+
)
59+
)
60+
61+
return Buffer.concat(chunks)
62+
}
63+
64+
describe('PdfParser', () => {
65+
it('bounds extracted text from a compression-bomb PDF instead of exhausting the heap', async () => {
66+
const bomb = buildTextBombPdf(200_000)
67+
expect(bomb.length).toBeLessThan(200 * 1024)
68+
69+
const result = await new PdfParser().parseBuffer(bomb)
70+
71+
expect(result.metadata?.truncated).toBe(true)
72+
expect(result.metadata?.warning).toMatch(/parser limit/i)
73+
expect(result.content.length).toBeLessThanOrEqual(MAX_PDF_TEXT_CHARS)
74+
}, 120_000)
75+
76+
it('extracts a small PDF in full and does not flag it as truncated', async () => {
77+
const result = await new PdfParser().parseBuffer(buildTextBombPdf(3))
78+
79+
expect(result.metadata?.truncated).toBe(false)
80+
expect(result.metadata?.warning).toBeUndefined()
81+
expect(result.metadata?.pageCount).toBe(1)
82+
expect(result.content).toContain('AAAA')
83+
}, 30_000)
84+
})

apps/sim/lib/file-parsers/pdf-parser.ts

Lines changed: 147 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,136 @@
11
import { readFile } from 'fs/promises'
22
import { createLogger } from '@sim/logger'
33
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
4+
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
45

56
const 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+
7134
export 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)

apps/sim/lib/file-parsers/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export interface FileParseMetadata {
22
characterCount?: number
33
pageCount?: number
4+
/** True when a parser limit stopped extraction before the input was exhausted. */
5+
truncated?: boolean
46
extractionMethod?: string
57
warning?: string
68
messages?: unknown[]

0 commit comments

Comments
 (0)