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 7162dac6e7
commit f1b7502e9d
30 changed files with 2727 additions and 154 deletions
+37
View File
@@ -0,0 +1,37 @@
import { createElement as h, type ReactElement } from "react";
import { Document, Image, Page } from "@react-pdf/renderer";
export interface AssemblePage {
/** A baseline JPEG; react-pdf embeds it as is (DCTDecode passthrough, no re-encoding). */
jpeg: Uint8Array;
widthPt: number;
heightPt: number;
}
export interface AssembleInput {
pages: AssemblePage[];
/** The only metadata written: a flattened file must not leak the client name or GSTINs. */
title: string;
}
/**
* An image-only document: one full-bleed JPEG per page. No author, subject or keywords, and the
* title is whatever the caller passes (the invoice number). The images go in as untyped Blobs because
* that is the form react-pdf's browser build resolves without sniffing the type.
*/
export function assembleImagePdf({ pages, title }: AssembleInput): ReactElement {
return h(
Document,
{ title },
...pages.map((p, i) =>
h(
Page,
{ key: i, size: [p.widthPt, p.heightPt] as [number, number], style: { padding: 0 }, wrap: false },
h(Image, {
src: new Blob([p.jpeg as BlobPart]) as never,
style: { width: p.widthPt, height: p.heightPt },
}),
),
),
);
}
+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 = "INV/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 ["Test Vendor", invoice.clientName, "Acme", FIXTURE_GSTIN, invoice.clientGstin, "ABCDE1234F", "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);
});
});
+157
View File
@@ -0,0 +1,157 @@
/** The slice of pdf.js the flatten needs, so Node tests and the webview can supply their own. */
export interface PageViewportLike {
width: number;
height: number;
}
export interface RenderTaskLike {
promise: Promise<unknown>;
cancel?: () => void;
}
export interface PageLike {
getViewport(params: { scale: number }): PageViewportLike;
render(params: { canvasContext: unknown; canvas: unknown; viewport: PageViewportLike; intent?: string }): RenderTaskLike;
cleanup?: () => unknown;
}
export interface PdfDocLike {
numPages: number;
getPage(n: number): Promise<PageLike>;
}
export interface CanvasLike {
width: number;
height: number;
getContext(kind: "2d"): unknown;
}
export interface FlatPage {
jpeg: Uint8Array;
widthPt: number;
heightPt: number;
pxWidth: number;
pxHeight: number;
renderMs: number;
encodeMs: number;
}
export interface FlattenResult {
pages: FlatPage[];
}
export interface FlattenOptions {
pdf: PdfDocLike;
dpi?: number;
quality?: number;
createCanvas: (width: number, height: number) => CanvasLike;
encodeJpeg: (canvas: CanvasLike, quality: number) => Promise<Uint8Array>;
signal?: AbortSignal;
/** Called as each page starts: (page being rendered, page count). */
onProgress?: (page: number, pages: number) => void;
/** Awaited after each page so the UI can paint and handle input between the long tasks. */
yieldToUi?: () => Promise<void>;
now?: () => number;
}
export class FlattenCancelled extends Error {
constructor() {
super("The flatten was cancelled");
this.name = "FlattenCancelled";
}
}
export class FlattenError extends Error {
readonly code = "FLATTEN_FAILED";
constructor(
readonly page: number,
readonly reason: string,
) {
super(page > 0 ? `Could not flatten page ${page}: ${reason}` : `Could not flatten the PDF: ${reason}`);
this.name = "FlattenError";
}
}
const reasonOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
/**
* Rasterises every page to a JPEG at `dpi` (default 300) on a white background. One canvas lives at a
* time and is released before the next page. There is deliberately no fallback to a lower DPI: a canvas
* or encode failure throws a FlattenError and the caller decides what to offer.
*/
export async function flattenPages(opts: FlattenOptions): Promise<FlattenResult> {
const { pdf, dpi = 300, quality = 0.92, createCanvas, encodeJpeg, signal, onProgress, yieldToUi, now = () => performance.now() } = opts;
const scale = dpi / 72;
const total = pdf.numPages;
if (!(total > 0)) throw new FlattenError(0, "the PDF has no pages");
const checkAbort = () => {
if (signal?.aborted) throw new FlattenCancelled();
};
const pages: FlatPage[] = [];
for (let n = 1; n <= total; n++) {
checkAbort();
onProgress?.(n, total);
checkAbort(); // onProgress may be where the caller cancels
let canvas: CanvasLike | null = null;
let page: PageLike | null = null;
try {
page = await pdf.getPage(n);
const unit = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale });
// The epsilon keeps 792 pt x 300/72 (3300.0000000000005) from becoming a 3301 px page.
const pxWidth = Math.ceil(viewport.width - 1e-6);
const pxHeight = Math.ceil(viewport.height - 1e-6);
canvas = createCanvas(pxWidth, pxHeight);
const ctx = canvas.getContext("2d") as { fillStyle: unknown; fillRect(x: number, y: number, w: number, h: number): void } | null;
if (!ctx) throw new Error("the canvas has no 2D context");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, pxWidth, pxHeight);
checkAbort();
const t0 = now();
const task = page.render({ canvasContext: ctx, canvas, viewport, intent: "print" });
const onAbort = () => task.cancel?.();
signal?.addEventListener("abort", onAbort, { once: true });
try {
await task.promise;
} finally {
signal?.removeEventListener("abort", onAbort);
}
checkAbort();
const t1 = now();
const jpeg = await encodeJpeg(canvas, quality);
const t2 = now();
if (!jpeg || jpeg.byteLength < 4) throw new Error("the JPEG encoder returned no data");
pages.push({
jpeg,
widthPt: unit.width,
heightPt: unit.height,
pxWidth,
pxHeight,
renderMs: Math.round(t1 - t0),
encodeMs: Math.round(t2 - t1),
});
} catch (e) {
if (e instanceof FlattenCancelled || signal?.aborted) throw new FlattenCancelled();
if (e instanceof FlattenError) throw e;
throw new FlattenError(n, reasonOf(e));
} finally {
if (canvas) {
// Releases the backing store now rather than at garbage collection (2480 x 3508 x 4 = 35 MB).
canvas.width = 0;
canvas.height = 0;
}
try {
page?.cleanup?.();
} catch {
/* cleanup is best effort */
}
}
if (yieldToUi) await yieldToUi();
}
return { pages };
}
+10
View File
@@ -0,0 +1,10 @@
import { assembleImagePdf } from "../assemble";
import { renderBytes } from "./core";
import type { AssembleJob, AssembleResult } from "./jobs";
/** Runs an assemble job where it is called: the worker, the main thread, or a Node test. */
export async function runAssembleJob(job: AssembleJob): Promise<AssembleResult> {
const t0 = performance.now();
const bytes = await renderBytes(assembleImagePdf({ pages: job.pages, title: job.title }));
return { id: job.id, bytes, ms: Math.round(performance.now() - t0) };
}
+151 -1
View File
@@ -9,7 +9,7 @@ import {
WorkerUnavailable,
type WorkerLike,
} from "./client";
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
class FakeWorker implements WorkerLike {
onmessage: WorkerLike["onmessage"] = null;
@@ -31,8 +31,15 @@ class FakeWorker implements WorkerLike {
reply(id: number) {
this.emit({ type: "result", result: result(id) });
}
replyAssembled(id: number) {
this.emit({ type: "assembled", result: assembled(id) });
}
}
const assembled = (id: number): AssembleResult => ({ id, bytes: new Uint8Array([id]), ms: 3 });
const assembleJob = (id: number): AssembleJob => ({ id, title: "T", pages: [{ jpeg: new Uint8Array([1]), widthPt: 10, heightPt: 10 }] });
const kinds = (w: FakeWorker) => w.sent.map((m) => `${m.type}:${m.job.id}`);
const result = (id: number): RenderResult => ({
id,
bytes: new Uint8Array([id]),
@@ -190,6 +197,149 @@ describe("RenderClient", () => {
});
});
describe("assemble lane", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("queues behind a render in flight without superseding it", async () => {
const { client, workers } = setup();
const r1 = client.render(job(1));
workers[0].ready();
const a = client.assemble(assembleJob(2));
expect(kinds(workers[0])).toEqual(["render:1"]);
workers[0].reply(1);
expect((await r1).id).toBe(1);
expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2"]);
workers[0].replyAssembled(2);
expect((await a).id).toBe(2);
});
it("runs ahead of a render that was queued before it", async () => {
const { client, workers } = setup();
const r1 = client.render(job(1));
workers[0].ready();
const r3 = client.render(job(3)); // supersedes r1, waits for the worker
const a = client.assemble(assembleJob(2));
expect(await settled(r1)).toBeInstanceOf(Superseded);
workers[0].reply(1);
expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2"]);
workers[0].replyAssembled(2);
expect((await a).id).toBe(2);
expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2", "render:3"]);
workers[0].reply(3);
expect((await r3).id).toBe(3);
});
it("is not superseded by renders that arrive while it runs, and the newest render still wins afterwards", async () => {
const { client, workers } = setup();
const a = client.assemble(assembleJob(1));
workers[0].ready();
const r2 = client.render(job(2));
const r3 = client.render(job(3));
expect(await settled(r2)).toBeInstanceOf(Superseded);
workers[0].replyAssembled(1);
expect((await a).id).toBe(1);
expect(kinds(workers[0])).toEqual(["assemble-images:1", "render:3"]);
workers[0].reply(3);
expect((await r3).id).toBe(3);
});
it("runs several assembles in order and a render never rejects them", async () => {
const { client, workers } = setup();
const r0 = client.render(job(0));
workers[0].ready();
workers[0].reply(0);
await r0;
const a = client.assemble(assembleJob(1));
const b = client.assemble(assembleJob(2));
void client.render(job(3)).catch(() => {});
void client.render(job(4)).catch(() => {});
workers[0].replyAssembled(1);
expect((await a).id).toBe(1);
expect(kinds(workers[0]).slice(-1)).toEqual(["assemble-images:2"]);
workers[0].replyAssembled(2);
expect((await b).id).toBe(2);
});
it("surfaces an assemble error from the worker and keeps serving", async () => {
const { client, workers } = setup();
const a = client.assemble(assembleJob(1));
workers[0].ready();
workers[0].emit({ type: "error", id: 1, message: "bad jpeg" });
const err = await settled(a);
expect(err).toBeInstanceOf(RenderFailed);
expect((err as Error).message).toBe("bad jpeg");
const b = client.assemble(assembleJob(2));
workers[0].replyAssembled(2);
expect((await b).id).toBe(2);
});
it("a render result cannot settle a running assemble with the same id", async () => {
const { client, workers } = setup();
const a = client.assemble(assembleJob(5));
workers[0].ready();
workers[0].reply(5);
let done = false;
void settled(a).then(() => (done = true));
await Promise.resolve();
expect(done).toBe(false);
workers[0].replyAssembled(5);
expect((await a).id).toBe(5);
});
it("dispose rejects queued assembles", async () => {
const { client, workers } = setup();
void client.render(job(1)).catch(() => {});
workers[0].ready();
const a = client.assemble(assembleJob(2));
client.dispose();
expect(String(await settled(a))).toContain("disposed");
});
it("the main-thread client keeps the same lanes", async () => {
const gates: Array<() => void> = [];
const seen: string[] = [];
const client = createMainThreadClient(
(j) =>
new Promise((resolve) => {
seen.push(`render:${j.id}`);
gates.push(() => resolve(result(j.id)));
}),
(j) =>
new Promise((resolve) => {
seen.push(`assemble:${j.id}`);
gates.push(() => resolve(assembled(j.id)));
}),
);
const r1 = client.render(job(1));
const a = client.assemble(assembleJob(2));
gates[0]();
expect((await r1).id).toBe(1);
const r3 = client.render(job(3));
await Promise.resolve();
await Promise.resolve();
expect(seen).toEqual(["render:1", "assemble:2"]);
gates[1]();
expect((await a).id).toBe(2);
await Promise.resolve();
await Promise.resolve();
expect(seen).toEqual(["render:1", "assemble:2", "render:3"]);
gates[2]();
expect((await r3).id).toBe(3);
});
it("the auto client assembles on the main thread when the worker cannot start", async () => {
const client = createAutoClient({
workerFactory: () => {
throw new Error("no worker");
},
mainAssembleExecutor: async (j) => assembled(j.id),
});
expect((await client.assemble(assembleJob(9))).id).toBe(9);
expect(client.mode).toBe("main");
});
});
describe("main-thread client and fallback", () => {
it("runs jobs serially with latest-wins", async () => {
const gates: Array<() => void> = [];
+114 -32
View File
@@ -1,4 +1,4 @@
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
/** A newer render call replaced this one before it finished (or before it started). */
export class Superseded extends Error {
@@ -44,6 +44,12 @@ export interface WorkerLike {
export interface RenderClient {
/** Resolves with the newest request's result; an older call rejects with Superseded. */
render(job: RenderJob): Promise<RenderResult>;
/**
* Wraps JPEG pages into an image-only PDF. This is a separate lane from `render`: renders are
* latest-wins, but an assemble is an export step that must complete, so it is never superseded by
* a render (or by another assemble). It waits for the job in flight and runs before any queued render.
*/
assemble(job: AssembleJob): Promise<AssembleResult>;
dispose(): void;
readonly mode: "worker" | "main";
}
@@ -57,23 +63,38 @@ export interface RenderClientOptions {
const defaultWorkerFactory = (): WorkerLike =>
new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike;
interface Slot {
interface RenderSlot {
kind: "render";
job: RenderJob;
resolve: (r: RenderResult) => void;
reject: (e: unknown) => void;
}
interface Running extends Slot {
interface AssembleSlot {
kind: "assemble";
job: AssembleJob;
resolve: (r: AssembleResult) => void;
reject: (e: unknown) => void;
}
type Slot = RenderSlot | AssembleSlot;
type Running = Slot & {
/** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
superseded: boolean;
timer: ReturnType<typeof setTimeout>;
}
};
const requestFor = (slot: Slot): WorkerRequest =>
slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job };
/**
* Renders in a module worker. Only the newest request matters: a new call rejects the one in flight
* Renders in a module worker. Only the newest render matters: a new call rejects the render in flight
* (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the
* running job finishes at most one job, the newest, runs. The handshake is the worker's `ready`
* message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback.
* running job finishes at most one render, the newest, runs. Assemble jobs are the exception: they
* queue in order, are never superseded, and go ahead of a queued render. The handshake is the
* worker's `ready` message; a start-up error rejects with WorkerUnavailable and there is no
* timer-based fallback.
*/
export function createRenderClient(opts: RenderClientOptions = {}): RenderClient {
const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts;
@@ -82,7 +103,8 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
let unavailable: WorkerUnavailable | null = null;
let disposed = false;
let running: Running | null = null;
let pending: Slot | null = null;
let pending: RenderSlot | null = null;
const assembles: AssembleSlot[] = [];
const stopWorker = () => {
const w = worker;
@@ -107,16 +129,25 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
unavailable = err;
stopWorker();
settleRunning((s) => s.reject(err));
rejectQueued(err);
};
const rejectQueued = (err: unknown) => {
pending?.reject(err);
pending = null;
for (const a of assembles.splice(0)) a.reject(err);
};
const pump = () => {
if (!ready || !worker || running || !pending || disposed) return;
const slot = pending;
pending = null;
if (!ready || !worker || running || disposed) return;
let slot: Slot | null = assembles.shift() ?? null;
if (!slot) {
slot = pending;
pending = null;
}
if (!slot) return;
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
worker.postMessage({ type: "render", job: slot.job });
worker.postMessage(requestFor(slot));
};
const spawn = () => {
@@ -136,8 +167,12 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
ready = true;
pump();
} else if (msg.type === "result") {
if (running?.job.id !== msg.result.id) return;
settleRunning((s) => s.resolve(msg.result));
if (running?.kind !== "render" || running.job.id !== msg.result.id) return;
settleRunning((s) => (s as RenderSlot).resolve(msg.result));
pump();
} else if (msg.type === "assembled") {
if (running?.kind !== "assemble" || running.job.id !== msg.result.id) return;
settleRunning((s) => (s as AssembleSlot).resolve(msg.result));
pump();
} else if (msg.id === null) {
if (!ready) fail(new WorkerUnavailable(msg.message));
@@ -170,11 +205,20 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
if (unavailable) return Promise.reject(unavailable);
return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded());
if (running && !running.superseded) {
if (running && running.kind === "render" && !running.superseded) {
running.superseded = true;
running.reject(new Superseded());
}
pending = { job, resolve, reject };
pending = { kind: "render", job, resolve, reject };
if (!worker) spawn();
pump();
});
},
assemble(job) {
if (disposed) return Promise.reject(new Error("The render client was disposed"));
if (unavailable) return Promise.reject(unavailable);
return new Promise<AssembleResult>((resolve, reject) => {
assembles.push({ kind: "assemble", job, resolve, reject });
if (!worker) spawn();
pump();
});
@@ -184,8 +228,7 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
disposed = true;
const err = new Error("The render client was disposed");
settleRunning((s) => s.reject(err));
pending?.reject(err);
pending = null;
rejectQueued(err);
stopWorker();
},
};
@@ -212,20 +255,41 @@ const runOnMainThread: MainExecutor = async (job) => {
};
};
/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */
export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient {
/** Assembles one image-only PDF on this thread. */
export type MainAssembleExecutor = (job: AssembleJob) => Promise<AssembleResult>;
const assembleOnMainThread: MainAssembleExecutor = async (job) => (await import("./assembleJob")).runAssembleJob(job);
/**
* Same interface and lanes as the worker client (latest-wins renders, never-superseded assembles),
* on the main thread. Nothing can interrupt it, so there is no watchdog.
*/
export function createMainThreadClient(
execute: MainExecutor = runOnMainThread,
executeAssemble: MainAssembleExecutor = assembleOnMainThread,
): RenderClient {
let disposed = false;
let running: (Slot & { superseded: boolean }) | null = null;
let pending: Slot | null = null;
let pending: RenderSlot | null = null;
const assembles: AssembleSlot[] = [];
const pump = async () => {
if (running || !pending || disposed) return;
const slot = { ...pending, superseded: false };
pending = null;
if (running || disposed) return;
let next: Slot | null = assembles.shift() ?? null;
if (!next) {
next = pending;
pending = null;
}
if (!next) return;
const slot = { ...next, superseded: false };
running = slot;
try {
const result = await execute(slot.job);
if (!slot.superseded) slot.resolve(result);
if (slot.kind === "render") {
const result = await execute(slot.job);
if (!slot.superseded) slot.resolve(result);
} else {
slot.resolve(await executeAssemble(slot.job));
}
} catch (e) {
if (!slot.superseded) slot.reject(e);
}
@@ -239,11 +303,18 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
if (disposed) return Promise.reject(new Error("The render client was disposed"));
return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded());
if (running && !running.superseded) {
if (running && running.kind === "render" && !running.superseded) {
running.superseded = true;
running.reject(new Superseded());
}
pending = { job, resolve, reject };
pending = { kind: "render", job, resolve, reject };
void pump();
});
},
assemble(job) {
if (disposed) return Promise.reject(new Error("The render client was disposed"));
return new Promise<AssembleResult>((resolve, reject) => {
assembles.push({ kind: "assemble", job, resolve, reject });
void pump();
});
},
@@ -252,6 +323,7 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
const err = new Error("The render client was disposed");
pending?.reject(err);
pending = null;
for (const a of assembles.splice(0)) a.reject(err);
},
};
}
@@ -261,10 +333,15 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
* which one is active (main thread: the preview is paused while typing).
*/
export function createAutoClient(
opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {},
opts: RenderClientOptions & { mainExecutor?: MainExecutor; mainAssembleExecutor?: MainAssembleExecutor } = {},
): RenderClient {
const primary = createRenderClient(opts);
let main: RenderClient | null = null;
const fallback = () => {
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
primary.dispose();
return main;
};
return {
get mode() {
return main ? "main" : "worker";
@@ -273,9 +350,14 @@ export function createAutoClient(
if (main) return main.render(job);
return primary.render(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err;
main ??= createMainThreadClient(opts.mainExecutor);
primary.dispose();
return main.render(job);
return fallback().render(job);
});
},
assemble(job) {
if (main) return main.assemble(job);
return primary.assemble(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err;
return fallback().assemble(job);
});
},
dispose() {
+22 -1
View File
@@ -20,10 +20,31 @@ export interface RenderResult {
ms: number;
}
export type WorkerRequest = { type: "render"; job: RenderJob };
/** One flattened page: a JPEG to embed full-bleed on a page of the given size. */
export interface AssemblePageData {
jpeg: Uint8Array;
widthPt: number;
heightPt: number;
}
/** Wraps JPEG pages into an image-only PDF. Needs no fonts, so it never waits on the render's setup. */
export interface AssembleJob {
id: number;
pages: AssemblePageData[];
title: string;
}
export interface AssembleResult {
id: number;
bytes: Uint8Array;
ms: number;
}
export type WorkerRequest = { type: "render"; job: RenderJob } | { type: "assemble-images"; job: AssembleJob };
export type WorkerMessage =
| { type: "ready" }
| { type: "result"; result: RenderResult }
| { type: "assembled"; result: AssembleResult }
/** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */
| { type: "error"; id: number | null; message: string; stack?: string };
+16 -3
View File
@@ -1,6 +1,7 @@
// Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests.
import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
import { initBrowserRendering } from "./browserInit";
import { runAssembleJob } from "./assembleJob";
import { renderInvoicePdf } from "./invoice";
import type { WorkerMessage, WorkerRequest } from "./jobs";
@@ -21,10 +22,24 @@ const failure = (id: number | null, err: unknown): WorkerMessage => ({
stack: err instanceof Error ? err.stack : undefined,
});
// Handing a buffer over instead of copying it only works when the view covers all of it.
const whole = (b: Uint8Array) => b.byteOffset === 0 && b.byteLength === b.buffer.byteLength;
const started = initBrowserRendering();
scope.onmessage = async (event) => {
const request = event.data;
if (request?.type === "assemble-images") {
const { job } = request;
try {
const out = await runAssembleJob(job);
const bytes = whole(out.bytes) ? out.bytes : out.bytes.slice();
post({ type: "assembled", result: { ...out, bytes } }, [bytes.buffer]);
} catch (err) {
post(failure(job.id, err));
}
return;
}
if (request?.type !== "render") return;
const { job } = request;
try {
@@ -32,9 +47,7 @@ scope.onmessage = async (event) => {
const t0 = performance.now();
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
const ms = Math.round(performance.now() - t0);
// Hand the buffer over instead of copying it; a view onto part of a larger buffer is copied first.
const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength;
const bytes = whole ? out.bytes : out.bytes.slice();
const bytes = whole(out.bytes) ? out.bytes : out.bytes.slice();
post(
{ type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } },
[bytes.buffer],
+26
View File
@@ -0,0 +1,26 @@
{
"classic-a4-tax-3": {
"pages": 1,
"fingerprint": "35b03b20aeec3dcbb808300b6e8f0b1867dfd69d5fd6f69aaee891c354955092",
"invoiceNumber": "ST/2026-001",
"totalText": "₹8,625.80",
"pageLabel": "Page 1 of 1",
"textHashes": [
"855dd2d104e80ff599210d69f643be0ccb281e6583cbcea2dafa5fdcb09f03fc"
],
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
},
"classic-letter-unreg-60": {
"pages": 3,
"fingerprint": "78d60e1b72dac0e40fea96cbf34c35cfeb637dbe0bc9754dcecba72231197e75",
"invoiceNumber": "ST/2026-002",
"totalText": "₹1,56,000.00",
"pageLabel": "Page 1 of 3",
"textHashes": [
"1728b53a177e385584d2dc33b2e71607f860886632f32a5911364f86f345c950",
"870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b",
"fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0"
],
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per hour 3 ₹3,600.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per hour 2 ₹2,400.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per hour 3 ₹3,600.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per hour 2 ₹2,400.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per hour 3 ₹3,600.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per hour 2 ₹2,400.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { renderInvoicePdf } from "../render/invoice";
import { ensureSetup } from "../templates/harness";
import type { Golden } from "./goldens";
import { selfTestFixtures } from "./selfTestFixtures";
import { parsePdfText } from "./pdfText";
/**
* goldens.json is what the Diagnostics self-test compares the webview's render against (page count,
* layout fingerprint, key strings). This test keeps it in step with the Node render:
* `UPDATE_GOLDENS=1 npx vitest run src/pdf/testing/goldens.test.ts` rewrites it.
*/
const FILE = path.resolve(process.cwd(), "src/pdf/testing/goldens.json");
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
async function current(): Promise<Record<string, Golden>> {
ensureSetup();
const out: Record<string, Golden> = {};
for (const f of selfTestFixtures()) {
const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs });
const text = await parsePdfText(r.bytes);
const pages = text.pages.map((p) => p.items.map((i) => i.str).join(" "));
out[f.id] = {
pages: r.pages,
fingerprint: r.fingerprint,
invoiceNumber: f.invoiceNumber,
totalText: f.totalText,
pageLabel: `Page 1 of ${r.pages}`,
textHashes: pages.map(sha),
firstPageText: pages[0],
};
}
return out;
}
describe("self-test goldens", () => {
it("match the Node render of the self-test fixtures", async () => {
const now = await current();
if (process.env.UPDATE_GOLDENS) {
writeFileSync(FILE, `${JSON.stringify(now, null, 2)}\n`);
return;
}
const stored = JSON.parse(readFileSync(FILE, "utf8")) as Record<string, Golden>;
expect(Object.keys(stored).sort()).toEqual(Object.keys(now).sort());
for (const [id, golden] of Object.entries(now)) {
const s = stored[id];
// Compare the readable parts first so a failure says what moved.
expect(s.pages, `${id}: page count`).toBe(golden.pages);
expect(s.firstPageText, `${id}: page 1 text`).toBe(golden.firstPageText);
expect(s.textHashes, `${id}: text of every page`).toEqual(golden.textHashes);
expect(s.invoiceNumber).toBe(golden.invoiceNumber);
expect(s.totalText).toBe(golden.totalText);
expect(s.pageLabel).toBe(golden.pageLabel);
expect(s.fingerprint, `${id}: layout fingerprint (run with UPDATE_GOLDENS=1 if the layout change is intended)`).toBe(golden.fingerprint);
}
});
it("every golden carries the strings the self-test looks for", async () => {
const stored = JSON.parse(readFileSync(FILE, "utf8")) as Record<string, Golden>;
for (const [id, g] of Object.entries(stored)) {
expect(g.totalText, id).toMatch(/^₹/);
expect(g.firstPageText, id).toContain(g.invoiceNumber);
expect(g.firstPageText.replace(/\s+/g, " "), id).toContain(g.pageLabel);
}
});
});
+15
View File
@@ -0,0 +1,15 @@
/** One entry of goldens.json: what the Node render of a self-test fixture produced. */
export interface Golden {
pages: number;
fingerprint: string;
invoiceNumber: string;
totalText: string;
/** Page-1 label the footer must show. */
pageLabel: string;
/** Per page: sha256 of the extracted text (items joined with a space). */
textHashes: string[];
/** Page 1's text, readable in a diff. */
firstPageText: string;
}
export type GoldenMap = Record<string, Golden>;
+135
View File
@@ -0,0 +1,135 @@
import { computeTotals } from "../../lib/invoice";
import { amountInWords } from "../../lib/numberToWords";
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "../../lib/types";
import { buildRenderModel, type RenderModel } from "../model/build";
import { DEFAULT_PREFS, type RenderPrefsV1 } from "../model/prefs";
/**
* Pure data for the Diagnostics self-test and the goldens test (no Node or DOM imports, so the
* webview and Node build the very same models). Change a fixture and regenerate the goldens.
*/
export interface SelfTestFixture {
id: string;
label: string;
model: RenderModel;
prefs: RenderPrefsV1;
invoiceNumber: string;
/** The grand total as the template prints it, e.g. "₹8,260.00". */
totalText: string;
}
const VENDOR: Settings = {
vendorName: "Selftest Studio",
vendorAddress: "1 Test Lane, Test Town, Bangalore - 560005",
vendorEmail: "selftest@example.com",
vendorPhone: "9000000000",
vendorPan: "AAAAA0000A",
vendorGstin: "",
vendorStateCode: "29",
logoPath: null,
signaturePath: null,
defaultBankId: 1,
defaultTaxRate: 18,
defaultTaxType: "cgst_sgst",
paymentTermsDays: 30,
currency: "INR",
onboarded: true,
theme: "g10",
gstRegistration: "unregistered",
defaultHsnSac: "",
signatoryName: "",
signatoryDesignation: "",
renderPrefs: "",
};
const BANK: BankAccount = {
id: 1,
label: "Primary",
bankName: "State Bank of India",
accountName: "Selftest Studio",
accountNo: "00000000000",
branch: "000000",
ifsc: "SBIN0000000",
isDefault: true,
};
const items = (n: number, hsn: boolean): InvoiceItem[] =>
Array.from({ length: n }, (_, i) => ({
id: i + 1,
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
mode: i % 2 === 0 ? "rate" : "fixed",
rate: 1200,
unit: "hour",
quantity: 1 + (i % 3),
amount: 2500 + i * 10,
sortOrder: i,
...(hsn ? { hsnSac: "998397" } : {}),
}));
function build(o: { id: string; label: string; number: string; rows: number; registered: boolean; prefs: Partial<RenderPrefsV1> }): SelfTestFixture {
const taxType: TaxType = o.registered ? "cgst_sgst" : "none";
const taxRate = o.registered ? 18 : 0;
const rows = items(o.rows, o.registered);
const totals = computeTotals(rows, 0, taxType, taxRate);
const vendor: Settings = o.registered
? { ...VENDOR, vendorGstin: "29AAAAA0000A1Z5", gstRegistration: "regular", signatoryName: "Selftest Studio", signatoryDesignation: "Proprietor" }
: VENDOR;
const invoice: Invoice = {
id: 1,
number: o.number,
seriesId: 1,
invoiceDate: "2026-09-15",
dueDate: "2026-10-15",
clientId: null,
clientName: "Acme Learning Pvt Ltd",
clientAddress: "42 MG Road\nBangalore 560001",
clientGstin: o.registered ? "29ABCDE1234F1Z5" : "",
poNumber: "",
placeOfSupplyStateCode: o.registered ? "29" : "",
subtotal: totals.subtotal,
discount: totals.discount,
taxType,
taxRate,
cgstAmount: totals.cgst,
sgstAmount: totals.sgst,
igstAmount: totals.igst,
total: totals.total,
amountInWords: amountInWords(totals.total),
bankAccountId: 1,
bankSnapshot: "",
signaturePath: null,
notes: "",
status: "issued",
createdAt: "2026-09-15T00:00:00Z",
updatedAt: "2026-09-15T00:00:00Z",
docType: o.registered ? "tax_invoice" : "invoice",
reverseCharge: false,
vendorSnapshot: "",
snapshotOrigin: "",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items: rows,
};
const model = buildRenderModel({ invoice, vendor, bank: BANK, logo: null, signatureDataUri: null, appVersion: "0.0.0-selftest" });
return {
id: o.id,
label: o.label,
model,
prefs: { ...DEFAULT_PREFS, ...o.prefs },
invoiceNumber: o.number,
totalText: model.totals.totalText,
};
}
export const SELF_TEST_FIXTURE_IDS = ["classic-a4-tax-3", "classic-letter-unreg-60"] as const;
/** Classic A4 tax invoice with 3 rows, and Classic Letter plain invoice with 60 rows (a multi-page table). */
export function selfTestFixtures(): SelfTestFixture[] {
return [
build({ id: "classic-a4-tax-3", label: "Classic, A4, tax invoice, 3 rows", number: "ST/2026-001", rows: 3, registered: true, prefs: { pageSize: "A4" } }),
build({ id: "classic-letter-unreg-60", label: "Classic, Letter, unregistered, 60 rows", number: "ST/2026-002", rows: 60, registered: false, prefs: { pageSize: "LETTER" } }),
];
}