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
+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],