Files
learnharness/tests/pdf-native-render.test.ts

189 lines
8.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { MAX_PDF_CANVAS_PIXELS, MAX_PDF_CANVAS_SIDE, pdfCanvasGeometry, realHeadless } from "../src/agent/subagents/headless.ts";
import { standardFontsDir } from "../src/agent/subagents/pdf-ops.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 };
}
/** Synthetic page only — no personal PDF or external fixture generator. */
function oversizedPdf(): Buffer {
const content = "0 0 1 rg 0 0 20000 10000 re f\n";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20000 10000] /Rotate 90 /Resources << >> /Contents 4 0 R >>",
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}endstream`,
];
let pdf = "%PDF-1.7\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(pdf));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`;
for (const offset of offsets.slice(1)) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return Buffer.from(pdf);
}
describe("PDF pre-allocation canvas geometry", () => {
it("uses decimal 16 megapixels and an 8192-pixel side limit", () => {
assert.equal(MAX_PDF_CANVAS_PIXELS, 16_000_000);
assert.equal(MAX_PDF_CANVAS_SIDE, 8192);
});
it("preserves ordinary requested DPI, including fractional ceil dimensions and low DPI", () => {
for (const dpi of [11, 72, 144, 150, 300]) {
const geometry = pdfCanvasGeometry(595.28, 841.89, dpi);
assert.deepEqual(geometry, {
scale: dpi / 72,
width: Math.ceil(595.28 * dpi / 72),
height: Math.ceil(841.89 * dpi / 72),
});
}
});
it("bounds extreme aspect ratios, huge geometry, sides, and rounded pixel area without upscaling", () => {
const cases: readonly (readonly [number, number, number])[] = [
[1e12, 0.000001, 300],
[0.000001, 1e12, 300],
[1e308, 1e308, Number.MAX_VALUE],
[8192.000001, 10, 72],
[6100, 3900, 72], // ceil at the area-limited scale exceeds 16MP
[4000.000001, 4000.000001, 72],
[4000, 4000, 72], // exactly 16MP stays unchanged
];
for (const [width, height, dpi] of cases) {
const result = pdfCanvasGeometry(width, height, dpi);
assert.ok(result.scale > 0 && result.scale <= dpi / 72);
assert.equal(result.width, Math.max(1, Math.ceil(width * result.scale)));
assert.equal(result.height, Math.max(1, Math.ceil(height * result.scale)));
assert.ok(result.width <= MAX_PDF_CANVAS_SIDE && result.height <= MAX_PDF_CANVAS_SIDE);
assert.ok(result.width * result.height <= MAX_PDF_CANVAS_PIXELS);
}
assert.deepEqual(pdfCanvasGeometry(4000, 4000, 72), { scale: 1, width: 4000, height: 4000 });
});
it("uses rotated viewport dimensions symmetrically", () => {
const normal = pdfCanvasGeometry(20000, 10000, 144);
const rotated = pdfCanvasGeometry(10000, 20000, 144);
assert.equal(rotated.scale, normal.scale);
assert.equal(rotated.width, normal.height);
assert.equal(rotated.height, normal.width);
});
it("rejects nonfinite or nonpositive dimensions and DPI cleanly", () => {
for (const invalid of [0, -1, NaN, Infinity, -Infinity]) {
assert.throws(() => pdfCanvasGeometry(invalid, 792, 72), /dimensions must be finite and positive/);
assert.throws(() => pdfCanvasGeometry(612, invalid, 72), /dimensions must be finite and positive/);
assert.throws(() => pdfCanvasGeometry(612, 792, invalid), /DPI must be finite and positive/);
}
});
});
describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
it("the shared font directory ends in pdfjs's required slash on every OS", () => {
const fonts = standardFontsDir();
assert.ok(fonts.endsWith("/"), "pdfjs rejects a trailing Windows backslash");
assert.ok(fs.statSync(fonts).isDirectory(), "the filesystem accepts the shared font path");
});
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("bounds a synthetic oversized rotated PDF before native canvas allocation", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-canvas-test-"));
try {
const fixture = path.join(dir, "oversized.pdf");
fs.writeFileSync(fixture, oversizedPdf());
const [page] = await realHeadless.renderPdfPages(fixture, [1], { dpi: 144 });
assert.ok(page);
const facts = pngFacts(page.png);
const expected = pdfCanvasGeometry(10000, 20000, 144);
assert.equal(facts.w, expected.width);
assert.equal(facts.h, expected.height);
assert.ok(facts.h > facts.w, "PDF rotation is included before scaling");
assert.ok(facts.w <= 8192 && facts.h <= 8192);
assert.ok(facts.w * facts.h <= 16_000_000);
assert.equal(facts.idats, 1, "raw renderer still returns canonical PNG");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("rejects invalid DPI before opening the PDF", async () => {
for (const dpi of [0, -1, NaN, Infinity, -Infinity, Number.MIN_VALUE]) {
await assert.rejects(realHeadless.renderPdfPages("unused.pdf", [1], { dpi }), /DPI must be finite and positive/);
}
});
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);
});
});