From f1b7502e9d12c4909b00603278dff035f2b9f0a1 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 06:11:23 +0530 Subject: [PATCH] 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. --- src/components/DiagnosticsPanel.tsx | 115 ++++++++ src/components/ExportButton.tsx | 49 ++++ src/components/ExportFeedback.tsx | 84 ++++++ src/components/FlattenProgressModal.tsx | 64 +++++ src/hooks/useInvoiceExport.ts | 94 +++++++ src/lib/browserFlatten.ts | 57 ++++ src/lib/exportDeps.ts | 25 ++ src/lib/exportFlow.test.ts | 223 +++++++++++++++ src/lib/exportFlow.ts | 219 +++++++++++++++ src/lib/exportMode.ts | 27 ++ src/lib/pdf.tsx | 28 +- src/lib/selfTest.test.ts | 87 ++++++ src/lib/selfTest.ts | 289 +++++++++++++++++++ src/lib/selfTestBrowser.ts | 57 ++++ src/pdf/assemble.ts | 37 +++ src/pdf/flatten.test.ts | 352 ++++++++++++++++++++++++ src/pdf/flatten.ts | 157 +++++++++++ src/pdf/render/assembleJob.ts | 10 + src/pdf/render/client.test.ts | 152 +++++++++- src/pdf/render/client.ts | 146 +++++++--- src/pdf/render/jobs.ts | 23 +- src/pdf/render/render.worker.ts | 19 +- src/pdf/testing/goldens.json | 26 ++ src/pdf/testing/goldens.test.ts | 70 +++++ src/pdf/testing/goldens.ts | 15 + src/pdf/testing/selfTestFixtures.ts | 135 +++++++++ src/styles/carbon.scss | 43 +++ src/views/AppSettings.tsx | 17 ++ src/views/InvoiceHistory.tsx | 131 ++++++--- src/views/NewInvoice.tsx | 130 ++++----- 30 files changed, 2727 insertions(+), 154 deletions(-) create mode 100644 src/components/DiagnosticsPanel.tsx create mode 100644 src/components/ExportButton.tsx create mode 100644 src/components/ExportFeedback.tsx create mode 100644 src/components/FlattenProgressModal.tsx create mode 100644 src/hooks/useInvoiceExport.ts create mode 100644 src/lib/browserFlatten.ts create mode 100644 src/lib/exportDeps.ts create mode 100644 src/lib/exportFlow.test.ts create mode 100644 src/lib/exportFlow.ts create mode 100644 src/lib/exportMode.ts create mode 100644 src/lib/selfTest.test.ts create mode 100644 src/lib/selfTest.ts create mode 100644 src/lib/selfTestBrowser.ts create mode 100644 src/pdf/assemble.ts create mode 100644 src/pdf/flatten.test.ts create mode 100644 src/pdf/flatten.ts create mode 100644 src/pdf/render/assembleJob.ts create mode 100644 src/pdf/testing/goldens.json create mode 100644 src/pdf/testing/goldens.test.ts create mode 100644 src/pdf/testing/goldens.ts create mode 100644 src/pdf/testing/selfTestFixtures.ts diff --git a/src/components/DiagnosticsPanel.tsx b/src/components/DiagnosticsPanel.tsx new file mode 100644 index 0000000..422721a --- /dev/null +++ b/src/components/DiagnosticsPanel.tsx @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, ComposedModal, InlineLoading, InlineNotification, ModalBody, ModalFooter, ModalHeader, Tag } from "@carbon/react"; +import { reportToText, runSelfTest, type Check, type SelfTestReport } from "../lib/selfTest"; + +const STATUS_TAG: Record = { + pass: { type: "green", text: "Pass" }, + fail: { type: "red", text: "Fail" }, + info: { type: "gray", text: "Info" }, +}; + +/** Renders the fixtures in this webview's real render client and reports what it found. */ +export default function DiagnosticsPanel({ open, onClose }: { open: boolean; onClose: () => void }) { + const [report, setReport] = useState(null); + const [fatal, setFatal] = useState(null); + const [copied, setCopied] = useState<"ok" | "failed" | null>(null); + const runId = useRef(0); + + const run = useCallback(async () => { + const id = ++runId.current; + setReport(null); + setFatal(null); + setCopied(null); + try { + // Loaded on demand: the fixtures, goldens and pdf.js are only needed here. + const { browserSelfTestDeps } = await import("../lib/selfTestBrowser"); + await runSelfTest(browserSelfTestDeps(), (r) => { + if (runId.current === id) setReport(r); + }); + } catch (e) { + if (runId.current === id) setFatal(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + if (open) void run(); + else runId.current++; + }, [open, run]); + + const copy = async () => { + if (!report) return; + try { + await navigator.clipboard.writeText(reportToText(report)); + setCopied("ok"); + } catch { + setCopied("failed"); + } + }; + + const running = open && !fatal && !report?.done; + + return ( + + + +

+ Renders two sample invoices in this app's own renderer, reads them back and compares them with the reference layouts. Nothing is saved or sent. +

+ {fatal ? : null} + {report && report.done ? ( + + ) : null} + {report?.sections.map((section) => ( +
+

{section.title}

+ {section.checks.map((check, i) => ( +
+
+ + {STATUS_TAG[check.status].text} + + {check.label} + {check.ms !== undefined ? {check.ms} ms : null} +
+ {check.detail ?

{check.detail}

: null} +
+ ))} +
+ ))} + {running ? ( +
+ +
+ ) : null} + {copied ? ( +
+ +
+ ) : null} +
+ + + + + +
+ ); +} diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx new file mode 100644 index 0000000..9dc121b --- /dev/null +++ b/src/components/ExportButton.tsx @@ -0,0 +1,49 @@ +import { MenuButton, MenuItem, OverflowMenu, OverflowMenuItem } from "@carbon/react"; +import { ChevronDown } from "@carbon/icons-react"; +import type { ExportMode } from "../lib/filename"; +import { EXPORT_MODE_LABEL, saveExportMode } from "../lib/exportMode"; + +const MODES: ExportMode[] = ["searchable", "flattened"]; + +/** "Export PDF" menu for an issued invoice. Choosing a mode also makes it the remembered one. */ +export default function ExportButton({ onExport, disabled }: { onExport: (mode: ExportMode) => void; disabled?: boolean }) { + return ( + + {MODES.map((mode) => ( + { + saveExportMode(mode); + onExport(mode); + }} + /> + ))} + + ); +} + +/** The format the primary "Issue & export" action uses; a small menu next to that button. */ +export function ExportModePicker({ mode, onChange, disabled }: { mode: ExportMode; onChange: (mode: ExportMode) => void; disabled?: boolean }) { + return ( + + {MODES.map((m) => ( + { + saveExportMode(m); + onChange(m); + }} + /> + ))} + + ); +} diff --git a/src/components/ExportFeedback.tsx b/src/components/ExportFeedback.tsx new file mode 100644 index 0000000..5bd3caf --- /dev/null +++ b/src/components/ExportFeedback.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Button, InlineLoading, InlineNotification } from "@carbon/react"; +import { api } from "../lib/api"; +import type { InvoiceExport } from "../hooks/useInvoiceExport"; +import FlattenProgressModal from "./FlattenProgressModal"; + +/** Progress modal, spinner, success and error notices for one `useInvoiceExport`. */ +export default function ExportFeedback({ exp }: { exp: InvoiceExport }) { + const { busy, progress, request, error, result } = exp; + const [actionError, setActionError] = useState(null); + useEffect(() => setActionError(null), [result]); + + const flattenFailed = request?.mode === "flattened" && error?.flatten === true; + const modalOpen = request?.mode === "flattened" && ((busy && progress?.phase !== "saving") || flattenFailed); + + const run = async (action: () => Promise) => { + setActionError(null); + try { + await action(); + } catch (e) { + setActionError(String(e)); + } + }; + + return ( + <> + + + {busy && !modalOpen ? : null} + + {error && !flattenFailed ? ( + +
+ +
+
+ ) : null} + + {result ? ( + <> + +
+ + +
+
+ {result.identicalToIssued === false ? ( + + ) : null} + {result.archiveError ? ( + + ) : null} + {actionError ? setActionError(null)} /> : null} + + ) : null} + + ); +} diff --git a/src/components/FlattenProgressModal.tsx b/src/components/FlattenProgressModal.tsx new file mode 100644 index 0000000..79fb6f6 --- /dev/null +++ b/src/components/FlattenProgressModal.tsx @@ -0,0 +1,64 @@ +import { Button, ComposedModal, InlineNotification, ModalBody, ModalFooter, ModalHeader, ProgressBar } from "@carbon/react"; +import type { ExportProgress } from "../lib/exportFlow"; + +export interface FlattenProgressModalProps { + open: boolean; + progress: ExportProgress | null; + /** The flatten failed: show it with Retry and "Export searchable instead". */ + error: string | null; + onCancel: () => void; + onRetry: () => void; + onExportSearchable: () => void; + onClose: () => void; +} + +function describe(p: ExportProgress | null): { label: string; value?: number; max: number } { + if (p?.phase === "flattening" && p.page && p.pages) { + // The page being rendered counts as in progress, so the bar shows the pages finished before it. + return { label: `Rendering page ${p.page} of ${p.pages}…`, value: p.page - 1, max: p.pages }; + } + if (p?.phase === "assembling") return { label: "Building the PDF…", value: 1, max: 1 }; + return { label: "Preparing the invoice…", max: 1 }; +} + +export default function FlattenProgressModal({ open, progress, error, onCancel, onRetry, onExportSearchable, onClose }: FlattenProgressModalProps) { + const { label, value, max } = describe(progress); + return ( + (error ? onClose() : onCancel())} aria-label="Flattened PDF export"> + + + {error ? ( + + ) : ( + + )} + + {error ? ( + // Three direct children: Carbon lays a three-button footer out differently from a two-button one. + + + + + + ) : ( + + + + )} + + ); +} diff --git a/src/hooks/useInvoiceExport.ts b/src/hooks/useInvoiceExport.ts new file mode 100644 index 0000000..c848d5b --- /dev/null +++ b/src/hooks/useInvoiceExport.ts @@ -0,0 +1,94 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { createExportDeps } from "../lib/exportDeps"; +import { exportInvoice, isExportCancelled, isFlattenFailure, type ExportProgress, type ExportResult, type ExportSource } from "../lib/exportFlow"; +import type { ExportMode } from "../lib/filename"; +import type { BankAccount, Invoice, Settings } from "../lib/types"; + +export interface ExportRequestState { + invoice: Invoice; + mode: ExportMode; + source: ExportSource; +} + +export interface ExportFailure { + message: string; + /** The flatten itself failed: the modal offers Retry and "Export searchable instead". */ + flatten: boolean; +} + +export interface InvoiceExport { + busy: boolean; + progress: ExportProgress | null; + request: ExportRequestState | null; + error: ExportFailure | null; + result: ExportResult | null; + start(invoice: Invoice, mode: ExportMode, source: ExportSource): Promise; + cancel(): void; + retry(): void; + exportSearchableInstead(): void; + dismiss(): void; +} + +/** + * Runs exports one at a time and keeps what the UI shows: progress, the last result, the last error and + * the request, so Retry and "Export searchable instead" can repeat it. `onInvoiceUpdated` hears about an + * invoice that was archived by the export. + */ +export function useInvoiceExport(opts: { settings: Settings; bank: BankAccount | null; onInvoiceUpdated?: (invoice: Invoice) => void }): InvoiceExport { + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState(null); + const [request, setRequest] = useState(null); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const controller = useRef(null); + const running = useRef(false); + + const latest = useRef(opts); + latest.current = opts; + + const start = useCallback(async (invoice: Invoice, mode: ExportMode, source: ExportSource) => { + if (running.current) return null; + running.current = true; + const ctl = new AbortController(); + controller.current = ctl; + setRequest({ invoice, mode, source }); + setBusy(true); + setError(null); + setResult(null); + setProgress({ phase: "rendering" }); + try { + const deps = createExportDeps({ settings: latest.current.settings, bank: latest.current.bank }); + const res = await exportInvoice({ invoice, mode, source, signal: ctl.signal, onProgress: setProgress }, deps); + if (res) { + setResult(res); + if (res.invoice !== invoice) latest.current.onInvoiceUpdated?.(res.invoice); + } + return res; + } catch (e) { + if (!isExportCancelled(e)) setError({ message: e instanceof Error ? e.message : String(e), flatten: isFlattenFailure(e) }); + return null; + } finally { + running.current = false; + controller.current = null; + setBusy(false); + setProgress(null); + } + }, []); + + const cancel = useCallback(() => controller.current?.abort(), []); + const dismiss = useCallback(() => { + setError(null); + setResult(null); + }, []); + const retry = useCallback(() => { + if (request) void start(request.invoice, request.mode, request.source); + }, [request, start]); + const exportSearchableInstead = useCallback(() => { + if (request) void start(request.invoice, "searchable", request.source); + }, [request, start]); + + return useMemo( + () => ({ busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss }), + [busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss], + ); +} diff --git a/src/lib/browserFlatten.ts b/src/lib/browserFlatten.ts new file mode 100644 index 0000000..4ea6601 --- /dev/null +++ b/src/lib/browserFlatten.ts @@ -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 => + new Promise((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 { + 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 { + 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(() => {}); + } +} diff --git a/src/lib/exportDeps.ts b/src/lib/exportDeps.ts new file mode 100644 index 0000000..8ace118 --- /dev/null +++ b/src/lib/exportDeps.ts @@ -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, + }; +} diff --git a/src/lib/exportFlow.test.ts b/src/lib/exportFlow.test.ts new file mode 100644 index 0000000..939a301 --- /dev/null +++ b/src/lib/exportFlow.test.ts @@ -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 => + ({ + id: 7, + number: "INV/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 = {}) { + 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).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/INV-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).mock.calls[0][0]).toEqual(bytes("ARCHIVED")); + expect(text(written[0].bytes)).toBe("FLAT[INV/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\\Sample\\Documents") }); + await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, win.deps); + expect((win.deps.save as ReturnType).mock.calls[0][0].defaultPath).toBe("C:\\Users\\Sample\\Documents\\INV-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).mock.calls[0][0].defaultPath).toBe("INV-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" }); + }); +}); diff --git a/src/lib/exportFlow.ts b/src/lib/exportFlow.ts new file mode 100644 index 0000000..07434fc --- /dev/null +++ b/src/lib/exportFlow.ts @@ -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; + /** 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; + readArchive(invoiceId: number): Promise; + /** Rasterises PDF bytes page by page. Throws FlattenError / FlattenCancelled. */ + flatten(bytes: Uint8Array, opts: { signal?: AbortSignal; onProgress?: (page: number, pages: number) => void }): Promise; + assemble(pages: AssemblePageData[], title: string): Promise; + getLastExportDir(): Promise; + /** The save dialog: the chosen path, or null when it is dismissed. */ + save(opts: { defaultPath: string }): Promise; + writeExportFile(path: string, bytes: Uint8Array): Promise; +} + +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): Promise { + 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, +): Promise { + 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 { + 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) { + try { + return await deps.render(invoice); + } catch (e) { + throw new ExportError("RENDER_FAILED", `The PDF could not be rendered: ${messageOf(e)}`, e); + } +} diff --git a/src/lib/exportMode.ts b/src/lib/exportMode.ts new file mode 100644 index 0000000..15003f9 --- /dev/null +++ b/src/lib/exportMode.ts @@ -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 = { + searchable: "Searchable PDF (copy-pasteable text)", + flattened: "Flattened PDF (image only)", +}; diff --git a/src/lib/pdf.tsx b/src/lib/pdf.tsx index 9dfba74..9d2df3b 100644 --- a/src/lib/pdf.tsx +++ b/src/lib/pdf.tsx @@ -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 { 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 { // 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 { - 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 { - const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs); - return savePdfBytes(bytes, invoice.number); -} diff --git a/src/lib/selfTest.test.ts b/src/lib/selfTest.test.ts new file mode 100644 index 0000000..3bf5b0a --- /dev/null +++ b/src/lib/selfTest.test.ts @@ -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 { + 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 }).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"); + }); +}); diff --git a/src/lib/selfTest.ts b/src/lib/selfTest.ts new file mode 100644 index 0000000..2bc7a8e --- /dev/null +++ b/src/lib/selfTest.ts @@ -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; + close(): Promise; +} + +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; + openPdf(bytes: Uint8Array): Promise; + createCanvas(width: number, height: number): CanvasLike; + encodeJpeg(canvas: CanvasLike, quality: number): Promise; + /** Wraps JPEG pages into an image-only PDF through the real client. */ + assemble(pages: AssemblePageData[], title: string): Promise; + /** Starts watching frame gaps; the returned function stops it and gives the longest gap in ms. */ + startFrameMonitor(): () => number; + yieldToUi?: () => Promise; + 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 { + 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 }; + }; + 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 { + 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 { + 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>) => { + 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(); + 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[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"); +} diff --git a/src/lib/selfTestBrowser.ts b/src/lib/selfTestBrowser.ts new file mode 100644 index 0000000..c0df786 --- /dev/null +++ b/src/lib/selfTestBrowser.ts @@ -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, + }; +} diff --git a/src/pdf/assemble.ts b/src/pdf/assemble.ts new file mode 100644 index 0000000..e1d90c5 --- /dev/null +++ b/src/pdf/assemble.ts @@ -0,0 +1,37 @@ +import { createElement as h, type ReactElement } from "react"; +import { Document, Image, Page } from "@react-pdf/renderer"; + +export interface AssemblePage { + /** A baseline JPEG; react-pdf embeds it as is (DCTDecode passthrough, no re-encoding). */ + jpeg: Uint8Array; + widthPt: number; + heightPt: number; +} + +export interface AssembleInput { + pages: AssemblePage[]; + /** The only metadata written: a flattened file must not leak the client name or GSTINs. */ + title: string; +} + +/** + * An image-only document: one full-bleed JPEG per page. No author, subject or keywords, and the + * title is whatever the caller passes (the invoice number). The images go in as untyped Blobs because + * that is the form react-pdf's browser build resolves without sniffing the type. + */ +export function assembleImagePdf({ pages, title }: AssembleInput): ReactElement { + return h( + Document, + { title }, + ...pages.map((p, i) => + h( + Page, + { key: i, size: [p.widthPt, p.heightPt] as [number, number], style: { padding: 0 }, wrap: false }, + h(Image, { + src: new Blob([p.jpeg as BlobPart]) as never, + style: { width: p.widthPt, height: p.heightPt }, + }), + ), + ), + ); +} diff --git a/src/pdf/flatten.test.ts b/src/pdf/flatten.test.ts new file mode 100644 index 0000000..0ed3c91 --- /dev/null +++ b/src/pdf/flatten.test.ts @@ -0,0 +1,352 @@ +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { describe, expect, it } from "vitest"; +import { flattenPages, FlattenCancelled, FlattenError, type CanvasLike, type PageLike, type PdfDocLike } from "./flatten"; +import { runAssembleJob } from "./render/assembleJob"; +import { FIXTURE_GSTIN, ensureSetup, buildFixture, prefsFor, renderFixture } from "./templates/harness"; +import { loadCanvas } from "./testing/rasterize"; + +// --- unit tests with fakes: no native canvas needed -------------------------------------------------- + +interface FakeCanvas extends CanvasLike { + fills: Array<[number, number, number, number]>; + fillStyle: unknown; +} + +function fakeDoc(sizes: Array<[number, number]>, opts: { renderDelay?: () => Promise; failRender?: number } = {}) { + const log: string[] = []; + const cancelled: number[] = []; + const pdf: PdfDocLike = { + numPages: sizes.length, + async getPage(n) { + const [w, h] = sizes[n - 1]; + const page: PageLike = { + getViewport: ({ scale }) => ({ width: w * scale, height: h * scale }), + render: () => { + log.push(`render ${n}`); + return { + promise: (async () => { + await opts.renderDelay?.(); + if (opts.failRender === n) throw new Error("out of memory"); + })(), + cancel: () => cancelled.push(n), + }; + }, + cleanup: () => void log.push(`cleanup ${n}`), + }; + return page; + }, + }; + return { pdf, log, cancelled }; +} + +function fakeCanvasFactory() { + const live = new Set(); + const made: Array<[number, number]> = []; + let maxLive = 0; + const createCanvas = (w: number, h: number): FakeCanvas => { + made.push([w, h]); + const c: FakeCanvas = { + width: w, + height: h, + fills: [], + fillStyle: "", + getContext() { + return { set fillStyle(v: unknown) { c.fillStyle = v; }, fillRect: (x: number, y: number, ww: number, hh: number) => c.fills.push([x, y, ww, hh]) }; + }, + }; + live.add(c); + maxLive = Math.max(maxLive, live.size); + return c; + }; + // A canvas counts as freed once its size is zeroed. + const sweep = () => { + for (const c of live) if (c.width === 0 && c.height === 0) live.delete(c); + }; + return { createCanvas, made, live, sweep, maxLive: () => maxLive }; +} + +const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); +const A4: [number, number] = [595.28, 841.89]; + +describe("flattenPages (fakes)", () => { + it("renders every page at 300 DPI on white, one canvas at a time, and reports progress", async () => { + const { pdf } = fakeDoc([A4, A4, [612, 792]]); + const f = fakeCanvasFactory(); + const progress: Array<[number, number]> = []; + let yields = 0; + const out = await flattenPages({ + pdf, + createCanvas: (w, h) => { + f.sweep(); // by the time the next canvas is requested the previous one must be released + expect(f.live.size).toBe(0); + return f.createCanvas(w, h); + }, + encodeJpeg: async () => jpegBytes, + onProgress: (p, n) => progress.push([p, n]), + yieldToUi: async () => void yields++, + }); + expect(f.made).toEqual([ + [Math.ceil((595.28 * 300) / 72), Math.ceil((841.89 * 300) / 72)], + [Math.ceil((595.28 * 300) / 72), Math.ceil((841.89 * 300) / 72)], + [2550, 3300], + ]); + expect(f.made[0][0]).toBeGreaterThanOrEqual(2480); + expect(f.maxLive()).toBe(1); + expect(progress).toEqual([[1, 3], [2, 3], [3, 3]]); + expect(yields).toBe(3); + expect(out.pages.map((p) => [p.widthPt, p.heightPt])).toEqual([A4, A4, [612, 792]]); + expect(out.pages[2]).toMatchObject({ pxWidth: 2550, pxHeight: 3300 }); + }); + + it("paints a white background before the page", async () => { + const { pdf } = fakeDoc([[100, 100]]); + const canvases: FakeCanvas[] = []; + await flattenPages({ + pdf, + createCanvas: (w, h) => { + const f = fakeCanvasFactory().createCanvas(w, h); + canvases.push(f); + return f; + }, + encodeJpeg: async () => jpegBytes, + }); + expect(canvases[0].fillStyle).toBe("#ffffff"); + expect(canvases[0].fills).toEqual([[0, 0, 417, 417]]); + }); + + it("passes the quality and the DPI through, with no silent downgrade", async () => { + const { pdf } = fakeDoc([[72, 72]]); + const qualities: number[] = []; + const sizes: Array<[number, number]> = []; + await flattenPages({ + pdf, + dpi: 150, + quality: 0.8, + createCanvas: (w, h) => (sizes.push([w, h]), fakeCanvasFactory().createCanvas(w, h)), + encodeJpeg: async (_c, q) => (qualities.push(q), jpegBytes), + }); + expect(sizes).toEqual([[150, 150]]); + expect(qualities).toEqual([0.8]); + }); + + it("a canvas failure is a FlattenError naming the page and reason, with no partial output", async () => { + const { pdf } = fakeDoc([A4, A4]); + let n = 0; + const err = await flattenPages({ + pdf, + createCanvas: (w, h) => { + if (++n === 2) throw new Error("Canvas area exceeds the maximum limit"); + return fakeCanvasFactory().createCanvas(w, h); + }, + encodeJpeg: async () => jpegBytes, + }).catch((e) => e); + expect(err).toBeInstanceOf(FlattenError); + expect(err).toMatchObject({ page: 2, code: "FLATTEN_FAILED", reason: "Canvas area exceeds the maximum limit" }); + expect(err.message).toContain("page 2"); + expect(n).toBe(2); // it did not retry at a lower resolution + }); + + it("render and encode failures are FlattenErrors too, and the canvas is still released", async () => { + const f = fakeCanvasFactory(); + const a = await flattenPages({ pdf: fakeDoc([A4], { failRender: 1 }).pdf, createCanvas: f.createCanvas, encodeJpeg: async () => jpegBytes }).catch((e) => e); + expect(a).toMatchObject({ name: "FlattenError", page: 1, reason: "out of memory" }); + const b = await flattenPages({ + pdf: fakeDoc([A4]).pdf, + createCanvas: f.createCanvas, + encodeJpeg: async () => { + throw new Error("toBlob returned null"); + }, + }).catch((e) => e); + expect(b).toMatchObject({ name: "FlattenError", page: 1, reason: "toBlob returned null" }); + f.sweep(); + expect(f.live.size).toBe(0); + }); + + it("rejects an empty document and an empty encode", async () => { + const f = fakeCanvasFactory(); + expect(await flattenPages({ pdf: { numPages: 0, getPage: async () => { throw new Error("x"); } }, createCanvas: f.createCanvas, encodeJpeg: async () => jpegBytes }).catch((e) => e)).toBeInstanceOf(FlattenError); + expect(await flattenPages({ pdf: fakeDoc([A4]).pdf, createCanvas: f.createCanvas, encodeJpeg: async () => new Uint8Array() }).catch((e) => e)).toBeInstanceOf(FlattenError); + }); + + it("an already-aborted signal throws FlattenCancelled before any work", async () => { + const ctl = new AbortController(); + ctl.abort(); + let made = 0; + await expect( + flattenPages({ pdf: fakeDoc([A4]).pdf, signal: ctl.signal, createCanvas: (w, h) => (made++, fakeCanvasFactory().createCanvas(w, h)), encodeJpeg: async () => jpegBytes }), + ).rejects.toBeInstanceOf(FlattenCancelled); + expect(made).toBe(0); + }); + + it("aborting between pages stops there; aborting during a render cancels the render task", async () => { + const ctl = new AbortController(); + const f = fakeCanvasFactory(); + await expect( + flattenPages({ + pdf: fakeDoc([A4, A4, A4]).pdf, + signal: ctl.signal, + createCanvas: f.createCanvas, + encodeJpeg: async () => jpegBytes, + onProgress: (page) => { + if (page === 2) ctl.abort(); + }, + }), + ).rejects.toBeInstanceOf(FlattenCancelled); + expect(f.made).toHaveLength(1); + + const ctl2 = new AbortController(); + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const doc = fakeDoc([A4], { renderDelay: () => gate }); + const run = flattenPages({ pdf: doc.pdf, signal: ctl2.signal, createCanvas: fakeCanvasFactory().createCanvas, encodeJpeg: async () => jpegBytes }); + await new Promise((r) => setTimeout(r, 10)); + ctl2.abort(); + expect(doc.cancelled).toEqual([1]); + release(); + await expect(run).rejects.toBeInstanceOf(FlattenCancelled); + }); +}); + +// --- Node integration: pdf.js + @napi-rs/canvas, flatten -> assemble -------------------------------- + +const canvasLib = await loadCanvas(); +if (!canvasLib) console.warn("[flatten.test] @napi-rs/canvas is not installed: skipping the flatten -> assemble integration tests"); + +/** Width and height from the first SOF marker of a JPEG. */ +function jpegSize(b: Uint8Array): { width: number; height: number } { + let i = 2; + while (i < b.length) { + if (b[i] !== 0xff) throw new Error("not a JPEG marker stream"); + const marker = b[i + 1]; + const len = (b[i + 2] << 8) | b[i + 3]; + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { height: (b[i + 5] << 8) | b[i + 6], width: (b[i + 7] << 8) | b[i + 8] }; + } + i += 2 + len; + } + throw new Error("no SOF marker"); +} + +describe.skipIf(!canvasLib)("flatten -> assemble (pdf.js + napi canvas)", () => { + const nodeDeps = () => ({ + createCanvas: (w: number, h: number) => canvasLib!.createCanvas(w, h) as unknown as CanvasLike, + encodeJpeg: async (c: CanvasLike, q: number) => new Uint8Array(await (c as unknown as { encode(f: "jpeg", q: number): Promise }).encode("jpeg", Math.round(q * 100))), + }); + + async function flatten(bytes: Uint8Array, extra: Partial[0]> = {}) { + const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true }); + const doc = await task.promise; + try { + return await flattenPages({ pdf: doc as unknown as PdfDocLike, ...nodeDeps(), ...extra }); + } finally { + await task.destroy(); + } + } + + // Rendered once and shared: A4, 60 rows (several pages), registered supplier with a GSTIN. + let shared: Promise<{ source: Uint8Array; sourcePages: number; flat: Awaited>; out: Uint8Array; title: string }> | null = null; + const prepare = () => + (shared ??= (async () => { + ensureSetup(); + const r = await renderFixture({ name: "flat", items: 60, signature: true, invoice: { notes: "Payment due within 30 days." } }, prefsFor("A4")); + const flat = await flatten(r.result.bytes); + const title = "INV/2026-001"; + const { bytes: out } = await runAssembleJob({ id: 1, title, pages: flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })) }); + return { source: r.result.bytes, sourcePages: r.result.pages, flat, out, title }; + })()); + + it("keeps the page count and writes 300 DPI A4 images (at least 2480 px wide)", async () => { + const { sourcePages, flat, out } = await prepare(); + expect(sourcePages).toBeGreaterThan(1); + expect(flat.pages).toHaveLength(sourcePages); + const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise; + expect(doc.numPages).toBe(sourcePages); + for (let n = 1; n <= doc.numPages; n++) { + const page = await doc.getPage(n); + const [, , w, h] = page.view; + expect(w).toBeCloseTo(595.28, 1); + expect(h).toBeCloseTo(841.89, 1); + } + for (const p of flat.pages) { + const sof = jpegSize(p.jpeg); + expect(sof.width).toBeGreaterThanOrEqual(2480); + expect(sof.width).toBe(p.pxWidth); + expect(sof.height).toBe(p.pxHeight); + } + // The JPEG goes in untouched (DCTDecode passthrough), so these exact bytes are inside the file. + const outBuf = Buffer.from(out); + for (const p of flat.pages) expect(outBuf.includes(Buffer.from(p.jpeg))).toBe(true); + await doc.loadingTask.destroy(); + }); + + it("has zero extractable text items on every page", async () => { + const { out, sourcePages } = await prepare(); + const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise; + for (let n = 1; n <= sourcePages; n++) { + const content = await (await doc.getPage(n)).getTextContent(); + expect(content.items, `page ${n}`).toHaveLength(0); + } + await doc.loadingTask.destroy(); + }); + + it("carries only a Title in the document info, and none of the invoice's names or GSTINs anywhere", async () => { + const { out, title } = await prepare(); + const raw = Buffer.from(out).toString("latin1"); + expect(raw).toContain("/Title"); + expect(raw).toContain(title); + for (const key of ["/Author", "/Subject", "/Keywords"]) expect(raw, key).not.toContain(key); + const { invoice } = buildFixture({ name: "flat", items: 1 }); + for (const secret of ["Test Vendor", invoice.clientName, "Acme", FIXTURE_GSTIN, invoice.clientGstin, "ABCDE1234F", "Bangalore"]) { + expect(raw, secret).not.toContain(secret); + } + const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise; + const info = (await doc.getMetadata()).info as Record; + expect(info.Title).toBe(title); + expect(info.Author ?? "").toBe(""); + expect(info.Subject ?? "").toBe(""); + expect(info.Keywords ?? "").toBe(""); + await doc.loadingTask.destroy(); + }); + + it("the page images are not blank", async () => { + const { flat } = await prepare(); + const img = await canvasLib!.loadImage(Buffer.from(flat.pages[0].jpeg)); + const c = canvasLib!.createCanvas(img.width, img.height); + const ctx = c.getContext("2d"); + ctx.drawImage(img, 0, 0); + const data = ctx.getImageData(0, 0, img.width, 400).data; + let ink = 0; + for (let i = 0; i < data.length; i += 4) if (data[i] < 200) ink++; + expect(ink).toBeGreaterThan(1000); + }); + + it("cancel via AbortSignal throws FlattenCancelled and returns nothing", async () => { + const { source } = await prepare(); + const ctl = new AbortController(); + const seen: number[] = []; + await expect( + flatten(source, { + signal: ctl.signal, + onProgress: (p) => { + seen.push(p); + if (p === 2) ctl.abort(); + }, + }), + ).rejects.toBeInstanceOf(FlattenCancelled); + expect(seen).toEqual([1, 2]); + }); + + it("a throwing createCanvas yields a FlattenError and no partial output", async () => { + const { source } = await prepare(); + let calls = 0; + const err = await flatten(source, { + createCanvas: () => { + calls++; + throw new Error("canvas allocation failed"); + }, + }).catch((e) => e); + expect(err).toBeInstanceOf(FlattenError); + expect(err).toMatchObject({ page: 1, code: "FLATTEN_FAILED" }); + expect(calls).toBe(1); + }); +}); diff --git a/src/pdf/flatten.ts b/src/pdf/flatten.ts new file mode 100644 index 0000000..8371cfd --- /dev/null +++ b/src/pdf/flatten.ts @@ -0,0 +1,157 @@ +/** The slice of pdf.js the flatten needs, so Node tests and the webview can supply their own. */ +export interface PageViewportLike { + width: number; + height: number; +} + +export interface RenderTaskLike { + promise: Promise; + cancel?: () => void; +} + +export interface PageLike { + getViewport(params: { scale: number }): PageViewportLike; + render(params: { canvasContext: unknown; canvas: unknown; viewport: PageViewportLike; intent?: string }): RenderTaskLike; + cleanup?: () => unknown; +} + +export interface PdfDocLike { + numPages: number; + getPage(n: number): Promise; +} + +export interface CanvasLike { + width: number; + height: number; + getContext(kind: "2d"): unknown; +} + +export interface FlatPage { + jpeg: Uint8Array; + widthPt: number; + heightPt: number; + pxWidth: number; + pxHeight: number; + renderMs: number; + encodeMs: number; +} + +export interface FlattenResult { + pages: FlatPage[]; +} + +export interface FlattenOptions { + pdf: PdfDocLike; + dpi?: number; + quality?: number; + createCanvas: (width: number, height: number) => CanvasLike; + encodeJpeg: (canvas: CanvasLike, quality: number) => Promise; + signal?: AbortSignal; + /** Called as each page starts: (page being rendered, page count). */ + onProgress?: (page: number, pages: number) => void; + /** Awaited after each page so the UI can paint and handle input between the long tasks. */ + yieldToUi?: () => Promise; + now?: () => number; +} + +export class FlattenCancelled extends Error { + constructor() { + super("The flatten was cancelled"); + this.name = "FlattenCancelled"; + } +} + +export class FlattenError extends Error { + readonly code = "FLATTEN_FAILED"; + constructor( + readonly page: number, + readonly reason: string, + ) { + super(page > 0 ? `Could not flatten page ${page}: ${reason}` : `Could not flatten the PDF: ${reason}`); + this.name = "FlattenError"; + } +} + +const reasonOf = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** + * Rasterises every page to a JPEG at `dpi` (default 300) on a white background. One canvas lives at a + * time and is released before the next page. There is deliberately no fallback to a lower DPI: a canvas + * or encode failure throws a FlattenError and the caller decides what to offer. + */ +export async function flattenPages(opts: FlattenOptions): Promise { + const { pdf, dpi = 300, quality = 0.92, createCanvas, encodeJpeg, signal, onProgress, yieldToUi, now = () => performance.now() } = opts; + const scale = dpi / 72; + const total = pdf.numPages; + if (!(total > 0)) throw new FlattenError(0, "the PDF has no pages"); + + const checkAbort = () => { + if (signal?.aborted) throw new FlattenCancelled(); + }; + + const pages: FlatPage[] = []; + for (let n = 1; n <= total; n++) { + checkAbort(); + onProgress?.(n, total); + checkAbort(); // onProgress may be where the caller cancels + + let canvas: CanvasLike | null = null; + let page: PageLike | null = null; + try { + page = await pdf.getPage(n); + const unit = page.getViewport({ scale: 1 }); + const viewport = page.getViewport({ scale }); + // The epsilon keeps 792 pt x 300/72 (3300.0000000000005) from becoming a 3301 px page. + const pxWidth = Math.ceil(viewport.width - 1e-6); + const pxHeight = Math.ceil(viewport.height - 1e-6); + + canvas = createCanvas(pxWidth, pxHeight); + const ctx = canvas.getContext("2d") as { fillStyle: unknown; fillRect(x: number, y: number, w: number, h: number): void } | null; + if (!ctx) throw new Error("the canvas has no 2D context"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, pxWidth, pxHeight); + + checkAbort(); + const t0 = now(); + const task = page.render({ canvasContext: ctx, canvas, viewport, intent: "print" }); + const onAbort = () => task.cancel?.(); + signal?.addEventListener("abort", onAbort, { once: true }); + try { + await task.promise; + } finally { + signal?.removeEventListener("abort", onAbort); + } + checkAbort(); + const t1 = now(); + const jpeg = await encodeJpeg(canvas, quality); + const t2 = now(); + if (!jpeg || jpeg.byteLength < 4) throw new Error("the JPEG encoder returned no data"); + pages.push({ + jpeg, + widthPt: unit.width, + heightPt: unit.height, + pxWidth, + pxHeight, + renderMs: Math.round(t1 - t0), + encodeMs: Math.round(t2 - t1), + }); + } catch (e) { + if (e instanceof FlattenCancelled || signal?.aborted) throw new FlattenCancelled(); + if (e instanceof FlattenError) throw e; + throw new FlattenError(n, reasonOf(e)); + } finally { + if (canvas) { + // Releases the backing store now rather than at garbage collection (2480 x 3508 x 4 = 35 MB). + canvas.width = 0; + canvas.height = 0; + } + try { + page?.cleanup?.(); + } catch { + /* cleanup is best effort */ + } + } + if (yieldToUi) await yieldToUi(); + } + return { pages }; +} diff --git a/src/pdf/render/assembleJob.ts b/src/pdf/render/assembleJob.ts new file mode 100644 index 0000000..1a953ca --- /dev/null +++ b/src/pdf/render/assembleJob.ts @@ -0,0 +1,10 @@ +import { assembleImagePdf } from "../assemble"; +import { renderBytes } from "./core"; +import type { AssembleJob, AssembleResult } from "./jobs"; + +/** Runs an assemble job where it is called: the worker, the main thread, or a Node test. */ +export async function runAssembleJob(job: AssembleJob): Promise { + const t0 = performance.now(); + const bytes = await renderBytes(assembleImagePdf({ pages: job.pages, title: job.title })); + return { id: job.id, bytes, ms: Math.round(performance.now() - t0) }; +} diff --git a/src/pdf/render/client.test.ts b/src/pdf/render/client.test.ts index 0b1460d..978eda9 100644 --- a/src/pdf/render/client.test.ts +++ b/src/pdf/render/client.test.ts @@ -9,7 +9,7 @@ import { WorkerUnavailable, type WorkerLike, } from "./client"; -import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; +import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; class FakeWorker implements WorkerLike { onmessage: WorkerLike["onmessage"] = null; @@ -31,8 +31,15 @@ class FakeWorker implements WorkerLike { reply(id: number) { this.emit({ type: "result", result: result(id) }); } + replyAssembled(id: number) { + this.emit({ type: "assembled", result: assembled(id) }); + } } +const assembled = (id: number): AssembleResult => ({ id, bytes: new Uint8Array([id]), ms: 3 }); +const assembleJob = (id: number): AssembleJob => ({ id, title: "T", pages: [{ jpeg: new Uint8Array([1]), widthPt: 10, heightPt: 10 }] }); +const kinds = (w: FakeWorker) => w.sent.map((m) => `${m.type}:${m.job.id}`); + const result = (id: number): RenderResult => ({ id, bytes: new Uint8Array([id]), @@ -190,6 +197,149 @@ describe("RenderClient", () => { }); }); +describe("assemble lane", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("queues behind a render in flight without superseding it", async () => { + const { client, workers } = setup(); + const r1 = client.render(job(1)); + workers[0].ready(); + const a = client.assemble(assembleJob(2)); + expect(kinds(workers[0])).toEqual(["render:1"]); + workers[0].reply(1); + expect((await r1).id).toBe(1); + expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2"]); + workers[0].replyAssembled(2); + expect((await a).id).toBe(2); + }); + + it("runs ahead of a render that was queued before it", async () => { + const { client, workers } = setup(); + const r1 = client.render(job(1)); + workers[0].ready(); + const r3 = client.render(job(3)); // supersedes r1, waits for the worker + const a = client.assemble(assembleJob(2)); + expect(await settled(r1)).toBeInstanceOf(Superseded); + workers[0].reply(1); + expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2"]); + workers[0].replyAssembled(2); + expect((await a).id).toBe(2); + expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2", "render:3"]); + workers[0].reply(3); + expect((await r3).id).toBe(3); + }); + + it("is not superseded by renders that arrive while it runs, and the newest render still wins afterwards", async () => { + const { client, workers } = setup(); + const a = client.assemble(assembleJob(1)); + workers[0].ready(); + const r2 = client.render(job(2)); + const r3 = client.render(job(3)); + expect(await settled(r2)).toBeInstanceOf(Superseded); + workers[0].replyAssembled(1); + expect((await a).id).toBe(1); + expect(kinds(workers[0])).toEqual(["assemble-images:1", "render:3"]); + workers[0].reply(3); + expect((await r3).id).toBe(3); + }); + + it("runs several assembles in order and a render never rejects them", async () => { + const { client, workers } = setup(); + const r0 = client.render(job(0)); + workers[0].ready(); + workers[0].reply(0); + await r0; + const a = client.assemble(assembleJob(1)); + const b = client.assemble(assembleJob(2)); + void client.render(job(3)).catch(() => {}); + void client.render(job(4)).catch(() => {}); + workers[0].replyAssembled(1); + expect((await a).id).toBe(1); + expect(kinds(workers[0]).slice(-1)).toEqual(["assemble-images:2"]); + workers[0].replyAssembled(2); + expect((await b).id).toBe(2); + }); + + it("surfaces an assemble error from the worker and keeps serving", async () => { + const { client, workers } = setup(); + const a = client.assemble(assembleJob(1)); + workers[0].ready(); + workers[0].emit({ type: "error", id: 1, message: "bad jpeg" }); + const err = await settled(a); + expect(err).toBeInstanceOf(RenderFailed); + expect((err as Error).message).toBe("bad jpeg"); + const b = client.assemble(assembleJob(2)); + workers[0].replyAssembled(2); + expect((await b).id).toBe(2); + }); + + it("a render result cannot settle a running assemble with the same id", async () => { + const { client, workers } = setup(); + const a = client.assemble(assembleJob(5)); + workers[0].ready(); + workers[0].reply(5); + let done = false; + void settled(a).then(() => (done = true)); + await Promise.resolve(); + expect(done).toBe(false); + workers[0].replyAssembled(5); + expect((await a).id).toBe(5); + }); + + it("dispose rejects queued assembles", async () => { + const { client, workers } = setup(); + void client.render(job(1)).catch(() => {}); + workers[0].ready(); + const a = client.assemble(assembleJob(2)); + client.dispose(); + expect(String(await settled(a))).toContain("disposed"); + }); + + it("the main-thread client keeps the same lanes", async () => { + const gates: Array<() => void> = []; + const seen: string[] = []; + const client = createMainThreadClient( + (j) => + new Promise((resolve) => { + seen.push(`render:${j.id}`); + gates.push(() => resolve(result(j.id))); + }), + (j) => + new Promise((resolve) => { + seen.push(`assemble:${j.id}`); + gates.push(() => resolve(assembled(j.id))); + }), + ); + const r1 = client.render(job(1)); + const a = client.assemble(assembleJob(2)); + gates[0](); + expect((await r1).id).toBe(1); + const r3 = client.render(job(3)); + await Promise.resolve(); + await Promise.resolve(); + expect(seen).toEqual(["render:1", "assemble:2"]); + gates[1](); + expect((await a).id).toBe(2); + await Promise.resolve(); + await Promise.resolve(); + expect(seen).toEqual(["render:1", "assemble:2", "render:3"]); + gates[2](); + expect((await r3).id).toBe(3); + }); + + it("the auto client assembles on the main thread when the worker cannot start", async () => { + const client = createAutoClient({ + workerFactory: () => { + throw new Error("no worker"); + }, + mainAssembleExecutor: async (j) => assembled(j.id), + }); + expect((await client.assemble(assembleJob(9))).id).toBe(9); + expect(client.mode).toBe("main"); + }); +}); + describe("main-thread client and fallback", () => { it("runs jobs serially with latest-wins", async () => { const gates: Array<() => void> = []; diff --git a/src/pdf/render/client.ts b/src/pdf/render/client.ts index 7f4f1ab..52311e0 100644 --- a/src/pdf/render/client.ts +++ b/src/pdf/render/client.ts @@ -1,4 +1,4 @@ -import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; +import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; /** A newer render call replaced this one before it finished (or before it started). */ export class Superseded extends Error { @@ -44,6 +44,12 @@ export interface WorkerLike { export interface RenderClient { /** Resolves with the newest request's result; an older call rejects with Superseded. */ render(job: RenderJob): Promise; + /** + * Wraps JPEG pages into an image-only PDF. This is a separate lane from `render`: renders are + * latest-wins, but an assemble is an export step that must complete, so it is never superseded by + * a render (or by another assemble). It waits for the job in flight and runs before any queued render. + */ + assemble(job: AssembleJob): Promise; dispose(): void; readonly mode: "worker" | "main"; } @@ -57,23 +63,38 @@ export interface RenderClientOptions { const defaultWorkerFactory = (): WorkerLike => new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike; -interface Slot { +interface RenderSlot { + kind: "render"; job: RenderJob; resolve: (r: RenderResult) => void; reject: (e: unknown) => void; } -interface Running extends Slot { +interface AssembleSlot { + kind: "assemble"; + job: AssembleJob; + resolve: (r: AssembleResult) => void; + reject: (e: unknown) => void; +} + +type Slot = RenderSlot | AssembleSlot; + +type Running = Slot & { /** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */ superseded: boolean; timer: ReturnType; -} +}; + +const requestFor = (slot: Slot): WorkerRequest => + slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job }; /** - * Renders in a module worker. Only the newest request matters: a new call rejects the one in flight + * Renders in a module worker. Only the newest render matters: a new call rejects the render in flight * (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the - * running job finishes at most one job, the newest, runs. The handshake is the worker's `ready` - * message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback. + * running job finishes at most one render, the newest, runs. Assemble jobs are the exception: they + * queue in order, are never superseded, and go ahead of a queued render. The handshake is the + * worker's `ready` message; a start-up error rejects with WorkerUnavailable and there is no + * timer-based fallback. */ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient { const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts; @@ -82,7 +103,8 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient let unavailable: WorkerUnavailable | null = null; let disposed = false; let running: Running | null = null; - let pending: Slot | null = null; + let pending: RenderSlot | null = null; + const assembles: AssembleSlot[] = []; const stopWorker = () => { const w = worker; @@ -107,16 +129,25 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient unavailable = err; stopWorker(); settleRunning((s) => s.reject(err)); + rejectQueued(err); + }; + + const rejectQueued = (err: unknown) => { pending?.reject(err); pending = null; + for (const a of assembles.splice(0)) a.reject(err); }; const pump = () => { - if (!ready || !worker || running || !pending || disposed) return; - const slot = pending; - pending = null; + if (!ready || !worker || running || disposed) return; + let slot: Slot | null = assembles.shift() ?? null; + if (!slot) { + slot = pending; + pending = null; + } + if (!slot) return; running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) }; - worker.postMessage({ type: "render", job: slot.job }); + worker.postMessage(requestFor(slot)); }; const spawn = () => { @@ -136,8 +167,12 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient ready = true; pump(); } else if (msg.type === "result") { - if (running?.job.id !== msg.result.id) return; - settleRunning((s) => s.resolve(msg.result)); + if (running?.kind !== "render" || running.job.id !== msg.result.id) return; + settleRunning((s) => (s as RenderSlot).resolve(msg.result)); + pump(); + } else if (msg.type === "assembled") { + if (running?.kind !== "assemble" || running.job.id !== msg.result.id) return; + settleRunning((s) => (s as AssembleSlot).resolve(msg.result)); pump(); } else if (msg.id === null) { if (!ready) fail(new WorkerUnavailable(msg.message)); @@ -170,11 +205,20 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient if (unavailable) return Promise.reject(unavailable); return new Promise((resolve, reject) => { pending?.reject(new Superseded()); - if (running && !running.superseded) { + if (running && running.kind === "render" && !running.superseded) { running.superseded = true; running.reject(new Superseded()); } - pending = { job, resolve, reject }; + pending = { kind: "render", job, resolve, reject }; + if (!worker) spawn(); + pump(); + }); + }, + assemble(job) { + if (disposed) return Promise.reject(new Error("The render client was disposed")); + if (unavailable) return Promise.reject(unavailable); + return new Promise((resolve, reject) => { + assembles.push({ kind: "assemble", job, resolve, reject }); if (!worker) spawn(); pump(); }); @@ -184,8 +228,7 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient disposed = true; const err = new Error("The render client was disposed"); settleRunning((s) => s.reject(err)); - pending?.reject(err); - pending = null; + rejectQueued(err); stopWorker(); }, }; @@ -212,20 +255,41 @@ const runOnMainThread: MainExecutor = async (job) => { }; }; -/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */ -export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient { +/** Assembles one image-only PDF on this thread. */ +export type MainAssembleExecutor = (job: AssembleJob) => Promise; + +const assembleOnMainThread: MainAssembleExecutor = async (job) => (await import("./assembleJob")).runAssembleJob(job); + +/** + * Same interface and lanes as the worker client (latest-wins renders, never-superseded assembles), + * on the main thread. Nothing can interrupt it, so there is no watchdog. + */ +export function createMainThreadClient( + execute: MainExecutor = runOnMainThread, + executeAssemble: MainAssembleExecutor = assembleOnMainThread, +): RenderClient { let disposed = false; let running: (Slot & { superseded: boolean }) | null = null; - let pending: Slot | null = null; + let pending: RenderSlot | null = null; + const assembles: AssembleSlot[] = []; const pump = async () => { - if (running || !pending || disposed) return; - const slot = { ...pending, superseded: false }; - pending = null; + if (running || disposed) return; + let next: Slot | null = assembles.shift() ?? null; + if (!next) { + next = pending; + pending = null; + } + if (!next) return; + const slot = { ...next, superseded: false }; running = slot; try { - const result = await execute(slot.job); - if (!slot.superseded) slot.resolve(result); + if (slot.kind === "render") { + const result = await execute(slot.job); + if (!slot.superseded) slot.resolve(result); + } else { + slot.resolve(await executeAssemble(slot.job)); + } } catch (e) { if (!slot.superseded) slot.reject(e); } @@ -239,11 +303,18 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread): if (disposed) return Promise.reject(new Error("The render client was disposed")); return new Promise((resolve, reject) => { pending?.reject(new Superseded()); - if (running && !running.superseded) { + if (running && running.kind === "render" && !running.superseded) { running.superseded = true; running.reject(new Superseded()); } - pending = { job, resolve, reject }; + pending = { kind: "render", job, resolve, reject }; + void pump(); + }); + }, + assemble(job) { + if (disposed) return Promise.reject(new Error("The render client was disposed")); + return new Promise((resolve, reject) => { + assembles.push({ kind: "assemble", job, resolve, reject }); void pump(); }); }, @@ -252,6 +323,7 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread): const err = new Error("The render client was disposed"); pending?.reject(err); pending = null; + for (const a of assembles.splice(0)) a.reject(err); }, }; } @@ -261,10 +333,15 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread): * which one is active (main thread: the preview is paused while typing). */ export function createAutoClient( - opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {}, + opts: RenderClientOptions & { mainExecutor?: MainExecutor; mainAssembleExecutor?: MainAssembleExecutor } = {}, ): RenderClient { const primary = createRenderClient(opts); let main: RenderClient | null = null; + const fallback = () => { + main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor); + primary.dispose(); + return main; + }; return { get mode() { return main ? "main" : "worker"; @@ -273,9 +350,14 @@ export function createAutoClient( if (main) return main.render(job); return primary.render(job).catch((err) => { if (!(err instanceof WorkerUnavailable)) throw err; - main ??= createMainThreadClient(opts.mainExecutor); - primary.dispose(); - return main.render(job); + return fallback().render(job); + }); + }, + assemble(job) { + if (main) return main.assemble(job); + return primary.assemble(job).catch((err) => { + if (!(err instanceof WorkerUnavailable)) throw err; + return fallback().assemble(job); }); }, dispose() { diff --git a/src/pdf/render/jobs.ts b/src/pdf/render/jobs.ts index 35fae2c..0249ee2 100644 --- a/src/pdf/render/jobs.ts +++ b/src/pdf/render/jobs.ts @@ -20,10 +20,31 @@ export interface RenderResult { ms: number; } -export type WorkerRequest = { type: "render"; job: RenderJob }; +/** One flattened page: a JPEG to embed full-bleed on a page of the given size. */ +export interface AssemblePageData { + jpeg: Uint8Array; + widthPt: number; + heightPt: number; +} + +/** Wraps JPEG pages into an image-only PDF. Needs no fonts, so it never waits on the render's setup. */ +export interface AssembleJob { + id: number; + pages: AssemblePageData[]; + title: string; +} + +export interface AssembleResult { + id: number; + bytes: Uint8Array; + ms: number; +} + +export type WorkerRequest = { type: "render"; job: RenderJob } | { type: "assemble-images"; job: AssembleJob }; export type WorkerMessage = | { type: "ready" } | { type: "result"; result: RenderResult } + | { type: "assembled"; result: AssembleResult } /** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */ | { type: "error"; id: number | null; message: string; stack?: string }; diff --git a/src/pdf/render/render.worker.ts b/src/pdf/render/render.worker.ts index bfeba85..1799d91 100644 --- a/src/pdf/render/render.worker.ts +++ b/src/pdf/render/render.worker.ts @@ -1,6 +1,7 @@ // Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests. import { installStreamAsyncIterator } from "../../lib/streamPolyfill"; import { initBrowserRendering } from "./browserInit"; +import { runAssembleJob } from "./assembleJob"; import { renderInvoicePdf } from "./invoice"; import type { WorkerMessage, WorkerRequest } from "./jobs"; @@ -21,10 +22,24 @@ const failure = (id: number | null, err: unknown): WorkerMessage => ({ stack: err instanceof Error ? err.stack : undefined, }); +// Handing a buffer over instead of copying it only works when the view covers all of it. +const whole = (b: Uint8Array) => b.byteOffset === 0 && b.byteLength === b.buffer.byteLength; + const started = initBrowserRendering(); scope.onmessage = async (event) => { const request = event.data; + if (request?.type === "assemble-images") { + const { job } = request; + try { + const out = await runAssembleJob(job); + const bytes = whole(out.bytes) ? out.bytes : out.bytes.slice(); + post({ type: "assembled", result: { ...out, bytes } }, [bytes.buffer]); + } catch (err) { + post(failure(job.id, err)); + } + return; + } if (request?.type !== "render") return; const { job } = request; try { @@ -32,9 +47,7 @@ scope.onmessage = async (event) => { const t0 = performance.now(); const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs }); const ms = Math.round(performance.now() - t0); - // Hand the buffer over instead of copying it; a view onto part of a larger buffer is copied first. - const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength; - const bytes = whole ? out.bytes : out.bytes.slice(); + const bytes = whole(out.bytes) ? out.bytes : out.bytes.slice(); post( { type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } }, [bytes.buffer], diff --git a/src/pdf/testing/goldens.json b/src/pdf/testing/goldens.json new file mode 100644 index 0000000..e47bdd0 --- /dev/null +++ b/src/pdf/testing/goldens.json @@ -0,0 +1,26 @@ +{ + "classic-a4-tax-3": { + "pages": 1, + "fingerprint": "35b03b20aeec3dcbb808300b6e8f0b1867dfd69d5fd6f69aaee891c354955092", + "invoiceNumber": "ST/2026-001", + "totalText": "₹8,625.80", + "pageLabel": "Page 1 of 1", + "textHashes": [ + "855dd2d104e80ff599210d69f643be0ccb281e6583cbcea2dafa5fdcb09f03fc" + ], + "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1" + }, + "classic-letter-unreg-60": { + "pages": 3, + "fingerprint": "78d60e1b72dac0e40fea96cbf34c35cfeb637dbe0bc9754dcecba72231197e75", + "invoiceNumber": "ST/2026-002", + "totalText": "₹1,56,000.00", + "pageLabel": "Page 1 of 3", + "textHashes": [ + "1728b53a177e385584d2dc33b2e71607f860886632f32a5911364f86f345c950", + "870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b", + "fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0" + ], + "firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per hour 3 ₹3,600.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per hour 2 ₹2,400.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per hour 3 ₹3,600.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per hour 2 ₹2,400.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per hour 3 ₹3,600.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per hour 2 ₹2,400.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3" + } +} diff --git a/src/pdf/testing/goldens.test.ts b/src/pdf/testing/goldens.test.ts new file mode 100644 index 0000000..1e7d3ee --- /dev/null +++ b/src/pdf/testing/goldens.test.ts @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { renderInvoicePdf } from "../render/invoice"; +import { ensureSetup } from "../templates/harness"; +import type { Golden } from "./goldens"; +import { selfTestFixtures } from "./selfTestFixtures"; +import { parsePdfText } from "./pdfText"; + +/** + * goldens.json is what the Diagnostics self-test compares the webview's render against (page count, + * layout fingerprint, key strings). This test keeps it in step with the Node render: + * `UPDATE_GOLDENS=1 npx vitest run src/pdf/testing/goldens.test.ts` rewrites it. + */ + +const FILE = path.resolve(process.cwd(), "src/pdf/testing/goldens.json"); +const sha = (s: string) => createHash("sha256").update(s).digest("hex"); + +async function current(): Promise> { + ensureSetup(); + const out: Record = {}; + for (const f of selfTestFixtures()) { + const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs }); + const text = await parsePdfText(r.bytes); + const pages = text.pages.map((p) => p.items.map((i) => i.str).join(" ")); + out[f.id] = { + pages: r.pages, + fingerprint: r.fingerprint, + invoiceNumber: f.invoiceNumber, + totalText: f.totalText, + pageLabel: `Page 1 of ${r.pages}`, + textHashes: pages.map(sha), + firstPageText: pages[0], + }; + } + return out; +} + +describe("self-test goldens", () => { + it("match the Node render of the self-test fixtures", async () => { + const now = await current(); + if (process.env.UPDATE_GOLDENS) { + writeFileSync(FILE, `${JSON.stringify(now, null, 2)}\n`); + return; + } + const stored = JSON.parse(readFileSync(FILE, "utf8")) as Record; + expect(Object.keys(stored).sort()).toEqual(Object.keys(now).sort()); + for (const [id, golden] of Object.entries(now)) { + const s = stored[id]; + // Compare the readable parts first so a failure says what moved. + expect(s.pages, `${id}: page count`).toBe(golden.pages); + expect(s.firstPageText, `${id}: page 1 text`).toBe(golden.firstPageText); + expect(s.textHashes, `${id}: text of every page`).toEqual(golden.textHashes); + expect(s.invoiceNumber).toBe(golden.invoiceNumber); + expect(s.totalText).toBe(golden.totalText); + expect(s.pageLabel).toBe(golden.pageLabel); + expect(s.fingerprint, `${id}: layout fingerprint (run with UPDATE_GOLDENS=1 if the layout change is intended)`).toBe(golden.fingerprint); + } + }); + + it("every golden carries the strings the self-test looks for", async () => { + const stored = JSON.parse(readFileSync(FILE, "utf8")) as Record; + for (const [id, g] of Object.entries(stored)) { + expect(g.totalText, id).toMatch(/^₹/); + expect(g.firstPageText, id).toContain(g.invoiceNumber); + expect(g.firstPageText.replace(/\s+/g, " "), id).toContain(g.pageLabel); + } + }); +}); diff --git a/src/pdf/testing/goldens.ts b/src/pdf/testing/goldens.ts new file mode 100644 index 0000000..c670eb5 --- /dev/null +++ b/src/pdf/testing/goldens.ts @@ -0,0 +1,15 @@ +/** One entry of goldens.json: what the Node render of a self-test fixture produced. */ +export interface Golden { + pages: number; + fingerprint: string; + invoiceNumber: string; + totalText: string; + /** Page-1 label the footer must show. */ + pageLabel: string; + /** Per page: sha256 of the extracted text (items joined with a space). */ + textHashes: string[]; + /** Page 1's text, readable in a diff. */ + firstPageText: string; +} + +export type GoldenMap = Record; diff --git a/src/pdf/testing/selfTestFixtures.ts b/src/pdf/testing/selfTestFixtures.ts new file mode 100644 index 0000000..0c5999d --- /dev/null +++ b/src/pdf/testing/selfTestFixtures.ts @@ -0,0 +1,135 @@ +import { computeTotals } from "../../lib/invoice"; +import { amountInWords } from "../../lib/numberToWords"; +import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "../../lib/types"; +import { buildRenderModel, type RenderModel } from "../model/build"; +import { DEFAULT_PREFS, type RenderPrefsV1 } from "../model/prefs"; + +/** + * Pure data for the Diagnostics self-test and the goldens test (no Node or DOM imports, so the + * webview and Node build the very same models). Change a fixture and regenerate the goldens. + */ + +export interface SelfTestFixture { + id: string; + label: string; + model: RenderModel; + prefs: RenderPrefsV1; + invoiceNumber: string; + /** The grand total as the template prints it, e.g. "₹8,260.00". */ + totalText: string; +} + +const VENDOR: Settings = { + vendorName: "Selftest Studio", + vendorAddress: "1 Test Lane, Test Town, Bangalore - 560005", + vendorEmail: "selftest@example.com", + vendorPhone: "9000000000", + vendorPan: "AAAAA0000A", + vendorGstin: "", + vendorStateCode: "29", + logoPath: null, + signaturePath: null, + defaultBankId: 1, + defaultTaxRate: 18, + defaultTaxType: "cgst_sgst", + paymentTermsDays: 30, + currency: "INR", + onboarded: true, + theme: "g10", + gstRegistration: "unregistered", + defaultHsnSac: "", + signatoryName: "", + signatoryDesignation: "", + renderPrefs: "", +}; + +const BANK: BankAccount = { + id: 1, + label: "Primary", + bankName: "State Bank of India", + accountName: "Selftest Studio", + accountNo: "00000000000", + branch: "000000", + ifsc: "SBIN0000000", + isDefault: true, +}; + +const items = (n: number, hsn: boolean): InvoiceItem[] => + Array.from({ length: n }, (_, i) => ({ + id: i + 1, + description: `Voice-over session ${i + 1} - narration for module ${i + 1}`, + mode: i % 2 === 0 ? "rate" : "fixed", + rate: 1200, + unit: "hour", + quantity: 1 + (i % 3), + amount: 2500 + i * 10, + sortOrder: i, + ...(hsn ? { hsnSac: "998397" } : {}), + })); + +function build(o: { id: string; label: string; number: string; rows: number; registered: boolean; prefs: Partial }): SelfTestFixture { + const taxType: TaxType = o.registered ? "cgst_sgst" : "none"; + const taxRate = o.registered ? 18 : 0; + const rows = items(o.rows, o.registered); + const totals = computeTotals(rows, 0, taxType, taxRate); + const vendor: Settings = o.registered + ? { ...VENDOR, vendorGstin: "29AAAAA0000A1Z5", gstRegistration: "regular", signatoryName: "Selftest Studio", signatoryDesignation: "Proprietor" } + : VENDOR; + const invoice: Invoice = { + id: 1, + number: o.number, + seriesId: 1, + invoiceDate: "2026-09-15", + dueDate: "2026-10-15", + clientId: null, + clientName: "Acme Learning Pvt Ltd", + clientAddress: "42 MG Road\nBangalore 560001", + clientGstin: o.registered ? "29ABCDE1234F1Z5" : "", + poNumber: "", + placeOfSupplyStateCode: o.registered ? "29" : "", + subtotal: totals.subtotal, + discount: totals.discount, + taxType, + taxRate, + cgstAmount: totals.cgst, + sgstAmount: totals.sgst, + igstAmount: totals.igst, + total: totals.total, + amountInWords: amountInWords(totals.total), + bankAccountId: 1, + bankSnapshot: "", + signaturePath: null, + notes: "", + status: "issued", + createdAt: "2026-09-15T00:00:00Z", + updatedAt: "2026-09-15T00:00:00Z", + docType: o.registered ? "tax_invoice" : "invoice", + reverseCharge: false, + vendorSnapshot: "", + snapshotOrigin: "", + cancelledAt: null, + cancelReason: "", + archivedPdfSha256: null, + renderPrefs: "", + items: rows, + }; + const model = buildRenderModel({ invoice, vendor, bank: BANK, logo: null, signatureDataUri: null, appVersion: "0.0.0-selftest" }); + return { + id: o.id, + label: o.label, + model, + prefs: { ...DEFAULT_PREFS, ...o.prefs }, + invoiceNumber: o.number, + totalText: model.totals.totalText, + }; +} + +export const SELF_TEST_FIXTURE_IDS = ["classic-a4-tax-3", "classic-letter-unreg-60"] as const; + +/** Classic A4 tax invoice with 3 rows, and Classic Letter plain invoice with 60 rows (a multi-page table). */ +export function selfTestFixtures(): SelfTestFixture[] { + return [ + build({ id: "classic-a4-tax-3", label: "Classic, A4, tax invoice, 3 rows", number: "ST/2026-001", rows: 3, registered: true, prefs: { pageSize: "A4" } }), + build({ id: "classic-letter-unreg-60", label: "Classic, Letter, unregistered, 60 rows", number: "ST/2026-002", rows: 60, registered: false, prefs: { pageSize: "LETTER" } }), + ]; +} diff --git a/src/styles/carbon.scss b/src/styles/carbon.scss index f69f935..6dca5c1 100644 --- a/src/styles/carbon.scss +++ b/src/styles/carbon.scss @@ -353,3 +353,46 @@ body { background-color: var(--cds-support-warning); color: #161616; } + +/* ---------- export feedback ---------- */ +.voiced-notice-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.voiced-export-split { + display: inline-flex; + align-items: stretch; +} + +.voiced-diagnostics__check { + padding: 0.5rem 0; + border-bottom: 1px solid var(--cds-border-subtle-01); +} + +.voiced-diagnostics__head { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.voiced-diagnostics__label { + flex: 1 1 auto; + min-width: 0; +} + +.voiced-diagnostics__ms { + color: var(--cds-text-secondary); + font-size: 0.75rem; + white-space: nowrap; +} + +.voiced-diagnostics__detail { + margin: 0.25rem 0 0; + color: var(--cds-text-secondary); + font-size: 0.75rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/src/views/AppSettings.tsx b/src/views/AppSettings.tsx index 8a8b1d5..32f36c3 100644 --- a/src/views/AppSettings.tsx +++ b/src/views/AppSettings.tsx @@ -4,6 +4,7 @@ import { Column, Grid, InlineNotification, + Link, NumberInput, RadioButton, RadioButtonGroup, @@ -32,6 +33,7 @@ import { } from "../lib/types"; import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators"; import { ImagePicker } from "../components/ImagePicker"; +import DiagnosticsPanel from "../components/DiagnosticsPanel"; const blankBank = (): BankAccount => ({ id: null, @@ -52,6 +54,7 @@ export default function AppSettings({ onSaved: () => void; }) { const [form, setForm] = useState({ ...settings }); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [banks, setBanks] = useState([]); const [editing, setEditing] = useState(null); const [busy, setBusy] = useState(false); @@ -474,6 +477,20 @@ export default function AppSettings({ + +

+ void }) => { + e.preventDefault(); + setDiagnosticsOpen(true); + }} + > + Diagnostics + +

