wave E stage 1: pdf_render goes native - pdfjs paints, Skia hosts

The browser was never the PDF engine - pdfjs always painted the pages;
Chrome only hosted the canvas tab. Now pdfjs-dist v6's own Node
integration (@napi-rs/canvas, its optionalDependency) hosts in-process:
same paint code, Skia canvas, zero browsers, zero temp files, no base64
hop. The whole pdf-reader runs browser-free - the Chrome gate on
pdf_render is gone.

The Skia binary (27MB per platform, exactly one per build - npm installs
only the building machine's) rides the standalone via the same
outputFileTracingIncludes pattern as pdfjs-dist. Verified: builds green
(1515), native render pinned against the real fixture (canonical
single-IDAT PNGs, dpi/72 arithmetic, page-range skipping), and the
canvas loads + paints from inside .next/standalone.

Stages 2-3 (resvg for SVG, the --browser-service demo helper) follow.
This commit is contained in:
amosblomqvist
2026-10-11 06:49:13 +03:00
parent 0af005d343
commit 951c7704b9
4 changed files with 150 additions and 101 deletions
+7
View File
@@ -55,6 +55,12 @@ const nextConfig: NextConfig = {
"*": [ "*": [
"node_modules/pdfjs-dist/legacy/**/*", "node_modules/pdfjs-dist/legacy/**/*",
"node_modules/pdfjs-dist/standard_fonts/**/*", "node_modules/pdfjs-dist/standard_fonts/**/*",
// Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its
// platform binary (@napi-rs/canvas-<platform>-<arch>) 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-ai/dist/providers/data/**/*",
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json", "node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
], ],
@@ -66,6 +72,7 @@ const nextConfig: NextConfig = {
"@earendil-works/pi-ai", "@earendil-works/pi-ai",
"@earendil-works/pi-agent-core", "@earendil-works/pi-agent-core",
"pdfjs-dist", // sources v0: bundling breaks its node fake-worker path "pdfjs-dist", // sources v0: bundling breaks its node fake-worker path
"@napi-rs/canvas", // native binding — bundling breaks the .node require
], ],
}; };
+40 -68
View File
@@ -290,92 +290,64 @@ async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise<Buff
} }
} }
// ─── PDF page rasterization (pdfjs paints, Chrome hosts) ─────────────── // ─── PDF page rasterization (pdfjs paints, Skia hosts) ─────────────────
/** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes. /** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes.
* *
* The engine (user-chosen hybrid, live-verified 2026-09-19): pdfjs paints * History: pdfjs ALWAYS painted the pages (scale = dpi/72, the skill's
* each page into a <canvas> (scale = dpi/72, the skill's zoom arithmetic) * zoom arithmetic) — first inside a headless Chrome tab (canvas shim,
* inside a headless Chrome tab, and the canvas pixels come back via * toDataURL, 2026-09-19), now into @napi-rs/canvas in this process (Wave
* toDataURL. Chrome's OWN PDF viewer plugin does not paint in headless * E, 2026-10-10). The paint engine never changed; only the canvas host:
* mode (verified: identical blank screenshots regardless of page), so the * Skia in-process via pdfjs-dist v6's own Node integration. No browser,
* canvas shim is the pixel path — Chrome still hosts and encodes, no * no temp files, no base64 hop. */
* 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. */
async function renderPdfPages( async function renderPdfPages(
pdfPath: string, pdfPath: string,
pages: number[], pages: number[],
opts: RenderPdfOptions = {}, opts: RenderPdfOptions = {},
): Promise<RenderedPdfPage[]> { ): Promise<RenderedPdfPage[]> {
const dpi = opts.dpi ?? 150; const dpi = opts.dpi ?? 150;
const pdfAbs = path.resolve(pdfPath); // NATIVE CANVAS (Wave E, owner 2026-10-10): pdfjs paints into
const pdfjsUrl = pathToFileURL( // @napi-rs/canvas in this process — no browser involved. pdfjs-dist v6's
path.join(PATHS_nodeModules(), "pdfjs-dist/legacy/build/pdf.mjs"), // own Node integration IS @napi-rs/canvas (its optionalDependency: the
).href; // NodeCanvasFactory requires it), so the paint engine is the exact code
const b64 = fs.readFileSync(pdfAbs).toString("base64"); // 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
const dir = tempDir("lh-pdf-"); // needed a browser; now renders don't either.
const file = path.join(dir, "shim.html"); const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
fs.writeFileSync( const { createCanvas } = await import("@napi-rs/canvas");
file, const data = new Uint8Array(fs.readFileSync(path.resolve(pdfPath)));
"<!doctype html><html><head><meta charset='utf-8'><style>html,body{margin:0;padding:0;background:#fff}canvas{display:block}</style></head><body></body></html>", // Standard-font data as a directory path (live-smoke finding 2026-09-19:
"utf-8",
);
const browser = await launchBrowser(["--allow-file-access-from-files"]);
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); // without it pdfjs renders non-embedded standard fonts via fallback);
// verbosity 0 = errors only (JBIG2 noise on scanned PDFs). // verbosity 0 = errors only (JBIG2 noise on scanned PDFs).
const fontsUrl = pathToFileURL(path.join(PATHS_nodeModules(), "pdfjs-dist", "standard_fonts") + path.sep).href; const standardFontDataUrl =
const results = await page.evaluate( path.join(PATHS_nodeModules(), "pdfjs-dist", "standard_fonts") + path.sep;
async (b64pdf, workerUrl, pageNos, dpi, fontsUrl) => { const loadingTask = pdfjs.getDocument({ data, standardFontDataUrl, verbosity: 0 });
// window-scope dynamic import (Chrome evaluates this string in the const doc = await loadingTask.promise;
// shim page, not under Node) try {
const pdfjs = (await import(workerUrl)) as typeof import("pdfjs-dist"); const out: RenderedPdfPage[] = [];
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl.replace("pdf.mjs", "pdf.worker.mjs"); for (const pageNo of pages) {
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; if (pageNo < 1 || pageNo > doc.numPages) continue;
const pg = await doc.getPage(pageNo); const pg = await doc.getPage(pageNo);
const vp = pg.getViewport({ scale: dpi / 72 }); const vp = pg.getViewport({ scale: dpi / 72 });
const canvas = document.createElement("canvas"); const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
canvas.width = Math.ceil(vp.width);
canvas.height = Math.ceil(vp.height);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas 2d context unavailable"); // pdfjs's render types speak the DOM context; @napi-rs/canvas's
await pg.render({ canvasContext: ctx, canvas, viewport: vp }).promise; // context is the same 2D API implemented over Skia.
out.push({ page: pageNo, dataUrl: canvas.toDataURL("image/png"), w: canvas.width, h: canvas.height }); 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; 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 { } finally {
await browser.close().catch(() => {}); await loadingTask.destroy().catch(() => {});
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* best-effort cleanup */
}
} }
} }
+3 -9
View File
@@ -173,9 +173,9 @@ export async function runPdfReaderAgent(
if (openError) { if (openError) {
return { path: pdfPath, question: input.question, brief: `PDF failed: ${openError}`, pagesRead: [], pagesConsulted: [] }; return { path: pdfPath, question: input.question, brief: `PDF failed: ${openError}`, pagesRead: [], pagesConsulted: [] };
} }
const chromeReason = headless.unavailableReason(); // No Chrome gate (Wave E, 2026-10-10): pdf_render is native now
// Chrome missing does NOT fail the run: text-side tools still work; the // (pdfjs paints into @napi-rs/canvas in-process) — the whole pdf-reader
// doctrine degrades to extract+search (render calls return an error note). // runs browser-free; text tools always worked without one.
const tools = createPdfTools(pdfPath); const tools = createPdfTools(pdfPath);
const toolDeclarations = Object.values(tools); const toolDeclarations = Object.values(tools);
@@ -280,11 +280,6 @@ export async function runPdfReaderAgent(
} else if (call.name === "pdf_render") { } else if (call.name === "pdf_render") {
const spec = String(args.pages ?? "1"); const spec = String(args.pages ?? "1");
const dpi = numOr(args.dpi, 150); const dpi = numOr(args.dpi, 150);
if (chromeReason) {
toolResults.push(
toolText(call.id, call.name, `ERROR: rendering unavailable — ${chromeReason} Read via pdf_extract instead.`),
);
} else {
const wanted = specNumbers(spec, RENDER_BATCH_CAP); const wanted = specNumbers(spec, RENDER_BATCH_CAP);
opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`); opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`);
const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi }); const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi });
@@ -304,7 +299,6 @@ export async function runPdfReaderAgent(
), ),
); );
} }
}
} else { } else {
toolResults.push(toolText(call.id, call.name, `ERROR: unknown tool "${call.name}"`)); toolResults.push(toolText(call.id, call.name, `ERROR: unknown tool "${call.name}"`));
} }
+76
View File
@@ -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);
});
});