From 0eb8c5b61adaa6279e76bdfdc0cdcfc084a0aa11 Mon Sep 17 00:00:00 2001 From: 16Miku <196724264+16Miku@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:15:14 +0800 Subject: [PATCH 1/5] fix(chrome): parse PDFs in offscreen document --- package.json | 3 +- src/chrome/src/agent/agent.js | 6 +- src/chrome/src/agent/pdf-extraction.js | 100 +++++++++++ src/chrome/src/agent/pdf-tools.js | 159 +++--------------- src/chrome/src/offscreen/ensure.js | 3 +- src/chrome/src/offscreen/offscreen.html | 2 + .../src/offscreen/pdf-extraction-host.js | 34 ++++ src/chrome/vendor/pdfjs/README.md | 13 +- test/pdf-mime-handler-e2e.mjs | 17 ++ test/pdf-read.mjs | 153 +++++++++++++++++ 10 files changed, 348 insertions(+), 142 deletions(-) create mode 100644 src/chrome/src/agent/pdf-extraction.js create mode 100644 src/chrome/src/offscreen/pdf-extraction-host.js create mode 100644 test/pdf-read.mjs diff --git a/package.json b/package.json index da6092199..66a3a94a6 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "private": true, "type": "module", "scripts": { - "test": "npm run test:provider-limits && npm run test:toolbar-guard && npm run test:pdf-selection && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", + "test": "npm run test:provider-limits && npm run test:toolbar-guard && npm run test:pdf-read && npm run test:pdf-selection && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", "test:provider-limits": "node test/provider-model-limits.mjs", "test:security": "node test/security/injection-corpus.mjs", "test:toolbar-guard": "node test/rich-text-toolbar-guard.mjs", + "test:pdf-read": "node test/pdf-read.mjs", "test:pdf-selection": "node test/pdf-selection.mjs", "test:pdf-mime-handler": "node test/pdf-mime-handler-e2e.mjs", "test:injection-bench": "node test/llm/run-scenarios.mjs --category prompt-injection", diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 66bda5e3a..f8cda6f7a 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -27779,10 +27779,13 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return { success: false, error: 'read_pdf: no url provided and could not read the active tab URL.' }; } + const provider = this._activeProvider(tabId); + const supportsPdfPassthrough = providerSupportsPdfPassthrough(provider); const result = await extractPdfText(pdfUrl, { fromPage: args.fromPage, toPage: args.toPage, maxChars: args.maxChars, + includeBytes: supportsPdfPassthrough, }); // Tier 2 — Anthropic Claude PDF passthrough. If the active provider @@ -27792,13 +27795,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // stringifying the tool result and pushes the document as a // follow-up user message (analogous to the `_attachImage` path // used by `screenshot`). - const provider = this._activeProvider(tabId); const bytes = result._pdfBytes; delete result._pdfBytes; if ( bytes && - providerSupportsPdfPassthrough(provider) && + supportsPdfPassthrough && bytes.length <= PDF_PASSTHROUGH_MAX_BYTES ) { let docName = result.title || ''; diff --git a/src/chrome/src/agent/pdf-extraction.js b/src/chrome/src/agent/pdf-extraction.js new file mode 100644 index 000000000..330ef1661 --- /dev/null +++ b/src/chrome/src/agent/pdf-extraction.js @@ -0,0 +1,100 @@ +/** + * Browser-neutral PDF fetching and text extraction helpers. + * + * PDF.js itself is loaded by an extension page, not by the MV3 service + * worker. Keeping the extraction loop here lets the offscreen host own the + * browser-only PDF.js runtime while the agent facade remains lightweight. + */ + +export const PDF_EXTRACTION_MESSAGE = 'offscreen-pdf-extract'; + +export async function fetchPdfBytes(url, { timeoutMs = 60000 } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + let response; + try { + response = await fetch(url, { credentials: 'include', signal: controller.signal }); + } catch (error) { + if (typeof url === 'string' && url.startsWith('file://')) { + throw new Error( + 'Cannot fetch local PDF from a file:// URL. WebBrain needs ' + + 'file-URL access in Chrome: open chrome://extensions, find ' + + 'WebBrain, click "Details", and enable "Allow access to file URLs". ' + + 'Then reload the PDF tab and try read_pdf again.' + ); + } + throw new Error(`PDF fetch failed: ${error.message}`); + } + if (!response.ok) { + throw new Error(`PDF fetch returned HTTP ${response.status} ${response.statusText}`); + } + return new Uint8Array(await response.arrayBuffer()); + } finally { + clearTimeout(timeout); + } +} + +export async function extractPdfTextFromBytes(pdfjs, bytes, opts = {}) { + const fromPage = Math.max(1, Math.floor(opts.fromPage || 1)); + const requestedTo = opts.toPage ? Math.floor(opts.toPage) : fromPage + 49; + const maxChars = Math.max(1000, Math.floor(opts.maxChars || 50000)); + + const loadingTask = pdfjs.getDocument({ + data: bytes, + verbosity: 0, + }); + const pdf = await loadingTask.promise; + + const totalPages = pdf.numPages; + const startPage = Math.min(fromPage, totalPages); + const endPage = Math.min(totalPages, Math.max(startPage, requestedTo)); + + let title = ''; + try { + const meta = await pdf.getMetadata(); + title = meta?.info?.Title || ''; + } catch { /* metadata is best-effort */ } + + const pages = []; + let charCount = 0; + let truncated = false; + let lastRead = startPage - 1; + + for (let pageNumber = startPage; pageNumber <= endPage; pageNumber++) { + const page = await pdf.getPage(pageNumber); + const content = await page.getTextContent(); + const pageText = content.items + .map(item => (item && typeof item.str === 'string' ? item.str : '')) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + + if (charCount + pageText.length > maxChars) { + const remaining = Math.max(0, maxChars - charCount); + pages.push(pageText.slice(0, remaining) + '… [page truncated, use read_pdf with fromPage to read more]'); + lastRead = pageNumber; + truncated = true; + page.cleanup?.(); + break; + } + + pages.push(pageText); + charCount += pageText.length; + lastRead = pageNumber; + page.cleanup?.(); + } + + return { + success: true, + title, + totalPages, + fromPage: startPage, + toPage: lastRead, + pageCount: pages.length, + pages, + hasExtractableText: pages.join('\n').length > 100, + truncated, + byteLength: bytes.length, + }; +} diff --git a/src/chrome/src/agent/pdf-tools.js b/src/chrome/src/agent/pdf-tools.js index aa0e183e3..32a521816 100644 --- a/src/chrome/src/agent/pdf-tools.js +++ b/src/chrome/src/agent/pdf-tools.js @@ -23,22 +23,8 @@ * additional context. */ -let pdfjsModule = null; - -/** - * Lazy-load pdfjs only on first PDF read. The legacy bundle is ~1 MB - * and the worker is ~2.3 MB; we don't want to pay that startup cost - * for users who never open a PDF. - */ -async function getPdfjs() { - if (pdfjsModule) return pdfjsModule; - pdfjsModule = await import(chrome.runtime.getURL('vendor/pdfjs/pdf.mjs')); - // Worker URL must be set BEFORE the first getDocument() call. We resolve - // it via runtime.getURL so it works at any extension-id deploy target. - pdfjsModule.GlobalWorkerOptions.workerSrc = - chrome.runtime.getURL('vendor/pdfjs/pdf.worker.mjs'); - return pdfjsModule; -} +import { ensureOffscreen } from '../offscreen/ensure.js'; +import { PDF_EXTRACTION_MESSAGE, fetchPdfBytes } from './pdf-extraction.js'; /** * Cheap byte-array → base64 conversion that doesn't blow the call @@ -79,41 +65,6 @@ export function isPdfUrl(url) { return false; } -/** - * Fetch the PDF binary from `url`. Returns a Uint8Array. - * Throws with a helpful message on failure — file:// URLs in Chrome - * require the user-toggle "Allow access to file URLs" at - * chrome://extensions, which we explain instead of leaving the - * agent guessing. - */ -export async function fetchPdfBytes(url) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 60000); - try { - let res; - try { - res = await fetch(url, { credentials: 'include', signal: controller.signal }); - } catch (e) { - if (typeof url === 'string' && url.startsWith('file://')) { - throw new Error( - 'Cannot fetch local PDF from a file:// URL. WebBrain needs ' + - 'file-URL access in Chrome: open chrome://extensions, find ' + - 'WebBrain, click "Details", and enable "Allow access to file URLs". ' + - 'Then reload the PDF tab and try read_pdf again.' - ); - } - throw new Error(`PDF fetch failed: ${e.message}`); - } - if (!res.ok) { - throw new Error(`PDF fetch returned HTTP ${res.status} ${res.statusText}`); - } - const buf = await res.arrayBuffer(); - return new Uint8Array(buf); - } finally { - clearTimeout(timeout); - } -} - /** * Extract text from a PDF. * @@ -130,91 +81,31 @@ export async function fetchPdfBytes(url) { * having to render every page to PNG ourselves. */ export async function extractPdfText(url, opts = {}) { - const fromPage = Math.max(1, Math.floor(opts.fromPage || 1)); - const requestedTo = opts.toPage ? Math.floor(opts.toPage) : fromPage + 49; - const maxChars = Math.max(1000, Math.floor(opts.maxChars || 50000)); - - const bytes = await fetchPdfBytes(url); - const pdfjs = await getPdfjs(); - - const loadingTask = pdfjs.getDocument({ - data: bytes, - // Suppress pdfjs's noisy console.warn for "non-embedded font fallback" etc. - // We surface real errors via the catch below. - verbosity: 0, - }); - const pdf = await loadingTask.promise; - - const totalPages = pdf.numPages; - const startPage = Math.min(fromPage, totalPages); - const endPage = Math.min(totalPages, Math.max(startPage, requestedTo)); - - // Best-effort title from the document's metadata dictionary. - let title = ''; - try { - const meta = await pdf.getMetadata(); - title = meta?.info?.Title || ''; - } catch { /* ignore */ } - - const pages = []; - let charCount = 0; - let truncated = false; - // Last page actually read, so the truncation notice's "read more with - // fromPage" advice resolves to a page that was really covered. Reporting - // `endPage` after an early `break` would make a caller resume past the - // unread pages and silently lose them. - let lastRead = startPage - 1; - - for (let i = startPage; i <= endPage; i++) { - const page = await pdf.getPage(i); - const content = await page.getTextContent(); - - // pdfjs returns text items as a flat array with positional info. - // For LLM consumption we just join them with spaces — preserving - // exact layout would be more accurate but blows the token budget. - const pageText = content.items - .map((item) => (item && typeof item.str === 'string' ? item.str : '')) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); - - if (charCount + pageText.length > maxChars) { - const remaining = Math.max(0, maxChars - charCount); - pages.push(pageText.slice(0, remaining) + '… [page truncated, use read_pdf with fromPage to read more]'); - lastRead = i; - truncated = true; - break; + await ensureOffscreen(); + const extraction = chrome.runtime.sendMessage({ + type: PDF_EXTRACTION_MESSAGE, + url, + options: { + fromPage: opts.fromPage, + toPage: opts.toPage, + maxChars: opts.maxChars, + }, + }).then((response) => { + if (!response?.ok || !response.result) { + throw new Error(response?.error || 'The offscreen PDF parser returned no result.'); } + return response.result; + }); - pages.push(pageText); - charCount += pageText.length; - lastRead = i; - - // Free per-page resources — pdfjs caches aggressively otherwise. - page.cleanup?.(); - } - - // Heuristic: <100 chars across the whole requested range almost certainly - // means the pages are scanned images with no text layer. Tell the model. - const hasExtractableText = pages.join('\n').length > 100; - - return { - success: true, - title, - totalPages, - fromPage: startPage, - toPage: lastRead, - pageCount: pages.length, - pages, - hasExtractableText, - truncated, - byteLength: bytes.length, - // The raw bytes are kept on `_pdfBytes` for the Tier 2 Claude - // passthrough path; the batch loop strips it before stringifying - // so the LLM doesn't see ~1 MB of base64 nonsense in the tool - // result text. - _pdfBytes: bytes, - }; + // Only Claude-compatible providers need the original bytes. Keeping this + // fetch in the service worker avoids sending multi-megabyte binary payloads + // through extension messaging for every normal PDF read. + const rawBytes = opts.includeBytes === true + ? fetchPdfBytes(url) + : Promise.resolve(null); + const [result, bytes] = await Promise.all([extraction, rawBytes]); + if (bytes) result._pdfBytes = bytes; + return result; } /** diff --git a/src/chrome/src/offscreen/ensure.js b/src/chrome/src/offscreen/ensure.js index 15f5c9ab7..269a9801e 100644 --- a/src/chrome/src/offscreen/ensure.js +++ b/src/chrome/src/offscreen/ensure.js @@ -5,6 +5,7 @@ * and the set of `reasons` declared at createDocument time is fixed — you * cannot add reasons later. So both consumers of the offscreen document * (the localhost-fetch proxy in offscreen.js, local WebGPU inference worker, + * PDF text extraction in pdf-extraction-host.js, * offline SQLite/E5 retrieval workers, * large-file staging in * skill-download.js, Emergency Box downloads in emergency-download-host.js, @@ -46,7 +47,7 @@ const OFFSCREEN_REASONS = [ 'AUDIO_PLAYBACK', ]; const OFFSCREEN_JUSTIFICATION = - 'Proxy localhost requests; run local WebGPU models and offline reference search; stage validated large downloads; capture active tab and mic; maintain a localhost controller WebSocket; play conditional watch alerts.'; + 'Proxy localhost requests; parse PDFs; run local WebGPU models and offline reference search; stage validated large downloads; capture active tab and mic; maintain a localhost controller WebSocket; play conditional watch alerts.'; let ready = false; let inflight = null; diff --git a/src/chrome/src/offscreen/offscreen.html b/src/chrome/src/offscreen/offscreen.html index e42ffa64f..d53ba0b9f 100644 --- a/src/chrome/src/offscreen/offscreen.html +++ b/src/chrome/src/offscreen/offscreen.html @@ -4,6 +4,7 @@ + diff --git a/src/chrome/src/offscreen/pdf-extraction-host.js b/src/chrome/src/offscreen/pdf-extraction-host.js new file mode 100644 index 000000000..732b017f8 --- /dev/null +++ b/src/chrome/src/offscreen/pdf-extraction-host.js @@ -0,0 +1,34 @@ +import { + PDF_EXTRACTION_MESSAGE, + extractPdfTextFromBytes, + fetchPdfBytes, +} from '../agent/pdf-extraction.js'; +let pdfjsPromise = null; + +function getPdfjs() { + if (!pdfjsPromise) { + pdfjsPromise = import(chrome.runtime.getURL('vendor/pdfjs/pdf.mjs')).then((pdfjs) => { + pdfjs.GlobalWorkerOptions.workerSrc = + chrome.runtime.getURL('vendor/pdfjs/pdf.worker.mjs'); + return pdfjs; + }); + } + return pdfjsPromise; +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type !== PDF_EXTRACTION_MESSAGE) return false; + + (async () => { + const url = String(message.url || '').trim(); + if (!url) throw new Error('PDF extraction requires a URL.'); + const bytes = await fetchPdfBytes(url); + const pdfjs = await getPdfjs(); + const result = await extractPdfTextFromBytes(pdfjs, bytes, message.options || {}); + sendResponse({ ok: true, result }); + })().catch((error) => { + sendResponse({ ok: false, error: error?.message || String(error) }); + }); + + return true; +}); diff --git a/src/chrome/vendor/pdfjs/README.md b/src/chrome/vendor/pdfjs/README.md index f62af8035..f9d00790c 100644 --- a/src/chrome/vendor/pdfjs/README.md +++ b/src/chrome/vendor/pdfjs/README.md @@ -4,7 +4,7 @@ Mozilla PDF.js, used by `src/agent/pdf-tools.js` to extract text from PDFs the user is viewing in their browser. The Chrome PDF viewer is a `chrome-extension://` page that our content scripts can't inject into, so instead of trying to scrape the viewer's DOM we fetch the PDF -binary and parse it with pdfjs in the service worker. +binary and parse it with pdfjs in the shared offscreen document. ## Source @@ -31,7 +31,9 @@ differently in the worker context). Worth the size for the resilience. ## How it's loaded -`pdf-tools.js` does a lazy dynamic import on the first PDF read: +`src/offscreen/pdf-extraction-host.js` does a lazy dynamic import on the first +PDF read. PDF.js must run in an extension page because the Chrome MV3 service +worker rejects dynamic `import()`: ```js const pdfjs = await import(chrome.runtime.getURL('vendor/pdfjs/pdf.mjs')); @@ -44,8 +46,11 @@ pdfjs.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL('vendor/pdfjs/pdf.worker.mjs'); ``` -Both files are listed in `manifest.json`'s `web_accessible_resources` -so `chrome.runtime.getURL` returns a fetchable URL. +The Agent's `read_pdf` facade ensures the shared offscreen document and sends +it the PDF URL plus bounded page options. Both PDF.js files are listed in +`manifest.json`'s `web_accessible_resources`, so `chrome.runtime.getURL` +returns a fetchable URL without loading the multi-megabyte modules during +ordinary service-worker startup. ## Updating diff --git a/test/pdf-mime-handler-e2e.mjs b/test/pdf-mime-handler-e2e.mjs index f470d991a..d4d33b721 100644 --- a/test/pdf-mime-handler-e2e.mjs +++ b/test/pdf-mime-handler-e2e.mjs @@ -159,6 +159,23 @@ async function main() { )); assert.equal(apiAvailable, true, `Chrome ${browser.version()} does not expose the public MIME handler options API.`); + const extraction = await settings.evaluate(async url => { + const ensured = await chrome.runtime.sendMessage({ + target: 'background', + action: 'ensure_offscreen_offline_rag_host', + }); + const response = await chrome.runtime.sendMessage({ + type: 'offscreen-pdf-extract', + url, + options: { fromPage: 1, toPage: 1, maxChars: 5000 }, + }); + return { ensured, response }; + }, fixture.url); + assert.equal(extraction.ensured?.ready, true, 'The background did not create the shared offscreen host.'); + assert.equal(extraction.response?.ok, true, extraction.response?.error || 'The offscreen PDF parser failed.'); + assert.equal(extraction.response.result?.totalPages, 1); + assert.match(extraction.response.result?.pages?.[0] || '', /WebBrain PDF MIME handler test/); + await waitForNativeHandlerOption(settings, false); // Installation initially registers public MIME handlers as enabled. Allow // the post-registration reconciliation pass to settle before navigating. diff --git a/test/pdf-read.mjs b/test/pdf-read.mjs new file mode 100644 index 000000000..2fc1ba4fa --- /dev/null +++ b/test/pdf-read.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const pdfExtractionModulePath = path.join(root, 'src', 'chrome', 'src', 'agent', 'pdf-extraction.js'); +const pdfToolsModulePath = path.join(root, 'src', 'chrome', 'src', 'agent', 'pdf-tools.js'); +const pdfExtractionHostPath = path.join(root, 'src', 'chrome', 'src', 'offscreen', 'pdf-extraction-host.js'); +const offscreenHtmlPath = path.join(root, 'src', 'chrome', 'src', 'offscreen', 'offscreen.html'); + +async function testMv3PdfExtractionUsesTheSharedOffscreenHost() { + const pdfTools = await readFile(pdfToolsModulePath, 'utf8'); + const host = await readFile(pdfExtractionHostPath, 'utf8'); + const offscreenHtml = await readFile(offscreenHtmlPath, 'utf8'); + assert.match(pdfTools, /ensureOffscreen\(\)/); + assert.match(pdfTools, /type: PDF_EXTRACTION_MESSAGE/); + assert.doesNotMatch( + pdfTools, + /import\(chrome\.runtime\.getURL\('vendor\/pdfjs\/pdf\.mjs'\)\)/, + 'MV3 service workers reject dynamic import() at runtime', + ); + assert.match(host, /import\(chrome\.runtime\.getURL\('vendor\/pdfjs\/pdf\.mjs'\)\)/); + assert.match(host, /extractPdfTextFromBytes/); + assert.match(offscreenHtml, /