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, /