From bd6863af8170cf8d0650261876db6e551a51d117 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Wed, 30 Sep 2026 16:13:23 +0200 Subject: [PATCH] fix(react-ui): extract text from PDF attachments in chat and home (#12374) The React UI read every non-media attachment with file.text(). For a PDF that decodes the binary bytes as UTF-8, so the model received raw "%PDF ... stream ... endobj" noise instead of the document. The legacy Alpine UI ran pdf.js; that step was not ported when the React UI replaced it, but both file pickers still advertise .pdf. Add a shared readAttachmentText helper that routes PDFs through pdfjs-dist and reads other files as before. pdf.js and its worker load on first use, so the main bundle does not grow. A PDF that cannot be parsed or has no text layer (scanned, encrypted, damaged) is rejected with a toast instead of being attached as an empty or garbage file. Cover the chat and home paths with Playwright specs that build a real PDF in the test. Assisted-by: Claude Code:claude-sonnet-5-5 [playwright] [eslint] Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- .../react-ui/e2e/chat-pdf-attachment.spec.js | 97 +++++++ core/http/react-ui/package-lock.json | 271 ++++++++++++++++++ core/http/react-ui/package.json | 1 + .../http/react-ui/public/locales/en/chat.json | 3 +- .../http/react-ui/public/locales/en/home.json | 3 +- core/http/react-ui/src/pages/Chat.jsx | 10 +- core/http/react-ui/src/pages/Home.jsx | 10 +- core/http/react-ui/src/utils/pdf.js | 48 ++++ 8 files changed, 437 insertions(+), 6 deletions(-) create mode 100644 core/http/react-ui/e2e/chat-pdf-attachment.spec.js create mode 100644 core/http/react-ui/src/utils/pdf.js diff --git a/core/http/react-ui/e2e/chat-pdf-attachment.spec.js b/core/http/react-ui/e2e/chat-pdf-attachment.spec.js new file mode 100644 index 000000000..eaf1d0fb1 --- /dev/null +++ b/core/http/react-ui/e2e/chat-pdf-attachment.spec.js @@ -0,0 +1,97 @@ +import { test, expect } from './coverage-fixtures.js' + +// Single-page PDF with a real text layer, built byte by byte so the xref +// offsets are valid and the spec needs no binary fixture. +function buildPdf(text) { + const stream = `BT /F1 18 Tf 20 100 Td (${text}) Tj ET` + const objs = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>', + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ] + let out = '%PDF-1.4\n' + const offsets = [] + objs.forEach((body, i) => { + offsets.push(out.length) + out += `${i + 1} 0 obj\n${body}\nendobj\n` + }) + const xref = out.length + out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n` + for (const o of offsets) out += `${String(o).padStart(10, '0')} 00000 n \n` + out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n` + return Buffer.from(out, 'latin1') +} + +async function openChat(page) { + await page.route('**/api/models/capabilities', (route) => { + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }), + }) + }) + await page.goto('/app/chat') + await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) +} + +test.describe('Chat - PDF attachments', () => { + test('sends the extracted text layer, not the raw PDF bytes', async ({ page }) => { + let requestBody = '' + await page.route('**/v1/chat/completions', (route) => { + requestBody = route.request().postData() || '' + route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'stop' } }) }) + }) + await openChat(page) + + await page.locator('input[type=file]').setInputFiles({ + name: 'report.pdf', + mimeType: 'application/pdf', + buffer: buildPdf('Quarterly revenue grew 42 percent'), + }) + await expect(page.locator('.chat-file-name', { hasText: 'report.pdf' })).toBeVisible() + + await page.locator('.chat-input').fill('Summarize') + await page.locator('.chat-send-btn').click() + + await expect.poll(() => requestBody).toContain('Quarterly revenue grew 42 percent') + expect(requestBody).toContain('File: report.pdf') + expect(requestBody).not.toContain('%PDF') + }) + + test('rejects a PDF that cannot be parsed instead of attaching garbage', async ({ page }) => { + await openChat(page) + + await page.locator('input[type=file]').setInputFiles({ + name: 'broken.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('%PDF-1.4 this is not a real document'), + }) + + await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('.chat-file-name', { hasText: 'broken.pdf' })).toHaveCount(0) + }) +}) + +test.describe('Home - PDF attachments', () => { + test('attaches a PDF that has a text layer', async ({ page }) => { + await page.goto('/app') + await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ + name: 'notes.pdf', + mimeType: 'application/pdf', + buffer: buildPdf('Meeting notes for Tuesday'), + }) + await expect(page.locator('.home-file-tag', { hasText: 'notes.pdf' })).toBeVisible({ timeout: 10_000 }) + }) + + test('rejects a PDF that cannot be parsed', async ({ page }) => { + await page.goto('/app') + await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ + name: 'broken.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('%PDF-1.4 this is not a real document'), + }) + await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('.home-file-tag')).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/package-lock.json b/core/http/react-ui/package-lock.json index 6683ac675..9c68bd1c7 100644 --- a/core/http/react-ui/package-lock.json +++ b/core/http/react-ui/package-lock.json @@ -29,6 +29,7 @@ "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.6", "marked": "^15.0.7", + "pdfjs-dist": "^5.6.205", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^17.0.6", @@ -1021,6 +1022,256 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -5219,6 +5470,13 @@ "node": ">=8" } }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.54", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", @@ -5752,6 +6010,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdfjs-dist": { + "version": "5.6.205", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0 || >=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.96", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/core/http/react-ui/package.json b/core/http/react-ui/package.json index 73aa8a8a0..abedc40e8 100644 --- a/core/http/react-ui/package.json +++ b/core/http/react-ui/package.json @@ -45,6 +45,7 @@ "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.6", "marked": "^15.0.7", + "pdfjs-dist": "^5.6.205", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^17.0.6", diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index f1c058790..1f9b1ad8a 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -117,7 +117,8 @@ "copied": "Copied to clipboard", "copyFailed": "Could not copy to clipboard", "chatCopied": "Chat copied to clipboard", - "forked": "Created a new chat" + "forked": "Created a new chat", + "pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged." }, "menu": { "trigger": "Chats", diff --git a/core/http/react-ui/public/locales/en/home.json b/core/http/react-ui/public/locales/en/home.json index b8bb3dd13..b65bea822 100644 --- a/core/http/react-ui/public/locales/en/home.json +++ b/core/http/react-ui/public/locales/en/home.json @@ -35,7 +35,8 @@ "enterToSend": "Enter to send", "selectModelFirst": "Select a model first", "sendMessage": "Send message", - "selectModelToast": "Please select a model first" + "selectModelToast": "Please select a model first", + "pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged." }, "quickLinks": { "manageByChat": "Manage by chat", diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index d799efba0..d714d4fde 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -9,6 +9,7 @@ import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/arti import CanvasPanel from '../components/CanvasPanel' import Toggle from '../components/Toggle' import { fileToBase64, modelsApi, mcpApi } from '../utils/api' +import { readAttachmentText } from '../utils/pdf' import { CAP_CHAT } from '../utils/capabilities' import { useMCPClient } from '../hooks/useMCPClient' import MCPAppFrame from '../components/MCPAppFrame' @@ -842,13 +843,18 @@ export default function Chat() { const base64 = await fileToBase64(file) const entry = { name: file.name, type: file.type, base64 } if (!file.type.startsWith('image/') && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) { - entry.textContent = await file.text().catch(() => '') + try { + entry.textContent = await readAttachmentText(file) + } catch { + addToast(t('toasts.pdfReadFailed', { name: file.name }), 'error') + continue + } } newFiles.push(entry) } setFiles(prev => [...prev, ...newFiles]) e.target.value = '' - }, []) + }, [addToast, t]) const handlePaste = useCallback(async (e) => { const items = e.clipboardData?.items diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx index a5e43b5b4..621358266 100644 --- a/core/http/react-ui/src/pages/Home.jsx +++ b/core/http/react-ui/src/pages/Home.jsx @@ -12,6 +12,7 @@ import HomeConnect from '../components/HomeConnect' import { useResources } from '../hooks/useResources' import { usePolling } from '../hooks/usePolling' import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api' +import { readAttachmentText } from '../utils/pdf' import { API_CONFIG } from '../utils/config' import { greetingKey } from '../utils/greeting' import StatusPill from '../components/StatusPill' @@ -158,12 +159,17 @@ export default function Home() { const base64 = await fileToBase64(file) const entry = { name: file.name, type: file.type, base64 } if (!file.type.startsWith('image/') && !file.type.startsWith('audio/')) { - entry.textContent = await file.text().catch(() => '') + try { + entry.textContent = await readAttachmentText(file) + } catch { + addToast(t('input.pdfReadFailed', { name: file.name }), 'error') + continue + } } newFiles.push(entry) } setter(prev => [...prev, ...newFiles]) - }, []) + }, [addToast, t]) const removeFile = useCallback((file) => { const removeFn = (prev) => prev.filter(f => f !== file) diff --git a/core/http/react-ui/src/utils/pdf.js b/core/http/react-ui/src/utils/pdf.js new file mode 100644 index 000000000..2996e50db --- /dev/null +++ b/core/http/react-ui/src/utils/pdf.js @@ -0,0 +1,48 @@ +export function isPdf(file) { + return file?.type === 'application/pdf' || /\.pdf$/i.test(file?.name || '') +} + +// pdf.js and its worker are loaded on first use so the main bundle does not +// pay for them when nobody attaches a PDF. +async function loadPdfjs() { + const [pdfjs, worker] = await Promise.all([ + import('pdfjs-dist'), + import('pdfjs-dist/build/pdf.worker.min.mjs?url'), + ]) + pdfjs.GlobalWorkerOptions.workerSrc = worker.default + return pdfjs +} + +// Returns the text layer of every page, one block per page. Throws when the +// file cannot be parsed or has no text layer (scanned PDFs): sending an empty +// attachment to the model would look like success and silently lose the file. +export async function extractPdfText(file) { + const pdfjs = await loadPdfjs() + const data = new Uint8Array(await file.arrayBuffer()) + const doc = await pdfjs.getDocument({ data }).promise + try { + const pages = [] + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i) + const content = await page.getTextContent() + let text = '' + for (const item of content.items) { + text += item.str + text += item.hasEOL ? '\n' : '' + } + pages.push(text.trim()) + } + const text = pages.filter(Boolean).join('\n\n') + if (!text) throw new Error('PDF has no extractable text') + return text + } finally { + await doc.destroy() + } +} + +// Text of an attached non-media file. PDFs go through pdf.js; everything else +// is read as UTF-8. +export async function readAttachmentText(file) { + if (isPdf(file)) return extractPdfText(file) + return file.text().catch(() => '') +}