Add searchable and flattened export, history re-export and diagnostics self-test

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.
This commit is contained in:
2026-10-04 06:11:23 +05:30
parent 2ade618fb5
commit 98e9e5860f
30 changed files with 2727 additions and 154 deletions
+352
View File
@@ -0,0 +1,352 @@
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);
});
});