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,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 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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, Frazer 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, Frazer 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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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, Frazer 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" } }),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user