2026-08-22 10:24:02 +03:00
|
|
|
|
import * as fs from "node:fs";
|
|
|
|
|
|
import * as os from "node:os";
|
|
|
|
|
|
import * as path from "node:path";
|
2026-09-19 15:24:26 +03:00
|
|
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
2026-08-22 10:24:02 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Shared headless-browser helper for the verifying sub-agents (W6):
|
|
|
|
|
|
* the demo-builder executes demos and captures errors; the visual-maker
|
|
|
|
|
|
* rasterizes SVGs so it can look at them. Both use the SAME system Chrome
|
|
|
|
|
|
* via puppeteer-core — NO bundled chromium is downloaded.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Chrome resolution: process.env.CHROME_PATH first, then the common
|
|
|
|
|
|
* macOS/Linux install paths. If none exists we fall back to puppeteer's
|
|
|
|
|
|
* `channel: "chrome"` at launch time; `available()` is cheap and only knows
|
|
|
|
|
|
* about the explicit candidates, so a channel-only setup reports unavailable
|
|
|
|
|
|
* and the builders refuse to run (the house rule: never skip testing).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Test seam: `headlessOverride.current` replaces the whole API (tests inject
|
|
|
|
|
|
* a fake; nothing here touches a real browser). Builders call `getHeadless()`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
export interface DemoRunResult {
|
|
|
|
|
|
/** console.error / console messages of type "error". */
|
|
|
|
|
|
consoleErrors: string[];
|
|
|
|
|
|
/** Uncaught exceptions (window.onerror / unhandled rejections). */
|
|
|
|
|
|
pageErrors: string[];
|
|
|
|
|
|
/** Network requests that failed (CDN scripts that didn't load, …). */
|
|
|
|
|
|
failedRequests: string[];
|
|
|
|
|
|
/** Value of the optional probe expression, when requested and successful. */
|
|
|
|
|
|
probeResult?: unknown;
|
|
|
|
|
|
/** True when nothing above recorded anything. */
|
|
|
|
|
|
ok: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RunDemoOptions {
|
|
|
|
|
|
/** How long to let the page settle (animations, async CDN loads) — default 2s. */
|
|
|
|
|
|
settleMs?: number;
|
|
|
|
|
|
/** Optional JS expression evaluated in the page after settling; its value
|
|
|
|
|
|
* is returned to the builder so it can assert on internal state. */
|
|
|
|
|
|
probe?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RenderSvgOptions {
|
|
|
|
|
|
/** Viewport size — defaults to the SVG's width/height attrs, else 900×700. */
|
|
|
|
|
|
width?: number;
|
|
|
|
|
|
height?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:24:26 +03:00
|
|
|
|
export interface RenderPdfOptions {
|
|
|
|
|
|
/** Render DPI — 72 is 100% zoom; the skill's guidance is 100/150/200. */
|
|
|
|
|
|
dpi?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RenderedPdfPage {
|
|
|
|
|
|
page: number;
|
|
|
|
|
|
png: Buffer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 10:24:02 +03:00
|
|
|
|
export interface HeadlessApi {
|
|
|
|
|
|
/** True when a Chrome executable is resolvable (cheap, no launch). */
|
|
|
|
|
|
available(): boolean;
|
|
|
|
|
|
/** Why not — for error messages. */
|
|
|
|
|
|
unavailableReason(): string | null;
|
|
|
|
|
|
/** Load the HTML in headless Chrome, capture errors, optionally probe. */
|
|
|
|
|
|
runDemo(html: string, opts?: RunDemoOptions): Promise<DemoRunResult>;
|
|
|
|
|
|
/** Rasterize an SVG to a PNG buffer (for verify-by-looking). */
|
|
|
|
|
|
renderSvg(svg: string, opts?: RenderSvgOptions): Promise<Buffer>;
|
2026-09-19 15:24:26 +03:00
|
|
|
|
/** Render PDF pages to PNG buffers via Chrome's pdfium viewer (for the
|
|
|
|
|
|
* pdf-reader sub-agent's look-at-the-page loop). */
|
|
|
|
|
|
renderPdfPages(pdfPath: string, pages: number[], opts?: RenderPdfOptions): Promise<RenderedPdfPage[]>;
|
2026-08-22 10:24:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Test seam — undefined in production. */
|
|
|
|
|
|
export const headlessOverride: { current?: HeadlessApi } = {};
|
|
|
|
|
|
|
|
|
|
|
|
export function getHeadless(): HeadlessApi {
|
|
|
|
|
|
return headlessOverride.current ?? realHeadless;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Chrome resolution ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const CHROME_CANDIDATES = [
|
|
|
|
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
|
|
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
|
|
|
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
|
|
|
|
"/usr/bin/google-chrome",
|
|
|
|
|
|
"/usr/bin/google-chrome-stable",
|
|
|
|
|
|
"/usr/bin/chromium",
|
|
|
|
|
|
"/usr/bin/chromium-browser",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveChromeExecutablePath(): string | null {
|
|
|
|
|
|
const candidates = [process.env.CHROME_PATH, ...CHROME_CANDIDATES].filter(
|
|
|
|
|
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
|
|
|
|
);
|
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* not readable — try the next */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const NO_CHROME_MESSAGE =
|
|
|
|
|
|
"No Chrome/Chromium found for headless testing. Install Google Chrome or set CHROME_PATH to a Chrome executable. " +
|
|
|
|
|
|
"(Verification is mandatory — demos and visuals are never delivered untested.)";
|
|
|
|
|
|
|
2026-09-19 15:24:26 +03:00
|
|
|
|
async function launchBrowser(extraArgs: string[] = []): Promise<import("puppeteer-core").Browser> {
|
2026-08-22 10:24:02 +03:00
|
|
|
|
const puppeteer = (await import("puppeteer-core")).default;
|
|
|
|
|
|
const executablePath = resolveChromeExecutablePath();
|
|
|
|
|
|
const browser = await puppeteer.launch({
|
|
|
|
|
|
headless: true,
|
|
|
|
|
|
// Explicit path when we found one; otherwise let puppeteer resolve the
|
|
|
|
|
|
// system Chrome install itself via the "chrome" channel.
|
|
|
|
|
|
...(executablePath ? { executablePath } : { channel: "chrome" as const }),
|
2026-09-19 15:24:26 +03:00
|
|
|
|
args: ["--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage", ...extraArgs],
|
2026-08-22 10:24:02 +03:00
|
|
|
|
});
|
|
|
|
|
|
return browser;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A temp dir per invocation; removed in finally. */
|
|
|
|
|
|
function tempDir(prefix: string): string {
|
|
|
|
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Demo execution ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
async function runDemo(html: string, opts: RunDemoOptions = {}): Promise<DemoRunResult> {
|
|
|
|
|
|
const settleMs = opts.settleMs ?? 2000;
|
|
|
|
|
|
const dir = tempDir("lh-demo-");
|
|
|
|
|
|
const file = path.join(dir, "demo.html");
|
|
|
|
|
|
fs.writeFileSync(file, html, "utf-8");
|
|
|
|
|
|
|
|
|
|
|
|
const consoleErrors: string[] = [];
|
|
|
|
|
|
const pageErrors: string[] = [];
|
|
|
|
|
|
const failedRequests: string[] = [];
|
|
|
|
|
|
let probeResult: unknown;
|
|
|
|
|
|
let probed = false;
|
|
|
|
|
|
|
|
|
|
|
|
const browser = await launchBrowser();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const page = await browser.newPage();
|
|
|
|
|
|
page.on("console", (msg) => {
|
|
|
|
|
|
if (msg.type() === "error") consoleErrors.push(msg.text());
|
|
|
|
|
|
});
|
|
|
|
|
|
page.on("pageerror", (err) => pageErrors.push(err instanceof Error ? err.message : String(err)));
|
|
|
|
|
|
page.on("requestfailed", (req) => failedRequests.push(`${req.url()} (${req.failure()?.errorText ?? "failed"})`));
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 });
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
|
|
|
|
if (opts.probe) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
probeResult = await page.evaluate(opts.probe);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
pageErrors.push(`probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
probed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
pageErrors.push(err instanceof Error ? err.message : String(err));
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await browser.close().catch(() => {});
|
|
|
|
|
|
try {
|
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* best-effort cleanup */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
consoleErrors,
|
|
|
|
|
|
pageErrors,
|
|
|
|
|
|
failedRequests,
|
|
|
|
|
|
...(probed ? { probeResult } : {}),
|
|
|
|
|
|
ok: consoleErrors.length === 0 && pageErrors.length === 0 && failedRequests.length === 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── SVG rasterization ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function svgViewport(svg: string, opts: RenderSvgOptions): { width: number; height: number } {
|
|
|
|
|
|
if (opts.width && opts.height) return { width: opts.width, height: opts.height };
|
|
|
|
|
|
const width = Number(svg.match(/\bwidth\s*=\s*"?(\d+(?:\.\d+)?)/)?.[1]);
|
|
|
|
|
|
const height = Number(svg.match(/\bheight\s*=\s*"?(\d+(?:\.\d+)?)/)?.[1]);
|
|
|
|
|
|
return {
|
|
|
|
|
|
width: Math.ceil(Number.isFinite(width) && width > 0 ? width : 900),
|
|
|
|
|
|
height: Math.ceil(Number.isFinite(height) && height > 0 ? height : 700),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 13:16:34 +03:00
|
|
|
|
/** Canonicalize a raw Chrome screenshot for the model's eyes (2026-09-19 fix).
|
|
|
|
|
|
*
|
|
|
|
|
|
* WHY: Chrome writes PNGs with a SPLIT IDAT stream (4KB chunks) — and
|
|
|
|
|
|
* Alibaba's vision decoder (behind OpenRouter's qwen3.8-flash, the
|
|
|
|
|
|
* visual-maker's look-model) rejected those outright during the
|
|
|
|
|
|
* 2026-09-18/19 window: "The image format is illegal and cannot be opened",
|
|
|
|
|
|
* deterministically, at every size (50–800px) — while every single-IDAT
|
|
|
|
|
|
* form passed (a handcrafted 1×1, and sharp re-encodes). On a later re-test
|
|
|
|
|
|
* raw Chrome PNGs passed too (the upstream decoder was fixed or is flaky
|
|
|
|
|
|
* per backend), but the canonical form stays: PNG spec says the IDAT chunks
|
|
|
|
|
|
* are an arbitrary SPLIT of one zlib stream, so merging them is the more
|
|
|
|
|
|
* standard shape, byte-identical stream, zero re-encoding.
|
|
|
|
|
|
*
|
2026-09-23 03:53:57 +03:00
|
|
|
|
* Pure Node on purpose (no sharp): historically the process already carried
|
|
|
|
|
|
* a native image lib via @huggingface/transformers' nested sharp + libvips,
|
|
|
|
|
|
* and importing the root sharp (Next's optional dependency) put a SECOND
|
|
|
|
|
|
* libvips into the process — macOS's ObjC runtime warns (duplicate
|
|
|
|
|
|
* GNotificationCenterDelegate, "mysterious crashes"). The transformers
|
|
|
|
|
|
* embedder died with wave 6 (2026-09-23) but the rule lives on: one native
|
|
|
|
|
|
* image lib per process; app code carries none. */
|
2026-09-19 13:16:34 +03:00
|
|
|
|
export function providerSafePng(png: Buffer): Buffer {
|
|
|
|
|
|
const SIG_LEN = 8;
|
|
|
|
|
|
if (png.length < SIG_LEN + 12 || png.subarray(0, SIG_LEN).toString("hex") !== "89504e470d0a1a0a") return png;
|
|
|
|
|
|
// Walk the chunks once, merging the IDAT split into its canonical
|
|
|
|
|
|
// single-chunk form (first IDAT's position, concatenated payload).
|
|
|
|
|
|
const parts: Array<{ type: string; data: Buffer }> = [];
|
|
|
|
|
|
let off = SIG_LEN;
|
|
|
|
|
|
while (off + 12 <= png.length) {
|
|
|
|
|
|
const len = png.readUInt32BE(off);
|
|
|
|
|
|
if (off + 12 + len > png.length) break; // truncated tail — leave as-is
|
|
|
|
|
|
parts.push({ type: png.subarray(off + 4, off + 8).toString("ascii"), data: png.subarray(off + 8, off + 8 + len) });
|
|
|
|
|
|
off += 12 + len;
|
|
|
|
|
|
}
|
|
|
|
|
|
const idats = parts.filter((p) => p.type === "IDAT");
|
|
|
|
|
|
if (idats.length < 2) return png; // already canonical
|
|
|
|
|
|
const merged = Buffer.concat(idats.map((p) => p.data));
|
|
|
|
|
|
let emittedIdat = false;
|
|
|
|
|
|
const out: Buffer[] = [png.subarray(0, SIG_LEN)];
|
|
|
|
|
|
for (const p of parts) {
|
|
|
|
|
|
if (p.type !== "IDAT") out.push(chunk(p.type, p.data));
|
|
|
|
|
|
else if (!emittedIdat) {
|
|
|
|
|
|
out.push(chunk("IDAT", merged));
|
|
|
|
|
|
emittedIdat = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return Buffer.concat(out);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** One PNG chunk (length + type + data + CRC32) — the spec's exact layout. */
|
|
|
|
|
|
function chunk(type: string, data: Buffer): Buffer {
|
|
|
|
|
|
const out = Buffer.alloc(12 + data.length);
|
|
|
|
|
|
out.writeUInt32BE(data.length, 0);
|
|
|
|
|
|
out.write(type, 4, "ascii");
|
|
|
|
|
|
data.copy(out, 8);
|
|
|
|
|
|
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** CRC-32 (PNG's polynomial, table-less) for chunk checksums. */
|
|
|
|
|
|
function crc32(buf: Buffer): number {
|
|
|
|
|
|
let c = ~0;
|
|
|
|
|
|
for (const byte of buf) {
|
|
|
|
|
|
c ^= byte;
|
|
|
|
|
|
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
|
|
|
|
|
}
|
|
|
|
|
|
return ~c >>> 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 10:24:02 +03:00
|
|
|
|
async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise<Buffer> {
|
|
|
|
|
|
const dir = tempDir("lh-visual-");
|
|
|
|
|
|
const file = path.join(dir, "figure.html");
|
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
|
file,
|
|
|
|
|
|
`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:#fff}svg{display:block}</style></head><body>${svg}</body></html>`,
|
|
|
|
|
|
"utf-8",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const browser = await launchBrowser();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const page = await browser.newPage();
|
|
|
|
|
|
const { width, height } = svgViewport(svg, opts);
|
|
|
|
|
|
await page.setViewport({ width, height });
|
|
|
|
|
|
await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 });
|
2026-09-19 13:16:34 +03:00
|
|
|
|
const shot = (await page.screenshot({ fullPage: true, type: "png" })) as Buffer;
|
|
|
|
|
|
// Chrome's raw split-IDAT PNG is canonicalized before it reaches a
|
|
|
|
|
|
// provider (see providerSafePng) — the visual-maker feeds this exact
|
|
|
|
|
|
// buffer back to the vision model as its only way to "look".
|
|
|
|
|
|
return providerSafePng(Buffer.from(shot));
|
2026-08-22 10:24:02 +03:00
|
|
|
|
} finally {
|
|
|
|
|
|
await browser.close().catch(() => {});
|
|
|
|
|
|
try {
|
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* best-effort cleanup */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:24:26 +03:00
|
|
|
|
// ─── PDF page rasterization (pdfjs paints, Chrome 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. */
|
|
|
|
|
|
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"]);
|
|
|
|
|
|
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 });
|
2026-09-19 16:23:20 +03:00
|
|
|
|
// 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;
|
2026-09-19 15:24:26 +03:00
|
|
|
|
const results = await page.evaluate(
|
2026-09-19 16:23:20 +03:00
|
|
|
|
async (b64pdf, workerUrl, pageNos, dpi, fontsUrl) => {
|
2026-09-19 15:24:26 +03:00
|
|
|
|
// 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);
|
2026-09-19 16:23:20 +03:00
|
|
|
|
const doc = await pdfjs.getDocument({ data: bytes, standardFontDataUrl: fontsUrl, verbosity: 0 }).promise;
|
2026-09-19 15:24:26 +03:00
|
|
|
|
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,
|
2026-09-19 16:23:20 +03:00
|
|
|
|
fontsUrl,
|
2026-09-19 15:24:26 +03:00
|
|
|
|
);
|
|
|
|
|
|
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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** node_modules — resolved from this module's location. */
|
|
|
|
|
|
function PATHS_nodeModules(): string {
|
|
|
|
|
|
// import.meta.url = .../src/agent/subagents/headless.ts → three up + node_modules
|
|
|
|
|
|
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "node_modules");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 10:24:02 +03:00
|
|
|
|
// ─── the real API ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export const realHeadless: HeadlessApi = {
|
|
|
|
|
|
available() {
|
|
|
|
|
|
return resolveChromeExecutablePath() !== null;
|
|
|
|
|
|
},
|
|
|
|
|
|
unavailableReason() {
|
|
|
|
|
|
return resolveChromeExecutablePath() === null ? NO_CHROME_MESSAGE : null;
|
|
|
|
|
|
},
|
|
|
|
|
|
runDemo,
|
|
|
|
|
|
renderSvg,
|
2026-09-19 15:24:26 +03:00
|
|
|
|
renderPdfPages,
|
2026-08-22 10:24:02 +03:00
|
|
|
|
};
|