|
1 | 1 | import { readFile } from 'fs/promises' |
2 | 2 | import { createLogger } from '@sim/logger' |
3 | 3 | import type { FileParseResult, FileParser } from '@/lib/file-parsers/types' |
4 | | -import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' |
| 4 | +import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils' |
5 | 5 |
|
6 | 6 | const logger = createLogger('PdfParser') |
7 | 7 |
|
8 | | -/** Highest page number visited, bounding documents that declare huge page counts. */ |
| 8 | +/** |
| 9 | + * Ceiling on the page loop. The character budget and the deadline already stop |
| 10 | + * extraction on their own, so this exists purely so the loop bound never comes |
| 11 | + * straight from the attacker-controlled `numPages` field. |
| 12 | + */ |
9 | 13 | const MAX_PDF_PAGES = 10_000 |
10 | 14 |
|
11 | 15 | /** Ceiling on extracted characters — roughly 3,000 pages of dense text. */ |
@@ -35,6 +39,8 @@ interface BoundedExtraction { |
35 | 39 | text: string |
36 | 40 | /** Page count the document declares, however many pages were actually read. */ |
37 | 41 | totalPages: number |
| 42 | + /** Pages actually visited before a budget stopped extraction. */ |
| 43 | + pagesRead: number |
38 | 44 | /** True when a budget stopped extraction before the document was exhausted. */ |
39 | 45 | truncated: boolean |
40 | 46 | } |
@@ -128,7 +134,12 @@ async function extractTextWithinBudget(pdf: PdfDocumentProxy): Promise<BoundedEx |
128 | 134 | } |
129 | 135 | } |
130 | 136 |
|
131 | | - return { text: pageTexts.join('\n').replace(/\s+/g, ' '), totalPages, truncated } |
| 137 | + return { |
| 138 | + text: pageTexts.join('\n').replace(/\s+/g, ' '), |
| 139 | + totalPages, |
| 140 | + pagesRead: pageTexts.length, |
| 141 | + truncated, |
| 142 | + } |
132 | 143 | } |
133 | 144 |
|
134 | 145 | export class PdfParser implements FileParser { |
@@ -162,16 +173,26 @@ export class PdfParser implements FileParser { |
162 | 173 | const pdf = await getDocumentProxy(uint8Array) |
163 | 174 |
|
164 | 175 | try { |
165 | | - const { text, totalPages, truncated } = await extractTextWithinBudget(pdf) |
| 176 | + const { text, totalPages, pagesRead, truncated } = await extractTextWithinBudget(pdf) |
166 | 177 |
|
167 | 178 | logger.info('PDF parsed successfully, pages:', totalPages, 'text length:', text.length) |
168 | 179 |
|
169 | 180 | if (truncated) { |
170 | | - logger.warn(PDF_TRUNCATION_WARNING, { totalPages, textLength: text.length }) |
| 181 | + logger.warn(PDF_TRUNCATION_WARNING, { totalPages, pagesRead, textLength: text.length }) |
171 | 182 | } |
172 | 183 |
|
| 184 | + // Callers only ever read `content`, so without an inline notice a truncated |
| 185 | + // document is indistinguishable from a complete one. Empty text yields no |
| 186 | + // notice, so a text-free PDF still reports as empty rather than as a lone notice. |
| 187 | + const notice = |
| 188 | + truncated && text.length > 0 |
| 189 | + ? truncationNotice( |
| 190 | + `PDF text truncated at parser limits, showing first ${pagesRead} of ${totalPages} pages` |
| 191 | + ) |
| 192 | + : '' |
| 193 | + |
173 | 194 | return { |
174 | | - content: sanitizeTextForUTF8(text), |
| 195 | + content: sanitizeTextForUTF8(text + notice), |
175 | 196 | metadata: { |
176 | 197 | pageCount: totalPages, |
177 | 198 | source: 'unpdf', |
|
0 commit comments