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..b97223137 100644
--- a/src/chrome/src/agent/agent.js
+++ b/src/chrome/src/agent/agent.js
@@ -27779,27 +27779,30 @@ 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,
+ includeDocument: supportsPdfPassthrough,
});
// Tier 2 — Anthropic Claude PDF passthrough. If the active provider
// can natively consume PDFs as a `document` content block AND the
- // file fits under the size cap, attach the raw bytes via
- // `_attachDocument`. The batch loop strips this field before
+ // file fits under the size cap, attach base64 encoded from the same
+ // bytes used for text extraction via `_attachDocument`. The batch
+ // loop strips this private field before
// 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;
+ const pdfBase64 = result._pdfBase64;
+ delete result._pdfBase64;
if (
- bytes &&
- providerSupportsPdfPassthrough(provider) &&
- bytes.length <= PDF_PASSTHROUGH_MAX_BYTES
+ pdfBase64 &&
+ supportsPdfPassthrough &&
+ result.byteLength <= PDF_PASSTHROUGH_MAX_BYTES
) {
let docName = result.title || '';
if (!docName) {
@@ -27808,11 +27811,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
docName = decodeURIComponent(u.pathname.split('/').pop() || 'document.pdf');
} catch { docName = 'document.pdf'; }
}
- const docBlock = buildClaudeDocumentBlock(bytes, docName);
+ const docBlock = buildClaudeDocumentBlock(pdfBase64, docName);
return {
...result,
method: 'pdf_text+claude_document',
- description: `PDF text extracted (${result.pageCount} pages); raw bytes also attached as Claude document block for full-fidelity reading.`,
+ description: `PDF text extracted (${result.pageCount} pages); the same PDF bytes are also attached as a Claude document block for full-fidelity reading.`,
_attachDocument: docBlock,
};
}
diff --git a/src/chrome/src/agent/pdf-extraction.js b/src/chrome/src/agent/pdf-extraction.js
new file mode 100644
index 000000000..27c951545
--- /dev/null
+++ b/src/chrome/src/agent/pdf-extraction.js
@@ -0,0 +1,182 @@
+/**
+ * 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 const PDF_EXTRACTION_READY_MESSAGE = 'offscreen-pdf-extract-ready';
+export const PDF_PASSTHROUGH_MAX_BYTES = 16 * 1024 * 1024;
+
+const ALLOWED_PDF_PROTOCOLS = new Set(['http:', 'https:', 'file:']);
+const DEFAULT_PDF_HANDLER_PAGE = 'src/ui/pdf-handler.html';
+const BASE64_MAX_INPUT_BYTES = 32 * 1024 * 1024;
+
+// Derived from the manifest for the same reason as the background path below:
+// renaming the viewer page would otherwise make read_pdf report a bogus
+// "must use http:, https:, or file:" on our own PDF tabs.
+function pdfHandlerPage(runtime) {
+ const manifest = typeof runtime?.getManifest === 'function' ? runtime.getManifest() : null;
+ return manifest?.mime_types_handler?.['application/pdf']?.handler_url || DEFAULT_PDF_HANDLER_PAGE;
+}
+
+// When the native MIME handler owns a PDF tab, the tab URL is our own viewer
+// page wrapping the real URL in ?url=. read_pdf falls back to the tab URL when
+// called without an explicit one, so unwrap it before the scheme check.
+function unwrapPdfHandlerUrl(url, runtime = globalThis.chrome?.runtime) {
+ if (url.protocol !== 'chrome-extension:' && url.protocol !== 'moz-extension:') return url;
+ if (typeof runtime?.getURL !== 'function') return url;
+ let handler;
+ try {
+ handler = new URL(runtime.getURL(pdfHandlerPage(runtime)));
+ } catch {
+ return url;
+ }
+ if (url.origin !== handler.origin || url.pathname !== handler.pathname) return url;
+ const inner = url.searchParams.get('url');
+ if (!inner) return url;
+ try {
+ return new URL(inner);
+ } catch {
+ return url;
+ }
+}
+
+export function normalizePdfUrl(value, runtime = globalThis.chrome?.runtime) {
+ let url;
+ try {
+ url = new URL(String(value || '').trim());
+ } catch {
+ throw new Error('PDF extraction requires a valid URL.');
+ }
+ url = unwrapPdfHandlerUrl(url, runtime);
+ if (!ALLOWED_PDF_PROTOCOLS.has(url.protocol)) {
+ throw new Error('PDF URL must use http:, https:, or file:.');
+ }
+ return url.href;
+}
+
+// Derived from the manifest rather than hardcoded: a rename of the service
+// worker entry would otherwise reject every extraction with a "not ready"
+// error that points at the wrong subsystem.
+function backgroundScriptPath(runtime) {
+ const manifest = typeof runtime?.getManifest === 'function' ? runtime.getManifest() : null;
+ return manifest?.background?.service_worker || 'src/background.js';
+}
+
+export function isTrustedPdfExtractionSender(sender, runtime = globalThis.chrome?.runtime) {
+ if (!runtime?.id || typeof runtime.getURL !== 'function') return false;
+ return sender?.id === runtime.id
+ && sender?.tab == null
+ && sender?.url === runtime.getURL(backgroundScriptPath(runtime));
+}
+
+export function bytesToBase64(bytes) {
+ if (bytes.length > BASE64_MAX_INPUT_BYTES) {
+ throw new Error(`PDF too large for base64 conversion (${bytes.length} bytes, cap ${BASE64_MAX_INPUT_BYTES}).`);
+ }
+ let binary = '';
+ const chunkSize = 0x8000;
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
+ binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + chunkSize));
+ }
+ return btoa(binary);
+}
+
+export async function fetchPdfBytes(url, { timeoutMs = 60000 } = {}) {
+ const normalizedUrl = normalizePdfUrl(url);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ let response;
+ try {
+ response = await fetch(normalizedUrl, { credentials: 'include', signal: controller.signal });
+ } catch (error) {
+ if (normalizedUrl.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));
+
+ // PDF.js may transfer this buffer to its worker, detaching the caller's
+ // Uint8Array. Capture the length before getDocument() so metadata remains
+ // accurate after parsing.
+ const byteLength = bytes.length;
+ 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,
+ };
+}
diff --git a/src/chrome/src/agent/pdf-tools.js b/src/chrome/src/agent/pdf-tools.js
index aa0e183e3..7795af98a 100644
--- a/src/chrome/src/agent/pdf-tools.js
+++ b/src/chrome/src/agent/pdf-tools.js
@@ -23,40 +23,50 @@
* 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,
+ PDF_EXTRACTION_READY_MESSAGE,
+ PDF_PASSTHROUGH_MAX_BYTES,
+ bytesToBase64,
+} from './pdf-extraction.js';
+
+// chrome.offscreen.createDocument() resolves when the document exists, not
+// when its module scripts have registered their listeners. Probe the PDF host
+// for up to one second so the first read after a cold start cannot race it.
+const PDF_HOST_READY_ATTEMPTS = 40;
+const PDF_HOST_READY_RETRY_MS = 25;
+
+function wait(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
}
-/**
- * Cheap byte-array → base64 conversion that doesn't blow the call
- * stack on multi-MB PDFs. fromCharCode.apply has a per-call argument
- * limit (~64k in V8), so we chunk.
- */
-const BASE64_MAX_INPUT_BYTES = 32 * 1024 * 1024; // 32 MB safety cap
-
-function bytesToBase64(bytes) {
- if (bytes.length > BASE64_MAX_INPUT_BYTES) {
- throw new Error(`PDF too large for base64 conversion (${bytes.length} bytes, cap ${BASE64_MAX_INPUT_BYTES}).`);
- }
- let bin = '';
- const chunk = 0x8000;
- for (let i = 0; i < bytes.length; i += chunk) {
- bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
+async function waitForPdfExtractionHost() {
+ let lastError = null;
+ for (let attempt = 0; attempt < PDF_HOST_READY_ATTEMPTS; attempt++) {
+ let refusal = null;
+ try {
+ const response = await chrome.runtime.sendMessage({ type: PDF_EXTRACTION_READY_MESSAGE });
+ if (response?.ready === true) return;
+ // An explicit error means the host answered and refused. Retrying cannot
+ // change the outcome, and reporting it as "not ready" sends whoever is
+ // debugging to the wrong subsystem.
+ if (response?.error) refusal = new Error(response.error);
+ } catch (error) {
+ // No listener: the document went away between ensureOffscreen() seeing it
+ // and this probe. Recreate it rather than retrying into a dead channel.
+ lastError = error;
+ try {
+ await ensureOffscreen();
+ } catch (ensureError) {
+ lastError = ensureError;
+ }
+ }
+ if (refusal) throw refusal;
+ if (attempt + 1 < PDF_HOST_READY_ATTEMPTS) await wait(PDF_HOST_READY_RETRY_MS);
}
- return btoa(bin);
+ const detail = lastError?.message ? ` ${lastError.message}` : '';
+ throw new Error(`The offscreen PDF parser did not become ready.${detail}`);
}
/**
@@ -79,41 +89,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 +105,22 @@ 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,
+ await ensureOffscreen();
+ await waitForPdfExtractionHost();
+ const response = await chrome.runtime.sendMessage({
+ type: PDF_EXTRACTION_MESSAGE,
+ url,
+ options: {
+ fromPage: opts.fromPage,
+ toPage: opts.toPage,
+ maxChars: opts.maxChars,
+ includeBase64: opts.includeDocument === true,
+ },
});
- 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;
- }
-
- pages.push(pageText);
- charCount += pageText.length;
- lastRead = i;
-
- // Free per-page resources — pdfjs caches aggressively otherwise.
- page.cleanup?.();
+ if (!response?.ok || !response.result) {
+ throw new Error(response?.error || 'The offscreen PDF parser returned no result.');
}
-
- // 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,
- };
+ return response.result;
}
/**
@@ -236,21 +142,24 @@ export function providerSupportsPdfPassthrough(provider) {
}
/**
- * Build the `document` content block for the Anthropic Messages API
- * from raw PDF bytes. Caller is responsible for size-checking — Claude's
- * cap is ~32 MB base64 / ~24 MB binary as of writing, but we cap
- * lower (16 MB binary) to leave room for the rest of the conversation.
+ * Build the `document` content block for the Anthropic Messages API from
+ * raw PDF bytes or base64 produced from those same bytes. Caller is
+ * responsible for size-checking — Claude's cap is ~32 MB base64 / ~24 MB
+ * binary as of writing, but we cap lower (16 MB binary) to leave room for
+ * the rest of the conversation.
*/
-export function buildClaudeDocumentBlock(bytes, name) {
+export function buildClaudeDocumentBlock(bytesOrBase64, name) {
return {
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
- data: bytesToBase64(bytes),
+ data: typeof bytesOrBase64 === 'string'
+ ? bytesOrBase64
+ : bytesToBase64(bytesOrBase64),
},
...(name ? { title: name } : {}),
};
}
-export const PDF_PASSTHROUGH_MAX_BYTES = 16 * 1024 * 1024; // 16 MB
+export { PDF_PASSTHROUGH_MAX_BYTES };
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..6287eb1bc
--- /dev/null
+++ b/src/chrome/src/offscreen/pdf-extraction-host.js
@@ -0,0 +1,79 @@
+import {
+ PDF_EXTRACTION_MESSAGE,
+ PDF_EXTRACTION_READY_MESSAGE,
+ PDF_PASSTHROUGH_MAX_BYTES,
+ bytesToBase64,
+ extractPdfTextFromBytes,
+ fetchPdfBytes,
+ isTrustedPdfExtractionSender,
+ normalizePdfUrl,
+} from '../agent/pdf-extraction.js';
+
+const PDF_MESSAGE_TYPES = new Set([PDF_EXTRACTION_MESSAGE, PDF_EXTRACTION_READY_MESSAGE]);
+
+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;
+ });
+ // The offscreen document outlives any single read, so never memoize a
+ // rejection: one transient import failure would otherwise make every
+ // later read_pdf fail until the document is torn down.
+ pdfjsPromise.catch(() => { pdfjsPromise = null; });
+ }
+ return pdfjsPromise;
+}
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (!PDF_MESSAGE_TYPES.has(message?.type)) return false;
+
+ // A 16 MB PDF answers with ~21 MB of base64, and an oversized or
+ // unserializable reply makes sendResponse throw. Without this guard that
+ // throw would reach the catch below and call sendResponse a second time on
+ // an already-closed channel, hiding the original failure.
+ let responded = false;
+ const respond = (payload) => {
+ if (responded) return;
+ responded = true;
+ try {
+ sendResponse(payload);
+ } catch (error) {
+ console.warn('[pdf-extraction-host] failed to deliver response', error);
+ }
+ };
+
+ if (!isTrustedPdfExtractionSender(sender)) {
+ respond({ ok: false, ready: false, error: 'Unauthorized PDF extraction sender.' });
+ return false;
+ }
+ if (message.type === PDF_EXTRACTION_READY_MESSAGE) {
+ respond({ ok: true, ready: true });
+ return false;
+ }
+
+ (async () => {
+ const url = normalizePdfUrl(message.url);
+ const bytes = await fetchPdfBytes(url);
+ // PDF.js can transfer and detach the input buffer, so keep a copy of the
+ // optional Claude document taken before parsing: text and document then
+ // come from the same fetch. The ~4/3 base64 message overhead is
+ // intentional to preserve that single-fetch, byte-identical guarantee.
+ // The encode itself is deferred until parsing succeeds, so a corrupt PDF
+ // that throws in getDocument() does not pay for a string nobody reads.
+ const wantsBase64 = message.options?.includeBase64 === true
+ && bytes.length <= PDF_PASSTHROUGH_MAX_BYTES;
+ const passthrough = wantsBase64 ? bytes.slice() : null;
+ const pdfjs = await getPdfjs();
+ const result = await extractPdfTextFromBytes(pdfjs, bytes, message.options || {});
+ if (passthrough) result._pdfBase64 = bytesToBase64(passthrough);
+ respond({ ok: true, result });
+ })().catch((error) => {
+ respond({ 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..beb3d7d58 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,16 @@ 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, waits for
+an explicit ready response, and then sends the PDF URL plus bounded page
+options. The host accepts requests only from the extension's background
+service worker and only fetches `http:`, `https:`, or `file:` URLs. For Claude
+passthrough, it encodes the same fetched bytes before PDF.js transfers the
+buffer to its worker, so text extraction and the document attachment cannot
+diverge. 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..fa769bc7e 100644
--- a/test/pdf-mime-handler-e2e.mjs
+++ b/test/pdf-mime-handler-e2e.mjs
@@ -65,12 +65,14 @@ function createMinimalPdf() {
async function startPdfServer() {
const pdf = createMinimalPdf();
+ let requestCount = 0;
const server = createServer((request, response) => {
const url = new URL(request.url || '/', 'http://127.0.0.1');
if (url.pathname !== '/document.pdf') {
response.writeHead(404).end('not found');
return;
}
+ requestCount += 1;
response.writeHead(200, {
'content-type': pdfMimeType,
'content-length': String(pdf.byteLength),
@@ -87,6 +89,8 @@ async function startPdfServer() {
assert.ok(address && typeof address === 'object');
return {
server,
+ pdf,
+ requestCount: () => requestCount,
url: `http://127.0.0.1:${address.port}/document.pdf`,
};
}
@@ -159,6 +163,50 @@ async function main() {
));
assert.equal(apiAvailable, true, `Chrome ${browser.version()} does not expose the public MIME handler options API.`);
+ const ensured = await settings.evaluate(async () => chrome.runtime.sendMessage({
+ target: 'background',
+ action: 'ensure_offscreen_offline_rag_host',
+ }));
+ assert.equal(ensured?.ready, true, 'The background did not create the shared offscreen host.');
+
+ const rejected = await settings.evaluate(async url => chrome.runtime.sendMessage({
+ type: 'offscreen-pdf-extract',
+ url,
+ options: { fromPage: 1, toPage: 1, maxChars: 5000 },
+ }), fixture.url);
+ assert.equal(rejected?.ok, false, 'An extension page bypassed the background-only PDF extraction gate.');
+ assert.match(rejected?.error || '', /Unauthorized PDF extraction sender/);
+ assert.equal(fixture.requestCount(), 0, 'An unauthorized PDF extraction request reached the network.');
+
+ const backgroundUrl = `chrome-extension://${extensionId}/src/background.js`;
+ // serviceWorkers() is a snapshot: Playwright may not have observed the
+ // worker yet, and Chrome can idle it out during the steps above.
+ let background = context.serviceWorkers().find(worker => worker.url() === backgroundUrl);
+ if (!background) {
+ background = await context.waitForEvent('serviceworker', {
+ predicate: worker => worker.url() === backgroundUrl,
+ timeout: 10000,
+ }).catch(() => null);
+ }
+ assert.ok(background, 'The WebBrain service worker was not available for the PDF extraction test.');
+ const ready = await background.evaluate(async () => chrome.runtime.sendMessage({
+ type: 'offscreen-pdf-extract-ready',
+ }));
+ assert.equal(ready?.ready, true, ready?.error || 'The offscreen PDF parser did not become ready.');
+
+ const requestsBeforeExtraction = fixture.requestCount();
+ const extraction = await background.evaluate(async url => chrome.runtime.sendMessage({
+ type: 'offscreen-pdf-extract',
+ url,
+ options: { fromPage: 1, toPage: 1, maxChars: 5000, includeBase64: true },
+ }), fixture.url);
+ assert.equal(extraction?.ok, true, extraction?.error || 'The offscreen PDF parser failed.');
+ assert.equal(fixture.requestCount() - requestsBeforeExtraction, 1, 'Claude-compatible extraction fetched the PDF more than once.');
+ assert.equal(extraction.result?.totalPages, 1);
+ assert.equal(extraction.result?.byteLength, fixture.pdf.byteLength);
+ assert.match(extraction.result?.pages?.[0] || '', /WebBrain PDF MIME handler test/);
+ assert.deepEqual(Buffer.from(extraction.result?._pdfBase64 || '', 'base64'), fixture.pdf);
+
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..32742a4a4
--- /dev/null
+++ b/test/pdf-read.mjs
@@ -0,0 +1,247 @@
+#!/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, /PDF_EXTRACTION_READY_MESSAGE/);
+ 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(host, /isTrustedPdfExtractionSender\(sender\)/);
+ assert.match(offscreenHtml, /