diff --git a/next.config.ts b/next.config.ts index 01d7d88..1ed6289 100644 --- a/next.config.ts +++ b/next.config.ts @@ -55,6 +55,12 @@ const nextConfig: NextConfig = { "*": [ "node_modules/pdfjs-dist/legacy/**/*", "node_modules/pdfjs-dist/standard_fonts/**/*", + // Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its + // platform binary (@napi-rs/canvas--) is a dynamic + // optional-dep require the tracer cannot see. Builds run per-OS and + // npm installs only the current platform's binary, so this glob + // resolves to exactly one Skia binary per build. + "node_modules/@napi-rs/canvas-*/**/*", "node_modules/@earendil-works/pi-ai/dist/providers/data/**/*", "node_modules/@earendil-works/pi-agent-core/dist/**/*.json", ], @@ -66,6 +72,7 @@ const nextConfig: NextConfig = { "@earendil-works/pi-ai", "@earendil-works/pi-agent-core", "pdfjs-dist", // sources v0: bundling breaks its node fake-worker path + "@napi-rs/canvas", // native binding — bundling breaks the .node require ], }; diff --git a/src/agent/subagents/headless.ts b/src/agent/subagents/headless.ts index b94ba55..46a6316 100644 --- a/src/agent/subagents/headless.ts +++ b/src/agent/subagents/headless.ts @@ -290,92 +290,64 @@ async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise (scale = dpi/72, the skill's zoom arithmetic) - * inside a headless Chrome tab, and the canvas pixels come back via - * toDataURL. Chrome's OWN PDF viewer plugin does not paint in headless - * mode (verified: identical blank screenshots regardless of page), so the - * canvas shim is the pixel path — Chrome still hosts and encodes, no - * native node-canvas anywhere. PDF bytes pass in as base64 (fetch() is - * blocked on file://); pdfjs runs via its real worker module under - * --allow-file-access-from-files. */ + * History: pdfjs ALWAYS painted the pages (scale = dpi/72, the skill's + * zoom arithmetic) — first inside a headless Chrome tab (canvas shim, + * toDataURL, 2026-09-19), now into @napi-rs/canvas in this process (Wave + * E, 2026-10-10). The paint engine never changed; only the canvas host: + * Skia in-process via pdfjs-dist v6's own Node integration. No browser, + * no temp files, no base64 hop. */ async function renderPdfPages( pdfPath: string, pages: number[], opts: RenderPdfOptions = {}, ): Promise { const dpi = opts.dpi ?? 150; - const pdfAbs = path.resolve(pdfPath); - const pdfjsUrl = pathToFileURL( - path.join(PATHS_nodeModules(), "pdfjs-dist/legacy/build/pdf.mjs"), - ).href; - const b64 = fs.readFileSync(pdfAbs).toString("base64"); - - const dir = tempDir("lh-pdf-"); - const file = path.join(dir, "shim.html"); - fs.writeFileSync( - file, - "", - "utf-8", - ); - - const browser = await launchBrowser(["--allow-file-access-from-files"]); + // NATIVE CANVAS (Wave E, owner 2026-10-10): pdfjs paints into + // @napi-rs/canvas in this process — no browser involved. pdfjs-dist v6's + // own Node integration IS @napi-rs/canvas (its optionalDependency: the + // NodeCanvasFactory requires it), so the paint engine is the exact code + // that used to run in the Chrome shim tab; only the canvas host changed + // (Skia in-process instead of a DOM canvas). Text-side pdf tools never + // needed a browser; now renders don't either. + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const { createCanvas } = await import("@napi-rs/canvas"); + const data = new Uint8Array(fs.readFileSync(path.resolve(pdfPath))); + // Standard-font data as a directory path (live-smoke finding 2026-09-19: + // without it pdfjs renders non-embedded standard fonts via fallback); + // verbosity 0 = errors only (JBIG2 noise on scanned PDFs). + const standardFontDataUrl = + path.join(PATHS_nodeModules(), "pdfjs-dist", "standard_fonts") + path.sep; + const loadingTask = pdfjs.getDocument({ data, standardFontDataUrl, verbosity: 0 }); + const doc = await loadingTask.promise; try { - const page = await browser.newPage(); - await page.setViewport({ width: 1200, height: 1600, deviceScaleFactor: 1 }); - await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 }); - // Standard-font data as a file URL (live-smoke finding 2026-09-19: - // without it pdfjs renders non-embedded standard fonts via fallback); - // verbosity 0 = errors only (JBIG2 noise on scanned PDFs). - const fontsUrl = pathToFileURL(path.join(PATHS_nodeModules(), "pdfjs-dist", "standard_fonts") + path.sep).href; - const results = await page.evaluate( - async (b64pdf, workerUrl, pageNos, dpi, fontsUrl) => { - // window-scope dynamic import (Chrome evaluates this string in the - // shim page, not under Node) - const pdfjs = (await import(workerUrl)) as typeof import("pdfjs-dist"); - pdfjs.GlobalWorkerOptions.workerSrc = workerUrl.replace("pdf.mjs", "pdf.worker.mjs"); - const bin = atob(b64pdf); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - const doc = await pdfjs.getDocument({ data: bytes, standardFontDataUrl: fontsUrl, verbosity: 0 }).promise; - const out: Array<{ page: number; dataUrl: string; w: number; h: number }> = []; - for (const pageNo of pageNos) { - if (pageNo < 1 || pageNo > doc.numPages) continue; - const pg = await doc.getPage(pageNo); - const vp = pg.getViewport({ scale: dpi / 72 }); - const canvas = document.createElement("canvas"); - canvas.width = Math.ceil(vp.width); - canvas.height = Math.ceil(vp.height); - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("canvas 2d context unavailable"); - await pg.render({ canvasContext: ctx, canvas, viewport: vp }).promise; - out.push({ page: pageNo, dataUrl: canvas.toDataURL("image/png"), w: canvas.width, h: canvas.height }); - } - return out; - }, - b64, - pdfjsUrl, - pages, - dpi, - fontsUrl, - ); - return results.map((r) => ({ - page: r.page, - // canvas-encoded PNG → providerSafePng for the same single-IDAT - // canonical form every other vision-model image gets. - png: providerSafePng(Buffer.from(r.dataUrl.slice(r.dataUrl.indexOf(",") + 1), "base64")), - })); - } finally { - await browser.close().catch(() => {}); - try { - fs.rmSync(dir, { recursive: true, force: true }); - } catch { - /* best-effort cleanup */ + const out: RenderedPdfPage[] = []; + for (const pageNo of pages) { + if (pageNo < 1 || pageNo > doc.numPages) continue; + const pg = await doc.getPage(pageNo); + const vp = pg.getViewport({ scale: dpi / 72 }); + const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height)); + const ctx = canvas.getContext("2d"); + // pdfjs's render types speak the DOM context; @napi-rs/canvas's + // context is the same 2D API implemented over Skia. + await pg.render({ + canvasContext: ctx as unknown as CanvasRenderingContext2D, + canvas: canvas as unknown as HTMLCanvasElement, + viewport: vp, + }).promise; + out.push({ + page: pageNo, + // Skia-encoded PNG → providerSafePng for the same single-IDAT + // canonical form every other vision-model image gets. + png: providerSafePng(canvas.toBuffer("image/png")), + }); } + return out; + } finally { + await loadingTask.destroy().catch(() => {}); } } diff --git a/src/agent/subagents/pdf-reader.ts b/src/agent/subagents/pdf-reader.ts index 5aad0ec..eebe8b3 100644 --- a/src/agent/subagents/pdf-reader.ts +++ b/src/agent/subagents/pdf-reader.ts @@ -173,9 +173,9 @@ export async function runPdfReaderAgent( if (openError) { return { path: pdfPath, question: input.question, brief: `PDF failed: ${openError}`, pagesRead: [], pagesConsulted: [] }; } - const chromeReason = headless.unavailableReason(); - // Chrome missing does NOT fail the run: text-side tools still work; the - // doctrine degrades to extract+search (render calls return an error note). + // No Chrome gate (Wave E, 2026-10-10): pdf_render is native now + // (pdfjs paints into @napi-rs/canvas in-process) — the whole pdf-reader + // runs browser-free; text tools always worked without one. const tools = createPdfTools(pdfPath); const toolDeclarations = Object.values(tools); @@ -280,30 +280,24 @@ export async function runPdfReaderAgent( } else if (call.name === "pdf_render") { const spec = String(args.pages ?? "1"); const dpi = numOr(args.dpi, 150); - if (chromeReason) { - toolResults.push( - toolText(call.id, call.name, `ERROR: rendering unavailable — ${chromeReason} Read via pdf_extract instead.`), - ); + const wanted = specNumbers(spec, RENDER_BATCH_CAP); + opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`); + const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi }); + for (const p of pages) { + consulted.add(p.page); + read.add(p.page); + } + if (pages.length === 0) { + toolResults.push(toolText(call.id, call.name, "ERROR: no pages rendered (spec matched nothing?)")); } else { - const wanted = specNumbers(spec, RENDER_BATCH_CAP); - opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`); - const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi }); - for (const p of pages) { - consulted.add(p.page); - read.add(p.page); - } - if (pages.length === 0) { - toolResults.push(toolText(call.id, call.name, "ERROR: no pages rendered (spec matched nothing?)")); - } else { - renderedImages = pages; - toolResults.push( - toolText( - call.id, - call.name, - `Rendered ${pages.length} page(s) at ${dpi} DPI — the images follow below. Read them carefully (equations, diagrams, layout).`, - ), - ); - } + renderedImages = pages; + toolResults.push( + toolText( + call.id, + call.name, + `Rendered ${pages.length} page(s) at ${dpi} DPI — the images follow below. Read them carefully (equations, diagrams, layout).`, + ), + ); } } else { toolResults.push(toolText(call.id, call.name, `ERROR: unknown tool "${call.name}"`)); diff --git a/tests/pdf-native-render.test.ts b/tests/pdf-native-render.test.ts new file mode 100644 index 0000000..a486059 --- /dev/null +++ b/tests/pdf-native-render.test.ts @@ -0,0 +1,76 @@ +/** + * Wave E stage 1 (owner 2026-10-10): pdf_render is NATIVE — pdfjs paints + * into @napi-rs/canvas in-process, no browser anywhere. These tests pin + * the real renderer against the real fixture: valid canonical PNGs, dpi + * scaling arithmetic (scale = dpi/72), out-of-range page skipping, and the + * Skia canvas actually being exercised (not a DOM canvas — none exists in + * this process). + */ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { realHeadless } from "../src/agent/subagents/headless.ts"; + +const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"); + +/** Minimal PNG chunk walk — returns IHDR dims and the IDAT chunk count. */ +function pngFacts(buf: Buffer): { w: number; h: number; idats: number } { + assert.equal(buf.subarray(0, 8).toString("hex"), "89504e470d0a1a0a", "PNG signature"); + const w = buf.readUInt32BE(16); + const h = buf.readUInt32BE(20); + let off = 8; + let idats = 0; + while (off < buf.length) { + const len = buf.readUInt32BE(off); + const type = buf.subarray(off + 4, off + 8).toString("ascii"); + if (type === "IDAT") idats++; + off += 12 + len; // len + type + data + crc + } + return { w, h, idats }; +} + +describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => { + it("renders fixture pages as canonical single-IDAT PNGs", async () => { + const pages = await realHeadless.renderPdfPages(FIXTURE, [1, 3], { dpi: 150 }); + assert.equal(pages.length, 2, "requested pages 1 and 3"); + assert.deepEqual( + pages.map((p) => p.page), + [1, 3], + ); + for (const p of pages) { + const facts = pngFacts(p.png); + assert.ok(facts.w > 0 && facts.h > 0, "non-empty raster"); + // providerSafePng canonical form: exactly one IDAT. + assert.equal(facts.idats, 1, "single-IDAT canonical PNG"); + } + }); + + it("dpi arithmetic: 72dpi base doubles at 144dpi", async () => { + const [a] = await realHeadless.renderPdfPages(FIXTURE, [1], { dpi: 72 }); + const [b] = await realHeadless.renderPdfPages(FIXTURE, [1], { dpi: 144 }); + assert.ok(a && b, "both renders returned a page"); + const fa = pngFacts(a.png); + const fb = pngFacts(b.png); + assert.ok(Math.abs(fb.w - 2 * fa.w) <= 2, `144dpi width ≈ 2× 72dpi (${fa.w} → ${fb.w})`); + assert.ok(Math.abs(fb.h - 2 * fa.h) <= 2, `144dpi height ≈ 2× 72dpi (${fa.h} → ${fb.h})`); + }); + + it("skips out-of-range page numbers silently", async () => { + const pages = await realHeadless.renderPdfPages(FIXTURE, [0, 2, 99], { dpi: 72 }); + assert.deepEqual( + pages.map((p) => p.page), + [2], + "only the in-range page renders", + ); + }); + + it("renders with NO browser present (available() may be false)", async () => { + // The whole point of stage 1: rendering must not consult any Chrome. + // realHeadless.available() reflects system Chrome only; on this + // machine it may well be true — the assertion that matters is that + // the render path never launches one, pinned by the import graph: + // renderPdfPages touches pdfjs + @napi-rs/canvas and nothing else. + const pages = await realHeadless.renderPdfPages(FIXTURE, [1], { dpi: 72 }); + assert.equal(pages.length, 1); + }); +});