import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; import { describe, expect, it } from "vitest"; import { flattenPages, FlattenCancelled, FlattenError, type CanvasLike, type PageLike, type PdfDocLike } from "./flatten"; import { runAssembleJob } from "./render/assembleJob"; import { FIXTURE_GSTIN, ensureSetup, buildFixture, prefsFor, renderFixture } from "./templates/harness"; import { loadCanvas } from "./testing/rasterize"; // --- unit tests with fakes: no native canvas needed -------------------------------------------------- interface FakeCanvas extends CanvasLike { fills: Array<[number, number, number, number]>; fillStyle: unknown; } function fakeDoc(sizes: Array<[number, number]>, opts: { renderDelay?: () => Promise; failRender?: number } = {}) { const log: string[] = []; const cancelled: number[] = []; const pdf: PdfDocLike = { numPages: sizes.length, async getPage(n) { const [w, h] = sizes[n - 1]; const page: PageLike = { getViewport: ({ scale }) => ({ width: w * scale, height: h * scale }), render: () => { log.push(`render ${n}`); return { promise: (async () => { await opts.renderDelay?.(); if (opts.failRender === n) throw new Error("out of memory"); })(), cancel: () => cancelled.push(n), }; }, cleanup: () => void log.push(`cleanup ${n}`), }; return page; }, }; return { pdf, log, cancelled }; } function fakeCanvasFactory() { const live = new Set(); const made: Array<[number, number]> = []; let maxLive = 0; const createCanvas = (w: number, h: number): FakeCanvas => { made.push([w, h]); const c: FakeCanvas = { width: w, height: h, fills: [], fillStyle: "", getContext() { return { set fillStyle(v: unknown) { c.fillStyle = v; }, fillRect: (x: number, y: number, ww: number, hh: number) => c.fills.push([x, y, ww, hh]) }; }, }; live.add(c); maxLive = Math.max(maxLive, live.size); return c; }; // A canvas counts as freed once its size is zeroed. const sweep = () => { for (const c of live) if (c.width === 0 && c.height === 0) live.delete(c); }; return { createCanvas, made, live, sweep, maxLive: () => maxLive }; } const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); const A4: [number, number] = [595.28, 841.89]; describe("flattenPages (fakes)", () => { it("renders every page at 300 DPI on white, one canvas at a time, and reports progress", async () => { const { pdf } = fakeDoc([A4, A4, [612, 792]]); const f = fakeCanvasFactory(); const progress: Array<[number, number]> = []; let yields = 0; const out = await flattenPages({ pdf, createCanvas: (w, h) => { f.sweep(); // by the time the next canvas is requested the previous one must be released expect(f.live.size).toBe(0); return f.createCanvas(w, h); }, encodeJpeg: async () => jpegBytes, onProgress: (p, n) => progress.push([p, n]), yieldToUi: async () => void yields++, }); expect(f.made).toEqual([ [Math.ceil((595.28 * 300) / 72), Math.ceil((841.89 * 300) / 72)], [Math.ceil((595.28 * 300) / 72), Math.ceil((841.89 * 300) / 72)], [2550, 3300], ]); expect(f.made[0][0]).toBeGreaterThanOrEqual(2480); expect(f.maxLive()).toBe(1); expect(progress).toEqual([[1, 3], [2, 3], [3, 3]]); expect(yields).toBe(3); expect(out.pages.map((p) => [p.widthPt, p.heightPt])).toEqual([A4, A4, [612, 792]]); expect(out.pages[2]).toMatchObject({ pxWidth: 2550, pxHeight: 3300 }); }); it("paints a white background before the page", async () => { const { pdf } = fakeDoc([[100, 100]]); const canvases: FakeCanvas[] = []; await flattenPages({ pdf, createCanvas: (w, h) => { const f = fakeCanvasFactory().createCanvas(w, h); canvases.push(f); return f; }, encodeJpeg: async () => jpegBytes, }); expect(canvases[0].fillStyle).toBe("#ffffff"); expect(canvases[0].fills).toEqual([[0, 0, 417, 417]]); }); it("passes the quality and the DPI through, with no silent downgrade", async () => { const { pdf } = fakeDoc([[72, 72]]); const qualities: number[] = []; const sizes: Array<[number, number]> = []; await flattenPages({ pdf, dpi: 150, quality: 0.8, createCanvas: (w, h) => (sizes.push([w, h]), fakeCanvasFactory().createCanvas(w, h)), encodeJpeg: async (_c, q) => (qualities.push(q), jpegBytes), }); expect(sizes).toEqual([[150, 150]]); expect(qualities).toEqual([0.8]); }); it("a canvas failure is a FlattenError naming the page and reason, with no partial output", async () => { const { pdf } = fakeDoc([A4, A4]); let n = 0; const err = await flattenPages({ pdf, createCanvas: (w, h) => { if (++n === 2) throw new Error("Canvas area exceeds the maximum limit"); return fakeCanvasFactory().createCanvas(w, h); }, encodeJpeg: async () => jpegBytes, }).catch((e) => e); expect(err).toBeInstanceOf(FlattenError); expect(err).toMatchObject({ page: 2, code: "FLATTEN_FAILED", reason: "Canvas area exceeds the maximum limit" }); expect(err.message).toContain("page 2"); expect(n).toBe(2); // it did not retry at a lower resolution }); it("render and encode failures are FlattenErrors too, and the canvas is still released", async () => { const f = fakeCanvasFactory(); const a = await flattenPages({ pdf: fakeDoc([A4], { failRender: 1 }).pdf, createCanvas: f.createCanvas, encodeJpeg: async () => jpegBytes }).catch((e) => e); expect(a).toMatchObject({ name: "FlattenError", page: 1, reason: "out of memory" }); const b = await flattenPages({ pdf: fakeDoc([A4]).pdf, createCanvas: f.createCanvas, encodeJpeg: async () => { throw new Error("toBlob returned null"); }, }).catch((e) => e); expect(b).toMatchObject({ name: "FlattenError", page: 1, reason: "toBlob returned null" }); f.sweep(); expect(f.live.size).toBe(0); }); it("rejects an empty document and an empty encode", async () => { const f = fakeCanvasFactory(); expect(await flattenPages({ pdf: { numPages: 0, getPage: async () => { throw new Error("x"); } }, createCanvas: f.createCanvas, encodeJpeg: async () => jpegBytes }).catch((e) => e)).toBeInstanceOf(FlattenError); expect(await flattenPages({ pdf: fakeDoc([A4]).pdf, createCanvas: f.createCanvas, encodeJpeg: async () => new Uint8Array() }).catch((e) => e)).toBeInstanceOf(FlattenError); }); it("an already-aborted signal throws FlattenCancelled before any work", async () => { const ctl = new AbortController(); ctl.abort(); let made = 0; await expect( flattenPages({ pdf: fakeDoc([A4]).pdf, signal: ctl.signal, createCanvas: (w, h) => (made++, fakeCanvasFactory().createCanvas(w, h)), encodeJpeg: async () => jpegBytes }), ).rejects.toBeInstanceOf(FlattenCancelled); expect(made).toBe(0); }); it("aborting between pages stops there; aborting during a render cancels the render task", async () => { const ctl = new AbortController(); const f = fakeCanvasFactory(); await expect( flattenPages({ pdf: fakeDoc([A4, A4, A4]).pdf, signal: ctl.signal, createCanvas: f.createCanvas, encodeJpeg: async () => jpegBytes, onProgress: (page) => { if (page === 2) ctl.abort(); }, }), ).rejects.toBeInstanceOf(FlattenCancelled); expect(f.made).toHaveLength(1); const ctl2 = new AbortController(); let release!: () => void; const gate = new Promise((r) => (release = r)); const doc = fakeDoc([A4], { renderDelay: () => gate }); const run = flattenPages({ pdf: doc.pdf, signal: ctl2.signal, createCanvas: fakeCanvasFactory().createCanvas, encodeJpeg: async () => jpegBytes }); await new Promise((r) => setTimeout(r, 10)); ctl2.abort(); expect(doc.cancelled).toEqual([1]); release(); await expect(run).rejects.toBeInstanceOf(FlattenCancelled); }); }); // --- Node integration: pdf.js + @napi-rs/canvas, flatten -> assemble -------------------------------- const canvasLib = await loadCanvas(); if (!canvasLib) console.warn("[flatten.test] @napi-rs/canvas is not installed: skipping the flatten -> assemble integration tests"); /** Width and height from the first SOF marker of a JPEG. */ function jpegSize(b: Uint8Array): { width: number; height: number } { let i = 2; while (i < b.length) { if (b[i] !== 0xff) throw new Error("not a JPEG marker stream"); const marker = b[i + 1]; const len = (b[i + 2] << 8) | b[i + 3]; if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { return { height: (b[i + 5] << 8) | b[i + 6], width: (b[i + 7] << 8) | b[i + 8] }; } i += 2 + len; } throw new Error("no SOF marker"); } describe.skipIf(!canvasLib)("flatten -> assemble (pdf.js + napi canvas)", () => { const nodeDeps = () => ({ createCanvas: (w: number, h: number) => canvasLib!.createCanvas(w, h) as unknown as CanvasLike, encodeJpeg: async (c: CanvasLike, q: number) => new Uint8Array(await (c as unknown as { encode(f: "jpeg", q: number): Promise }).encode("jpeg", Math.round(q * 100))), }); async function flatten(bytes: Uint8Array, extra: Partial[0]> = {}) { const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true }); const doc = await task.promise; try { return await flattenPages({ pdf: doc as unknown as PdfDocLike, ...nodeDeps(), ...extra }); } finally { await task.destroy(); } } // Rendered once and shared: A4, 60 rows (several pages), registered supplier with a GSTIN. let shared: Promise<{ source: Uint8Array; sourcePages: number; flat: Awaited>; out: Uint8Array; title: string }> | null = null; const prepare = () => (shared ??= (async () => { ensureSetup(); const r = await renderFixture({ name: "flat", items: 60, signature: true, invoice: { notes: "Payment due within 30 days." } }, prefsFor("A4")); const flat = await flatten(r.result.bytes); const title = "AP/2026-001"; const { bytes: out } = await runAssembleJob({ id: 1, title, pages: flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })) }); return { source: r.result.bytes, sourcePages: r.result.pages, flat, out, title }; })()); it("keeps the page count and writes 300 DPI A4 images (at least 2480 px wide)", async () => { const { sourcePages, flat, out } = await prepare(); expect(sourcePages).toBeGreaterThan(1); expect(flat.pages).toHaveLength(sourcePages); const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise; expect(doc.numPages).toBe(sourcePages); for (let n = 1; n <= doc.numPages; n++) { const page = await doc.getPage(n); const [, , w, h] = page.view; expect(w).toBeCloseTo(595.28, 1); expect(h).toBeCloseTo(841.89, 1); } for (const p of flat.pages) { const sof = jpegSize(p.jpeg); expect(sof.width).toBeGreaterThanOrEqual(2480); expect(sof.width).toBe(p.pxWidth); expect(sof.height).toBe(p.pxHeight); } // The JPEG goes in untouched (DCTDecode passthrough), so these exact bytes are inside the file. const outBuf = Buffer.from(out); for (const p of flat.pages) expect(outBuf.includes(Buffer.from(p.jpeg))).toBe(true); await doc.loadingTask.destroy(); }); it("has zero extractable text items on every page", async () => { const { out, sourcePages } = await prepare(); const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise; for (let n = 1; n <= sourcePages; n++) { const content = await (await doc.getPage(n)).getTextContent(); expect(content.items, `page ${n}`).toHaveLength(0); } await doc.loadingTask.destroy(); }); it("carries only a Title in the document info, and none of the invoice's names or GSTINs anywhere", async () => { const { out, title } = await prepare(); const raw = Buffer.from(out).toString("latin1"); expect(raw).toContain("/Title"); expect(raw).toContain(title); for (const key of ["/Author", "/Subject", "/Keywords"]) expect(raw, key).not.toContain(key); const { invoice } = buildFixture({ name: "flat", items: 1 }); for (const secret of ["Arun P", invoice.clientName, "Acme", FIXTURE_GSTIN, invoice.clientGstin, "CPOPP7388K", "Bangalore"]) { expect(raw, secret).not.toContain(secret); } const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise; const info = (await doc.getMetadata()).info as Record; expect(info.Title).toBe(title); expect(info.Author ?? "").toBe(""); expect(info.Subject ?? "").toBe(""); expect(info.Keywords ?? "").toBe(""); await doc.loadingTask.destroy(); }); it("the page images are not blank", async () => { const { flat } = await prepare(); const img = await canvasLib!.loadImage(Buffer.from(flat.pages[0].jpeg)); const c = canvasLib!.createCanvas(img.width, img.height); const ctx = c.getContext("2d"); ctx.drawImage(img, 0, 0); const data = ctx.getImageData(0, 0, img.width, 400).data; let ink = 0; for (let i = 0; i < data.length; i += 4) if (data[i] < 200) ink++; expect(ink).toBeGreaterThan(1000); }); it("cancel via AbortSignal throws FlattenCancelled and returns nothing", async () => { const { source } = await prepare(); const ctl = new AbortController(); const seen: number[] = []; await expect( flatten(source, { signal: ctl.signal, onProgress: (p) => { seen.push(p); if (p === 2) ctl.abort(); }, }), ).rejects.toBeInstanceOf(FlattenCancelled); expect(seen).toEqual([1, 2]); }); it("a throwing createCanvas yields a FlattenError and no partial output", async () => { const { source } = await prepare(); let calls = 0; const err = await flatten(source, { createCanvas: () => { calls++; throw new Error("canvas allocation failed"); }, }).catch((e) => e); expect(err).toBeInstanceOf(FlattenError); expect(err).toMatchObject({ page: 1, code: "FLATTEN_FAILED" }); expect(calls).toBe(1); }); });