issueAndArchive keeps an issued invoice even when rendering or archiving fails (Issued - not archived, with a Render & archive action). The export menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is the invoice number alone; flatten failures offer Retry or searchable export and never silently lower the resolution. History re-exports the original as issued by default and warns when a re-render's layout fingerprint differs. The render client gains a FIFO assemble lane that previews cannot supersede. A Diagnostics panel runs fixtures in the real worker and compares against goldens generated from Node.
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
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<void>; 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<FakeCanvas>();
|
|
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<void>((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<Buffer> }).encode("jpeg", Math.round(q * 100))),
|
|
});
|
|
|
|
async function flatten(bytes: Uint8Array, extra: Partial<Parameters<typeof flattenPages>[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<ReturnType<typeof flatten>>; 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<string, unknown>;
|
|
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);
|
|
});
|
|
});
|