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
+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 { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
@@ -57,6 +56,11 @@ export async function renderInvoicePdf(
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
): Promise<RenderResult> {
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.
for (let attempt = 0; ; attempt++) {
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,
};
}