Skip to content

Commit b59f116

Browse files
committed
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.
1 parent 6c6d8a5 commit b59f116

3 files changed

Lines changed: 227 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: 141 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,130 @@
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+
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+
7128
export 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)

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)