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

issueAndArchive keeps an issued invoice even when rendering or archiving
fails (Issued - not archived, with a Render & archive action). The export
menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is
the invoice number alone; flatten failures offer Retry or searchable export
and never silently lower the resolution. History re-exports the original
as issued by default and warns when a re-render's layout fingerprint
differs. The render client gains a FIFO assemble lane that previews cannot
supersede. A Diagnostics panel runs fixtures in the real worker and
compares against goldens generated from Node.
This commit is contained in:
2026-10-04 06:11:23 +05:30
parent 2ade618fb5
commit 98e9e5860f
30 changed files with 2727 additions and 154 deletions
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, ComposedModal, InlineLoading, InlineNotification, ModalBody, ModalFooter, ModalHeader, Tag } from "@carbon/react";
import { reportToText, runSelfTest, type Check, type SelfTestReport } from "../lib/selfTest";
const STATUS_TAG: Record<Check["status"], { type: "green" | "red" | "gray"; text: string }> = {
pass: { type: "green", text: "Pass" },
fail: { type: "red", text: "Fail" },
info: { type: "gray", text: "Info" },
};
/** Renders the fixtures in this webview's real render client and reports what it found. */
export default function DiagnosticsPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
const [report, setReport] = useState<SelfTestReport | null>(null);
const [fatal, setFatal] = useState<string | null>(null);
const [copied, setCopied] = useState<"ok" | "failed" | null>(null);
const runId = useRef(0);
const run = useCallback(async () => {
const id = ++runId.current;
setReport(null);
setFatal(null);
setCopied(null);
try {
// Loaded on demand: the fixtures, goldens and pdf.js are only needed here.
const { browserSelfTestDeps } = await import("../lib/selfTestBrowser");
await runSelfTest(browserSelfTestDeps(), (r) => {
if (runId.current === id) setReport(r);
});
} catch (e) {
if (runId.current === id) setFatal(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
if (open) void run();
else runId.current++;
}, [open, run]);
const copy = async () => {
if (!report) return;
try {
await navigator.clipboard.writeText(reportToText(report));
setCopied("ok");
} catch {
setCopied("failed");
}
};
const running = open && !fatal && !report?.done;
return (
<ComposedModal open={open} onClose={onClose} size="md" preventCloseOnClickOutside aria-label="Diagnostics">
<ModalHeader title="Diagnostics" label="Settings" />
<ModalBody hasScrollingContent>
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
Renders two sample invoices in this app's own renderer, reads them back and compares them with the reference layouts. Nothing is saved or sent.
</p>
{fatal ? <InlineNotification kind="error" lowContrast hideCloseButton title="The self-test could not run" subtitle={fatal} /> : null}
{report && report.done ? (
<InlineNotification
kind={report.passed ? "success" : "error"}
lowContrast
hideCloseButton
title={report.passed ? "All checks passed" : "Some checks failed"}
subtitle={report.passed ? undefined : "Copy the report and include it when you report the problem."}
/>
) : null}
{report?.sections.map((section) => (
<section key={section.title} style={{ marginTop: "1.5rem" }} aria-label={section.title}>
<h4 style={{ marginBottom: "0.5rem" }}>{section.title}</h4>
{section.checks.map((check, i) => (
<div key={`${check.label}-${i}`} className="voiced-diagnostics__check">
<div className="voiced-diagnostics__head">
<Tag type={STATUS_TAG[check.status].type} size="sm">
{STATUS_TAG[check.status].text}
</Tag>
<span className="voiced-diagnostics__label">{check.label}</span>
{check.ms !== undefined ? <span className="voiced-diagnostics__ms">{check.ms} ms</span> : null}
</div>
{check.detail ? <p className="voiced-diagnostics__detail">{check.detail}</p> : null}
</div>
))}
</section>
))}
{running ? (
<div style={{ marginTop: "1.5rem" }}>
<InlineLoading description="Running checks…" />
</div>
) : null}
{copied ? (
<div style={{ marginTop: "1rem" }}>
<InlineNotification
kind={copied === "ok" ? "success" : "warning"}
lowContrast
hideCloseButton
title={copied === "ok" ? "Report copied" : "Could not copy"}
subtitle={copied === "ok" ? undefined : "The clipboard is not available here."}
/>
</div>
) : null}
</ModalBody>
<ModalFooter>
<Button kind="secondary" onClick={() => void run()} disabled={running}>
Run again
</Button>
<Button kind="secondary" onClick={() => void copy()} disabled={!report}>
Copy report
</Button>
<Button kind="primary" onClick={onClose}>
Close
</Button>
</ModalFooter>
</ComposedModal>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { MenuButton, MenuItem, OverflowMenu, OverflowMenuItem } from "@carbon/react";
import { ChevronDown } from "@carbon/icons-react";
import type { ExportMode } from "../lib/filename";
import { EXPORT_MODE_LABEL, saveExportMode } from "../lib/exportMode";
const MODES: ExportMode[] = ["searchable", "flattened"];
/** "Export PDF" menu for an issued invoice. Choosing a mode also makes it the remembered one. */
export default function ExportButton({ onExport, disabled }: { onExport: (mode: ExportMode) => void; disabled?: boolean }) {
return (
<MenuButton label="Export PDF" kind="tertiary" disabled={disabled} menuAlignment="bottom-start">
{MODES.map((mode) => (
<MenuItem
key={mode}
label={EXPORT_MODE_LABEL[mode]}
onClick={() => {
saveExportMode(mode);
onExport(mode);
}}
/>
))}
</MenuButton>
);
}
/** The format the primary "Issue & export" action uses; a small menu next to that button. */
export function ExportModePicker({ mode, onChange, disabled }: { mode: ExportMode; onChange: (mode: ExportMode) => void; disabled?: boolean }) {
return (
<OverflowMenu
aria-label="Choose the PDF format for Issue & export"
iconDescription={`PDF format: ${EXPORT_MODE_LABEL[mode]}`}
renderIcon={ChevronDown}
flipped
size="lg"
disabled={disabled}
>
{MODES.map((m) => (
<OverflowMenuItem
key={m}
itemText={m === mode ? `${EXPORT_MODE_LABEL[m]} (selected)` : EXPORT_MODE_LABEL[m]}
onClick={() => {
saveExportMode(m);
onChange(m);
}}
/>
))}
</OverflowMenu>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification } from "@carbon/react";
import { api } from "../lib/api";
import type { InvoiceExport } from "../hooks/useInvoiceExport";
import FlattenProgressModal from "./FlattenProgressModal";
/** Progress modal, spinner, success and error notices for one `useInvoiceExport`. */
export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
const { busy, progress, request, error, result } = exp;
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => setActionError(null), [result]);
const flattenFailed = request?.mode === "flattened" && error?.flatten === true;
const modalOpen = request?.mode === "flattened" && ((busy && progress?.phase !== "saving") || flattenFailed);
const run = async (action: () => Promise<void>) => {
setActionError(null);
try {
await action();
} catch (e) {
setActionError(String(e));
}
};
return (
<>
<FlattenProgressModal
open={modalOpen}
progress={progress}
error={flattenFailed ? error!.message : null}
onCancel={exp.cancel}
onRetry={exp.retry}
onExportSearchable={exp.exportSearchableInstead}
onClose={exp.dismiss}
/>
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
{error && !flattenFailed ? (
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}>
<div className="voiced-notice-actions">
<Button kind="ghost" size="sm" onClick={exp.retry}>
Retry
</Button>
</div>
</InlineNotification>
) : null}
{result ? (
<>
<InlineNotification
kind="success"
lowContrast
title={`Saved to ${result.path}`}
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
onCloseButtonClick={exp.dismiss}
>
<div className="voiced-notice-actions">
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
Open file
</Button>
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
Show in folder
</Button>
</div>
</InlineNotification>
{result.identicalToIssued === false ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="Layout differs from the issued invoice"
subtitle="The current template lays this invoice out differently from the PDF issued. The archived original is unchanged; use Export PDF to get it."
/>
) : null}
{result.archiveError ? (
<InlineNotification kind="warning" lowContrast hideCloseButton title="This invoice is still not archived" subtitle={result.archiveError} />
) : null}
{actionError ? <InlineNotification kind="error" lowContrast title="Could not open the file" subtitle={actionError} onCloseButtonClick={() => setActionError(null)} /> : null}
</>
) : null}
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { Button, ComposedModal, InlineNotification, ModalBody, ModalFooter, ModalHeader, ProgressBar } from "@carbon/react";
import type { ExportProgress } from "../lib/exportFlow";
export interface FlattenProgressModalProps {
open: boolean;
progress: ExportProgress | null;
/** The flatten failed: show it with Retry and "Export searchable instead". */
error: string | null;
onCancel: () => void;
onRetry: () => void;
onExportSearchable: () => void;
onClose: () => void;
}
function describe(p: ExportProgress | null): { label: string; value?: number; max: number } {
if (p?.phase === "flattening" && p.page && p.pages) {
// The page being rendered counts as in progress, so the bar shows the pages finished before it.
return { label: `Rendering page ${p.page} of ${p.pages}…`, value: p.page - 1, max: p.pages };
}
if (p?.phase === "assembling") return { label: "Building the PDF…", value: 1, max: 1 };
return { label: "Preparing the invoice…", max: 1 };
}
export default function FlattenProgressModal({ open, progress, error, onCancel, onRetry, onExportSearchable, onClose }: FlattenProgressModalProps) {
const { label, value, max } = describe(progress);
return (
<ComposedModal open={open} size="sm" preventCloseOnClickOutside onClose={() => (error ? onClose() : onCancel())} aria-label="Flattened PDF export">
<ModalHeader title="Flattened PDF" label="Export" />
<ModalBody hasForm={false}>
{error ? (
<InlineNotification
kind="error"
lowContrast
hideCloseButton
title="The flattened PDF could not be made"
subtitle={`${error} Nothing was saved, and the resolution was not lowered.`}
/>
) : (
<ProgressBar label={label} value={value} max={max} helperText="Each page is drawn at 300 DPI; this takes a few seconds per page." />
)}
</ModalBody>
{error ? (
// Three direct children: Carbon lays a three-button footer out differently from a two-button one.
<ModalFooter>
<Button kind="secondary" onClick={onClose}>
Close
</Button>
<Button kind="tertiary" onClick={onExportSearchable}>
Export searchable instead
</Button>
<Button kind="primary" onClick={onRetry}>
Retry
</Button>
</ModalFooter>
) : (
<ModalFooter>
<Button kind="secondary" onClick={onCancel}>
Cancel
</Button>
</ModalFooter>
)}
</ComposedModal>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { createExportDeps } from "../lib/exportDeps";
import { exportInvoice, isExportCancelled, isFlattenFailure, type ExportProgress, type ExportResult, type ExportSource } from "../lib/exportFlow";
import type { ExportMode } from "../lib/filename";
import type { BankAccount, Invoice, Settings } from "../lib/types";
export interface ExportRequestState {
invoice: Invoice;
mode: ExportMode;
source: ExportSource;
}
export interface ExportFailure {
message: string;
/** The flatten itself failed: the modal offers Retry and "Export searchable instead". */
flatten: boolean;
}
export interface InvoiceExport {
busy: boolean;
progress: ExportProgress | null;
request: ExportRequestState | null;
error: ExportFailure | null;
result: ExportResult | null;
start(invoice: Invoice, mode: ExportMode, source: ExportSource): Promise<ExportResult | null>;
cancel(): void;
retry(): void;
exportSearchableInstead(): void;
dismiss(): void;
}
/**
* Runs exports one at a time and keeps what the UI shows: progress, the last result, the last error and
* the request, so Retry and "Export searchable instead" can repeat it. `onInvoiceUpdated` hears about an
* invoice that was archived by the export.
*/
export function useInvoiceExport(opts: { settings: Settings; bank: BankAccount | null; onInvoiceUpdated?: (invoice: Invoice) => void }): InvoiceExport {
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState<ExportProgress | null>(null);
const [request, setRequest] = useState<ExportRequestState | null>(null);
const [error, setError] = useState<ExportFailure | null>(null);
const [result, setResult] = useState<ExportResult | null>(null);
const controller = useRef<AbortController | null>(null);
const running = useRef(false);
const latest = useRef(opts);
latest.current = opts;
const start = useCallback(async (invoice: Invoice, mode: ExportMode, source: ExportSource) => {
if (running.current) return null;
running.current = true;
const ctl = new AbortController();
controller.current = ctl;
setRequest({ invoice, mode, source });
setBusy(true);
setError(null);
setResult(null);
setProgress({ phase: "rendering" });
try {
const deps = createExportDeps({ settings: latest.current.settings, bank: latest.current.bank });
const res = await exportInvoice({ invoice, mode, source, signal: ctl.signal, onProgress: setProgress }, deps);
if (res) {
setResult(res);
if (res.invoice !== invoice) latest.current.onInvoiceUpdated?.(res.invoice);
}
return res;
} catch (e) {
if (!isExportCancelled(e)) setError({ message: e instanceof Error ? e.message : String(e), flatten: isFlattenFailure(e) });
return null;
} finally {
running.current = false;
controller.current = null;
setBusy(false);
setProgress(null);
}
}, []);
const cancel = useCallback(() => controller.current?.abort(), []);
const dismiss = useCallback(() => {
setError(null);
setResult(null);
}, []);
const retry = useCallback(() => {
if (request) void start(request.invoice, request.mode, request.source);
}, [request, start]);
const exportSearchableInstead = useCallback(() => {
if (request) void start(request.invoice, "searchable", request.source);
}, [request, start]);
return useMemo(
() => ({ busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss }),
[busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss],
);
}
+57
View File
@@ -0,0 +1,57 @@
import { flattenPages, type CanvasLike, type FlattenResult, type PdfDocLike } from "../pdf/flatten";
/** Lets the browser paint and handle input; falls back to a timer when rAF is paused (hidden window). */
export const yieldToBrowser = (): Promise<void> =>
new Promise<void>((resolve) => {
let done = false;
const finish = () => {
if (!done) {
done = true;
resolve();
}
};
setTimeout(finish, 50);
requestAnimationFrame(() => setTimeout(finish, 0));
});
/** JPEG bytes of a canvas via the browser's encoder. */
export function encodeCanvasJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
(canvas as unknown as HTMLCanvasElement).toBlob(
(blob) => {
if (!blob) return reject(new Error("the browser could not encode the page image (out of memory?)"));
blob.arrayBuffer().then((buf) => resolve(new Uint8Array(buf)), reject);
},
"image/jpeg",
quality,
);
});
}
export interface BrowserFlattenOptions {
signal?: AbortSignal;
onProgress?: (page: number, pages: number) => void;
}
/** Flattens PDF bytes at 300 DPI with pdf.js and a DOM canvas; opens and closes the document itself. */
export async function flattenInBrowser(bytes: Uint8Array, opts: BrowserFlattenOptions = {}): Promise<FlattenResult> {
const { openPdf, closePdf } = await import("./pdfjs");
const doc = await openPdf(bytes);
try {
return await flattenPages({
pdf: doc as unknown as PdfDocLike,
createCanvas: (width, height) => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas as unknown as CanvasLike;
},
encodeJpeg: encodeCanvasJpeg,
yieldToUi: yieldToBrowser,
signal: opts.signal,
onProgress: opts.onProgress,
});
} finally {
await closePdf(doc).catch(() => {});
}
}
+25
View File
@@ -0,0 +1,25 @@
import { save } from "@tauri-apps/plugin-dialog";
import { api } from "./api";
import { flattenInBrowser } from "./browserFlatten";
import type { ExportDeps } from "./exportFlow";
import { renderInvoicePdf } from "./pdf";
import { getRenderClient, nextRenderJobId } from "./renderClient";
import type { BankAccount, Settings } from "./types";
/** The real collaborators of the export flow: Tauri commands, the render client, pdf.js and the save dialog. */
export function createExportDeps(ctx: { settings: Settings; bank: BankAccount | null }): ExportDeps {
return {
issueInvoice: (input, renderPrefs) => api.issueInvoice(input, renderPrefs),
render: async (invoice) => {
const { bytes, fingerprint } = await renderInvoicePdf(invoice, ctx.settings, ctx.bank);
return { bytes, fingerprint };
},
archivePdf: api.archivePdf,
readArchive: api.readArchive,
flatten: (bytes, opts) => flattenInBrowser(bytes, opts),
assemble: async (pages, title) => (await getRenderClient().assemble({ id: nextRenderJobId(), pages, title })).bytes,
getLastExportDir: api.getLastExportDir,
save: ({ defaultPath }) => save({ defaultPath, filters: [{ name: "PDF document", extensions: ["pdf"] }] }),
writeExportFile: api.writeExportFile,
};
}
+223
View File
@@ -0,0 +1,223 @@
import { describe, expect, it, vi } from "vitest";
import { FlattenCancelled, FlattenError } from "../pdf/flatten";
import { exportInvoice, ExportError, isFlattenFailure, issueAndArchive, renderAndArchive, type ExportDeps, type ExportProgress } from "./exportFlow";
import type { Invoice, InvoiceInput } from "./types";
const bytes = (s: string) => new TextEncoder().encode(s);
const text = (b: Uint8Array) => new TextDecoder().decode(b);
const invoice = (over: Partial<Invoice> = {}): Invoice =>
({
id: 7,
number: "AP/2026-007",
clientName: "Acme Learning",
invoiceDate: "2026-09-15",
status: "issued",
renderPrefs: '{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"template"}',
archivedPdfSha256: "a".repeat(64),
archivedFingerprint: "fp-issued",
items: [],
...over,
}) as Invoice;
function fakes(over: Partial<ExportDeps> = {}) {
const calls: string[] = [];
const written: Array<{ path: string; bytes: Uint8Array }> = [];
const deps: ExportDeps = {
issueInvoice: vi.fn(async () => (calls.push("issue"), invoice({ archivedPdfSha256: null, archivedFingerprint: null }))),
render: vi.fn(async () => (calls.push("render"), { bytes: bytes("RENDERED"), fingerprint: "fp-now" })),
archivePdf: vi.fn(async () => (calls.push("archive"), "b".repeat(64))),
readArchive: vi.fn(async () => (calls.push("readArchive"), bytes("ARCHIVED"))),
flatten: vi.fn(async (b, o) => {
calls.push("flatten");
o.onProgress?.(1, 2);
o.onProgress?.(2, 2);
return {
pages: [1, 2].map((n) => ({ jpeg: bytes(`${text(b)}-jpeg${n}`), widthPt: 595, heightPt: 842, pxWidth: 2480, pxHeight: 3508, renderMs: 1, encodeMs: 1 })),
};
}),
assemble: vi.fn(async (pages, title) => (calls.push("assemble"), bytes(`FLAT[${title}|${pages.map((p: { jpeg: Uint8Array }) => text(p.jpeg)).join(",")}]`))),
getLastExportDir: vi.fn(async () => "/home/u/Documents"),
save: vi.fn(async ({ defaultPath }) => (calls.push("save"), defaultPath)),
writeExportFile: vi.fn(async (path, b) => (calls.push("write"), written.push({ path, bytes: b }), path)),
...over,
};
return { deps, calls, written };
}
describe("issueAndArchive / renderAndArchive", () => {
it("issues, renders the issued invoice and archives it with the layout fingerprint", async () => {
const { deps, calls } = fakes();
const out = await issueAndArchive({} as InvoiceInput, { pageSize: "LETTER" }, deps);
expect(calls).toEqual(["issue", "render", "archive"]);
expect(deps.issueInvoice).toHaveBeenCalledWith({}, { pageSize: "LETTER" });
// The render is of the invoice the backend returned (so its frozen prefs), not of the form.
expect((deps.render as ReturnType<typeof vi.fn>).mock.calls[0][0].renderPrefs).toContain("LETTER");
expect(deps.archivePdf).toHaveBeenCalledWith(7, bytes("RENDERED"), "fp-now");
expect(out).toMatchObject({ archived: true });
expect(out.invoice).toMatchObject({ archivedPdfSha256: "b".repeat(64), archivedFingerprint: "fp-now" });
});
it("a render failure keeps the invoice and reports the error", async () => {
const { deps } = fakes({ render: vi.fn(async () => Promise.reject(new Error("worker died"))) });
const out = await issueAndArchive({} as InvoiceInput, {}, deps);
expect(out.archived).toBe(false);
expect(out.invoice.id).toBe(7);
expect(out.error).toContain("worker died");
expect(deps.archivePdf).not.toHaveBeenCalled();
});
it("an archive failure keeps the invoice and reports the error", async () => {
const { deps } = fakes({ archivePdf: vi.fn(async () => Promise.reject("disk full")) });
const out = await issueAndArchive({} as InvoiceInput, {}, deps);
expect(out).toMatchObject({ archived: false });
expect(out.error).toContain("disk full");
});
it("a failed issue throws: nothing was issued, nothing is rendered", async () => {
const { deps } = fakes({ issueInvoice: vi.fn(async () => Promise.reject(new Error("series locked"))) });
await expect(issueAndArchive({} as InvoiceInput, {}, deps)).rejects.toThrow("series locked");
expect(deps.render).not.toHaveBeenCalled();
});
it("renderAndArchive retries for an invoice issued without an archive", async () => {
const { deps } = fakes();
const out = await renderAndArchive(invoice({ archivedPdfSha256: null, archivedFingerprint: null }), deps);
expect(out.archived).toBe(true);
expect(out.invoice.archivedPdfSha256).toBe("b".repeat(64));
});
});
describe("exportInvoice", () => {
it("original: exports the archived bytes exactly and never renders", async () => {
const { deps, written } = fakes();
const r = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, deps);
expect(deps.render).not.toHaveBeenCalled();
expect(deps.readArchive).toHaveBeenCalledWith(7);
expect(text(written[0].bytes)).toBe("ARCHIVED");
expect(r).toMatchObject({ mode: "searchable", path: "/home/u/Documents/AP-2026-007_Acme Learning_2026-09-15.pdf" });
expect(r?.identicalToIssued).toBeUndefined();
});
it("rerender: renders now (not the archive) and reports whether the layout matches the issued one", async () => {
const same = fakes({ render: vi.fn(async () => ({ bytes: bytes("NEW"), fingerprint: "fp-issued" })) });
const a = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, same.deps);
expect(same.deps.readArchive).not.toHaveBeenCalled();
expect(text(same.written[0].bytes)).toBe("NEW");
expect(a?.identicalToIssued).toBe(true);
const diff = fakes();
const b = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, diff.deps);
expect(b?.identicalToIssued).toBe(false);
// The archive is never touched by a re-render.
expect(diff.deps.archivePdf).not.toHaveBeenCalled();
});
it("rerender of an invoice with nothing archived has nothing to compare with", async () => {
const { deps } = fakes();
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null, archivedFingerprint: null }), mode: "searchable", source: "rerender" }, deps);
expect(r && "identicalToIssued" in r).toBe(false);
expect(deps.archivePdf).not.toHaveBeenCalled();
});
it("not archived: renders, archives, then exports that render and returns the healed invoice", async () => {
const { deps, calls, written } = fakes();
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null, archivedFingerprint: null }), mode: "searchable", source: "original" }, deps);
expect(calls).toEqual(["render", "archive", "save", "write"]);
expect(text(written[0].bytes)).toBe("RENDERED");
expect(r?.invoice.archivedPdfSha256).toBe("b".repeat(64));
expect(r?.archiveError).toBeUndefined();
});
it("not archived and the archive fails: still exports the render and says so", async () => {
const { deps, written } = fakes({ archivePdf: vi.fn(async () => Promise.reject(new Error("read-only"))) });
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null }), mode: "searchable", source: "original" }, deps);
expect(text(written[0].bytes)).toBe("RENDERED");
expect(r?.archiveError).toContain("read-only");
expect(r?.invoice.archivedPdfSha256).toBeNull();
});
it("flattened: flattens the chosen source, assembles with the invoice number as title, names the file -flat", async () => {
const { deps, written } = fakes();
const progress: ExportProgress[] = [];
const r = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original", onProgress: (p) => progress.push(p) }, deps);
expect((deps.flatten as ReturnType<typeof vi.fn>).mock.calls[0][0]).toEqual(bytes("ARCHIVED"));
expect(text(written[0].bytes)).toBe("FLAT[AP/2026-007|ARCHIVED-jpeg1,ARCHIVED-jpeg2]");
expect(r?.path.endsWith("-flat.pdf")).toBe(true);
expect(progress.map((p) => p.phase)).toEqual(["rendering", "flattening", "flattening", "assembling", "saving"]);
expect(progress[1]).toMatchObject({ page: 1, pages: 2 });
});
it("flattened rerender flattens the fresh render", async () => {
const { deps, written } = fakes();
await exportInvoice({ invoice: invoice(), mode: "flattened", source: "rerender" }, deps);
expect(text(written[0].bytes)).toContain("RENDERED-jpeg1");
});
it("a flatten failure surfaces as FLATTEN_FAILED: no searchable fallback, nothing saved", async () => {
const { deps, calls } = fakes({ flatten: vi.fn(async () => Promise.reject(new FlattenError(2, "out of memory"))) });
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps).catch((e) => e);
expect(err).toBeInstanceOf(FlattenError);
expect(isFlattenFailure(err)).toBe(true);
expect(calls).not.toContain("save");
expect(calls).not.toContain("write");
expect(deps.flatten).toHaveBeenCalledTimes(1);
});
it("an assemble failure is also FLATTEN_FAILED", async () => {
const { deps } = fakes({ assemble: vi.fn(async () => Promise.reject(new Error("worker timed out"))) });
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps).catch((e) => e);
expect(err).toBeInstanceOf(ExportError);
expect(err.code).toBe("FLATTEN_FAILED");
expect(err.message).toContain("worker timed out");
});
it("cancel: an aborted signal stops the flow with FlattenCancelled and saves nothing", async () => {
const ctl = new AbortController();
const { deps, calls } = fakes({
flatten: vi.fn(async (_b, o) => {
ctl.abort();
o.onProgress?.(1, 2);
return { pages: [] };
}),
});
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original", signal: ctl.signal }, deps).catch((e) => e);
expect(err).toBeInstanceOf(FlattenCancelled);
expect(calls).not.toContain("assemble");
expect(calls).not.toContain("save");
const pre = new AbortController();
pre.abort();
const f2 = fakes();
await expect(exportInvoice({ invoice: invoice(), mode: "searchable", source: "original", signal: pre.signal }, f2.deps)).rejects.toBeInstanceOf(FlattenCancelled);
expect(f2.deps.readArchive).not.toHaveBeenCalled();
});
it("a FlattenCancelled thrown by flatten passes through unchanged", async () => {
const { deps } = fakes({ flatten: vi.fn(async () => Promise.reject(new FlattenCancelled())) });
await expect(exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps)).rejects.toBeInstanceOf(FlattenCancelled);
});
it("dismissing the save dialog resolves null and writes nothing", async () => {
const { deps, calls } = fakes({ save: vi.fn(async () => null) });
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, deps)).toBeNull();
expect(calls).not.toContain("write");
});
it("opens the dialog in the last folder, handles Windows folders, and survives a missing folder", async () => {
const win = fakes({ getLastExportDir: vi.fn(async () => "C:\\Users\\arun\\Documents") });
await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, win.deps);
expect((win.deps.save as ReturnType<typeof vi.fn>).mock.calls[0][0].defaultPath).toBe("C:\\Users\\arun\\Documents\\AP-2026-007_Acme Learning_2026-09-15.pdf");
const none = fakes({ getLastExportDir: vi.fn(async () => Promise.reject(new Error("no dir"))) });
await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, none.deps);
expect((none.deps.save as ReturnType<typeof vi.fn>).mock.calls[0][0].defaultPath).toBe("AP-2026-007_Acme Learning_2026-09-15.pdf");
});
it("a write failure is WRITE_FAILED; a render failure on re-render is RENDER_FAILED", async () => {
const w = fakes({ writeExportFile: vi.fn(async () => Promise.reject("permission denied")) });
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, w.deps).catch((e) => e)).toMatchObject({ code: "WRITE_FAILED" });
const r = fakes({ render: vi.fn(async () => Promise.reject(new Error("fonts"))) });
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, r.deps).catch((e) => e)).toMatchObject({ code: "RENDER_FAILED" });
});
});
+219
View File
@@ -0,0 +1,219 @@
import { FlattenCancelled } from "../pdf/flatten";
import type { FlattenResult } from "../pdf/flatten";
import type { AssemblePageData } from "../pdf/render/jobs";
import { exportFileName, type ExportMode } from "./filename";
import type { Invoice, InvoiceInput } from "./types";
export type { ExportMode };
export type ExportSource = "original" | "rerender";
/** Everything the flow touches outside itself, so it can run against fakes. */
export interface ExportDeps {
issueInvoice(input: InvoiceInput, renderPrefs: object): Promise<Invoice>;
/** Renders with the invoice's frozen prefs and supplier snapshot and the current template code. */
render(invoice: Invoice): Promise<{ bytes: Uint8Array; fingerprint: string }>;
archivePdf(invoiceId: number, bytes: Uint8Array, fingerprint: string): Promise<string>;
readArchive(invoiceId: number): Promise<Uint8Array>;
/** Rasterises PDF bytes page by page. Throws FlattenError / FlattenCancelled. */
flatten(bytes: Uint8Array, opts: { signal?: AbortSignal; onProgress?: (page: number, pages: number) => void }): Promise<FlattenResult>;
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
getLastExportDir(): Promise<string>;
/** The save dialog: the chosen path, or null when it is dismissed. */
save(opts: { defaultPath: string }): Promise<string | null>;
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
}
export type ExportPhase = "rendering" | "flattening" | "assembling" | "saving";
export interface ExportProgress {
phase: ExportPhase;
/** While flattening: the page being rendered and the page count. */
page?: number;
pages?: number;
}
export type ExportErrorCode = "RENDER_FAILED" | "ARCHIVE_FAILED" | "FLATTEN_FAILED" | "WRITE_FAILED";
export class ExportError extends Error {
constructor(
readonly code: ExportErrorCode,
message: string,
readonly cause?: unknown,
) {
super(message);
this.name = "ExportError";
}
}
const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
export const isFlattenFailure = (e: unknown): boolean => (e as { code?: unknown } | null)?.code === "FLATTEN_FAILED";
export const isExportCancelled = (e: unknown): boolean => e instanceof FlattenCancelled || (e as { name?: string } | null)?.name === "FlattenCancelled";
const checkAbort = (signal?: AbortSignal) => {
if (signal?.aborted) throw new FlattenCancelled();
};
// --- archive at issue ------------------------------------------------------------------------------
export interface ArchiveOutcome {
/** The invoice with its archive fields filled in when archiving worked. */
invoice: Invoice;
archived: boolean;
error?: string;
}
/** Fields the backend sets when it archives, mirrored locally so the UI needs no refetch. */
const withArchive = (invoice: Invoice, sha256: string, fingerprint: string): Invoice => ({
...invoice,
archivedPdfSha256: sha256,
archivedFingerprint: fingerprint,
archivedAt: invoice.archivedAt ?? new Date().toISOString(),
});
/**
* Renders an issued invoice with its frozen prefs and archives the result. Never throws: a failure
* comes back as `archived: false` with the reason, because the invoice already exists.
*/
export async function renderAndArchive(invoice: Invoice, deps: Pick<ExportDeps, "render" | "archivePdf">): Promise<ArchiveOutcome> {
let rendered: { bytes: Uint8Array; fingerprint: string };
try {
rendered = await deps.render(invoice);
} catch (e) {
return { invoice, archived: false, error: `The PDF could not be rendered: ${messageOf(e)}` };
}
try {
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
return { invoice: withArchive(invoice, sha, rendered.fingerprint), archived: true };
} catch (e) {
return { invoice, archived: false, error: `The PDF could not be archived: ${messageOf(e)}` };
}
}
/**
* Issues the invoice, then renders it with the prefs the backend froze and archives that PDF. The
* issue call's own failure throws (nothing was issued); everything after it is reported, never thrown.
*/
export async function issueAndArchive(
input: InvoiceInput,
renderPrefs: object,
deps: Pick<ExportDeps, "issueInvoice" | "render" | "archivePdf">,
): Promise<ArchiveOutcome> {
const invoice = await deps.issueInvoice(input, renderPrefs);
return renderAndArchive(invoice, deps);
}
// --- export ----------------------------------------------------------------------------------------
export interface ExportRequest {
invoice: Invoice;
mode: ExportMode;
source: ExportSource;
signal?: AbortSignal;
onProgress?: (p: ExportProgress) => void;
}
export interface ExportResult {
path: string;
mode: ExportMode;
/** Only for a re-render of an archived invoice: did the layout come out the same as at issue? */
identicalToIssued?: boolean;
/** The invoice with archive fields filled in when this export had to archive it first. */
invoice: Invoice;
/** The export used a fresh render because archiving the missing original failed. */
archiveError?: string;
}
function joinPath(dir: string, name: string): string {
if (!dir) return name;
const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
return /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${sep}${name}`;
}
/**
* Produces the PDF bytes for one export and saves them. Returns null when the save dialog is
* dismissed. Errors: FlattenCancelled when aborted; an ExportError (or a FlattenError, which carries
* the same `FLATTEN_FAILED` code) otherwise. A failed flatten is never replaced by a searchable export
* or a lower resolution; the caller offers that choice.
*/
export async function exportInvoice(req: ExportRequest, deps: ExportDeps): Promise<ExportResult | null> {
const { mode, source, signal, onProgress } = req;
let invoice = req.invoice;
checkAbort(signal);
let bytes: Uint8Array;
let identicalToIssued: boolean | undefined;
let archiveError: string | undefined;
if (source === "rerender") {
onProgress?.({ phase: "rendering" });
const rendered = await renderOrThrow(invoice, deps);
bytes = rendered.bytes;
if (invoice.archivedPdfSha256 && invoice.archivedFingerprint) {
identicalToIssued = rendered.fingerprint === invoice.archivedFingerprint;
}
} else if (invoice.archivedPdfSha256) {
onProgress?.({ phase: "rendering" });
bytes = await deps.readArchive(invoice.id);
} else {
// Issued but never archived (the render failed at issue): do it now, then use that file.
onProgress?.({ phase: "rendering" });
const rendered = await renderOrThrow(invoice, deps);
bytes = rendered.bytes;
try {
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
invoice = withArchive(invoice, sha, rendered.fingerprint);
} catch (e) {
// The invoice is intact; export the fresh render and let the caller say the archive is still missing.
archiveError = `The PDF could not be archived: ${messageOf(e)}`;
}
}
checkAbort(signal);
if (mode === "flattened") {
try {
const flat = await deps.flatten(bytes, {
signal,
onProgress: (page, pages) => onProgress?.({ phase: "flattening", page, pages }),
});
checkAbort(signal);
onProgress?.({ phase: "assembling" });
// The title is the invoice number only; anything more would leak what the image-only file hides.
bytes = await deps.assemble(
flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
invoice.number,
);
} catch (e) {
if (isExportCancelled(e) || isFlattenFailure(e)) throw e;
throw new ExportError("FLATTEN_FAILED", `The flattened PDF could not be built: ${messageOf(e)}`, e);
}
checkAbort(signal);
}
let dir = "";
try {
dir = await deps.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const name = exportFileName({ number: invoice.number, client: invoice.clientName, date: invoice.invoiceDate, mode });
onProgress?.({ phase: "saving" });
const chosen = await deps.save({ defaultPath: joinPath(dir, name) });
if (!chosen) return null;
let path: string;
try {
path = (await deps.writeExportFile(chosen, bytes)) || chosen;
} catch (e) {
throw new ExportError("WRITE_FAILED", `The file could not be saved: ${messageOf(e)}`, e);
}
return { path, mode, invoice, ...(identicalToIssued === undefined ? {} : { identicalToIssued }), ...(archiveError ? { archiveError } : {}) };
}
async function renderOrThrow(invoice: Invoice, deps: Pick<ExportDeps, "render">) {
try {
return await deps.render(invoice);
} catch (e) {
throw new ExportError("RENDER_FAILED", `The PDF could not be rendered: ${messageOf(e)}`, e);
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { ExportMode } from "./filename";
const KEY = "voiced.exportMode";
/** The last export mode this viewer used; searchable until one is chosen. A per-viewer convenience only. */
export function loadExportMode(): ExportMode {
try {
const v = localStorage.getItem(KEY);
if (v === "searchable" || v === "flattened") return v;
} catch {
// Storage can be blocked or empty; the default is fine.
}
return "searchable";
}
export function saveExportMode(mode: ExportMode): void {
try {
localStorage.setItem(KEY, mode);
} catch {
// Not remembering is harmless.
}
}
export const EXPORT_MODE_LABEL: Record<ExportMode, string> = {
searchable: "Searchable PDF (copy-pasteable text)",
flattened: "Flattened PDF (image only)",
};
+5 -23
View File
@@ -1,4 +1,3 @@
import { save } from "@tauri-apps/plugin-dialog";
import pkg from "../../package.json"; import pkg from "../../package.json";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs"; import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { buildRenderModel, type RenderModel } from "../pdf/model/build"; import { buildRenderModel, type RenderModel } from "../pdf/model/build";
@@ -57,6 +56,11 @@ export async function renderInvoicePdf(
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings), prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
): Promise<RenderResult> { ): Promise<RenderResult> {
const model = await buildModelForInvoice(invoice, settings, bank); const model = await buildModelForInvoice(invoice, settings, bank);
return renderModelWithRetry(model, prefs);
}
/** Renders a ready model in the shared client. */
export async function renderModelWithRetry(model: RenderModel, prefs: RenderPrefsV1): Promise<RenderResult> {
// The live preview shares the client; if it takes the worker first this render is superseded, so go again. // The live preview shares the client; if it takes the worker first this render is superseded, so go again.
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
try { try {
@@ -66,25 +70,3 @@ export async function renderInvoicePdf(
} }
} }
} }
/** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */
export async function savePdfBytes(bytes: Uint8Array, fileName: string): Promise<string | null> {
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
const path = await save({
defaultPath: `${suggested}.pdf`,
filters: [{ name: "PDF document", extensions: ["pdf"] }],
});
if (!path) return null;
await api.writeExportFile(path, bytes);
return path;
}
export async function exportInvoicePdf(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
prefs?: RenderPrefsV1,
): Promise<string | null> {
const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
return savePdfBytes(bytes, invoice.number);
}
+87
View File
@@ -0,0 +1,87 @@
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { describe, expect, it } from "vitest";
import type { CanvasLike } from "../pdf/flatten";
import { runAssembleJob } from "../pdf/render/assembleJob";
import { renderInvoicePdf } from "../pdf/render/invoice";
import { ensureSetup } from "../pdf/templates/harness";
import type { GoldenMap } from "../pdf/testing/goldens";
import goldens from "../pdf/testing/goldens.json";
import { loadCanvas } from "../pdf/testing/rasterize";
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
import { reportToText, runSelfTest, type SelfTestDeps, type SelfTestDoc } from "./selfTest";
const canvasLib = await loadCanvas();
if (!canvasLib) console.warn("[selfTest.test] @napi-rs/canvas is not installed: skipping the self-test run under Node");
function nodeDeps(over: Partial<SelfTestDeps> = {}): SelfTestDeps {
ensureSetup();
return {
env: () => ({ userAgent: "node", isSecureContext: true, renderMode: "worker" }),
fixtures: selfTestFixtures(),
goldens: goldens as GoldenMap,
render: async (f) => {
const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs });
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: 1 };
},
openPdf: async (bytes) => {
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
const doc = await task.promise;
return Object.assign(doc, { close: () => task.destroy() }) as unknown as SelfTestDoc;
},
createCanvas: (w, h) => canvasLib!.createCanvas(w, h) as unknown as CanvasLike,
encodeJpeg: async (c, q) => new Uint8Array(await (c as unknown as { encode(f: "jpeg", q: number): Promise<Buffer> }).encode("jpeg", Math.round(q * 100))),
assemble: async (pages, title) => (await runAssembleJob({ id: 1, pages, title })).bytes,
startFrameMonitor: () => () => 0,
...over,
};
}
describe.skipIf(!canvasLib)("runSelfTest under Node", () => {
it("passes every check against the committed goldens", async () => {
const updates: number[] = [];
const report = await runSelfTest(nodeDeps(), (r) => updates.push(r.sections.length));
const failed = report.sections.flatMap((s) => s.checks.filter((c) => c.status === "fail").map((c) => `${s.title}: ${c.label}: ${c.detail}`));
expect(failed).toEqual([]);
expect(report.passed && report.done).toBe(true);
expect(updates.length).toBeGreaterThan(10);
const labels = report.sections.flatMap((s) => s.checks.map((c) => c.label));
for (const l of ["Page count", "Layout audit", "Rasterise page 1", "Text extraction", "Layout fingerprint matches the Node golden", "Longest main-thread stall", "Assemble the image-only PDF"]) {
expect(labels).toContain(l);
}
expect(reportToText(report)).toContain("RESULT: all checks passed");
});
it("reports a fingerprint difference clearly, with both values", async () => {
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
const id = Object.keys(tampered)[0];
tampered[id].fingerprint = "0".repeat(64);
const report = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
const check = report.sections.flatMap((s) => s.checks).find((c) => c.label.startsWith("Layout fingerprint"))!;
expect(check.status).toBe("fail");
expect(check.detail).toContain("0".repeat(64));
expect(check.detail).toContain("this webview");
expect(report.passed).toBe(false);
expect(reportToText(report)).toContain("RESULT: FAILED");
});
it("fails the text check when the page does not contain the expected strings, and a render error stops that fixture", async () => {
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
const id = Object.keys(tampered)[0];
tampered[id].totalText = "₹9,999,999.99";
const a = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
expect(a.sections.flatMap((s) => s.checks).find((c) => c.label === "Text extraction")).toMatchObject({ status: "fail" });
const b = await runSelfTest(nodeDeps({ render: async () => Promise.reject(new Error("worker could not start")), fixtures: selfTestFixtures().slice(0, 1) }));
const sec = b.sections.find((s) => s.title.startsWith("Classic, A4"))!;
expect(sec.checks.map((c) => c.status)).toEqual(["fail"]);
expect(sec.checks[0].detail).toContain("worker could not start");
expect(b.sections.some((s) => s.title.startsWith("300 DPI"))).toBe(false);
});
it("fails the stall check when the longest frame gap exceeds the budget", async () => {
const report = await runSelfTest(nodeDeps({ startFrameMonitor: () => () => 900 }));
const stall = report.sections.flatMap((s) => s.checks).find((c) => c.label === "Longest main-thread stall")!;
expect(stall.status).toBe("fail");
expect(stall.detail).toContain("900 ms");
});
});
+289
View File
@@ -0,0 +1,289 @@
import { flattenPages, type CanvasLike, type FlattenResult, type PageLike } from "../pdf/flatten";
import type { AssemblePageData } from "../pdf/render/jobs";
import type { GoldenMap } from "../pdf/testing/goldens";
import type { SelfTestFixture } from "../pdf/testing/selfTestFixtures";
/**
* The Diagnostics self-test: renders fixtures through the app's real render client, rasterises and
* reads them back with pdf.js, compares with the Node goldens, and times a 300 DPI flatten. Everything
* environment-specific is injected, so the same checks run in the webview and under Node.
*/
export type CheckStatus = "pass" | "fail" | "info";
export interface Check {
label: string;
status: CheckStatus;
detail?: string;
ms?: number;
}
export interface Section {
title: string;
checks: Check[];
}
export interface SelfTestReport {
startedAt: string;
sections: Section[];
done: boolean;
/** No check failed (informational rows never fail). */
passed: boolean;
}
export interface SelfTestPage extends PageLike {
getTextContent(): Promise<{ items: Array<{ str?: string }> }>;
}
export interface SelfTestDoc {
numPages: number;
getPage(n: number): Promise<SelfTestPage>;
close(): Promise<void>;
}
export interface SelfTestRender {
bytes: Uint8Array;
pages: number;
issues: Array<{ severity: string; message?: string }>;
fingerprint: string;
ms: number;
}
export interface SelfTestDeps {
env(): { userAgent: string; isSecureContext: boolean; renderMode: string };
fixtures: SelfTestFixture[];
goldens: GoldenMap;
/** Renders in the app's real client (the worker in the app). */
render(fixture: SelfTestFixture): Promise<SelfTestRender>;
openPdf(bytes: Uint8Array): Promise<SelfTestDoc>;
createCanvas(width: number, height: number): CanvasLike;
encodeJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array>;
/** Wraps JPEG pages into an image-only PDF through the real client. */
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
/** Starts watching frame gaps; the returned function stops it and gives the longest gap in ms. */
startFrameMonitor(): () => number;
yieldToUi?: () => Promise<void>;
now?: () => number;
}
/** Longest allowed main-thread stall while flattening (the plan's budget). */
export const MAX_FLATTEN_STALL_MS = 500;
/** A rendered invoice page is never blank: at least this share of pixels must carry ink. */
export const MIN_INK_RATIO = 0.003;
const squash = (s: string) => s.replace(/\s+/g, "");
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
const short = (fp: string) => (fp.length > 16 ? `${fp.slice(0, 8)}…${fp.slice(-6)}` : fp);
const mb = (n: number) => `${(n / 1048576).toFixed(2)} MB`;
/** Share of non-white pixels of page 1 drawn at 1 px per point. */
async function inkRatioOfFirstPage(doc: SelfTestDoc, deps: SelfTestDeps): Promise<number> {
const page = await doc.getPage(1);
const viewport = page.getViewport({ scale: 1 });
const w = Math.ceil(viewport.width);
const h = Math.ceil(viewport.height);
const canvas = deps.createCanvas(w, h);
try {
const ctx = canvas.getContext("2d") as {
fillStyle: unknown;
fillRect(x: number, y: number, w: number, h: number): void;
getImageData(x: number, y: number, w: number, h: number): { data: ArrayLike<number> };
};
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
await page.render({ canvasContext: ctx, canvas, viewport, intent: "print" }).promise;
const data = ctx.getImageData(0, 0, w, h).data;
let ink = 0;
for (let i = 0; i < data.length; i += 4) if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) ink++;
return ink / (w * h);
} finally {
canvas.width = 0;
canvas.height = 0;
}
}
async function pageTexts(doc: SelfTestDoc): Promise<string[]> {
const out: string[] = [];
for (let n = 1; n <= doc.numPages; n++) {
const content = await (await doc.getPage(n)).getTextContent();
out.push(content.items.map((i) => i.str ?? "").join(" "));
}
return out;
}
export async function runSelfTest(deps: SelfTestDeps, onUpdate?: (report: SelfTestReport) => void): Promise<SelfTestReport> {
const now = deps.now ?? (() => performance.now());
const report: SelfTestReport = { startedAt: new Date().toISOString(), sections: [], done: false, passed: true };
const emit = () => {
report.passed = report.sections.every((s) => s.checks.every((c) => c.status !== "fail"));
onUpdate?.({ ...report, sections: report.sections.map((s) => ({ ...s, checks: [...s.checks] })) });
};
const section = (title: string): Section => {
const s: Section = { title, checks: [] };
report.sections.push(s);
emit();
return s;
};
const add = (s: Section, check: Check) => {
s.checks.push(check);
emit();
};
/** Runs a check body that returns a Check, turning a throw into a failure. */
const timed = async (s: Section, label: string, body: () => Promise<Omit<Check, "label">>) => {
const t0 = now();
try {
const r = await body();
add(s, { label, ms: Math.round(now() - t0), ...r });
} catch (e) {
add(s, { label, status: "fail", detail: errText(e), ms: Math.round(now() - t0) });
}
};
const env = section("Webview");
const info = deps.env();
add(env, { label: "User agent", status: "info", detail: info.userAgent });
add(env, { label: "Secure context", status: "info", detail: String(info.isSecureContext) });
const rendered = new Map<string, SelfTestRender>();
for (const fixture of deps.fixtures) {
const sec = section(fixture.label);
const golden = deps.goldens[fixture.id];
let r: SelfTestRender | null = null;
await timed(sec, "Render in the app's client", async () => {
r = await deps.render(fixture);
rendered.set(fixture.id, r);
return { status: "pass", detail: `${r.pages} page${r.pages === 1 ? "" : "s"}, ${(r.bytes.byteLength / 1024).toFixed(0)} KB, ${r.ms} ms in the render client` };
});
if (!r) continue;
const res: SelfTestRender = r;
if (!golden) {
add(sec, { label: "Golden", status: "fail", detail: `goldens.json has no entry for ${fixture.id}; regenerate it with UPDATE_GOLDENS=1` });
} else {
add(sec, {
label: "Page count",
status: res.pages === golden.pages ? "pass" : "fail",
detail: res.pages === golden.pages ? `${res.pages}` : `expected ${golden.pages}, got ${res.pages}`,
});
}
const errors = res.issues.filter((i) => i.severity === "error");
add(sec, {
label: "Layout audit",
status: errors.length === 0 ? "pass" : "fail",
detail: errors.length === 0 ? `no errors, ${res.issues.length} warning${res.issues.length === 1 ? "" : "s"}` : errors.slice(0, 3).map((e) => e.message ?? "error").join("; "),
});
let doc: SelfTestDoc | null = null;
try {
doc = await deps.openPdf(res.bytes);
} catch (e) {
add(sec, { label: "Open with pdf.js", status: "fail", detail: errText(e) });
}
if (doc) {
const opened = doc;
await timed(sec, "Rasterise page 1", async () => {
const ratio = await inkRatioOfFirstPage(opened, deps);
return { status: ratio >= MIN_INK_RATIO ? "pass" : "fail", detail: `${(ratio * 100).toFixed(1)}% of pixels carry ink${ratio >= MIN_INK_RATIO ? "" : " (blank page?)"}` };
});
await timed(sec, "Text extraction", async () => {
const texts = await pageTexts(opened);
const flat = squash(texts.join(" "));
const wanted = golden
? [fixture.invoiceNumber, golden.totalText, golden.pageLabel]
: [fixture.invoiceNumber, fixture.totalText, `Page 1 of ${res.pages}`];
const missing = wanted.filter((w) => !flat.includes(squash(w)));
return { status: missing.length === 0 ? "pass" : "fail", detail: missing.length === 0 ? `found ${wanted.join(", ")}` : `missing ${missing.map((m) => `"${m}"`).join(", ")}` };
});
await opened.close().catch(() => {});
}
if (golden) {
const same = res.fingerprint === golden.fingerprint;
add(sec, {
label: "Layout fingerprint matches the Node golden",
status: same ? "pass" : "fail",
detail: same
? short(res.fingerprint)
: `golden ${golden.fingerprint}\nthis webview ${res.fingerprint}\nThe layout differs from Node: text measuring or the JavaScript engine does not agree with the goldens.`,
});
}
}
// Flatten timing on the longest rendered fixture.
const multi = [...rendered.entries()].sort((a, b) => b[1].pages - a[1].pages)[0];
if (multi) {
const [id, src] = multi;
const fixture = deps.fixtures.find((f) => f.id === id)!;
const sec = section(`300 DPI flatten (${fixture.label})`);
const held: { flat: FlattenResult | null } = { flat: null };
const stop = deps.startFrameMonitor();
await timed(sec, `Flatten ${src.pages} page${src.pages === 1 ? "" : "s"}`, async () => {
const doc = await deps.openPdf(src.bytes);
let flat: FlattenResult;
try {
flat = await flattenPages({
pdf: doc as unknown as Parameters<typeof flattenPages>[0]["pdf"],
createCanvas: deps.createCanvas,
encodeJpeg: deps.encodeJpeg,
yieldToUi: deps.yieldToUi,
now,
});
} finally {
await doc.close().catch(() => {});
}
held.flat = flat;
const total = flat.pages.reduce((n, p) => n + p.jpeg.byteLength, 0);
return {
status: "pass",
detail: flat.pages.map((p, i) => `page ${i + 1}: render ${p.renderMs} ms + encode ${p.encodeMs} ms, ${p.pxWidth}x${p.pxHeight}, ${mb(p.jpeg.byteLength)}`).join("\n") + `\ntotal ${mb(total)}`,
};
});
const longest = Math.round(stop());
const done = held.flat;
add(sec, {
label: "Longest main-thread stall",
status: !done ? "info" : longest <= MAX_FLATTEN_STALL_MS ? "pass" : "fail",
detail: !done ? "not measured" : `${longest} ms (budget ${MAX_FLATTEN_STALL_MS} ms)`,
});
if (done) {
await timed(sec, "Assemble the image-only PDF", async () => {
const out = await deps.assemble(
done.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
fixture.invoiceNumber,
);
const doc = await deps.openPdf(out);
try {
const texts = await pageTexts(doc);
const textItems = texts.filter((t) => t.trim() !== "").length;
const ok = doc.numPages === src.pages && textItems === 0;
return { status: ok ? "pass" : "fail", detail: `${doc.numPages} page${doc.numPages === 1 ? "" : "s"}, ${textItems === 0 ? "no extractable text" : `${textItems} page(s) still have text`}, ${mb(out.byteLength)}` };
} finally {
await doc.close().catch(() => {});
}
});
}
}
// Known only now: the client falls back to the main thread on its first failed start.
const renderMode = deps.env().renderMode;
add(env, { label: "Render client", status: "info", detail: renderMode === "worker" ? "worker" : "main thread (the worker could not start)" });
report.done = true;
emit();
return report;
}
export function reportToText(report: SelfTestReport): string {
const lines = [`Voiced diagnostics ${report.startedAt}`, report.done ? (report.passed ? "RESULT: all checks passed" : "RESULT: FAILED") : "RESULT: running", ""];
for (const s of report.sections) {
lines.push(`== ${s.title}`);
for (const c of s.checks) {
lines.push(`[${c.status.toUpperCase()}] ${c.label}${c.ms !== undefined ? ` (${c.ms} ms)` : ""}`);
if (c.detail) for (const l of c.detail.split("\n")) lines.push(` ${l}`);
}
lines.push("");
}
return lines.join("\n");
}
+57
View File
@@ -0,0 +1,57 @@
import goldens from "../pdf/testing/goldens.json";
import type { GoldenMap } from "../pdf/testing/goldens";
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
import type { CanvasLike } from "../pdf/flatten";
import { encodeCanvasJpeg, yieldToBrowser } from "./browserFlatten";
import { renderModelWithRetry } from "./pdf";
import { getRenderClient, nextRenderJobId } from "./renderClient";
import type { SelfTestDeps, SelfTestDoc } from "./selfTest";
/** The self-test wired to this webview: the real render client, pdf.js and DOM canvases. */
export function browserSelfTestDeps(): SelfTestDeps {
return {
env: () => ({
userAgent: navigator.userAgent,
isSecureContext: window.isSecureContext,
renderMode: getRenderClient().mode,
}),
fixtures: selfTestFixtures(),
goldens: goldens as GoldenMap,
render: async (fixture) => {
const r = await renderModelWithRetry(fixture.model, fixture.prefs);
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: r.ms };
},
openPdf: async (bytes) => {
const { openPdf, closePdf } = await import("./pdfjs");
const doc = await openPdf(bytes);
return Object.assign(doc, { close: () => closePdf(doc) }) as unknown as SelfTestDoc;
},
createCanvas: (width, height) => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas as unknown as CanvasLike;
},
encodeJpeg: encodeCanvasJpeg,
assemble: async (pages, title) => (await getRenderClient().assemble({ id: nextRenderJobId(), pages, title })).bytes,
startFrameMonitor: () => {
let last = performance.now();
let longest = 0;
let running = true;
const tick = () => {
if (!running) return;
const t = performance.now();
longest = Math.max(longest, t - last);
last = t;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
return () => {
running = false;
// A frame that never arrived because the thread was still busy counts too.
return Math.max(longest, performance.now() - last);
};
},
yieldToUi: yieldToBrowser,
};
}
+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 = "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);
});
});
+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, WorkerUnavailable,
type WorkerLike, type WorkerLike,
} from "./client"; } from "./client";
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
class FakeWorker implements WorkerLike { class FakeWorker implements WorkerLike {
onmessage: WorkerLike["onmessage"] = null; onmessage: WorkerLike["onmessage"] = null;
@@ -31,7 +31,14 @@ class FakeWorker implements WorkerLike {
reply(id: number) { reply(id: number) {
this.emit({ type: "result", result: result(id) }); 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 => ({ const result = (id: number): RenderResult => ({
id, 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", () => { describe("main-thread client and fallback", () => {
it("runs jobs serially with latest-wins", async () => { it("runs jobs serially with latest-wins", async () => {
const gates: Array<() => void> = []; const gates: Array<() => void> = [];
+110 -28
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). */ /** A newer render call replaced this one before it finished (or before it started). */
export class Superseded extends Error { export class Superseded extends Error {
@@ -44,6 +44,12 @@ export interface WorkerLike {
export interface RenderClient { export interface RenderClient {
/** Resolves with the newest request's result; an older call rejects with Superseded. */ /** Resolves with the newest request's result; an older call rejects with Superseded. */
render(job: RenderJob): Promise<RenderResult>; 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; dispose(): void;
readonly mode: "worker" | "main"; readonly mode: "worker" | "main";
} }
@@ -57,23 +63,38 @@ export interface RenderClientOptions {
const defaultWorkerFactory = (): WorkerLike => const defaultWorkerFactory = (): WorkerLike =>
new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as 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; job: RenderJob;
resolve: (r: RenderResult) => void; resolve: (r: RenderResult) => void;
reject: (e: unknown) => 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. */ /** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
superseded: boolean; superseded: boolean;
timer: ReturnType<typeof setTimeout>; 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 * (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` * running job finishes at most one render, the newest, runs. Assemble jobs are the exception: they
* message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback. * 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 { export function createRenderClient(opts: RenderClientOptions = {}): RenderClient {
const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts; const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts;
@@ -82,7 +103,8 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
let unavailable: WorkerUnavailable | null = null; let unavailable: WorkerUnavailable | null = null;
let disposed = false; let disposed = false;
let running: Running | null = null; let running: Running | null = null;
let pending: Slot | null = null; let pending: RenderSlot | null = null;
const assembles: AssembleSlot[] = [];
const stopWorker = () => { const stopWorker = () => {
const w = worker; const w = worker;
@@ -107,16 +129,25 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
unavailable = err; unavailable = err;
stopWorker(); stopWorker();
settleRunning((s) => s.reject(err)); settleRunning((s) => s.reject(err));
rejectQueued(err);
};
const rejectQueued = (err: unknown) => {
pending?.reject(err); pending?.reject(err);
pending = null; pending = null;
for (const a of assembles.splice(0)) a.reject(err);
}; };
const pump = () => { const pump = () => {
if (!ready || !worker || running || !pending || disposed) return; if (!ready || !worker || running || disposed) return;
const slot = pending; let slot: Slot | null = assembles.shift() ?? null;
if (!slot) {
slot = pending;
pending = null; pending = null;
}
if (!slot) return;
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) }; running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
worker.postMessage({ type: "render", job: slot.job }); worker.postMessage(requestFor(slot));
}; };
const spawn = () => { const spawn = () => {
@@ -136,8 +167,12 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
ready = true; ready = true;
pump(); pump();
} else if (msg.type === "result") { } else if (msg.type === "result") {
if (running?.job.id !== msg.result.id) return; if (running?.kind !== "render" || running.job.id !== msg.result.id) return;
settleRunning((s) => s.resolve(msg.result)); 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(); pump();
} else if (msg.id === null) { } else if (msg.id === null) {
if (!ready) fail(new WorkerUnavailable(msg.message)); if (!ready) fail(new WorkerUnavailable(msg.message));
@@ -170,11 +205,20 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
if (unavailable) return Promise.reject(unavailable); if (unavailable) return Promise.reject(unavailable);
return new Promise<RenderResult>((resolve, reject) => { return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded()); pending?.reject(new Superseded());
if (running && !running.superseded) { if (running && running.kind === "render" && !running.superseded) {
running.superseded = true; running.superseded = true;
running.reject(new Superseded()); 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(); if (!worker) spawn();
pump(); pump();
}); });
@@ -184,8 +228,7 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
disposed = true; disposed = true;
const err = new Error("The render client was disposed"); const err = new Error("The render client was disposed");
settleRunning((s) => s.reject(err)); settleRunning((s) => s.reject(err));
pending?.reject(err); rejectQueued(err);
pending = null;
stopWorker(); 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). */ /** Assembles one image-only PDF on this thread. */
export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient { 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 disposed = false;
let running: (Slot & { superseded: boolean }) | null = null; let running: (Slot & { superseded: boolean }) | null = null;
let pending: Slot | null = null; let pending: RenderSlot | null = null;
const assembles: AssembleSlot[] = [];
const pump = async () => { const pump = async () => {
if (running || !pending || disposed) return; if (running || disposed) return;
const slot = { ...pending, superseded: false }; let next: Slot | null = assembles.shift() ?? null;
if (!next) {
next = pending;
pending = null; pending = null;
}
if (!next) return;
const slot = { ...next, superseded: false };
running = slot; running = slot;
try { try {
if (slot.kind === "render") {
const result = await execute(slot.job); const result = await execute(slot.job);
if (!slot.superseded) slot.resolve(result); if (!slot.superseded) slot.resolve(result);
} else {
slot.resolve(await executeAssemble(slot.job));
}
} catch (e) { } catch (e) {
if (!slot.superseded) slot.reject(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")); if (disposed) return Promise.reject(new Error("The render client was disposed"));
return new Promise<RenderResult>((resolve, reject) => { return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded()); pending?.reject(new Superseded());
if (running && !running.superseded) { if (running && running.kind === "render" && !running.superseded) {
running.superseded = true; running.superseded = true;
running.reject(new Superseded()); 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(); void pump();
}); });
}, },
@@ -252,6 +323,7 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
const err = new Error("The render client was disposed"); const err = new Error("The render client was disposed");
pending?.reject(err); pending?.reject(err);
pending = null; 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). * which one is active (main thread: the preview is paused while typing).
*/ */
export function createAutoClient( export function createAutoClient(
opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {}, opts: RenderClientOptions & { mainExecutor?: MainExecutor; mainAssembleExecutor?: MainAssembleExecutor } = {},
): RenderClient { ): RenderClient {
const primary = createRenderClient(opts); const primary = createRenderClient(opts);
let main: RenderClient | null = null; let main: RenderClient | null = null;
const fallback = () => {
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
primary.dispose();
return main;
};
return { return {
get mode() { get mode() {
return main ? "main" : "worker"; return main ? "main" : "worker";
@@ -273,9 +350,14 @@ export function createAutoClient(
if (main) return main.render(job); if (main) return main.render(job);
return primary.render(job).catch((err) => { return primary.render(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err; if (!(err instanceof WorkerUnavailable)) throw err;
main ??= createMainThreadClient(opts.mainExecutor); return fallback().render(job);
primary.dispose(); });
return main.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() { dispose() {
+22 -1
View File
@@ -20,10 +20,31 @@ export interface RenderResult {
ms: number; 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 = export type WorkerMessage =
| { type: "ready" } | { type: "ready" }
| { type: "result"; result: RenderResult } | { type: "result"; result: RenderResult }
| { type: "assembled"; result: AssembleResult }
/** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */ /** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */
| { type: "error"; id: number | null; message: string; stack?: string }; | { 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. // Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests.
import { installStreamAsyncIterator } from "../../lib/streamPolyfill"; import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
import { initBrowserRendering } from "./browserInit"; import { initBrowserRendering } from "./browserInit";
import { runAssembleJob } from "./assembleJob";
import { renderInvoicePdf } from "./invoice"; import { renderInvoicePdf } from "./invoice";
import type { WorkerMessage, WorkerRequest } from "./jobs"; 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, 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(); const started = initBrowserRendering();
scope.onmessage = async (event) => { scope.onmessage = async (event) => {
const request = event.data; 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; if (request?.type !== "render") return;
const { job } = request; const { job } = request;
try { try {
@@ -32,9 +47,7 @@ scope.onmessage = async (event) => {
const t0 = performance.now(); const t0 = performance.now();
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs }); const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
const ms = Math.round(performance.now() - t0); 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 bytes = whole(out.bytes) ? out.bytes : out.bytes.slice();
const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength;
const bytes = whole ? out.bytes : out.bytes.slice();
post( post(
{ type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } }, { type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } },
[bytes.buffer], [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, 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"
}
}
+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, 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" } }),
];
}
+43
View File
@@ -353,3 +353,46 @@ body {
background-color: var(--cds-support-warning); background-color: var(--cds-support-warning);
color: #161616; color: #161616;
} }
/* ---------- export feedback ---------- */
.voiced-notice-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.voiced-export-split {
display: inline-flex;
align-items: stretch;
}
.voiced-diagnostics__check {
padding: 0.5rem 0;
border-bottom: 1px solid var(--cds-border-subtle-01);
}
.voiced-diagnostics__head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.voiced-diagnostics__label {
flex: 1 1 auto;
min-width: 0;
}
.voiced-diagnostics__ms {
color: var(--cds-text-secondary);
font-size: 0.75rem;
white-space: nowrap;
}
.voiced-diagnostics__detail {
margin: 0.25rem 0 0;
color: var(--cds-text-secondary);
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+17
View File
@@ -4,6 +4,7 @@ import {
Column, Column,
Grid, Grid,
InlineNotification, InlineNotification,
Link,
NumberInput, NumberInput,
RadioButton, RadioButton,
RadioButtonGroup, RadioButtonGroup,
@@ -32,6 +33,7 @@ import {
} from "../lib/types"; } from "../lib/types";
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators"; import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker"; import { ImagePicker } from "../components/ImagePicker";
import DiagnosticsPanel from "../components/DiagnosticsPanel";
const blankBank = (): BankAccount => ({ const blankBank = (): BankAccount => ({
id: null, id: null,
@@ -52,6 +54,7 @@ export default function AppSettings({
onSaved: () => void; onSaved: () => void;
}) { }) {
const [form, setForm] = useState<Settings>({ ...settings }); const [form, setForm] = useState<Settings>({ ...settings });
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [banks, setBanks] = useState<BankAccount[]>([]); const [banks, setBanks] = useState<BankAccount[]>([]);
const [editing, setEditing] = useState<BankAccount | null>(null); const [editing, setEditing] = useState<BankAccount | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -474,6 +477,20 @@ export default function AppSettings({
<Button renderIcon={Save} onClick={saveAll} disabled={busy}> <Button renderIcon={Save} onClick={saveAll} disabled={busy}>
{busy ? "Saving…" : "Save settings"} {busy ? "Saving…" : "Save settings"}
</Button> </Button>
<p className="voiced-muted" style={{ marginTop: "3rem", fontSize: "0.75rem" }}>
<Link
href="#"
size="sm"
onClick={(e: { preventDefault: () => void }) => {
e.preventDefault();
setDiagnosticsOpen(true);
}}
>
Diagnostics
</Link>
</p>
<DiagnosticsPanel open={diagnosticsOpen} onClose={() => setDiagnosticsOpen(false)} />
</div> </div>
); );
} }
+88 -19
View File
@@ -4,6 +4,8 @@ import {
InlineLoading, InlineLoading,
InlineNotification, InlineNotification,
Modal, Modal,
OverflowMenu,
OverflowMenuItem,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -14,10 +16,13 @@ import {
Tag, Tag,
TextInput, TextInput,
} from "@carbon/react"; } from "@carbon/react";
import { Download, Close } from "@carbon/icons-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format"; import { formatAmount, formatDate } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf"; import { createExportDeps } from "../lib/exportDeps";
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceSummary, Settings } from "../lib/types"; import type { InvoiceSummary, Settings } from "../lib/types";
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) { export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
@@ -27,6 +32,14 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
const [busyId, setBusyId] = useState<number | null>(null); const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null); const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
const [archived, setArchived] = useState<Record<number, boolean>>({});
const [notice, setNotice] = useState<string | null>(null);
const exp = useInvoiceExport({
settings,
bank: null,
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
});
const load = async () => { const load = async () => {
try { try {
@@ -44,12 +57,53 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
if (active) void load(); if (active) void load();
}, [active]); }, [active]);
const onExport = async (id: number) => { // Read each invoice's archive status in the background; it is a local call per row.
useEffect(() => {
let stop = false;
void (async () => {
for (const row of rows) {
if (stop) return;
try {
const status = await api.archiveStatus(row.id);
if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived }));
} catch {
// Unknown stays unknown: the row just shows no tag.
}
}
})();
return () => {
stop = true;
};
}, [rows]);
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
setBusyId(id); setBusyId(id);
setError(null); setError(null);
setNotice(null);
try {
// Fetched now, not at list time: the row summary does not carry the archive fields.
const invoice = await api.getInvoice(id);
await exp.start(invoice, mode, source);
} catch (e) {
setError(String(e));
} finally {
setBusyId(null);
}
};
const onArchive = async (id: number) => {
setBusyId(id);
setError(null);
setNotice(null);
try { try {
const invoice = await api.getInvoice(id); const invoice = await api.getInvoice(id);
await exportInvoicePdf(invoice, settings, null); const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) {
setArchived((a) => ({ ...a, [id]: true }));
setNotice(`Invoice ${invoice.number} is archived.`);
} else {
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
}
} catch (e) { } catch (e) {
setError(String(e)); setError(String(e));
} finally { } finally {
@@ -79,6 +133,9 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} /> <InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null} ) : null}
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
<ExportFeedback exp={exp} />
{loading ? ( {loading ? (
<InlineLoading description="Loading invoices" /> <InlineLoading description="Loading invoices" />
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
@@ -104,6 +161,7 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
<TableCell>{row.clientName || "—"}</TableCell> <TableCell>{row.clientName || "—"}</TableCell>
<TableCell>₹{formatAmount(row.total)}</TableCell> <TableCell>₹{formatAmount(row.total)}</TableCell>
<TableCell> <TableCell>
<div className="voiced-header-action">
{row.status === "cancelled" ? ( {row.status === "cancelled" ? (
<Tag type="red" size="sm"> <Tag type="red" size="sm">
Cancelled Cancelled
@@ -111,31 +169,42 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
) : ( ) : (
row.status row.status
)} )}
{archived[row.id] === false ? (
<Tag type="warm-gray" size="sm">
Not archived
</Tag>
) : null}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="voiced-header-action"> <div className="voiced-header-action">
<Button {archived[row.id] === false ? (
kind="ghost" <Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
size="sm" {busyId === row.id ? "Working…" : "Render & archive"}
renderIcon={Download}
onClick={() => onExport(row.id)}
disabled={busyId === row.id}
>
{busyId === row.id ? "Exporting…" : "PDF"}
</Button> </Button>
{row.status === "issued" ? ( ) : null}
<Button <OverflowMenu
kind="ghost" aria-label={`Actions for invoice ${row.number}`}
iconDescription="Actions"
flipped
size="sm" size="sm"
renderIcon={Close} disabled={busyId !== null || exp.busy}
>
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
<OverflowMenuItem itemText="Re-render with current template" onClick={() => void onExport(row.id, "searchable", "rerender")} />
{row.status === "issued" ? (
<OverflowMenuItem
itemText="Cancel invoice"
hasDivider
isDelete
onClick={() => { onClick={() => {
setReason(""); setReason("");
setCancelTarget(row); setCancelTarget(row);
}} }}
> />
Cancel
</Button>
) : null} ) : null}
</OverflowMenu>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
+66 -62
View File
@@ -28,7 +28,6 @@ import {
DocumentAdd, DocumentAdd,
Download, Download,
Keyboard, Keyboard,
Renew,
Save, Save,
TrashCan, TrashCan,
} from "@carbon/icons-react"; } from "@carbon/icons-react";
@@ -51,12 +50,19 @@ import { computeLineAmount, computeTotals } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords"; import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators"; import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format"; import { addDays, formatAmount, todayIso } from "../lib/format";
import { exportInvoicePdf, modelFromAssets, prefsForInvoice } from "../lib/pdf"; import { modelFromAssets, prefsForInvoice } from "../lib/pdf";
import { formToPreviewInvoice } from "../lib/previewInvoice"; import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft"; import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts"; import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls"; import PageSetupControls from "../components/PageSetupControls";
import { useInvoicePdf } from "../hooks/useInvoicePdf"; import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps";
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
import { loadExportMode } from "../lib/exportMode";
import type { ExportMode } from "../lib/filename";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs"; import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { ImagePicker } from "../components/ImagePicker"; import { ImagePicker } from "../components/ImagePicker";
@@ -138,10 +144,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
// Issued invoice shown read-only after a successful issue. Its number is consumed, so the // Issued invoice shown read-only after a successful issue. Its number is consumed, so the
// form never issues again until the user starts a new one. // form never issues again until the user starts a new one.
const [issued, setIssued] = useState<Invoice | null>(null); const [issued, setIssued] = useState<Invoice | null>(null);
const [exporting, setExporting] = useState(false); // What the backend recorded when the issued PDF was archived; null until that worked.
const [exportedPath, setExportedPath] = useState<string | null>(null); const [archive, setArchive] = useState<{ sha256: string; fingerprint: string } | null>(null);
const [exportError, setExportError] = useState<string | null>(null); const [archiveError, setArchiveError] = useState<string | null>(null);
const [archiving, setArchiving] = useState(false);
const [exportMode, setExportMode] = useState<ExportMode>(loadExportMode);
const locked = issued !== null; const locked = issued !== null;
// The issued invoice as the export flow needs it: with its archive fields once archived.
const exportable = useMemo<Invoice | null>(
() => (issued ? { ...issued, archivedPdfSha256: archive?.sha256 ?? issued.archivedPdfSha256, archivedFingerprint: archive?.fingerprint ?? issued.archivedFingerprint ?? null } : null),
[issued, archive],
);
// Drafts. // Drafts.
const [draftId, setDraftId] = useState<number | null>(null); const [draftId, setDraftId] = useState<number | null>(null);
@@ -392,8 +405,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
const clearIssued = () => { const clearIssued = () => {
issuedRef.current = false; issuedRef.current = false;
setIssued(null); setIssued(null);
setExportedPath(null); setArchive(null);
setExportError(null); setArchiveError(null);
exp.dismiss();
setError(null); setError(null);
}; };
@@ -517,19 +531,24 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
await refreshDrafts(); await refreshDrafts();
}; };
const runExport = async (invoice: Invoice) => { const noteArchived = (invoice: Invoice) => {
setExporting(true); if (invoice.archivedPdfSha256) {
setExportError(null); setArchive({ sha256: invoice.archivedPdfSha256, fingerprint: invoice.archivedFingerprint ?? "" });
try { setArchiveError(null);
const path = await exportInvoicePdf(invoice, settings, bank);
setExportedPath(path ?? null);
} catch (e) {
setExportError(String(e));
} finally {
setExporting(false);
} }
}; };
const exp = useInvoiceExport({ settings, bank, onInvoiceUpdated: noteArchived });
const retryArchive = async () => {
if (!exportable || archiving) return;
setArchiving(true);
const outcome = await renderAndArchive(exportable, createExportDeps({ settings, bank }));
if (outcome.archived) noteArchived(outcome.invoice);
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
setArchiving(false);
};
const onSave = async () => { const onSave = async () => {
if (locked || saving) return; if (locked || saving) return;
if (clientGstinError) { if (clientGstinError) {
@@ -538,9 +557,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
} }
setSaving(true); setSaving(true);
setError(null); setError(null);
setExportedPath(null); exp.dismiss();
setExportError(null); const mode = loadExportMode();
let invoice: Invoice; let outcome: Awaited<ReturnType<typeof issueAndArchive>>;
try { try {
// Let a pending autosave land first so the issue call knows which draft to delete. // Let a pending autosave land first so the issue call knows which draft to delete.
await chain.current; await chain.current;
@@ -565,19 +584,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
draftId: draftIdRef.current, draftId: draftIdRef.current,
items: items.map((item, index) => ({ ...item, sortOrder: index })), items: items.map((item, index) => ({ ...item, sortOrder: index })),
}; };
invoice = await api.issueInvoice(input, activePrefs); outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
} catch (e) { } catch (e) {
issuedRef.current = false; issuedRef.current = false;
setError(String(e)); setError(String(e));
setSaving(false); setSaving(false);
return; return;
} }
// The number is consumed from here on. A failed export must never lead to a second issue. // The number is consumed from here on. A failed archive or export must never lead to a second issue.
setIssued(invoice); setIssued(outcome.invoice);
if (outcome.archived) noteArchived(outcome.invoice);
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
detachDraft(); detachDraft();
api.listClients().then(setClients).catch(() => {}); api.listClients().then(setClients).catch(() => {});
void refreshDrafts(); void refreshDrafts();
await runExport(invoice); // Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works.
await exp.start(outcome.invoice, mode, "original");
setSaving(false); setSaving(false);
}; };
@@ -590,7 +612,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
}); });
const otherDrafts = drafts.filter((d) => d.id !== draftId); const otherDrafts = drafts.filter((d) => d.id !== draftId);
const busy = saving || exporting; const busy = saving || exp.busy || archiving;
// Yield the shared render worker to the export; the model changes back when it is done and re-renders. // Yield the shared render worker to the export; the model changes back when it is done and re-renders.
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs }); const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
@@ -637,9 +659,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}> <Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}>
Save draft Save draft
</Button> </Button>
<Button renderIcon={Download} onClick={onSave} disabled={busy}> <div className="voiced-export-split">
{saving ? "Saving…" : "Save & export PDF"} <Button renderIcon={Download} onClick={onSave} disabled={busy} title={`Issues the invoice and exports the ${exportMode} PDF`}>
{saving ? "Issuing…" : "Issue & export"}
</Button> </Button>
<ExportModePicker mode={exportMode} onChange={setExportMode} disabled={busy} />
</div>
</div> </div>
)} )}
</div> </div>
@@ -673,48 +698,27 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onCloseButtonClick={() => setNoticeOpen(false)} onCloseButtonClick={() => setNoticeOpen(false)}
/> />
) : null} ) : null}
{locked ? ( {locked && exportable ? (
<> <>
{exportError ? ( {!exportable.archivedPdfSha256 ? (
<InlineNotification <ActionableNotification
kind="error" inline
kind="warning"
lowContrast lowContrast
hideCloseButton hideCloseButton
title={`Invoice ${issued.number} was issued, but the PDF export failed`} title="Issued — PDF not archived yet"
subtitle={`${exportError} The number is already used; retry the export, do not issue again.`} subtitle={`${archiveError ?? "The PDF was not archived."} The invoice is safe; it is not issued again. You can still export.`}
actionButtonLabel={archiving ? "Archiving…" : "Render & archive"}
onActionButtonClick={() => void retryArchive()}
/> />
) : exportedPath ? ( ) : null}
<InlineNotification <ExportFeedback exp={exp} />
kind="success"
lowContrast
hideCloseButton
title={`Invoice ${issued.number} issued`}
subtitle={exportedPath}
/>
) : exporting ? (
<InlineLoading description="Exporting PDF…" />
) : (
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title={`Invoice ${issued.number} issued`}
subtitle="The PDF was not saved. Use Export again to save it."
/>
)}
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}> <div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<Button <ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
kind={exportError ? "primary" : "tertiary"} <Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>
renderIcon={exportError ? Renew : Download}
onClick={() => void runExport(issued)}
disabled={exporting}
>
{exporting ? "Exporting…" : exportError ? "Retry export" : "Export again"}
</Button>
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={exporting}>
Duplicate as new Duplicate as new
</Button> </Button>
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={exporting}> <Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={busy}>
New invoice New invoice
</Button> </Button>
</div> </div>