+ setDiagnosticsOpen(false)} /> ); } diff --git a/src/views/InvoiceHistory.tsx b/src/views/InvoiceHistory.tsx index 550df7c..ca53cee 100644 --- a/src/views/InvoiceHistory.tsx +++ b/src/views/InvoiceHistory.tsx @@ -4,6 +4,8 @@ import { InlineLoading, InlineNotification, Modal, + OverflowMenu, + OverflowMenuItem, Table, TableBody, TableCell, @@ -14,10 +16,13 @@ import { Tag, TextInput, } from "@carbon/react"; -import { Download, Close } from "@carbon/icons-react"; import { api } from "../lib/api"; import { formatAmount, formatDate } from "../lib/format"; -import { exportInvoicePdf } from "../lib/pdf"; +import { createExportDeps } from "../lib/exportDeps"; +import { renderAndArchive, type ExportSource } from "../lib/exportFlow"; +import type { ExportMode } from "../lib/filename"; +import { useInvoiceExport } from "../hooks/useInvoiceExport"; +import ExportFeedback from "../components/ExportFeedback"; import type { InvoiceSummary, Settings } from "../lib/types"; export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) { @@ -27,6 +32,14 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting const [busyId, setBusyId] = useState(null); const [cancelTarget, setCancelTarget] = useState(null); const [reason, setReason] = useState(""); + // Whether each invoice has an archived PDF; unknown (absent) until its status was read. + const [archived, setArchived] = useState>({}); + const [notice, setNotice] = useState(null); + const exp = useInvoiceExport({ + settings, + bank: null, + onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })), + }); const load = async () => { try { @@ -44,12 +57,53 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting if (active) void load(); }, [active]); - const onExport = async (id: number) => { + // Read each invoice's archive status in the background; it is a local call per row. + useEffect(() => { + let stop = false; + void (async () => { + for (const row of rows) { + if (stop) return; + try { + const status = await api.archiveStatus(row.id); + if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived })); + } catch { + // Unknown stays unknown: the row just shows no tag. + } + } + })(); + return () => { + stop = true; + }; + }, [rows]); + + const onExport = async (id: number, mode: ExportMode, source: ExportSource) => { setBusyId(id); setError(null); + setNotice(null); + try { + // Fetched now, not at list time: the row summary does not carry the archive fields. + const invoice = await api.getInvoice(id); + await exp.start(invoice, mode, source); + } catch (e) { + setError(String(e)); + } finally { + setBusyId(null); + } + }; + + const onArchive = async (id: number) => { + setBusyId(id); + setError(null); + setNotice(null); try { const invoice = await api.getInvoice(id); - await exportInvoicePdf(invoice, settings, null); + const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null })); + if (outcome.archived) { + setArchived((a) => ({ ...a, [id]: true })); + setNotice(`Invoice ${invoice.number} is archived.`); + } else { + setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`); + } } catch (e) { setError(String(e)); } finally { @@ -79,6 +133,9 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting ) : null} + {notice ? setNotice(null)} /> : null} + + {loading ? ( ) : rows.length === 0 ? ( @@ -104,38 +161,50 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting {row.clientName || "—"} ₹{formatAmount(row.total)} - {row.status === "cancelled" ? ( - - Cancelled - - ) : ( - row.status - )} +
+ {row.status === "cancelled" ? ( + + Cancelled + + ) : ( + row.status + )} + {archived[row.id] === false ? ( + + Not archived + + ) : null} +
- - {row.status === "issued" ? ( - ) : null} + + void onExport(row.id, "searchable", "original")} /> + void onExport(row.id, "flattened", "original")} /> + void onExport(row.id, "searchable", "rerender")} /> + {row.status === "issued" ? ( + { + setReason(""); + setCancelTarget(row); + }} + /> + ) : null} +
diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index 3a25fd1..083fab3 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -28,7 +28,6 @@ import { DocumentAdd, Download, Keyboard, - Renew, Save, TrashCan, } from "@carbon/icons-react"; @@ -51,12 +50,19 @@ import { computeLineAmount, computeTotals } from "../lib/invoice"; import { amountInWords } from "../lib/numberToWords"; import { gstinFullError } from "../lib/validators"; import { addDays, formatAmount, todayIso } from "../lib/format"; -import { exportInvoicePdf, modelFromAssets, prefsForInvoice } from "../lib/pdf"; +import { modelFromAssets, prefsForInvoice } from "../lib/pdf"; import { formToPreviewInvoice } from "../lib/previewInvoice"; import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft"; import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts"; import PageSetupControls from "../components/PageSetupControls"; import { useInvoicePdf } from "../hooks/useInvoicePdf"; +import { useInvoiceExport } from "../hooks/useInvoiceExport"; +import ExportButton, { ExportModePicker } from "../components/ExportButton"; +import ExportFeedback from "../components/ExportFeedback"; +import { createExportDeps } from "../lib/exportDeps"; +import { issueAndArchive, renderAndArchive } from "../lib/exportFlow"; +import { loadExportMode } from "../lib/exportMode"; +import type { ExportMode } from "../lib/filename"; import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs"; import { ImagePicker } from "../components/ImagePicker"; @@ -138,10 +144,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv // Issued invoice shown read-only after a successful issue. Its number is consumed, so the // form never issues again until the user starts a new one. const [issued, setIssued] = useState(null); - const [exporting, setExporting] = useState(false); - const [exportedPath, setExportedPath] = useState(null); - const [exportError, setExportError] = useState(null); + // What the backend recorded when the issued PDF was archived; null until that worked. + const [archive, setArchive] = useState<{ sha256: string; fingerprint: string } | null>(null); + const [archiveError, setArchiveError] = useState(null); + const [archiving, setArchiving] = useState(false); + const [exportMode, setExportMode] = useState(loadExportMode); const locked = issued !== null; + // The issued invoice as the export flow needs it: with its archive fields once archived. + const exportable = useMemo( + () => (issued ? { ...issued, archivedPdfSha256: archive?.sha256 ?? issued.archivedPdfSha256, archivedFingerprint: archive?.fingerprint ?? issued.archivedFingerprint ?? null } : null), + [issued, archive], + ); // Drafts. const [draftId, setDraftId] = useState(null); @@ -392,8 +405,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv const clearIssued = () => { issuedRef.current = false; setIssued(null); - setExportedPath(null); - setExportError(null); + setArchive(null); + setArchiveError(null); + exp.dismiss(); setError(null); }; @@ -517,19 +531,24 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv await refreshDrafts(); }; - const runExport = async (invoice: Invoice) => { - setExporting(true); - setExportError(null); - try { - const path = await exportInvoicePdf(invoice, settings, bank); - setExportedPath(path ?? null); - } catch (e) { - setExportError(String(e)); - } finally { - setExporting(false); + const noteArchived = (invoice: Invoice) => { + if (invoice.archivedPdfSha256) { + setArchive({ sha256: invoice.archivedPdfSha256, fingerprint: invoice.archivedFingerprint ?? "" }); + setArchiveError(null); } }; + const exp = useInvoiceExport({ settings, bank, onInvoiceUpdated: noteArchived }); + + const retryArchive = async () => { + if (!exportable || archiving) return; + setArchiving(true); + const outcome = await renderAndArchive(exportable, createExportDeps({ settings, bank })); + if (outcome.archived) noteArchived(outcome.invoice); + else setArchiveError(outcome.error ?? "The PDF could not be archived."); + setArchiving(false); + }; + const onSave = async () => { if (locked || saving) return; if (clientGstinError) { @@ -538,9 +557,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv } setSaving(true); setError(null); - setExportedPath(null); - setExportError(null); - let invoice: Invoice; + exp.dismiss(); + const mode = loadExportMode(); + let outcome: Awaited>; try { // Let a pending autosave land first so the issue call knows which draft to delete. await chain.current; @@ -565,19 +584,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv draftId: draftIdRef.current, items: items.map((item, index) => ({ ...item, sortOrder: index })), }; - invoice = await api.issueInvoice(input, activePrefs); + outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank })); } catch (e) { issuedRef.current = false; setError(String(e)); setSaving(false); return; } - // The number is consumed from here on. A failed export must never lead to a second issue. - setIssued(invoice); + // The number is consumed from here on. A failed archive or export must never lead to a second issue. + setIssued(outcome.invoice); + if (outcome.archived) noteArchived(outcome.invoice); + else setArchiveError(outcome.error ?? "The PDF could not be archived."); detachDraft(); api.listClients().then(setClients).catch(() => {}); void refreshDrafts(); - await runExport(invoice); + // Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works. + await exp.start(outcome.invoice, mode, "original"); setSaving(false); }; @@ -590,7 +612,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv }); const otherDrafts = drafts.filter((d) => d.id !== draftId); - const busy = saving || exporting; + const busy = saving || exp.busy || archiving; // Yield the shared render worker to the export; the model changes back when it is done and re-renders. const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs }); @@ -637,9 +659,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv - +
+ + +
)} @@ -673,48 +698,27 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv onCloseButtonClick={() => setNoticeOpen(false)} /> ) : null} - {locked ? ( + {locked && exportable ? ( <> - {exportError ? ( - void retryArchive()} /> - ) : exportedPath ? ( - - ) : exporting ? ( - - ) : ( - - )} + ) : null} +
- - -