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/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
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}"`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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