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:
@@ -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-<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-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
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* The engine (user-chosen hybrid, live-verified 2026-09-19): pdfjs paints
|
||||
* each page into a <canvas> (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<RenderedPdfPage[]> {
|
||||
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,
|
||||
"<!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>",
|
||||
"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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"`));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user