Add searchable and flattened export, history re-export and diagnostics self-test
issueAndArchive keeps an issued invoice even when rendering or archiving fails (Issued - not archived, with a Render & archive action). The export menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is the invoice number alone; flatten failures offer Retry or searchable export and never silently lower the resolution. History re-exports the original as issued by default and warns when a re-render's layout fingerprint differs. The render client gains a FIFO assemble lane that previews cannot supersede. A Diagnostics panel runs fixtures in the real worker and compares against goldens generated from Node.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, ComposedModal, InlineLoading, InlineNotification, ModalBody, ModalFooter, ModalHeader, Tag } from "@carbon/react";
|
||||
import { reportToText, runSelfTest, type Check, type SelfTestReport } from "../lib/selfTest";
|
||||
|
||||
const STATUS_TAG: Record<Check["status"], { type: "green" | "red" | "gray"; text: string }> = {
|
||||
pass: { type: "green", text: "Pass" },
|
||||
fail: { type: "red", text: "Fail" },
|
||||
info: { type: "gray", text: "Info" },
|
||||
};
|
||||
|
||||
/** Renders the fixtures in this webview's real render client and reports what it found. */
|
||||
export default function DiagnosticsPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [report, setReport] = useState<SelfTestReport | null>(null);
|
||||
const [fatal, setFatal] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<"ok" | "failed" | null>(null);
|
||||
const runId = useRef(0);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const id = ++runId.current;
|
||||
setReport(null);
|
||||
setFatal(null);
|
||||
setCopied(null);
|
||||
try {
|
||||
// Loaded on demand: the fixtures, goldens and pdf.js are only needed here.
|
||||
const { browserSelfTestDeps } = await import("../lib/selfTestBrowser");
|
||||
await runSelfTest(browserSelfTestDeps(), (r) => {
|
||||
if (runId.current === id) setReport(r);
|
||||
});
|
||||
} catch (e) {
|
||||
if (runId.current === id) setFatal(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void run();
|
||||
else runId.current++;
|
||||
}, [open, run]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!report) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(reportToText(report));
|
||||
setCopied("ok");
|
||||
} catch {
|
||||
setCopied("failed");
|
||||
}
|
||||
};
|
||||
|
||||
const running = open && !fatal && !report?.done;
|
||||
|
||||
return (
|
||||
<ComposedModal open={open} onClose={onClose} size="md" preventCloseOnClickOutside aria-label="Diagnostics">
|
||||
<ModalHeader title="Diagnostics" label="Settings" />
|
||||
<ModalBody hasScrollingContent>
|
||||
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
|
||||
Renders two sample invoices in this app's own renderer, reads them back and compares them with the reference layouts. Nothing is saved or sent.
|
||||
</p>
|
||||
{fatal ? <InlineNotification kind="error" lowContrast hideCloseButton title="The self-test could not run" subtitle={fatal} /> : null}
|
||||
{report && report.done ? (
|
||||
<InlineNotification
|
||||
kind={report.passed ? "success" : "error"}
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={report.passed ? "All checks passed" : "Some checks failed"}
|
||||
subtitle={report.passed ? undefined : "Copy the report and include it when you report the problem."}
|
||||
/>
|
||||
) : null}
|
||||
{report?.sections.map((section) => (
|
||||
<section key={section.title} style={{ marginTop: "1.5rem" }} aria-label={section.title}>
|
||||
<h4 style={{ marginBottom: "0.5rem" }}>{section.title}</h4>
|
||||
{section.checks.map((check, i) => (
|
||||
<div key={`${check.label}-${i}`} className="voiced-diagnostics__check">
|
||||
<div className="voiced-diagnostics__head">
|
||||
<Tag type={STATUS_TAG[check.status].type} size="sm">
|
||||
{STATUS_TAG[check.status].text}
|
||||
</Tag>
|
||||
<span className="voiced-diagnostics__label">{check.label}</span>
|
||||
{check.ms !== undefined ? <span className="voiced-diagnostics__ms">{check.ms} ms</span> : null}
|
||||
</div>
|
||||
{check.detail ? <p className="voiced-diagnostics__detail">{check.detail}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
{running ? (
|
||||
<div style={{ marginTop: "1.5rem" }}>
|
||||
<InlineLoading description="Running checks…" />
|
||||
</div>
|
||||
) : null}
|
||||
{copied ? (
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<InlineNotification
|
||||
kind={copied === "ok" ? "success" : "warning"}
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={copied === "ok" ? "Report copied" : "Could not copy"}
|
||||
subtitle={copied === "ok" ? undefined : "The clipboard is not available here."}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button kind="secondary" onClick={() => void run()} disabled={running}>
|
||||
Run again
|
||||
</Button>
|
||||
<Button kind="secondary" onClick={() => void copy()} disabled={!report}>
|
||||
Copy report
|
||||
</Button>
|
||||
<Button kind="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ComposedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { MenuButton, MenuItem, OverflowMenu, OverflowMenuItem } from "@carbon/react";
|
||||
import { ChevronDown } from "@carbon/icons-react";
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import { EXPORT_MODE_LABEL, saveExportMode } from "../lib/exportMode";
|
||||
|
||||
const MODES: ExportMode[] = ["searchable", "flattened"];
|
||||
|
||||
/** "Export PDF" menu for an issued invoice. Choosing a mode also makes it the remembered one. */
|
||||
export default function ExportButton({ onExport, disabled }: { onExport: (mode: ExportMode) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<MenuButton label="Export PDF" kind="tertiary" disabled={disabled} menuAlignment="bottom-start">
|
||||
{MODES.map((mode) => (
|
||||
<MenuItem
|
||||
key={mode}
|
||||
label={EXPORT_MODE_LABEL[mode]}
|
||||
onClick={() => {
|
||||
saveExportMode(mode);
|
||||
onExport(mode);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** The format the primary "Issue & export" action uses; a small menu next to that button. */
|
||||
export function ExportModePicker({ mode, onChange, disabled }: { mode: ExportMode; onChange: (mode: ExportMode) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<OverflowMenu
|
||||
aria-label="Choose the PDF format for Issue & export"
|
||||
iconDescription={`PDF format: ${EXPORT_MODE_LABEL[mode]}`}
|
||||
renderIcon={ChevronDown}
|
||||
flipped
|
||||
size="lg"
|
||||
disabled={disabled}
|
||||
>
|
||||
{MODES.map((m) => (
|
||||
<OverflowMenuItem
|
||||
key={m}
|
||||
itemText={m === mode ? `${EXPORT_MODE_LABEL[m]} (selected)` : EXPORT_MODE_LABEL[m]}
|
||||
onClick={() => {
|
||||
saveExportMode(m);
|
||||
onChange(m);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</OverflowMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, InlineLoading, InlineNotification } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { InvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import FlattenProgressModal from "./FlattenProgressModal";
|
||||
|
||||
/** Progress modal, spinner, success and error notices for one `useInvoiceExport`. */
|
||||
export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
||||
const { busy, progress, request, error, result } = exp;
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
useEffect(() => setActionError(null), [result]);
|
||||
|
||||
const flattenFailed = request?.mode === "flattened" && error?.flatten === true;
|
||||
const modalOpen = request?.mode === "flattened" && ((busy && progress?.phase !== "saving") || flattenFailed);
|
||||
|
||||
const run = async (action: () => Promise<void>) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
setActionError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlattenProgressModal
|
||||
open={modalOpen}
|
||||
progress={progress}
|
||||
error={flattenFailed ? error!.message : null}
|
||||
onCancel={exp.cancel}
|
||||
onRetry={exp.retry}
|
||||
onExportSearchable={exp.exportSearchableInstead}
|
||||
onClose={exp.dismiss}
|
||||
/>
|
||||
|
||||
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
|
||||
|
||||
{error && !flattenFailed ? (
|
||||
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}>
|
||||
<div className="voiced-notice-actions">
|
||||
<Button kind="ghost" size="sm" onClick={exp.retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</InlineNotification>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title={`Saved to ${result.path}`}
|
||||
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
|
||||
onCloseButtonClick={exp.dismiss}
|
||||
>
|
||||
<div className="voiced-notice-actions">
|
||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
||||
Open file
|
||||
</Button>
|
||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
|
||||
Show in folder
|
||||
</Button>
|
||||
</div>
|
||||
</InlineNotification>
|
||||
{result.identicalToIssued === false ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="Layout differs from the issued invoice"
|
||||
subtitle="The current template lays this invoice out differently from the PDF issued. The archived original is unchanged; use Export PDF to get it."
|
||||
/>
|
||||
) : null}
|
||||
{result.archiveError ? (
|
||||
<InlineNotification kind="warning" lowContrast hideCloseButton title="This invoice is still not archived" subtitle={result.archiveError} />
|
||||
) : null}
|
||||
{actionError ? <InlineNotification kind="error" lowContrast title="Could not open the file" subtitle={actionError} onCloseButtonClick={() => setActionError(null)} /> : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button, ComposedModal, InlineNotification, ModalBody, ModalFooter, ModalHeader, ProgressBar } from "@carbon/react";
|
||||
import type { ExportProgress } from "../lib/exportFlow";
|
||||
|
||||
export interface FlattenProgressModalProps {
|
||||
open: boolean;
|
||||
progress: ExportProgress | null;
|
||||
/** The flatten failed: show it with Retry and "Export searchable instead". */
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onExportSearchable: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function describe(p: ExportProgress | null): { label: string; value?: number; max: number } {
|
||||
if (p?.phase === "flattening" && p.page && p.pages) {
|
||||
// The page being rendered counts as in progress, so the bar shows the pages finished before it.
|
||||
return { label: `Rendering page ${p.page} of ${p.pages}…`, value: p.page - 1, max: p.pages };
|
||||
}
|
||||
if (p?.phase === "assembling") return { label: "Building the PDF…", value: 1, max: 1 };
|
||||
return { label: "Preparing the invoice…", max: 1 };
|
||||
}
|
||||
|
||||
export default function FlattenProgressModal({ open, progress, error, onCancel, onRetry, onExportSearchable, onClose }: FlattenProgressModalProps) {
|
||||
const { label, value, max } = describe(progress);
|
||||
return (
|
||||
<ComposedModal open={open} size="sm" preventCloseOnClickOutside onClose={() => (error ? onClose() : onCancel())} aria-label="Flattened PDF export">
|
||||
<ModalHeader title="Flattened PDF" label="Export" />
|
||||
<ModalBody hasForm={false}>
|
||||
{error ? (
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="The flattened PDF could not be made"
|
||||
subtitle={`${error} Nothing was saved, and the resolution was not lowered.`}
|
||||
/>
|
||||
) : (
|
||||
<ProgressBar label={label} value={value} max={max} helperText="Each page is drawn at 300 DPI; this takes a few seconds per page." />
|
||||
)}
|
||||
</ModalBody>
|
||||
{error ? (
|
||||
// Three direct children: Carbon lays a three-button footer out differently from a two-button one.
|
||||
<ModalFooter>
|
||||
<Button kind="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button kind="tertiary" onClick={onExportSearchable}>
|
||||
Export searchable instead
|
||||
</Button>
|
||||
<Button kind="primary" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
) : (
|
||||
<ModalFooter>
|
||||
<Button kind="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</ComposedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createExportDeps } from "../lib/exportDeps";
|
||||
import { exportInvoice, isExportCancelled, isFlattenFailure, type ExportProgress, type ExportResult, type ExportSource } from "../lib/exportFlow";
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import type { BankAccount, Invoice, Settings } from "../lib/types";
|
||||
|
||||
export interface ExportRequestState {
|
||||
invoice: Invoice;
|
||||
mode: ExportMode;
|
||||
source: ExportSource;
|
||||
}
|
||||
|
||||
export interface ExportFailure {
|
||||
message: string;
|
||||
/** The flatten itself failed: the modal offers Retry and "Export searchable instead". */
|
||||
flatten: boolean;
|
||||
}
|
||||
|
||||
export interface InvoiceExport {
|
||||
busy: boolean;
|
||||
progress: ExportProgress | null;
|
||||
request: ExportRequestState | null;
|
||||
error: ExportFailure | null;
|
||||
result: ExportResult | null;
|
||||
start(invoice: Invoice, mode: ExportMode, source: ExportSource): Promise<ExportResult | null>;
|
||||
cancel(): void;
|
||||
retry(): void;
|
||||
exportSearchableInstead(): void;
|
||||
dismiss(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs exports one at a time and keeps what the UI shows: progress, the last result, the last error and
|
||||
* the request, so Retry and "Export searchable instead" can repeat it. `onInvoiceUpdated` hears about an
|
||||
* invoice that was archived by the export.
|
||||
*/
|
||||
export function useInvoiceExport(opts: { settings: Settings; bank: BankAccount | null; onInvoiceUpdated?: (invoice: Invoice) => void }): InvoiceExport {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [progress, setProgress] = useState<ExportProgress | null>(null);
|
||||
const [request, setRequest] = useState<ExportRequestState | null>(null);
|
||||
const [error, setError] = useState<ExportFailure | null>(null);
|
||||
const [result, setResult] = useState<ExportResult | null>(null);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const running = useRef(false);
|
||||
|
||||
const latest = useRef(opts);
|
||||
latest.current = opts;
|
||||
|
||||
const start = useCallback(async (invoice: Invoice, mode: ExportMode, source: ExportSource) => {
|
||||
if (running.current) return null;
|
||||
running.current = true;
|
||||
const ctl = new AbortController();
|
||||
controller.current = ctl;
|
||||
setRequest({ invoice, mode, source });
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress({ phase: "rendering" });
|
||||
try {
|
||||
const deps = createExportDeps({ settings: latest.current.settings, bank: latest.current.bank });
|
||||
const res = await exportInvoice({ invoice, mode, source, signal: ctl.signal, onProgress: setProgress }, deps);
|
||||
if (res) {
|
||||
setResult(res);
|
||||
if (res.invoice !== invoice) latest.current.onInvoiceUpdated?.(res.invoice);
|
||||
}
|
||||
return res;
|
||||
} catch (e) {
|
||||
if (!isExportCancelled(e)) setError({ message: e instanceof Error ? e.message : String(e), flatten: isFlattenFailure(e) });
|
||||
return null;
|
||||
} finally {
|
||||
running.current = false;
|
||||
controller.current = null;
|
||||
setBusy(false);
|
||||
setProgress(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => controller.current?.abort(), []);
|
||||
const dismiss = useCallback(() => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
}, []);
|
||||
const retry = useCallback(() => {
|
||||
if (request) void start(request.invoice, request.mode, request.source);
|
||||
}, [request, start]);
|
||||
const exportSearchableInstead = useCallback(() => {
|
||||
if (request) void start(request.invoice, "searchable", request.source);
|
||||
}, [request, start]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss }),
|
||||
[busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { flattenPages, type CanvasLike, type FlattenResult, type PdfDocLike } from "../pdf/flatten";
|
||||
|
||||
/** Lets the browser paint and handle input; falls back to a timer when rAF is paused (hidden window). */
|
||||
export const yieldToBrowser = (): Promise<void> =>
|
||||
new Promise<void>((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
setTimeout(finish, 50);
|
||||
requestAnimationFrame(() => setTimeout(finish, 0));
|
||||
});
|
||||
|
||||
/** JPEG bytes of a canvas via the browser's encoder. */
|
||||
export function encodeCanvasJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
(canvas as unknown as HTMLCanvasElement).toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error("the browser could not encode the page image (out of memory?)"));
|
||||
blob.arrayBuffer().then((buf) => resolve(new Uint8Array(buf)), reject);
|
||||
},
|
||||
"image/jpeg",
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface BrowserFlattenOptions {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (page: number, pages: number) => void;
|
||||
}
|
||||
|
||||
/** Flattens PDF bytes at 300 DPI with pdf.js and a DOM canvas; opens and closes the document itself. */
|
||||
export async function flattenInBrowser(bytes: Uint8Array, opts: BrowserFlattenOptions = {}): Promise<FlattenResult> {
|
||||
const { openPdf, closePdf } = await import("./pdfjs");
|
||||
const doc = await openPdf(bytes);
|
||||
try {
|
||||
return await flattenPages({
|
||||
pdf: doc as unknown as PdfDocLike,
|
||||
createCanvas: (width, height) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas as unknown as CanvasLike;
|
||||
},
|
||||
encodeJpeg: encodeCanvasJpeg,
|
||||
yieldToUi: yieldToBrowser,
|
||||
signal: opts.signal,
|
||||
onProgress: opts.onProgress,
|
||||
});
|
||||
} finally {
|
||||
await closePdf(doc).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "./api";
|
||||
import { flattenInBrowser } from "./browserFlatten";
|
||||
import type { ExportDeps } from "./exportFlow";
|
||||
import { renderInvoicePdf } from "./pdf";
|
||||
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||
import type { BankAccount, Settings } from "./types";
|
||||
|
||||
/** The real collaborators of the export flow: Tauri commands, the render client, pdf.js and the save dialog. */
|
||||
export function createExportDeps(ctx: { settings: Settings; bank: BankAccount | null }): ExportDeps {
|
||||
return {
|
||||
issueInvoice: (input, renderPrefs) => api.issueInvoice(input, renderPrefs),
|
||||
render: async (invoice) => {
|
||||
const { bytes, fingerprint } = await renderInvoicePdf(invoice, ctx.settings, ctx.bank);
|
||||
return { bytes, fingerprint };
|
||||
},
|
||||
archivePdf: api.archivePdf,
|
||||
readArchive: api.readArchive,
|
||||
flatten: (bytes, opts) => flattenInBrowser(bytes, opts),
|
||||
assemble: async (pages, title) => (await getRenderClient().assemble({ id: nextRenderJobId(), pages, title })).bytes,
|
||||
getLastExportDir: api.getLastExportDir,
|
||||
save: ({ defaultPath }) => save({ defaultPath, filters: [{ name: "PDF document", extensions: ["pdf"] }] }),
|
||||
writeExportFile: api.writeExportFile,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FlattenCancelled, FlattenError } from "../pdf/flatten";
|
||||
import { exportInvoice, ExportError, isFlattenFailure, issueAndArchive, renderAndArchive, type ExportDeps, type ExportProgress } from "./exportFlow";
|
||||
import type { Invoice, InvoiceInput } from "./types";
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s);
|
||||
const text = (b: Uint8Array) => new TextDecoder().decode(b);
|
||||
|
||||
const invoice = (over: Partial<Invoice> = {}): Invoice =>
|
||||
({
|
||||
id: 7,
|
||||
number: "AP/2026-007",
|
||||
clientName: "Acme Learning",
|
||||
invoiceDate: "2026-09-15",
|
||||
status: "issued",
|
||||
renderPrefs: '{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"template"}',
|
||||
archivedPdfSha256: "a".repeat(64),
|
||||
archivedFingerprint: "fp-issued",
|
||||
items: [],
|
||||
...over,
|
||||
}) as Invoice;
|
||||
|
||||
function fakes(over: Partial<ExportDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const written: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||
const deps: ExportDeps = {
|
||||
issueInvoice: vi.fn(async () => (calls.push("issue"), invoice({ archivedPdfSha256: null, archivedFingerprint: null }))),
|
||||
render: vi.fn(async () => (calls.push("render"), { bytes: bytes("RENDERED"), fingerprint: "fp-now" })),
|
||||
archivePdf: vi.fn(async () => (calls.push("archive"), "b".repeat(64))),
|
||||
readArchive: vi.fn(async () => (calls.push("readArchive"), bytes("ARCHIVED"))),
|
||||
flatten: vi.fn(async (b, o) => {
|
||||
calls.push("flatten");
|
||||
o.onProgress?.(1, 2);
|
||||
o.onProgress?.(2, 2);
|
||||
return {
|
||||
pages: [1, 2].map((n) => ({ jpeg: bytes(`${text(b)}-jpeg${n}`), widthPt: 595, heightPt: 842, pxWidth: 2480, pxHeight: 3508, renderMs: 1, encodeMs: 1 })),
|
||||
};
|
||||
}),
|
||||
assemble: vi.fn(async (pages, title) => (calls.push("assemble"), bytes(`FLAT[${title}|${pages.map((p: { jpeg: Uint8Array }) => text(p.jpeg)).join(",")}]`))),
|
||||
getLastExportDir: vi.fn(async () => "/home/u/Documents"),
|
||||
save: vi.fn(async ({ defaultPath }) => (calls.push("save"), defaultPath)),
|
||||
writeExportFile: vi.fn(async (path, b) => (calls.push("write"), written.push({ path, bytes: b }), path)),
|
||||
...over,
|
||||
};
|
||||
return { deps, calls, written };
|
||||
}
|
||||
|
||||
describe("issueAndArchive / renderAndArchive", () => {
|
||||
it("issues, renders the issued invoice and archives it with the layout fingerprint", async () => {
|
||||
const { deps, calls } = fakes();
|
||||
const out = await issueAndArchive({} as InvoiceInput, { pageSize: "LETTER" }, deps);
|
||||
expect(calls).toEqual(["issue", "render", "archive"]);
|
||||
expect(deps.issueInvoice).toHaveBeenCalledWith({}, { pageSize: "LETTER" });
|
||||
// The render is of the invoice the backend returned (so its frozen prefs), not of the form.
|
||||
expect((deps.render as ReturnType<typeof vi.fn>).mock.calls[0][0].renderPrefs).toContain("LETTER");
|
||||
expect(deps.archivePdf).toHaveBeenCalledWith(7, bytes("RENDERED"), "fp-now");
|
||||
expect(out).toMatchObject({ archived: true });
|
||||
expect(out.invoice).toMatchObject({ archivedPdfSha256: "b".repeat(64), archivedFingerprint: "fp-now" });
|
||||
});
|
||||
|
||||
it("a render failure keeps the invoice and reports the error", async () => {
|
||||
const { deps } = fakes({ render: vi.fn(async () => Promise.reject(new Error("worker died"))) });
|
||||
const out = await issueAndArchive({} as InvoiceInput, {}, deps);
|
||||
expect(out.archived).toBe(false);
|
||||
expect(out.invoice.id).toBe(7);
|
||||
expect(out.error).toContain("worker died");
|
||||
expect(deps.archivePdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an archive failure keeps the invoice and reports the error", async () => {
|
||||
const { deps } = fakes({ archivePdf: vi.fn(async () => Promise.reject("disk full")) });
|
||||
const out = await issueAndArchive({} as InvoiceInput, {}, deps);
|
||||
expect(out).toMatchObject({ archived: false });
|
||||
expect(out.error).toContain("disk full");
|
||||
});
|
||||
|
||||
it("a failed issue throws: nothing was issued, nothing is rendered", async () => {
|
||||
const { deps } = fakes({ issueInvoice: vi.fn(async () => Promise.reject(new Error("series locked"))) });
|
||||
await expect(issueAndArchive({} as InvoiceInput, {}, deps)).rejects.toThrow("series locked");
|
||||
expect(deps.render).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renderAndArchive retries for an invoice issued without an archive", async () => {
|
||||
const { deps } = fakes();
|
||||
const out = await renderAndArchive(invoice({ archivedPdfSha256: null, archivedFingerprint: null }), deps);
|
||||
expect(out.archived).toBe(true);
|
||||
expect(out.invoice.archivedPdfSha256).toBe("b".repeat(64));
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportInvoice", () => {
|
||||
it("original: exports the archived bytes exactly and never renders", async () => {
|
||||
const { deps, written } = fakes();
|
||||
const r = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, deps);
|
||||
expect(deps.render).not.toHaveBeenCalled();
|
||||
expect(deps.readArchive).toHaveBeenCalledWith(7);
|
||||
expect(text(written[0].bytes)).toBe("ARCHIVED");
|
||||
expect(r).toMatchObject({ mode: "searchable", path: "/home/u/Documents/AP-2026-007_Acme Learning_2026-09-15.pdf" });
|
||||
expect(r?.identicalToIssued).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rerender: renders now (not the archive) and reports whether the layout matches the issued one", async () => {
|
||||
const same = fakes({ render: vi.fn(async () => ({ bytes: bytes("NEW"), fingerprint: "fp-issued" })) });
|
||||
const a = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, same.deps);
|
||||
expect(same.deps.readArchive).not.toHaveBeenCalled();
|
||||
expect(text(same.written[0].bytes)).toBe("NEW");
|
||||
expect(a?.identicalToIssued).toBe(true);
|
||||
|
||||
const diff = fakes();
|
||||
const b = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, diff.deps);
|
||||
expect(b?.identicalToIssued).toBe(false);
|
||||
// The archive is never touched by a re-render.
|
||||
expect(diff.deps.archivePdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rerender of an invoice with nothing archived has nothing to compare with", async () => {
|
||||
const { deps } = fakes();
|
||||
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null, archivedFingerprint: null }), mode: "searchable", source: "rerender" }, deps);
|
||||
expect(r && "identicalToIssued" in r).toBe(false);
|
||||
expect(deps.archivePdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("not archived: renders, archives, then exports that render and returns the healed invoice", async () => {
|
||||
const { deps, calls, written } = fakes();
|
||||
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null, archivedFingerprint: null }), mode: "searchable", source: "original" }, deps);
|
||||
expect(calls).toEqual(["render", "archive", "save", "write"]);
|
||||
expect(text(written[0].bytes)).toBe("RENDERED");
|
||||
expect(r?.invoice.archivedPdfSha256).toBe("b".repeat(64));
|
||||
expect(r?.archiveError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("not archived and the archive fails: still exports the render and says so", async () => {
|
||||
const { deps, written } = fakes({ archivePdf: vi.fn(async () => Promise.reject(new Error("read-only"))) });
|
||||
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null }), mode: "searchable", source: "original" }, deps);
|
||||
expect(text(written[0].bytes)).toBe("RENDERED");
|
||||
expect(r?.archiveError).toContain("read-only");
|
||||
expect(r?.invoice.archivedPdfSha256).toBeNull();
|
||||
});
|
||||
|
||||
it("flattened: flattens the chosen source, assembles with the invoice number as title, names the file -flat", async () => {
|
||||
const { deps, written } = fakes();
|
||||
const progress: ExportProgress[] = [];
|
||||
const r = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original", onProgress: (p) => progress.push(p) }, deps);
|
||||
expect((deps.flatten as ReturnType<typeof vi.fn>).mock.calls[0][0]).toEqual(bytes("ARCHIVED"));
|
||||
expect(text(written[0].bytes)).toBe("FLAT[AP/2026-007|ARCHIVED-jpeg1,ARCHIVED-jpeg2]");
|
||||
expect(r?.path.endsWith("-flat.pdf")).toBe(true);
|
||||
expect(progress.map((p) => p.phase)).toEqual(["rendering", "flattening", "flattening", "assembling", "saving"]);
|
||||
expect(progress[1]).toMatchObject({ page: 1, pages: 2 });
|
||||
});
|
||||
|
||||
it("flattened rerender flattens the fresh render", async () => {
|
||||
const { deps, written } = fakes();
|
||||
await exportInvoice({ invoice: invoice(), mode: "flattened", source: "rerender" }, deps);
|
||||
expect(text(written[0].bytes)).toContain("RENDERED-jpeg1");
|
||||
});
|
||||
|
||||
it("a flatten failure surfaces as FLATTEN_FAILED: no searchable fallback, nothing saved", async () => {
|
||||
const { deps, calls } = fakes({ flatten: vi.fn(async () => Promise.reject(new FlattenError(2, "out of memory"))) });
|
||||
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(FlattenError);
|
||||
expect(isFlattenFailure(err)).toBe(true);
|
||||
expect(calls).not.toContain("save");
|
||||
expect(calls).not.toContain("write");
|
||||
expect(deps.flatten).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("an assemble failure is also FLATTEN_FAILED", async () => {
|
||||
const { deps } = fakes({ assemble: vi.fn(async () => Promise.reject(new Error("worker timed out"))) });
|
||||
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ExportError);
|
||||
expect(err.code).toBe("FLATTEN_FAILED");
|
||||
expect(err.message).toContain("worker timed out");
|
||||
});
|
||||
|
||||
it("cancel: an aborted signal stops the flow with FlattenCancelled and saves nothing", async () => {
|
||||
const ctl = new AbortController();
|
||||
const { deps, calls } = fakes({
|
||||
flatten: vi.fn(async (_b, o) => {
|
||||
ctl.abort();
|
||||
o.onProgress?.(1, 2);
|
||||
return { pages: [] };
|
||||
}),
|
||||
});
|
||||
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original", signal: ctl.signal }, deps).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(FlattenCancelled);
|
||||
expect(calls).not.toContain("assemble");
|
||||
expect(calls).not.toContain("save");
|
||||
|
||||
const pre = new AbortController();
|
||||
pre.abort();
|
||||
const f2 = fakes();
|
||||
await expect(exportInvoice({ invoice: invoice(), mode: "searchable", source: "original", signal: pre.signal }, f2.deps)).rejects.toBeInstanceOf(FlattenCancelled);
|
||||
expect(f2.deps.readArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a FlattenCancelled thrown by flatten passes through unchanged", async () => {
|
||||
const { deps } = fakes({ flatten: vi.fn(async () => Promise.reject(new FlattenCancelled())) });
|
||||
await expect(exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps)).rejects.toBeInstanceOf(FlattenCancelled);
|
||||
});
|
||||
|
||||
it("dismissing the save dialog resolves null and writes nothing", async () => {
|
||||
const { deps, calls } = fakes({ save: vi.fn(async () => null) });
|
||||
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, deps)).toBeNull();
|
||||
expect(calls).not.toContain("write");
|
||||
});
|
||||
|
||||
it("opens the dialog in the last folder, handles Windows folders, and survives a missing folder", async () => {
|
||||
const win = fakes({ getLastExportDir: vi.fn(async () => "C:\\Users\\arun\\Documents") });
|
||||
await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, win.deps);
|
||||
expect((win.deps.save as ReturnType<typeof vi.fn>).mock.calls[0][0].defaultPath).toBe("C:\\Users\\arun\\Documents\\AP-2026-007_Acme Learning_2026-09-15.pdf");
|
||||
|
||||
const none = fakes({ getLastExportDir: vi.fn(async () => Promise.reject(new Error("no dir"))) });
|
||||
await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, none.deps);
|
||||
expect((none.deps.save as ReturnType<typeof vi.fn>).mock.calls[0][0].defaultPath).toBe("AP-2026-007_Acme Learning_2026-09-15.pdf");
|
||||
});
|
||||
|
||||
it("a write failure is WRITE_FAILED; a render failure on re-render is RENDER_FAILED", async () => {
|
||||
const w = fakes({ writeExportFile: vi.fn(async () => Promise.reject("permission denied")) });
|
||||
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, w.deps).catch((e) => e)).toMatchObject({ code: "WRITE_FAILED" });
|
||||
const r = fakes({ render: vi.fn(async () => Promise.reject(new Error("fonts"))) });
|
||||
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, r.deps).catch((e) => e)).toMatchObject({ code: "RENDER_FAILED" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { FlattenCancelled } from "../pdf/flatten";
|
||||
import type { FlattenResult } from "../pdf/flatten";
|
||||
import type { AssemblePageData } from "../pdf/render/jobs";
|
||||
import { exportFileName, type ExportMode } from "./filename";
|
||||
import type { Invoice, InvoiceInput } from "./types";
|
||||
|
||||
export type { ExportMode };
|
||||
export type ExportSource = "original" | "rerender";
|
||||
|
||||
/** Everything the flow touches outside itself, so it can run against fakes. */
|
||||
export interface ExportDeps {
|
||||
issueInvoice(input: InvoiceInput, renderPrefs: object): Promise<Invoice>;
|
||||
/** Renders with the invoice's frozen prefs and supplier snapshot and the current template code. */
|
||||
render(invoice: Invoice): Promise<{ bytes: Uint8Array; fingerprint: string }>;
|
||||
archivePdf(invoiceId: number, bytes: Uint8Array, fingerprint: string): Promise<string>;
|
||||
readArchive(invoiceId: number): Promise<Uint8Array>;
|
||||
/** Rasterises PDF bytes page by page. Throws FlattenError / FlattenCancelled. */
|
||||
flatten(bytes: Uint8Array, opts: { signal?: AbortSignal; onProgress?: (page: number, pages: number) => void }): Promise<FlattenResult>;
|
||||
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
|
||||
getLastExportDir(): Promise<string>;
|
||||
/** The save dialog: the chosen path, or null when it is dismissed. */
|
||||
save(opts: { defaultPath: string }): Promise<string | null>;
|
||||
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
|
||||
}
|
||||
|
||||
export type ExportPhase = "rendering" | "flattening" | "assembling" | "saving";
|
||||
|
||||
export interface ExportProgress {
|
||||
phase: ExportPhase;
|
||||
/** While flattening: the page being rendered and the page count. */
|
||||
page?: number;
|
||||
pages?: number;
|
||||
}
|
||||
|
||||
export type ExportErrorCode = "RENDER_FAILED" | "ARCHIVE_FAILED" | "FLATTEN_FAILED" | "WRITE_FAILED";
|
||||
|
||||
export class ExportError extends Error {
|
||||
constructor(
|
||||
readonly code: ExportErrorCode,
|
||||
message: string,
|
||||
readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ExportError";
|
||||
}
|
||||
}
|
||||
|
||||
const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
export const isFlattenFailure = (e: unknown): boolean => (e as { code?: unknown } | null)?.code === "FLATTEN_FAILED";
|
||||
export const isExportCancelled = (e: unknown): boolean => e instanceof FlattenCancelled || (e as { name?: string } | null)?.name === "FlattenCancelled";
|
||||
|
||||
const checkAbort = (signal?: AbortSignal) => {
|
||||
if (signal?.aborted) throw new FlattenCancelled();
|
||||
};
|
||||
|
||||
// --- archive at issue ------------------------------------------------------------------------------
|
||||
|
||||
export interface ArchiveOutcome {
|
||||
/** The invoice with its archive fields filled in when archiving worked. */
|
||||
invoice: Invoice;
|
||||
archived: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Fields the backend sets when it archives, mirrored locally so the UI needs no refetch. */
|
||||
const withArchive = (invoice: Invoice, sha256: string, fingerprint: string): Invoice => ({
|
||||
...invoice,
|
||||
archivedPdfSha256: sha256,
|
||||
archivedFingerprint: fingerprint,
|
||||
archivedAt: invoice.archivedAt ?? new Date().toISOString(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders an issued invoice with its frozen prefs and archives the result. Never throws: a failure
|
||||
* comes back as `archived: false` with the reason, because the invoice already exists.
|
||||
*/
|
||||
export async function renderAndArchive(invoice: Invoice, deps: Pick<ExportDeps, "render" | "archivePdf">): Promise<ArchiveOutcome> {
|
||||
let rendered: { bytes: Uint8Array; fingerprint: string };
|
||||
try {
|
||||
rendered = await deps.render(invoice);
|
||||
} catch (e) {
|
||||
return { invoice, archived: false, error: `The PDF could not be rendered: ${messageOf(e)}` };
|
||||
}
|
||||
try {
|
||||
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
|
||||
return { invoice: withArchive(invoice, sha, rendered.fingerprint), archived: true };
|
||||
} catch (e) {
|
||||
return { invoice, archived: false, error: `The PDF could not be archived: ${messageOf(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues the invoice, then renders it with the prefs the backend froze and archives that PDF. The
|
||||
* issue call's own failure throws (nothing was issued); everything after it is reported, never thrown.
|
||||
*/
|
||||
export async function issueAndArchive(
|
||||
input: InvoiceInput,
|
||||
renderPrefs: object,
|
||||
deps: Pick<ExportDeps, "issueInvoice" | "render" | "archivePdf">,
|
||||
): Promise<ArchiveOutcome> {
|
||||
const invoice = await deps.issueInvoice(input, renderPrefs);
|
||||
return renderAndArchive(invoice, deps);
|
||||
}
|
||||
|
||||
// --- export ----------------------------------------------------------------------------------------
|
||||
|
||||
export interface ExportRequest {
|
||||
invoice: Invoice;
|
||||
mode: ExportMode;
|
||||
source: ExportSource;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (p: ExportProgress) => void;
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
path: string;
|
||||
mode: ExportMode;
|
||||
/** Only for a re-render of an archived invoice: did the layout come out the same as at issue? */
|
||||
identicalToIssued?: boolean;
|
||||
/** The invoice with archive fields filled in when this export had to archive it first. */
|
||||
invoice: Invoice;
|
||||
/** The export used a fresh render because archiving the missing original failed. */
|
||||
archiveError?: string;
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
if (!dir) return name;
|
||||
const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
|
||||
return /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${sep}${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the PDF bytes for one export and saves them. Returns null when the save dialog is
|
||||
* dismissed. Errors: FlattenCancelled when aborted; an ExportError (or a FlattenError, which carries
|
||||
* the same `FLATTEN_FAILED` code) otherwise. A failed flatten is never replaced by a searchable export
|
||||
* or a lower resolution; the caller offers that choice.
|
||||
*/
|
||||
export async function exportInvoice(req: ExportRequest, deps: ExportDeps): Promise<ExportResult | null> {
|
||||
const { mode, source, signal, onProgress } = req;
|
||||
let invoice = req.invoice;
|
||||
checkAbort(signal);
|
||||
|
||||
let bytes: Uint8Array;
|
||||
let identicalToIssued: boolean | undefined;
|
||||
let archiveError: string | undefined;
|
||||
|
||||
if (source === "rerender") {
|
||||
onProgress?.({ phase: "rendering" });
|
||||
const rendered = await renderOrThrow(invoice, deps);
|
||||
bytes = rendered.bytes;
|
||||
if (invoice.archivedPdfSha256 && invoice.archivedFingerprint) {
|
||||
identicalToIssued = rendered.fingerprint === invoice.archivedFingerprint;
|
||||
}
|
||||
} else if (invoice.archivedPdfSha256) {
|
||||
onProgress?.({ phase: "rendering" });
|
||||
bytes = await deps.readArchive(invoice.id);
|
||||
} else {
|
||||
// Issued but never archived (the render failed at issue): do it now, then use that file.
|
||||
onProgress?.({ phase: "rendering" });
|
||||
const rendered = await renderOrThrow(invoice, deps);
|
||||
bytes = rendered.bytes;
|
||||
try {
|
||||
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
|
||||
invoice = withArchive(invoice, sha, rendered.fingerprint);
|
||||
} catch (e) {
|
||||
// The invoice is intact; export the fresh render and let the caller say the archive is still missing.
|
||||
archiveError = `The PDF could not be archived: ${messageOf(e)}`;
|
||||
}
|
||||
}
|
||||
checkAbort(signal);
|
||||
|
||||
if (mode === "flattened") {
|
||||
try {
|
||||
const flat = await deps.flatten(bytes, {
|
||||
signal,
|
||||
onProgress: (page, pages) => onProgress?.({ phase: "flattening", page, pages }),
|
||||
});
|
||||
checkAbort(signal);
|
||||
onProgress?.({ phase: "assembling" });
|
||||
// The title is the invoice number only; anything more would leak what the image-only file hides.
|
||||
bytes = await deps.assemble(
|
||||
flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
|
||||
invoice.number,
|
||||
);
|
||||
} catch (e) {
|
||||
if (isExportCancelled(e) || isFlattenFailure(e)) throw e;
|
||||
throw new ExportError("FLATTEN_FAILED", `The flattened PDF could not be built: ${messageOf(e)}`, e);
|
||||
}
|
||||
checkAbort(signal);
|
||||
}
|
||||
|
||||
let dir = "";
|
||||
try {
|
||||
dir = await deps.getLastExportDir();
|
||||
} catch {
|
||||
// No remembered folder: the dialog opens wherever the system prefers.
|
||||
}
|
||||
const name = exportFileName({ number: invoice.number, client: invoice.clientName, date: invoice.invoiceDate, mode });
|
||||
onProgress?.({ phase: "saving" });
|
||||
const chosen = await deps.save({ defaultPath: joinPath(dir, name) });
|
||||
if (!chosen) return null;
|
||||
|
||||
let path: string;
|
||||
try {
|
||||
path = (await deps.writeExportFile(chosen, bytes)) || chosen;
|
||||
} catch (e) {
|
||||
throw new ExportError("WRITE_FAILED", `The file could not be saved: ${messageOf(e)}`, e);
|
||||
}
|
||||
return { path, mode, invoice, ...(identicalToIssued === undefined ? {} : { identicalToIssued }), ...(archiveError ? { archiveError } : {}) };
|
||||
}
|
||||
|
||||
async function renderOrThrow(invoice: Invoice, deps: Pick<ExportDeps, "render">) {
|
||||
try {
|
||||
return await deps.render(invoice);
|
||||
} catch (e) {
|
||||
throw new ExportError("RENDER_FAILED", `The PDF could not be rendered: ${messageOf(e)}`, e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ExportMode } from "./filename";
|
||||
|
||||
const KEY = "voiced.exportMode";
|
||||
|
||||
/** The last export mode this viewer used; searchable until one is chosen. A per-viewer convenience only. */
|
||||
export function loadExportMode(): ExportMode {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
if (v === "searchable" || v === "flattened") return v;
|
||||
} catch {
|
||||
// Storage can be blocked or empty; the default is fine.
|
||||
}
|
||||
return "searchable";
|
||||
}
|
||||
|
||||
export function saveExportMode(mode: ExportMode): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, mode);
|
||||
} catch {
|
||||
// Not remembering is harmless.
|
||||
}
|
||||
}
|
||||
|
||||
export const EXPORT_MODE_LABEL: Record<ExportMode, string> = {
|
||||
searchable: "Searchable PDF (copy-pasteable text)",
|
||||
flattened: "Flattened PDF (image only)",
|
||||
};
|
||||
+5
-23
@@ -1,4 +1,3 @@
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import pkg from "../../package.json";
|
||||
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
|
||||
@@ -57,6 +56,11 @@ export async function renderInvoicePdf(
|
||||
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
|
||||
): Promise<RenderResult> {
|
||||
const model = await buildModelForInvoice(invoice, settings, bank);
|
||||
return renderModelWithRetry(model, prefs);
|
||||
}
|
||||
|
||||
/** Renders a ready model in the shared client. */
|
||||
export async function renderModelWithRetry(model: RenderModel, prefs: RenderPrefsV1): Promise<RenderResult> {
|
||||
// The live preview shares the client; if it takes the worker first this render is superseded, so go again.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
@@ -66,25 +70,3 @@ export async function renderInvoicePdf(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */
|
||||
export async function savePdfBytes(bytes: Uint8Array, fileName: string): Promise<string | null> {
|
||||
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
|
||||
const path = await save({
|
||||
defaultPath: `${suggested}.pdf`,
|
||||
filters: [{ name: "PDF document", extensions: ["pdf"] }],
|
||||
});
|
||||
if (!path) return null;
|
||||
await api.writeExportFile(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function exportInvoicePdf(
|
||||
invoice: Invoice,
|
||||
settings: Settings,
|
||||
bank: BankAccount | null,
|
||||
prefs?: RenderPrefsV1,
|
||||
): Promise<string | null> {
|
||||
const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
|
||||
return savePdfBytes(bytes, invoice.number);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CanvasLike } from "../pdf/flatten";
|
||||
import { runAssembleJob } from "../pdf/render/assembleJob";
|
||||
import { renderInvoicePdf } from "../pdf/render/invoice";
|
||||
import { ensureSetup } from "../pdf/templates/harness";
|
||||
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||
import goldens from "../pdf/testing/goldens.json";
|
||||
import { loadCanvas } from "../pdf/testing/rasterize";
|
||||
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
|
||||
import { reportToText, runSelfTest, type SelfTestDeps, type SelfTestDoc } from "./selfTest";
|
||||
|
||||
const canvasLib = await loadCanvas();
|
||||
if (!canvasLib) console.warn("[selfTest.test] @napi-rs/canvas is not installed: skipping the self-test run under Node");
|
||||
|
||||
function nodeDeps(over: Partial<SelfTestDeps> = {}): SelfTestDeps {
|
||||
ensureSetup();
|
||||
return {
|
||||
env: () => ({ userAgent: "node", isSecureContext: true, renderMode: "worker" }),
|
||||
fixtures: selfTestFixtures(),
|
||||
goldens: goldens as GoldenMap,
|
||||
render: async (f) => {
|
||||
const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs });
|
||||
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: 1 };
|
||||
},
|
||||
openPdf: async (bytes) => {
|
||||
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
|
||||
const doc = await task.promise;
|
||||
return Object.assign(doc, { close: () => task.destroy() }) as unknown as SelfTestDoc;
|
||||
},
|
||||
createCanvas: (w, h) => canvasLib!.createCanvas(w, h) as unknown as CanvasLike,
|
||||
encodeJpeg: async (c, q) => new Uint8Array(await (c as unknown as { encode(f: "jpeg", q: number): Promise<Buffer> }).encode("jpeg", Math.round(q * 100))),
|
||||
assemble: async (pages, title) => (await runAssembleJob({ id: 1, pages, title })).bytes,
|
||||
startFrameMonitor: () => () => 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!canvasLib)("runSelfTest under Node", () => {
|
||||
it("passes every check against the committed goldens", async () => {
|
||||
const updates: number[] = [];
|
||||
const report = await runSelfTest(nodeDeps(), (r) => updates.push(r.sections.length));
|
||||
const failed = report.sections.flatMap((s) => s.checks.filter((c) => c.status === "fail").map((c) => `${s.title}: ${c.label}: ${c.detail}`));
|
||||
expect(failed).toEqual([]);
|
||||
expect(report.passed && report.done).toBe(true);
|
||||
expect(updates.length).toBeGreaterThan(10);
|
||||
const labels = report.sections.flatMap((s) => s.checks.map((c) => c.label));
|
||||
for (const l of ["Page count", "Layout audit", "Rasterise page 1", "Text extraction", "Layout fingerprint matches the Node golden", "Longest main-thread stall", "Assemble the image-only PDF"]) {
|
||||
expect(labels).toContain(l);
|
||||
}
|
||||
expect(reportToText(report)).toContain("RESULT: all checks passed");
|
||||
});
|
||||
|
||||
it("reports a fingerprint difference clearly, with both values", async () => {
|
||||
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
|
||||
const id = Object.keys(tampered)[0];
|
||||
tampered[id].fingerprint = "0".repeat(64);
|
||||
const report = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
|
||||
const check = report.sections.flatMap((s) => s.checks).find((c) => c.label.startsWith("Layout fingerprint"))!;
|
||||
expect(check.status).toBe("fail");
|
||||
expect(check.detail).toContain("0".repeat(64));
|
||||
expect(check.detail).toContain("this webview");
|
||||
expect(report.passed).toBe(false);
|
||||
expect(reportToText(report)).toContain("RESULT: FAILED");
|
||||
});
|
||||
|
||||
it("fails the text check when the page does not contain the expected strings, and a render error stops that fixture", async () => {
|
||||
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
|
||||
const id = Object.keys(tampered)[0];
|
||||
tampered[id].totalText = "₹9,999,999.99";
|
||||
const a = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
|
||||
expect(a.sections.flatMap((s) => s.checks).find((c) => c.label === "Text extraction")).toMatchObject({ status: "fail" });
|
||||
|
||||
const b = await runSelfTest(nodeDeps({ render: async () => Promise.reject(new Error("worker could not start")), fixtures: selfTestFixtures().slice(0, 1) }));
|
||||
const sec = b.sections.find((s) => s.title.startsWith("Classic, A4"))!;
|
||||
expect(sec.checks.map((c) => c.status)).toEqual(["fail"]);
|
||||
expect(sec.checks[0].detail).toContain("worker could not start");
|
||||
expect(b.sections.some((s) => s.title.startsWith("300 DPI"))).toBe(false);
|
||||
});
|
||||
|
||||
it("fails the stall check when the longest frame gap exceeds the budget", async () => {
|
||||
const report = await runSelfTest(nodeDeps({ startFrameMonitor: () => () => 900 }));
|
||||
const stall = report.sections.flatMap((s) => s.checks).find((c) => c.label === "Longest main-thread stall")!;
|
||||
expect(stall.status).toBe("fail");
|
||||
expect(stall.detail).toContain("900 ms");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { flattenPages, type CanvasLike, type FlattenResult, type PageLike } from "../pdf/flatten";
|
||||
import type { AssemblePageData } from "../pdf/render/jobs";
|
||||
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||
import type { SelfTestFixture } from "../pdf/testing/selfTestFixtures";
|
||||
|
||||
/**
|
||||
* The Diagnostics self-test: renders fixtures through the app's real render client, rasterises and
|
||||
* reads them back with pdf.js, compares with the Node goldens, and times a 300 DPI flatten. Everything
|
||||
* environment-specific is injected, so the same checks run in the webview and under Node.
|
||||
*/
|
||||
|
||||
export type CheckStatus = "pass" | "fail" | "info";
|
||||
|
||||
export interface Check {
|
||||
label: string;
|
||||
status: CheckStatus;
|
||||
detail?: string;
|
||||
ms?: number;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
title: string;
|
||||
checks: Check[];
|
||||
}
|
||||
|
||||
export interface SelfTestReport {
|
||||
startedAt: string;
|
||||
sections: Section[];
|
||||
done: boolean;
|
||||
/** No check failed (informational rows never fail). */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export interface SelfTestPage extends PageLike {
|
||||
getTextContent(): Promise<{ items: Array<{ str?: string }> }>;
|
||||
}
|
||||
|
||||
export interface SelfTestDoc {
|
||||
numPages: number;
|
||||
getPage(n: number): Promise<SelfTestPage>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SelfTestRender {
|
||||
bytes: Uint8Array;
|
||||
pages: number;
|
||||
issues: Array<{ severity: string; message?: string }>;
|
||||
fingerprint: string;
|
||||
ms: number;
|
||||
}
|
||||
|
||||
export interface SelfTestDeps {
|
||||
env(): { userAgent: string; isSecureContext: boolean; renderMode: string };
|
||||
fixtures: SelfTestFixture[];
|
||||
goldens: GoldenMap;
|
||||
/** Renders in the app's real client (the worker in the app). */
|
||||
render(fixture: SelfTestFixture): Promise<SelfTestRender>;
|
||||
openPdf(bytes: Uint8Array): Promise<SelfTestDoc>;
|
||||
createCanvas(width: number, height: number): CanvasLike;
|
||||
encodeJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array>;
|
||||
/** Wraps JPEG pages into an image-only PDF through the real client. */
|
||||
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
|
||||
/** Starts watching frame gaps; the returned function stops it and gives the longest gap in ms. */
|
||||
startFrameMonitor(): () => number;
|
||||
yieldToUi?: () => Promise<void>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Longest allowed main-thread stall while flattening (the plan's budget). */
|
||||
export const MAX_FLATTEN_STALL_MS = 500;
|
||||
/** A rendered invoice page is never blank: at least this share of pixels must carry ink. */
|
||||
export const MIN_INK_RATIO = 0.003;
|
||||
|
||||
const squash = (s: string) => s.replace(/\s+/g, "");
|
||||
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const short = (fp: string) => (fp.length > 16 ? `${fp.slice(0, 8)}…${fp.slice(-6)}` : fp);
|
||||
const mb = (n: number) => `${(n / 1048576).toFixed(2)} MB`;
|
||||
|
||||
/** Share of non-white pixels of page 1 drawn at 1 px per point. */
|
||||
async function inkRatioOfFirstPage(doc: SelfTestDoc, deps: SelfTestDeps): Promise<number> {
|
||||
const page = await doc.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const w = Math.ceil(viewport.width);
|
||||
const h = Math.ceil(viewport.height);
|
||||
const canvas = deps.createCanvas(w, h);
|
||||
try {
|
||||
const ctx = canvas.getContext("2d") as {
|
||||
fillStyle: unknown;
|
||||
fillRect(x: number, y: number, w: number, h: number): void;
|
||||
getImageData(x: number, y: number, w: number, h: number): { data: ArrayLike<number> };
|
||||
};
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
await page.render({ canvasContext: ctx, canvas, viewport, intent: "print" }).promise;
|
||||
const data = ctx.getImageData(0, 0, w, h).data;
|
||||
let ink = 0;
|
||||
for (let i = 0; i < data.length; i += 4) if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) ink++;
|
||||
return ink / (w * h);
|
||||
} finally {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function pageTexts(doc: SelfTestDoc): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const content = await (await doc.getPage(n)).getTextContent();
|
||||
out.push(content.items.map((i) => i.str ?? "").join(" "));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function runSelfTest(deps: SelfTestDeps, onUpdate?: (report: SelfTestReport) => void): Promise<SelfTestReport> {
|
||||
const now = deps.now ?? (() => performance.now());
|
||||
const report: SelfTestReport = { startedAt: new Date().toISOString(), sections: [], done: false, passed: true };
|
||||
const emit = () => {
|
||||
report.passed = report.sections.every((s) => s.checks.every((c) => c.status !== "fail"));
|
||||
onUpdate?.({ ...report, sections: report.sections.map((s) => ({ ...s, checks: [...s.checks] })) });
|
||||
};
|
||||
const section = (title: string): Section => {
|
||||
const s: Section = { title, checks: [] };
|
||||
report.sections.push(s);
|
||||
emit();
|
||||
return s;
|
||||
};
|
||||
const add = (s: Section, check: Check) => {
|
||||
s.checks.push(check);
|
||||
emit();
|
||||
};
|
||||
/** Runs a check body that returns a Check, turning a throw into a failure. */
|
||||
const timed = async (s: Section, label: string, body: () => Promise<Omit<Check, "label">>) => {
|
||||
const t0 = now();
|
||||
try {
|
||||
const r = await body();
|
||||
add(s, { label, ms: Math.round(now() - t0), ...r });
|
||||
} catch (e) {
|
||||
add(s, { label, status: "fail", detail: errText(e), ms: Math.round(now() - t0) });
|
||||
}
|
||||
};
|
||||
|
||||
const env = section("Webview");
|
||||
const info = deps.env();
|
||||
add(env, { label: "User agent", status: "info", detail: info.userAgent });
|
||||
add(env, { label: "Secure context", status: "info", detail: String(info.isSecureContext) });
|
||||
|
||||
const rendered = new Map<string, SelfTestRender>();
|
||||
for (const fixture of deps.fixtures) {
|
||||
const sec = section(fixture.label);
|
||||
const golden = deps.goldens[fixture.id];
|
||||
let r: SelfTestRender | null = null;
|
||||
await timed(sec, "Render in the app's client", async () => {
|
||||
r = await deps.render(fixture);
|
||||
rendered.set(fixture.id, r);
|
||||
return { status: "pass", detail: `${r.pages} page${r.pages === 1 ? "" : "s"}, ${(r.bytes.byteLength / 1024).toFixed(0)} KB, ${r.ms} ms in the render client` };
|
||||
});
|
||||
if (!r) continue;
|
||||
const res: SelfTestRender = r;
|
||||
|
||||
if (!golden) {
|
||||
add(sec, { label: "Golden", status: "fail", detail: `goldens.json has no entry for ${fixture.id}; regenerate it with UPDATE_GOLDENS=1` });
|
||||
} else {
|
||||
add(sec, {
|
||||
label: "Page count",
|
||||
status: res.pages === golden.pages ? "pass" : "fail",
|
||||
detail: res.pages === golden.pages ? `${res.pages}` : `expected ${golden.pages}, got ${res.pages}`,
|
||||
});
|
||||
}
|
||||
|
||||
const errors = res.issues.filter((i) => i.severity === "error");
|
||||
add(sec, {
|
||||
label: "Layout audit",
|
||||
status: errors.length === 0 ? "pass" : "fail",
|
||||
detail: errors.length === 0 ? `no errors, ${res.issues.length} warning${res.issues.length === 1 ? "" : "s"}` : errors.slice(0, 3).map((e) => e.message ?? "error").join("; "),
|
||||
});
|
||||
|
||||
let doc: SelfTestDoc | null = null;
|
||||
try {
|
||||
doc = await deps.openPdf(res.bytes);
|
||||
} catch (e) {
|
||||
add(sec, { label: "Open with pdf.js", status: "fail", detail: errText(e) });
|
||||
}
|
||||
if (doc) {
|
||||
const opened = doc;
|
||||
await timed(sec, "Rasterise page 1", async () => {
|
||||
const ratio = await inkRatioOfFirstPage(opened, deps);
|
||||
return { status: ratio >= MIN_INK_RATIO ? "pass" : "fail", detail: `${(ratio * 100).toFixed(1)}% of pixels carry ink${ratio >= MIN_INK_RATIO ? "" : " (blank page?)"}` };
|
||||
});
|
||||
await timed(sec, "Text extraction", async () => {
|
||||
const texts = await pageTexts(opened);
|
||||
const flat = squash(texts.join(" "));
|
||||
const wanted = golden
|
||||
? [fixture.invoiceNumber, golden.totalText, golden.pageLabel]
|
||||
: [fixture.invoiceNumber, fixture.totalText, `Page 1 of ${res.pages}`];
|
||||
const missing = wanted.filter((w) => !flat.includes(squash(w)));
|
||||
return { status: missing.length === 0 ? "pass" : "fail", detail: missing.length === 0 ? `found ${wanted.join(", ")}` : `missing ${missing.map((m) => `"${m}"`).join(", ")}` };
|
||||
});
|
||||
await opened.close().catch(() => {});
|
||||
}
|
||||
|
||||
if (golden) {
|
||||
const same = res.fingerprint === golden.fingerprint;
|
||||
add(sec, {
|
||||
label: "Layout fingerprint matches the Node golden",
|
||||
status: same ? "pass" : "fail",
|
||||
detail: same
|
||||
? short(res.fingerprint)
|
||||
: `golden ${golden.fingerprint}\nthis webview ${res.fingerprint}\nThe layout differs from Node: text measuring or the JavaScript engine does not agree with the goldens.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten timing on the longest rendered fixture.
|
||||
const multi = [...rendered.entries()].sort((a, b) => b[1].pages - a[1].pages)[0];
|
||||
if (multi) {
|
||||
const [id, src] = multi;
|
||||
const fixture = deps.fixtures.find((f) => f.id === id)!;
|
||||
const sec = section(`300 DPI flatten (${fixture.label})`);
|
||||
const held: { flat: FlattenResult | null } = { flat: null };
|
||||
const stop = deps.startFrameMonitor();
|
||||
await timed(sec, `Flatten ${src.pages} page${src.pages === 1 ? "" : "s"}`, async () => {
|
||||
const doc = await deps.openPdf(src.bytes);
|
||||
let flat: FlattenResult;
|
||||
try {
|
||||
flat = await flattenPages({
|
||||
pdf: doc as unknown as Parameters<typeof flattenPages>[0]["pdf"],
|
||||
createCanvas: deps.createCanvas,
|
||||
encodeJpeg: deps.encodeJpeg,
|
||||
yieldToUi: deps.yieldToUi,
|
||||
now,
|
||||
});
|
||||
} finally {
|
||||
await doc.close().catch(() => {});
|
||||
}
|
||||
held.flat = flat;
|
||||
const total = flat.pages.reduce((n, p) => n + p.jpeg.byteLength, 0);
|
||||
return {
|
||||
status: "pass",
|
||||
detail: flat.pages.map((p, i) => `page ${i + 1}: render ${p.renderMs} ms + encode ${p.encodeMs} ms, ${p.pxWidth}x${p.pxHeight}, ${mb(p.jpeg.byteLength)}`).join("\n") + `\ntotal ${mb(total)}`,
|
||||
};
|
||||
});
|
||||
const longest = Math.round(stop());
|
||||
const done = held.flat;
|
||||
add(sec, {
|
||||
label: "Longest main-thread stall",
|
||||
status: !done ? "info" : longest <= MAX_FLATTEN_STALL_MS ? "pass" : "fail",
|
||||
detail: !done ? "not measured" : `${longest} ms (budget ${MAX_FLATTEN_STALL_MS} ms)`,
|
||||
});
|
||||
|
||||
if (done) {
|
||||
await timed(sec, "Assemble the image-only PDF", async () => {
|
||||
const out = await deps.assemble(
|
||||
done.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
|
||||
fixture.invoiceNumber,
|
||||
);
|
||||
const doc = await deps.openPdf(out);
|
||||
try {
|
||||
const texts = await pageTexts(doc);
|
||||
const textItems = texts.filter((t) => t.trim() !== "").length;
|
||||
const ok = doc.numPages === src.pages && textItems === 0;
|
||||
return { status: ok ? "pass" : "fail", detail: `${doc.numPages} page${doc.numPages === 1 ? "" : "s"}, ${textItems === 0 ? "no extractable text" : `${textItems} page(s) still have text`}, ${mb(out.byteLength)}` };
|
||||
} finally {
|
||||
await doc.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Known only now: the client falls back to the main thread on its first failed start.
|
||||
const renderMode = deps.env().renderMode;
|
||||
add(env, { label: "Render client", status: "info", detail: renderMode === "worker" ? "worker" : "main thread (the worker could not start)" });
|
||||
|
||||
report.done = true;
|
||||
emit();
|
||||
return report;
|
||||
}
|
||||
|
||||
export function reportToText(report: SelfTestReport): string {
|
||||
const lines = [`Voiced diagnostics ${report.startedAt}`, report.done ? (report.passed ? "RESULT: all checks passed" : "RESULT: FAILED") : "RESULT: running", ""];
|
||||
for (const s of report.sections) {
|
||||
lines.push(`== ${s.title}`);
|
||||
for (const c of s.checks) {
|
||||
lines.push(`[${c.status.toUpperCase()}] ${c.label}${c.ms !== undefined ? ` (${c.ms} ms)` : ""}`);
|
||||
if (c.detail) for (const l of c.detail.split("\n")) lines.push(` ${l}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import goldens from "../pdf/testing/goldens.json";
|
||||
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
|
||||
import type { CanvasLike } from "../pdf/flatten";
|
||||
import { encodeCanvasJpeg, yieldToBrowser } from "./browserFlatten";
|
||||
import { renderModelWithRetry } from "./pdf";
|
||||
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||
import type { SelfTestDeps, SelfTestDoc } from "./selfTest";
|
||||
|
||||
/** The self-test wired to this webview: the real render client, pdf.js and DOM canvases. */
|
||||
export function browserSelfTestDeps(): SelfTestDeps {
|
||||
return {
|
||||
env: () => ({
|
||||
userAgent: navigator.userAgent,
|
||||
isSecureContext: window.isSecureContext,
|
||||
renderMode: getRenderClient().mode,
|
||||
}),
|
||||
fixtures: selfTestFixtures(),
|
||||
goldens: goldens as GoldenMap,
|
||||
render: async (fixture) => {
|
||||
const r = await renderModelWithRetry(fixture.model, fixture.prefs);
|
||||
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: r.ms };
|
||||
},
|
||||
openPdf: async (bytes) => {
|
||||
const { openPdf, closePdf } = await import("./pdfjs");
|
||||
const doc = await openPdf(bytes);
|
||||
return Object.assign(doc, { close: () => closePdf(doc) }) as unknown as SelfTestDoc;
|
||||
},
|
||||
createCanvas: (width, height) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas as unknown as CanvasLike;
|
||||
},
|
||||
encodeJpeg: encodeCanvasJpeg,
|
||||
assemble: async (pages, title) => (await getRenderClient().assemble({ id: nextRenderJobId(), pages, title })).bytes,
|
||||
startFrameMonitor: () => {
|
||||
let last = performance.now();
|
||||
let longest = 0;
|
||||
let running = true;
|
||||
const tick = () => {
|
||||
if (!running) return;
|
||||
const t = performance.now();
|
||||
longest = Math.max(longest, t - last);
|
||||
last = t;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return () => {
|
||||
running = false;
|
||||
// A frame that never arrived because the thread was still busy counts too.
|
||||
return Math.max(longest, performance.now() - last);
|
||||
};
|
||||
},
|
||||
yieldToUi: yieldToBrowser,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { flattenPages, FlattenCancelled, FlattenError, type CanvasLike, type PageLike, type PdfDocLike } from "./flatten";
|
||||
import { runAssembleJob } from "./render/assembleJob";
|
||||
import { FIXTURE_GSTIN, ensureSetup, buildFixture, prefsFor, renderFixture } from "./templates/harness";
|
||||
import { loadCanvas } from "./testing/rasterize";
|
||||
|
||||
// --- unit tests with fakes: no native canvas needed --------------------------------------------------
|
||||
|
||||
interface FakeCanvas extends CanvasLike {
|
||||
fills: Array<[number, number, number, number]>;
|
||||
fillStyle: unknown;
|
||||
}
|
||||
|
||||
function fakeDoc(sizes: Array<[number, number]>, opts: { renderDelay?: () => Promise<void>; failRender?: number } = {}) {
|
||||
const log: string[] = [];
|
||||
const cancelled: number[] = [];
|
||||
const pdf: PdfDocLike = {
|
||||
numPages: sizes.length,
|
||||
async getPage(n) {
|
||||
const [w, h] = sizes[n - 1];
|
||||
const page: PageLike = {
|
||||
getViewport: ({ scale }) => ({ width: w * scale, height: h * scale }),
|
||||
render: () => {
|
||||
log.push(`render ${n}`);
|
||||
return {
|
||||
promise: (async () => {
|
||||
await opts.renderDelay?.();
|
||||
if (opts.failRender === n) throw new Error("out of memory");
|
||||
})(),
|
||||
cancel: () => cancelled.push(n),
|
||||
};
|
||||
},
|
||||
cleanup: () => void log.push(`cleanup ${n}`),
|
||||
};
|
||||
return page;
|
||||
},
|
||||
};
|
||||
return { pdf, log, cancelled };
|
||||
}
|
||||
|
||||
function fakeCanvasFactory() {
|
||||
const live = new Set<FakeCanvas>();
|
||||
const made: Array<[number, number]> = [];
|
||||
let maxLive = 0;
|
||||
const createCanvas = (w: number, h: number): FakeCanvas => {
|
||||
made.push([w, h]);
|
||||
const c: FakeCanvas = {
|
||||
width: w,
|
||||
height: h,
|
||||
fills: [],
|
||||
fillStyle: "",
|
||||
getContext() {
|
||||
return { set fillStyle(v: unknown) { c.fillStyle = v; }, fillRect: (x: number, y: number, ww: number, hh: number) => c.fills.push([x, y, ww, hh]) };
|
||||
},
|
||||
};
|
||||
live.add(c);
|
||||
maxLive = Math.max(maxLive, live.size);
|
||||
return c;
|
||||
};
|
||||
// A canvas counts as freed once its size is zeroed.
|
||||
const sweep = () => {
|
||||
for (const c of live) if (c.width === 0 && c.height === 0) live.delete(c);
|
||||
};
|
||||
return { createCanvas, made, live, sweep, maxLive: () => maxLive };
|
||||
}
|
||||
|
||||
const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]);
|
||||
const A4: [number, number] = [595.28, 841.89];
|
||||
|
||||
describe("flattenPages (fakes)", () => {
|
||||
it("renders every page at 300 DPI on white, one canvas at a time, and reports progress", async () => {
|
||||
const { pdf } = fakeDoc([A4, A4, [612, 792]]);
|
||||
const f = fakeCanvasFactory();
|
||||
const progress: Array<[number, number]> = [];
|
||||
let yields = 0;
|
||||
const out = await flattenPages({
|
||||
pdf,
|
||||
createCanvas: (w, h) => {
|
||||
f.sweep(); // by the time the next canvas is requested the previous one must be released
|
||||
expect(f.live.size).toBe(0);
|
||||
return f.createCanvas(w, h);
|
||||
},
|
||||
encodeJpeg: async () => jpegBytes,
|
||||
onProgress: (p, n) => progress.push([p, n]),
|
||||
yieldToUi: async () => void yields++,
|
||||
});
|
||||
expect(f.made).toEqual([
|
||||
[Math.ceil((595.28 * 300) / 72), Math.ceil((841.89 * 300) / 72)],
|
||||
[Math.ceil((595.28 * 300) / 72), Math.ceil((841.89 * 300) / 72)],
|
||||
[2550, 3300],
|
||||
]);
|
||||
expect(f.made[0][0]).toBeGreaterThanOrEqual(2480);
|
||||
expect(f.maxLive()).toBe(1);
|
||||
expect(progress).toEqual([[1, 3], [2, 3], [3, 3]]);
|
||||
expect(yields).toBe(3);
|
||||
expect(out.pages.map((p) => [p.widthPt, p.heightPt])).toEqual([A4, A4, [612, 792]]);
|
||||
expect(out.pages[2]).toMatchObject({ pxWidth: 2550, pxHeight: 3300 });
|
||||
});
|
||||
|
||||
it("paints a white background before the page", async () => {
|
||||
const { pdf } = fakeDoc([[100, 100]]);
|
||||
const canvases: FakeCanvas[] = [];
|
||||
await flattenPages({
|
||||
pdf,
|
||||
createCanvas: (w, h) => {
|
||||
const f = fakeCanvasFactory().createCanvas(w, h);
|
||||
canvases.push(f);
|
||||
return f;
|
||||
},
|
||||
encodeJpeg: async () => jpegBytes,
|
||||
});
|
||||
expect(canvases[0].fillStyle).toBe("#ffffff");
|
||||
expect(canvases[0].fills).toEqual([[0, 0, 417, 417]]);
|
||||
});
|
||||
|
||||
it("passes the quality and the DPI through, with no silent downgrade", async () => {
|
||||
const { pdf } = fakeDoc([[72, 72]]);
|
||||
const qualities: number[] = [];
|
||||
const sizes: Array<[number, number]> = [];
|
||||
await flattenPages({
|
||||
pdf,
|
||||
dpi: 150,
|
||||
quality: 0.8,
|
||||
createCanvas: (w, h) => (sizes.push([w, h]), fakeCanvasFactory().createCanvas(w, h)),
|
||||
encodeJpeg: async (_c, q) => (qualities.push(q), jpegBytes),
|
||||
});
|
||||
expect(sizes).toEqual([[150, 150]]);
|
||||
expect(qualities).toEqual([0.8]);
|
||||
});
|
||||
|
||||
it("a canvas failure is a FlattenError naming the page and reason, with no partial output", async () => {
|
||||
const { pdf } = fakeDoc([A4, A4]);
|
||||
let n = 0;
|
||||
const err = await flattenPages({
|
||||
pdf,
|
||||
createCanvas: (w, h) => {
|
||||
if (++n === 2) throw new Error("Canvas area exceeds the maximum limit");
|
||||
return fakeCanvasFactory().createCanvas(w, h);
|
||||
},
|
||||
encodeJpeg: async () => jpegBytes,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(FlattenError);
|
||||
expect(err).toMatchObject({ page: 2, code: "FLATTEN_FAILED", reason: "Canvas area exceeds the maximum limit" });
|
||||
expect(err.message).toContain("page 2");
|
||||
expect(n).toBe(2); // it did not retry at a lower resolution
|
||||
});
|
||||
|
||||
it("render and encode failures are FlattenErrors too, and the canvas is still released", async () => {
|
||||
const f = fakeCanvasFactory();
|
||||
const a = await flattenPages({ pdf: fakeDoc([A4], { failRender: 1 }).pdf, createCanvas: f.createCanvas, encodeJpeg: async () => jpegBytes }).catch((e) => e);
|
||||
expect(a).toMatchObject({ name: "FlattenError", page: 1, reason: "out of memory" });
|
||||
const b = await flattenPages({
|
||||
pdf: fakeDoc([A4]).pdf,
|
||||
createCanvas: f.createCanvas,
|
||||
encodeJpeg: async () => {
|
||||
throw new Error("toBlob returned null");
|
||||
},
|
||||
}).catch((e) => e);
|
||||
expect(b).toMatchObject({ name: "FlattenError", page: 1, reason: "toBlob returned null" });
|
||||
f.sweep();
|
||||
expect(f.live.size).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an empty document and an empty encode", async () => {
|
||||
const f = fakeCanvasFactory();
|
||||
expect(await flattenPages({ pdf: { numPages: 0, getPage: async () => { throw new Error("x"); } }, createCanvas: f.createCanvas, encodeJpeg: async () => jpegBytes }).catch((e) => e)).toBeInstanceOf(FlattenError);
|
||||
expect(await flattenPages({ pdf: fakeDoc([A4]).pdf, createCanvas: f.createCanvas, encodeJpeg: async () => new Uint8Array() }).catch((e) => e)).toBeInstanceOf(FlattenError);
|
||||
});
|
||||
|
||||
it("an already-aborted signal throws FlattenCancelled before any work", async () => {
|
||||
const ctl = new AbortController();
|
||||
ctl.abort();
|
||||
let made = 0;
|
||||
await expect(
|
||||
flattenPages({ pdf: fakeDoc([A4]).pdf, signal: ctl.signal, createCanvas: (w, h) => (made++, fakeCanvasFactory().createCanvas(w, h)), encodeJpeg: async () => jpegBytes }),
|
||||
).rejects.toBeInstanceOf(FlattenCancelled);
|
||||
expect(made).toBe(0);
|
||||
});
|
||||
|
||||
it("aborting between pages stops there; aborting during a render cancels the render task", async () => {
|
||||
const ctl = new AbortController();
|
||||
const f = fakeCanvasFactory();
|
||||
await expect(
|
||||
flattenPages({
|
||||
pdf: fakeDoc([A4, A4, A4]).pdf,
|
||||
signal: ctl.signal,
|
||||
createCanvas: f.createCanvas,
|
||||
encodeJpeg: async () => jpegBytes,
|
||||
onProgress: (page) => {
|
||||
if (page === 2) ctl.abort();
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FlattenCancelled);
|
||||
expect(f.made).toHaveLength(1);
|
||||
|
||||
const ctl2 = new AbortController();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const doc = fakeDoc([A4], { renderDelay: () => gate });
|
||||
const run = flattenPages({ pdf: doc.pdf, signal: ctl2.signal, createCanvas: fakeCanvasFactory().createCanvas, encodeJpeg: async () => jpegBytes });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
ctl2.abort();
|
||||
expect(doc.cancelled).toEqual([1]);
|
||||
release();
|
||||
await expect(run).rejects.toBeInstanceOf(FlattenCancelled);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Node integration: pdf.js + @napi-rs/canvas, flatten -> assemble --------------------------------
|
||||
|
||||
const canvasLib = await loadCanvas();
|
||||
if (!canvasLib) console.warn("[flatten.test] @napi-rs/canvas is not installed: skipping the flatten -> assemble integration tests");
|
||||
|
||||
/** Width and height from the first SOF marker of a JPEG. */
|
||||
function jpegSize(b: Uint8Array): { width: number; height: number } {
|
||||
let i = 2;
|
||||
while (i < b.length) {
|
||||
if (b[i] !== 0xff) throw new Error("not a JPEG marker stream");
|
||||
const marker = b[i + 1];
|
||||
const len = (b[i + 2] << 8) | b[i + 3];
|
||||
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||
return { height: (b[i + 5] << 8) | b[i + 6], width: (b[i + 7] << 8) | b[i + 8] };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
throw new Error("no SOF marker");
|
||||
}
|
||||
|
||||
describe.skipIf(!canvasLib)("flatten -> assemble (pdf.js + napi canvas)", () => {
|
||||
const nodeDeps = () => ({
|
||||
createCanvas: (w: number, h: number) => canvasLib!.createCanvas(w, h) as unknown as CanvasLike,
|
||||
encodeJpeg: async (c: CanvasLike, q: number) => new Uint8Array(await (c as unknown as { encode(f: "jpeg", q: number): Promise<Buffer> }).encode("jpeg", Math.round(q * 100))),
|
||||
});
|
||||
|
||||
async function flatten(bytes: Uint8Array, extra: Partial<Parameters<typeof flattenPages>[0]> = {}) {
|
||||
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
|
||||
const doc = await task.promise;
|
||||
try {
|
||||
return await flattenPages({ pdf: doc as unknown as PdfDocLike, ...nodeDeps(), ...extra });
|
||||
} finally {
|
||||
await task.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Rendered once and shared: A4, 60 rows (several pages), registered supplier with a GSTIN.
|
||||
let shared: Promise<{ source: Uint8Array; sourcePages: number; flat: Awaited<ReturnType<typeof flatten>>; out: Uint8Array; title: string }> | null = null;
|
||||
const prepare = () =>
|
||||
(shared ??= (async () => {
|
||||
ensureSetup();
|
||||
const r = await renderFixture({ name: "flat", items: 60, signature: true, invoice: { notes: "Payment due within 30 days." } }, prefsFor("A4"));
|
||||
const flat = await flatten(r.result.bytes);
|
||||
const title = "AP/2026-001";
|
||||
const { bytes: out } = await runAssembleJob({ id: 1, title, pages: flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })) });
|
||||
return { source: r.result.bytes, sourcePages: r.result.pages, flat, out, title };
|
||||
})());
|
||||
|
||||
it("keeps the page count and writes 300 DPI A4 images (at least 2480 px wide)", async () => {
|
||||
const { sourcePages, flat, out } = await prepare();
|
||||
expect(sourcePages).toBeGreaterThan(1);
|
||||
expect(flat.pages).toHaveLength(sourcePages);
|
||||
const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise;
|
||||
expect(doc.numPages).toBe(sourcePages);
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
const [, , w, h] = page.view;
|
||||
expect(w).toBeCloseTo(595.28, 1);
|
||||
expect(h).toBeCloseTo(841.89, 1);
|
||||
}
|
||||
for (const p of flat.pages) {
|
||||
const sof = jpegSize(p.jpeg);
|
||||
expect(sof.width).toBeGreaterThanOrEqual(2480);
|
||||
expect(sof.width).toBe(p.pxWidth);
|
||||
expect(sof.height).toBe(p.pxHeight);
|
||||
}
|
||||
// The JPEG goes in untouched (DCTDecode passthrough), so these exact bytes are inside the file.
|
||||
const outBuf = Buffer.from(out);
|
||||
for (const p of flat.pages) expect(outBuf.includes(Buffer.from(p.jpeg))).toBe(true);
|
||||
await doc.loadingTask.destroy();
|
||||
});
|
||||
|
||||
it("has zero extractable text items on every page", async () => {
|
||||
const { out, sourcePages } = await prepare();
|
||||
const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise;
|
||||
for (let n = 1; n <= sourcePages; n++) {
|
||||
const content = await (await doc.getPage(n)).getTextContent();
|
||||
expect(content.items, `page ${n}`).toHaveLength(0);
|
||||
}
|
||||
await doc.loadingTask.destroy();
|
||||
});
|
||||
|
||||
it("carries only a Title in the document info, and none of the invoice's names or GSTINs anywhere", async () => {
|
||||
const { out, title } = await prepare();
|
||||
const raw = Buffer.from(out).toString("latin1");
|
||||
expect(raw).toContain("/Title");
|
||||
expect(raw).toContain(title);
|
||||
for (const key of ["/Author", "/Subject", "/Keywords"]) expect(raw, key).not.toContain(key);
|
||||
const { invoice } = buildFixture({ name: "flat", items: 1 });
|
||||
for (const secret of ["Arun P", invoice.clientName, "Acme", FIXTURE_GSTIN, invoice.clientGstin, "CPOPP7388K", "Bangalore"]) {
|
||||
expect(raw, secret).not.toContain(secret);
|
||||
}
|
||||
const doc = await getDocument({ data: new Uint8Array(out), useSystemFonts: false }).promise;
|
||||
const info = (await doc.getMetadata()).info as Record<string, unknown>;
|
||||
expect(info.Title).toBe(title);
|
||||
expect(info.Author ?? "").toBe("");
|
||||
expect(info.Subject ?? "").toBe("");
|
||||
expect(info.Keywords ?? "").toBe("");
|
||||
await doc.loadingTask.destroy();
|
||||
});
|
||||
|
||||
it("the page images are not blank", async () => {
|
||||
const { flat } = await prepare();
|
||||
const img = await canvasLib!.loadImage(Buffer.from(flat.pages[0].jpeg));
|
||||
const c = canvasLib!.createCanvas(img.width, img.height);
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const data = ctx.getImageData(0, 0, img.width, 400).data;
|
||||
let ink = 0;
|
||||
for (let i = 0; i < data.length; i += 4) if (data[i] < 200) ink++;
|
||||
expect(ink).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("cancel via AbortSignal throws FlattenCancelled and returns nothing", async () => {
|
||||
const { source } = await prepare();
|
||||
const ctl = new AbortController();
|
||||
const seen: number[] = [];
|
||||
await expect(
|
||||
flatten(source, {
|
||||
signal: ctl.signal,
|
||||
onProgress: (p) => {
|
||||
seen.push(p);
|
||||
if (p === 2) ctl.abort();
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FlattenCancelled);
|
||||
expect(seen).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("a throwing createCanvas yields a FlattenError and no partial output", async () => {
|
||||
const { source } = await prepare();
|
||||
let calls = 0;
|
||||
const err = await flatten(source, {
|
||||
createCanvas: () => {
|
||||
calls++;
|
||||
throw new Error("canvas allocation failed");
|
||||
},
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(FlattenError);
|
||||
expect(err).toMatchObject({ page: 1, code: "FLATTEN_FAILED" });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/** The slice of pdf.js the flatten needs, so Node tests and the webview can supply their own. */
|
||||
export interface PageViewportLike {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RenderTaskLike {
|
||||
promise: Promise<unknown>;
|
||||
cancel?: () => void;
|
||||
}
|
||||
|
||||
export interface PageLike {
|
||||
getViewport(params: { scale: number }): PageViewportLike;
|
||||
render(params: { canvasContext: unknown; canvas: unknown; viewport: PageViewportLike; intent?: string }): RenderTaskLike;
|
||||
cleanup?: () => unknown;
|
||||
}
|
||||
|
||||
export interface PdfDocLike {
|
||||
numPages: number;
|
||||
getPage(n: number): Promise<PageLike>;
|
||||
}
|
||||
|
||||
export interface CanvasLike {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(kind: "2d"): unknown;
|
||||
}
|
||||
|
||||
export interface FlatPage {
|
||||
jpeg: Uint8Array;
|
||||
widthPt: number;
|
||||
heightPt: number;
|
||||
pxWidth: number;
|
||||
pxHeight: number;
|
||||
renderMs: number;
|
||||
encodeMs: number;
|
||||
}
|
||||
|
||||
export interface FlattenResult {
|
||||
pages: FlatPage[];
|
||||
}
|
||||
|
||||
export interface FlattenOptions {
|
||||
pdf: PdfDocLike;
|
||||
dpi?: number;
|
||||
quality?: number;
|
||||
createCanvas: (width: number, height: number) => CanvasLike;
|
||||
encodeJpeg: (canvas: CanvasLike, quality: number) => Promise<Uint8Array>;
|
||||
signal?: AbortSignal;
|
||||
/** Called as each page starts: (page being rendered, page count). */
|
||||
onProgress?: (page: number, pages: number) => void;
|
||||
/** Awaited after each page so the UI can paint and handle input between the long tasks. */
|
||||
yieldToUi?: () => Promise<void>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class FlattenCancelled extends Error {
|
||||
constructor() {
|
||||
super("The flatten was cancelled");
|
||||
this.name = "FlattenCancelled";
|
||||
}
|
||||
}
|
||||
|
||||
export class FlattenError extends Error {
|
||||
readonly code = "FLATTEN_FAILED";
|
||||
constructor(
|
||||
readonly page: number,
|
||||
readonly reason: string,
|
||||
) {
|
||||
super(page > 0 ? `Could not flatten page ${page}: ${reason}` : `Could not flatten the PDF: ${reason}`);
|
||||
this.name = "FlattenError";
|
||||
}
|
||||
}
|
||||
|
||||
const reasonOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/**
|
||||
* Rasterises every page to a JPEG at `dpi` (default 300) on a white background. One canvas lives at a
|
||||
* time and is released before the next page. There is deliberately no fallback to a lower DPI: a canvas
|
||||
* or encode failure throws a FlattenError and the caller decides what to offer.
|
||||
*/
|
||||
export async function flattenPages(opts: FlattenOptions): Promise<FlattenResult> {
|
||||
const { pdf, dpi = 300, quality = 0.92, createCanvas, encodeJpeg, signal, onProgress, yieldToUi, now = () => performance.now() } = opts;
|
||||
const scale = dpi / 72;
|
||||
const total = pdf.numPages;
|
||||
if (!(total > 0)) throw new FlattenError(0, "the PDF has no pages");
|
||||
|
||||
const checkAbort = () => {
|
||||
if (signal?.aborted) throw new FlattenCancelled();
|
||||
};
|
||||
|
||||
const pages: FlatPage[] = [];
|
||||
for (let n = 1; n <= total; n++) {
|
||||
checkAbort();
|
||||
onProgress?.(n, total);
|
||||
checkAbort(); // onProgress may be where the caller cancels
|
||||
|
||||
let canvas: CanvasLike | null = null;
|
||||
let page: PageLike | null = null;
|
||||
try {
|
||||
page = await pdf.getPage(n);
|
||||
const unit = page.getViewport({ scale: 1 });
|
||||
const viewport = page.getViewport({ scale });
|
||||
// The epsilon keeps 792 pt x 300/72 (3300.0000000000005) from becoming a 3301 px page.
|
||||
const pxWidth = Math.ceil(viewport.width - 1e-6);
|
||||
const pxHeight = Math.ceil(viewport.height - 1e-6);
|
||||
|
||||
canvas = createCanvas(pxWidth, pxHeight);
|
||||
const ctx = canvas.getContext("2d") as { fillStyle: unknown; fillRect(x: number, y: number, w: number, h: number): void } | null;
|
||||
if (!ctx) throw new Error("the canvas has no 2D context");
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, pxWidth, pxHeight);
|
||||
|
||||
checkAbort();
|
||||
const t0 = now();
|
||||
const task = page.render({ canvasContext: ctx, canvas, viewport, intent: "print" });
|
||||
const onAbort = () => task.cancel?.();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await task.promise;
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
checkAbort();
|
||||
const t1 = now();
|
||||
const jpeg = await encodeJpeg(canvas, quality);
|
||||
const t2 = now();
|
||||
if (!jpeg || jpeg.byteLength < 4) throw new Error("the JPEG encoder returned no data");
|
||||
pages.push({
|
||||
jpeg,
|
||||
widthPt: unit.width,
|
||||
heightPt: unit.height,
|
||||
pxWidth,
|
||||
pxHeight,
|
||||
renderMs: Math.round(t1 - t0),
|
||||
encodeMs: Math.round(t2 - t1),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof FlattenCancelled || signal?.aborted) throw new FlattenCancelled();
|
||||
if (e instanceof FlattenError) throw e;
|
||||
throw new FlattenError(n, reasonOf(e));
|
||||
} finally {
|
||||
if (canvas) {
|
||||
// Releases the backing store now rather than at garbage collection (2480 x 3508 x 4 = 35 MB).
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
try {
|
||||
page?.cleanup?.();
|
||||
} catch {
|
||||
/* cleanup is best effort */
|
||||
}
|
||||
}
|
||||
if (yieldToUi) await yieldToUi();
|
||||
}
|
||||
return { pages };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { assembleImagePdf } from "../assemble";
|
||||
import { renderBytes } from "./core";
|
||||
import type { AssembleJob, AssembleResult } from "./jobs";
|
||||
|
||||
/** Runs an assemble job where it is called: the worker, the main thread, or a Node test. */
|
||||
export async function runAssembleJob(job: AssembleJob): Promise<AssembleResult> {
|
||||
const t0 = performance.now();
|
||||
const bytes = await renderBytes(assembleImagePdf({ pages: job.pages, title: job.title }));
|
||||
return { id: job.id, bytes, ms: Math.round(performance.now() - t0) };
|
||||
}
|
||||
@@ -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> = [];
|
||||
|
||||
+110
-28
@@ -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<RenderResult>;
|
||||
/**
|
||||
* Wraps JPEG pages into an image-only PDF. This is a separate lane from `render`: renders are
|
||||
* latest-wins, but an assemble is an export step that must complete, so it is never superseded by
|
||||
* a render (or by another assemble). It waits for the job in flight and runs before any queued render.
|
||||
*/
|
||||
assemble(job: AssembleJob): Promise<AssembleResult>;
|
||||
dispose(): void;
|
||||
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<typeof setTimeout>;
|
||||
}
|
||||
};
|
||||
|
||||
const requestFor = (slot: Slot): WorkerRequest =>
|
||||
slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job };
|
||||
|
||||
/**
|
||||
* Renders in a module worker. Only the newest request matters: a new call rejects the one in flight
|
||||
* Renders in a module worker. Only the newest render matters: a new call rejects the render in flight
|
||||
* (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the
|
||||
* 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;
|
||||
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<RenderResult>((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<AssembleResult>((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<AssembleResult>;
|
||||
|
||||
const assembleOnMainThread: MainAssembleExecutor = async (job) => (await import("./assembleJob")).runAssembleJob(job);
|
||||
|
||||
/**
|
||||
* Same interface and lanes as the worker client (latest-wins renders, never-superseded assembles),
|
||||
* on the main thread. Nothing can interrupt it, so there is no watchdog.
|
||||
*/
|
||||
export function createMainThreadClient(
|
||||
execute: MainExecutor = runOnMainThread,
|
||||
executeAssemble: MainAssembleExecutor = assembleOnMainThread,
|
||||
): RenderClient {
|
||||
let disposed = false;
|
||||
let 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 };
|
||||
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 {
|
||||
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<RenderResult>((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<AssembleResult>((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() {
|
||||
|
||||
+22
-1
@@ -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 };
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"classic-a4-tax-3": {
|
||||
"pages": 1,
|
||||
"fingerprint": "35b03b20aeec3dcbb808300b6e8f0b1867dfd69d5fd6f69aaee891c354955092",
|
||||
"invoiceNumber": "ST/2026-001",
|
||||
"totalText": "₹8,625.80",
|
||||
"pageLabel": "Page 1 of 1",
|
||||
"textHashes": [
|
||||
"855dd2d104e80ff599210d69f643be0ccb281e6583cbcea2dafa5fdcb09f03fc"
|
||||
],
|
||||
"firstPageText": "Selftest Studio 1 Test Lane, Frazer Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
|
||||
},
|
||||
"classic-letter-unreg-60": {
|
||||
"pages": 3,
|
||||
"fingerprint": "78d60e1b72dac0e40fea96cbf34c35cfeb637dbe0bc9754dcecba72231197e75",
|
||||
"invoiceNumber": "ST/2026-002",
|
||||
"totalText": "₹1,56,000.00",
|
||||
"pageLabel": "Page 1 of 3",
|
||||
"textHashes": [
|
||||
"1728b53a177e385584d2dc33b2e71607f860886632f32a5911364f86f345c950",
|
||||
"870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b",
|
||||
"fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0"
|
||||
],
|
||||
"firstPageText": "Selftest Studio 1 Test Lane, Frazer Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per hour 3 ₹3,600.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per hour 2 ₹2,400.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per hour 3 ₹3,600.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per hour 2 ₹2,400.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per hour 3 ₹3,600.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per hour 2 ₹2,400.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderInvoicePdf } from "../render/invoice";
|
||||
import { ensureSetup } from "../templates/harness";
|
||||
import type { Golden } from "./goldens";
|
||||
import { selfTestFixtures } from "./selfTestFixtures";
|
||||
import { parsePdfText } from "./pdfText";
|
||||
|
||||
/**
|
||||
* goldens.json is what the Diagnostics self-test compares the webview's render against (page count,
|
||||
* layout fingerprint, key strings). This test keeps it in step with the Node render:
|
||||
* `UPDATE_GOLDENS=1 npx vitest run src/pdf/testing/goldens.test.ts` rewrites it.
|
||||
*/
|
||||
|
||||
const FILE = path.resolve(process.cwd(), "src/pdf/testing/goldens.json");
|
||||
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
|
||||
|
||||
async function current(): Promise<Record<string, Golden>> {
|
||||
ensureSetup();
|
||||
const out: Record<string, Golden> = {};
|
||||
for (const f of selfTestFixtures()) {
|
||||
const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs });
|
||||
const text = await parsePdfText(r.bytes);
|
||||
const pages = text.pages.map((p) => p.items.map((i) => i.str).join(" "));
|
||||
out[f.id] = {
|
||||
pages: r.pages,
|
||||
fingerprint: r.fingerprint,
|
||||
invoiceNumber: f.invoiceNumber,
|
||||
totalText: f.totalText,
|
||||
pageLabel: `Page 1 of ${r.pages}`,
|
||||
textHashes: pages.map(sha),
|
||||
firstPageText: pages[0],
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("self-test goldens", () => {
|
||||
it("match the Node render of the self-test fixtures", async () => {
|
||||
const now = await current();
|
||||
if (process.env.UPDATE_GOLDENS) {
|
||||
writeFileSync(FILE, `${JSON.stringify(now, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
const stored = JSON.parse(readFileSync(FILE, "utf8")) as Record<string, Golden>;
|
||||
expect(Object.keys(stored).sort()).toEqual(Object.keys(now).sort());
|
||||
for (const [id, golden] of Object.entries(now)) {
|
||||
const s = stored[id];
|
||||
// Compare the readable parts first so a failure says what moved.
|
||||
expect(s.pages, `${id}: page count`).toBe(golden.pages);
|
||||
expect(s.firstPageText, `${id}: page 1 text`).toBe(golden.firstPageText);
|
||||
expect(s.textHashes, `${id}: text of every page`).toEqual(golden.textHashes);
|
||||
expect(s.invoiceNumber).toBe(golden.invoiceNumber);
|
||||
expect(s.totalText).toBe(golden.totalText);
|
||||
expect(s.pageLabel).toBe(golden.pageLabel);
|
||||
expect(s.fingerprint, `${id}: layout fingerprint (run with UPDATE_GOLDENS=1 if the layout change is intended)`).toBe(golden.fingerprint);
|
||||
}
|
||||
});
|
||||
|
||||
it("every golden carries the strings the self-test looks for", async () => {
|
||||
const stored = JSON.parse(readFileSync(FILE, "utf8")) as Record<string, Golden>;
|
||||
for (const [id, g] of Object.entries(stored)) {
|
||||
expect(g.totalText, id).toMatch(/^₹/);
|
||||
expect(g.firstPageText, id).toContain(g.invoiceNumber);
|
||||
expect(g.firstPageText.replace(/\s+/g, " "), id).toContain(g.pageLabel);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** One entry of goldens.json: what the Node render of a self-test fixture produced. */
|
||||
export interface Golden {
|
||||
pages: number;
|
||||
fingerprint: string;
|
||||
invoiceNumber: string;
|
||||
totalText: string;
|
||||
/** Page-1 label the footer must show. */
|
||||
pageLabel: string;
|
||||
/** Per page: sha256 of the extracted text (items joined with a space). */
|
||||
textHashes: string[];
|
||||
/** Page 1's text, readable in a diff. */
|
||||
firstPageText: string;
|
||||
}
|
||||
|
||||
export type GoldenMap = Record<string, Golden>;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { computeTotals } from "../../lib/invoice";
|
||||
import { amountInWords } from "../../lib/numberToWords";
|
||||
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "../../lib/types";
|
||||
import { buildRenderModel, type RenderModel } from "../model/build";
|
||||
import { DEFAULT_PREFS, type RenderPrefsV1 } from "../model/prefs";
|
||||
|
||||
/**
|
||||
* Pure data for the Diagnostics self-test and the goldens test (no Node or DOM imports, so the
|
||||
* webview and Node build the very same models). Change a fixture and regenerate the goldens.
|
||||
*/
|
||||
|
||||
export interface SelfTestFixture {
|
||||
id: string;
|
||||
label: string;
|
||||
model: RenderModel;
|
||||
prefs: RenderPrefsV1;
|
||||
invoiceNumber: string;
|
||||
/** The grand total as the template prints it, e.g. "₹8,260.00". */
|
||||
totalText: string;
|
||||
}
|
||||
|
||||
const VENDOR: Settings = {
|
||||
vendorName: "Selftest Studio",
|
||||
vendorAddress: "1 Test Lane, Frazer Town, Bangalore - 560005",
|
||||
vendorEmail: "selftest@example.com",
|
||||
vendorPhone: "9000000000",
|
||||
vendorPan: "AAAAA0000A",
|
||||
vendorGstin: "",
|
||||
vendorStateCode: "29",
|
||||
logoPath: null,
|
||||
signaturePath: null,
|
||||
defaultBankId: 1,
|
||||
defaultTaxRate: 18,
|
||||
defaultTaxType: "cgst_sgst",
|
||||
paymentTermsDays: 30,
|
||||
currency: "INR",
|
||||
onboarded: true,
|
||||
theme: "g10",
|
||||
gstRegistration: "unregistered",
|
||||
defaultHsnSac: "",
|
||||
signatoryName: "",
|
||||
signatoryDesignation: "",
|
||||
renderPrefs: "",
|
||||
};
|
||||
|
||||
const BANK: BankAccount = {
|
||||
id: 1,
|
||||
label: "Primary",
|
||||
bankName: "State Bank of India",
|
||||
accountName: "Selftest Studio",
|
||||
accountNo: "00000000000",
|
||||
branch: "000000",
|
||||
ifsc: "SBIN0000000",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const items = (n: number, hsn: boolean): InvoiceItem[] =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: i + 1,
|
||||
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
|
||||
mode: i % 2 === 0 ? "rate" : "fixed",
|
||||
rate: 1200,
|
||||
unit: "hour",
|
||||
quantity: 1 + (i % 3),
|
||||
amount: 2500 + i * 10,
|
||||
sortOrder: i,
|
||||
...(hsn ? { hsnSac: "998397" } : {}),
|
||||
}));
|
||||
|
||||
function build(o: { id: string; label: string; number: string; rows: number; registered: boolean; prefs: Partial<RenderPrefsV1> }): SelfTestFixture {
|
||||
const taxType: TaxType = o.registered ? "cgst_sgst" : "none";
|
||||
const taxRate = o.registered ? 18 : 0;
|
||||
const rows = items(o.rows, o.registered);
|
||||
const totals = computeTotals(rows, 0, taxType, taxRate);
|
||||
const vendor: Settings = o.registered
|
||||
? { ...VENDOR, vendorGstin: "29AAAAA0000A1Z5", gstRegistration: "regular", signatoryName: "Selftest Studio", signatoryDesignation: "Proprietor" }
|
||||
: VENDOR;
|
||||
const invoice: Invoice = {
|
||||
id: 1,
|
||||
number: o.number,
|
||||
seriesId: 1,
|
||||
invoiceDate: "2026-09-15",
|
||||
dueDate: "2026-10-15",
|
||||
clientId: null,
|
||||
clientName: "Acme Learning Pvt Ltd",
|
||||
clientAddress: "42 MG Road\nBangalore 560001",
|
||||
clientGstin: o.registered ? "29ABCDE1234F1Z5" : "",
|
||||
poNumber: "",
|
||||
placeOfSupplyStateCode: o.registered ? "29" : "",
|
||||
subtotal: totals.subtotal,
|
||||
discount: totals.discount,
|
||||
taxType,
|
||||
taxRate,
|
||||
cgstAmount: totals.cgst,
|
||||
sgstAmount: totals.sgst,
|
||||
igstAmount: totals.igst,
|
||||
total: totals.total,
|
||||
amountInWords: amountInWords(totals.total),
|
||||
bankAccountId: 1,
|
||||
bankSnapshot: "",
|
||||
signaturePath: null,
|
||||
notes: "",
|
||||
status: "issued",
|
||||
createdAt: "2026-09-15T00:00:00Z",
|
||||
updatedAt: "2026-09-15T00:00:00Z",
|
||||
docType: o.registered ? "tax_invoice" : "invoice",
|
||||
reverseCharge: false,
|
||||
vendorSnapshot: "",
|
||||
snapshotOrigin: "",
|
||||
cancelledAt: null,
|
||||
cancelReason: "",
|
||||
archivedPdfSha256: null,
|
||||
renderPrefs: "",
|
||||
items: rows,
|
||||
};
|
||||
const model = buildRenderModel({ invoice, vendor, bank: BANK, logo: null, signatureDataUri: null, appVersion: "0.0.0-selftest" });
|
||||
return {
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
model,
|
||||
prefs: { ...DEFAULT_PREFS, ...o.prefs },
|
||||
invoiceNumber: o.number,
|
||||
totalText: model.totals.totalText,
|
||||
};
|
||||
}
|
||||
|
||||
export const SELF_TEST_FIXTURE_IDS = ["classic-a4-tax-3", "classic-letter-unreg-60"] as const;
|
||||
|
||||
/** Classic A4 tax invoice with 3 rows, and Classic Letter plain invoice with 60 rows (a multi-page table). */
|
||||
export function selfTestFixtures(): SelfTestFixture[] {
|
||||
return [
|
||||
build({ id: "classic-a4-tax-3", label: "Classic, A4, tax invoice, 3 rows", number: "ST/2026-001", rows: 3, registered: true, prefs: { pageSize: "A4" } }),
|
||||
build({ id: "classic-letter-unreg-60", label: "Classic, Letter, unregistered, 60 rows", number: "ST/2026-002", rows: 60, registered: false, prefs: { pageSize: "LETTER" } }),
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>({ ...settings });
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -474,6 +477,20 @@ export default function AppSettings({
|
||||
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
|
||||
{busy ? "Saving…" : "Save settings"}
|
||||
</Button>
|
||||
|
||||
<p className="voiced-muted" style={{ marginTop: "3rem", fontSize: "0.75rem" }}>
|
||||
<Link
|
||||
href="#"
|
||||
size="sm"
|
||||
onClick={(e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
setDiagnosticsOpen(true);
|
||||
}}
|
||||
>
|
||||
Diagnostics
|
||||
</Link>
|
||||
</p>
|
||||
<DiagnosticsPanel open={diagnosticsOpen} onClose={() => setDiagnosticsOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
|
||||
const [archived, setArchived] = useState<Record<number, boolean>>({});
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const exp = useInvoiceExport({
|
||||
settings,
|
||||
bank: null,
|
||||
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
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
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
|
||||
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
|
||||
<ExportFeedback exp={exp} />
|
||||
|
||||
{loading ? (
|
||||
<InlineLoading description="Loading invoices" />
|
||||
) : rows.length === 0 ? (
|
||||
@@ -104,6 +161,7 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
<TableCell>{row.clientName || "—"}</TableCell>
|
||||
<TableCell>₹{formatAmount(row.total)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
{row.status === "cancelled" ? (
|
||||
<Tag type="red" size="sm">
|
||||
Cancelled
|
||||
@@ -111,31 +169,42 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
) : (
|
||||
row.status
|
||||
)}
|
||||
{archived[row.id] === false ? (
|
||||
<Tag type="warm-gray" size="sm">
|
||||
Not archived
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Download}
|
||||
onClick={() => onExport(row.id)}
|
||||
disabled={busyId === row.id}
|
||||
>
|
||||
{busyId === row.id ? "Exporting…" : "PDF"}
|
||||
{archived[row.id] === false ? (
|
||||
<Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
|
||||
{busyId === row.id ? "Working…" : "Render & archive"}
|
||||
</Button>
|
||||
{row.status === "issued" ? (
|
||||
<Button
|
||||
kind="ghost"
|
||||
) : null}
|
||||
<OverflowMenu
|
||||
aria-label={`Actions for invoice ${row.number}`}
|
||||
iconDescription="Actions"
|
||||
flipped
|
||||
size="sm"
|
||||
renderIcon={Close}
|
||||
disabled={busyId !== null || exp.busy}
|
||||
>
|
||||
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
|
||||
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
|
||||
<OverflowMenuItem itemText="Re-render with current template" onClick={() => void onExport(row.id, "searchable", "rerender")} />
|
||||
{row.status === "issued" ? (
|
||||
<OverflowMenuItem
|
||||
itemText="Cancel invoice"
|
||||
hasDivider
|
||||
isDelete
|
||||
onClick={() => {
|
||||
setReason("");
|
||||
setCancelTarget(row);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
/>
|
||||
) : null}
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
+66
-62
@@ -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<Invoice | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportedPath, setExportedPath] = useState<string | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(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<string | null>(null);
|
||||
const [archiving, setArchiving] = useState(false);
|
||||
const [exportMode, setExportMode] = useState<ExportMode>(loadExportMode);
|
||||
const locked = issued !== null;
|
||||
// The issued invoice as the export flow needs it: with its archive fields once archived.
|
||||
const exportable = useMemo<Invoice | null>(
|
||||
() => (issued ? { ...issued, archivedPdfSha256: archive?.sha256 ?? issued.archivedPdfSha256, archivedFingerprint: archive?.fingerprint ?? issued.archivedFingerprint ?? null } : null),
|
||||
[issued, archive],
|
||||
);
|
||||
|
||||
// Drafts.
|
||||
const [draftId, setDraftId] = useState<number | null>(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<ReturnType<typeof issueAndArchive>>;
|
||||
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
|
||||
<Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}>
|
||||
Save draft
|
||||
</Button>
|
||||
<Button renderIcon={Download} onClick={onSave} disabled={busy}>
|
||||
{saving ? "Saving…" : "Save & export PDF"}
|
||||
<div className="voiced-export-split">
|
||||
<Button renderIcon={Download} onClick={onSave} disabled={busy} title={`Issues the invoice and exports the ${exportMode} PDF`}>
|
||||
{saving ? "Issuing…" : "Issue & export"}
|
||||
</Button>
|
||||
<ExportModePicker mode={exportMode} onChange={setExportMode} disabled={busy} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -673,48 +698,27 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
onCloseButtonClick={() => setNoticeOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{locked ? (
|
||||
{locked && exportable ? (
|
||||
<>
|
||||
{exportError ? (
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
{!exportable.archivedPdfSha256 ? (
|
||||
<ActionableNotification
|
||||
inline
|
||||
kind="warning"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={`Invoice ${issued.number} was issued, but the PDF export failed`}
|
||||
subtitle={`${exportError} The number is already used; retry the export, do not issue again.`}
|
||||
title="Issued — PDF not archived yet"
|
||||
subtitle={`${archiveError ?? "The PDF was not archived."} The invoice is safe; it is not issued again. You can still export.`}
|
||||
actionButtonLabel={archiving ? "Archiving…" : "Render & archive"}
|
||||
onActionButtonClick={() => void retryArchive()}
|
||||
/>
|
||||
) : exportedPath ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={`Invoice ${issued.number} issued`}
|
||||
subtitle={exportedPath}
|
||||
/>
|
||||
) : exporting ? (
|
||||
<InlineLoading description="Exporting PDF…" />
|
||||
) : (
|
||||
<InlineNotification
|
||||
kind="info"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={`Invoice ${issued.number} issued`}
|
||||
subtitle="The PDF was not saved. Use Export again to save it."
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<ExportFeedback exp={exp} />
|
||||
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
|
||||
<Button
|
||||
kind={exportError ? "primary" : "tertiary"}
|
||||
renderIcon={exportError ? Renew : Download}
|
||||
onClick={() => void runExport(issued)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? "Exporting…" : exportError ? "Retry export" : "Export again"}
|
||||
</Button>
|
||||
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={exporting}>
|
||||
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
|
||||
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>
|
||||
Duplicate as new
|
||||
</Button>
|
||||
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={exporting}>
|
||||
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={busy}>
|
||||
New invoice
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user