Add searchable and flattened export, history re-export and diagnostics self-test
issueAndArchive keeps an issued invoice even when rendering or archiving fails (Issued - not archived, with a Render & archive action). The export menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is the invoice number alone; flatten failures offer Retry or searchable export and never silently lower the resolution. History re-exports the original as issued by default and warns when a re-render's layout fingerprint differs. The render client gains a FIFO assemble lane that previews cannot supersede. A Diagnostics panel runs fixtures in the real worker and compares against goldens generated from Node.
This commit is contained in:
@@ -0,0 +1,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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user