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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user