fix: read PDF attachments only as far as needed, with a time limit and no scripts

This commit is contained in:
Linus Rath
2026-11-02 23:31:47 +01:00
parent 3e6686f700
commit 774aeeb313
3 changed files with 113 additions and 19 deletions
+70
View File
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { pdfText, setPdfLoader, type PdfLib } from '@/lib/attachment-text/pdf';
import { extractText } from '@/lib/attachment-text';
// pdf.js is stood in for: each page is a list of text lines.
const calls = { pages: 0, sources: [] as unknown[], destroyed: 0 };
function fakePdf(pages: string[][], { hang = false } = {}): PdfLib {
return {
getDocument: (src) => {
calls.sources.push(src);
return {
promise: hang ? new Promise(() => {}) : Promise.resolve({
numPages: pages.length,
getPage: async (n: number) => {
calls.pages++;
return { getTextContent: async () => ({ items: pages[n - 1].map((str, i, all) => ({ str, hasEOL: i < all.length - 1 })) }) };
},
}),
destroy: async () => { calls.destroyed++; },
};
},
};
}
const pdf = new TextEncoder().encode('%PDF-1.4');
afterEach(() => {
setPdfLoader();
calls.pages = 0;
calls.sources.length = 0;
calls.destroyed = 0;
});
describe('pdfText', () => {
it('reads a typed cover letter in front of a scan', async () => {
setPdfLoader(async () => fakePdf([['Dear Alice, please find the signed contract attached.'], [''], [''], ['']]));
const out = await extractText(pdf, { name: 'scan.pdf' });
expect(out).toMatchObject({ status: 'ok', pages: 4 });
expect(out.text).toContain('signed contract');
});
it('stops reading pages once the budget is used up', async () => {
setPdfLoader(async () => fakePdf(Array.from({ length: 50 }, (_, i) => [`Page ${i + 1}: ${'word '.repeat(200)}`])));
const out = await pdfText(pdf, { budget: 2_500 });
expect(out).toMatchObject({ status: 'ok', truncated: true, pages: 50 });
expect(calls.pages).toBeLessThanOrEqual(4);
expect(out.text.length).toBeLessThanOrEqual(2_500);
});
it('never lets pdf.js evaluate scripts', async () => {
setPdfLoader(async () => fakePdf([['Some text that is long enough to count']]));
await pdfText(pdf);
expect(calls.sources[0]).toMatchObject({ isEvalSupported: false });
});
it('gives up after the timeout and stops pdf.js', async () => {
vi.useFakeTimers();
try {
setPdfLoader(async () => fakePdf([], { hang: true }));
const pending = pdfText(pdf, { timeoutMs: 1_000 });
await vi.advanceTimersByTimeAsync(1_001);
expect(await pending).toMatchObject({ status: 'failed' });
expect(calls.destroyed).toBe(1);
} finally {
vi.useRealTimers();
}
});
});
+1 -1
View File
@@ -78,7 +78,7 @@ async function read(bytes: Uint8Array, format: TextFormat, budget: number): Prom
case 'vcf':
return { status: 'ok', text: vcfText(decodeText(bytes)), truncated: false };
case 'pdf':
return pdfText(bytes);
return pdfText(bytes, { budget });
case 'docx': case 'xlsx': case 'pptx': case 'odt': case 'ods': case 'odp':
return officeText(bytes, format, { budget });
default:
+42 -18
View File
@@ -4,17 +4,21 @@
// refuses blob: workers).
import { liteMountedAssetUrl } from '@/lib/lite';
import { MAX_PDF_PAGES, MIN_PDF_CHARS_PER_PAGE, type ExtractedText } from './types';
import { MAX_PDF_PAGES, MAX_TEXT_CHARS, MIN_PDF_CHARS_PER_PAGE, TEXT_BUDGET_SLACK, type ExtractedText } from './types';
import { TextSink } from './sink';
/** The part of pdf.js used here (tests pass a stand-in). */
export interface PdfLib {
getDocument(src: { data: Uint8Array }): { promise: Promise<PdfDoc>; destroy(): Promise<void> };
getDocument(src: { data: Uint8Array; isEvalSupported?: boolean }): { promise: Promise<PdfDoc>; destroy(): Promise<void> };
}
interface PdfDoc {
numPages: number;
getPage(n: number): Promise<{ getTextContent(): Promise<{ items: unknown[] }> }>;
}
/** Longest a PDF may take to read before the read gives up. */
export const PDF_TIMEOUT_MS = 30_000;
async function loadPdfJs(): Promise<PdfLib> {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = liteMountedAssetUrl(new URL(
@@ -44,36 +48,56 @@ function pageText(items: unknown[]): string {
return out.replace(/[ \t]+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').trim();
}
class TimedOut extends Error {}
export interface PdfOptions {
/** Characters to read at most; reading stops at the page that fills it. */
budget?: number;
/** Longest the read may take (default PDF_TIMEOUT_MS). */
timeoutMs?: number;
}
/**
* The text of at most MAX_PDF_PAGES pages, a "Page n" line before each.
* no_text when the pages hold fewer than MIN_PDF_CHARS_PER_PAGE characters
* on average (a scan); encrypted for a PDF that needs a password.
* The text of at most MAX_PDF_PAGES pages, a "Page n" line before each,
* stopping once `budget` characters are read. no_text when no page has
* MIN_PDF_CHARS_PER_PAGE characters (a scan; a typed cover letter in front
* of one is read); encrypted for a PDF that needs a password; failed after
* `timeoutMs`. Scripts in the PDF are never evaluated.
*/
export async function pdfText(bytes: Uint8Array): Promise<Omit<ExtractedText, 'format'>> {
export async function pdfText(bytes: Uint8Array, { budget = MAX_TEXT_CHARS + TEXT_BUDGET_SLACK, timeoutMs = PDF_TIMEOUT_MS }: PdfOptions = {}): Promise<Omit<ExtractedText, 'format'>> {
const lib = await loader();
// pdf.js may take over the buffer: give it a copy.
const task = lib.getDocument({ data: bytes.slice() });
try {
const task = lib.getDocument({ data: bytes.slice(), isEvalSupported: false });
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new TimedOut()), timeoutMs); });
const read = async (): Promise<Omit<ExtractedText, 'format'>> => {
const doc = await task.promise;
const pages = doc.numPages;
const read = Math.min(pages, MAX_PDF_PAGES);
const parts: string[] = [];
let chars = 0;
for (let n = 1; n <= read; n++) {
const last = Math.min(pages, MAX_PDF_PAGES);
const sink = new TextSink(budget);
let readable = false;
let n = 0;
while (n < last && !sink.full) {
n++;
const page = await doc.getPage(n);
const text = pageText((await page.getTextContent()).items);
chars += text.replace(/\s/g, '').length;
if (text) parts.push(read > 1 ? `Page ${n}\n${text}` : text);
if (text.replace(/\s/g, '').length >= MIN_PDF_CHARS_PER_PAGE) readable = true;
if (!text) continue;
if (pages > 1) sink.line(`${n > 1 ? '\n' : ''}Page ${n}`);
sink.line(text);
}
if (read === 0 || chars / read < MIN_PDF_CHARS_PER_PAGE) {
return { status: 'no_text', text: '', truncated: false, pages };
}
return { status: 'ok', text: parts.join('\n\n'), truncated: pages > read, pages };
if (!readable) return { status: 'no_text', text: '', truncated: false, pages };
return { status: 'ok', text: sink.text(), truncated: sink.full || n < pages, pages };
};
try {
return await Promise.race([read(), timeout]);
} catch (err) {
const name = (err as { name?: unknown } | null)?.name;
if (name === 'PasswordException') return { status: 'encrypted', text: '', truncated: false };
return { status: 'failed', text: '', truncated: false };
} finally {
clearTimeout(timer);
// Also stops a read still running after the timeout.
void task.destroy().catch(() => {});
}
}