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 pkg from "../../package.json";
|
||||||
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
|
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
|
||||||
@@ -57,6 +56,11 @@ export async function renderInvoicePdf(
|
|||||||
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
|
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
|
||||||
): Promise<RenderResult> {
|
): Promise<RenderResult> {
|
||||||
const model = await buildModelForInvoice(invoice, settings, bank);
|
const model = await buildModelForInvoice(invoice, settings, bank);
|
||||||
|
return renderModelWithRetry(model, prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a ready model in the shared client. */
|
||||||
|
export async function renderModelWithRetry(model: RenderModel, prefs: RenderPrefsV1): Promise<RenderResult> {
|
||||||
// The live preview shares the client; if it takes the worker first this render is superseded, so go again.
|
// The live preview shares the client; if it takes the worker first this render is superseded, so go again.
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -66,25 +70,3 @@ export async function renderInvoicePdf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */
|
|
||||||
export async function savePdfBytes(bytes: Uint8Array, fileName: string): Promise<string | null> {
|
|
||||||
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
|
|
||||||
const path = await save({
|
|
||||||
defaultPath: `${suggested}.pdf`,
|
|
||||||
filters: [{ name: "PDF document", extensions: ["pdf"] }],
|
|
||||||
});
|
|
||||||
if (!path) return null;
|
|
||||||
await api.writeExportFile(path, bytes);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportInvoicePdf(
|
|
||||||
invoice: Invoice,
|
|
||||||
settings: Settings,
|
|
||||||
bank: BankAccount | null,
|
|
||||||
prefs?: RenderPrefsV1,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
|
|
||||||
return savePdfBytes(bytes, invoice.number);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
WorkerUnavailable,
|
||||||
type WorkerLike,
|
type WorkerLike,
|
||||||
} from "./client";
|
} from "./client";
|
||||||
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
||||||
|
|
||||||
class FakeWorker implements WorkerLike {
|
class FakeWorker implements WorkerLike {
|
||||||
onmessage: WorkerLike["onmessage"] = null;
|
onmessage: WorkerLike["onmessage"] = null;
|
||||||
@@ -31,8 +31,15 @@ class FakeWorker implements WorkerLike {
|
|||||||
reply(id: number) {
|
reply(id: number) {
|
||||||
this.emit({ type: "result", result: result(id) });
|
this.emit({ type: "result", result: result(id) });
|
||||||
}
|
}
|
||||||
|
replyAssembled(id: number) {
|
||||||
|
this.emit({ type: "assembled", result: assembled(id) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assembled = (id: number): AssembleResult => ({ id, bytes: new Uint8Array([id]), ms: 3 });
|
||||||
|
const assembleJob = (id: number): AssembleJob => ({ id, title: "T", pages: [{ jpeg: new Uint8Array([1]), widthPt: 10, heightPt: 10 }] });
|
||||||
|
const kinds = (w: FakeWorker) => w.sent.map((m) => `${m.type}:${m.job.id}`);
|
||||||
|
|
||||||
const result = (id: number): RenderResult => ({
|
const result = (id: number): RenderResult => ({
|
||||||
id,
|
id,
|
||||||
bytes: new Uint8Array([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", () => {
|
describe("main-thread client and fallback", () => {
|
||||||
it("runs jobs serially with latest-wins", async () => {
|
it("runs jobs serially with latest-wins", async () => {
|
||||||
const gates: Array<() => void> = [];
|
const gates: Array<() => void> = [];
|
||||||
|
|||||||
+114
-32
@@ -1,4 +1,4 @@
|
|||||||
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
||||||
|
|
||||||
/** A newer render call replaced this one before it finished (or before it started). */
|
/** A newer render call replaced this one before it finished (or before it started). */
|
||||||
export class Superseded extends Error {
|
export class Superseded extends Error {
|
||||||
@@ -44,6 +44,12 @@ export interface WorkerLike {
|
|||||||
export interface RenderClient {
|
export interface RenderClient {
|
||||||
/** Resolves with the newest request's result; an older call rejects with Superseded. */
|
/** Resolves with the newest request's result; an older call rejects with Superseded. */
|
||||||
render(job: RenderJob): Promise<RenderResult>;
|
render(job: RenderJob): Promise<RenderResult>;
|
||||||
|
/**
|
||||||
|
* Wraps JPEG pages into an image-only PDF. This is a separate lane from `render`: renders are
|
||||||
|
* latest-wins, but an assemble is an export step that must complete, so it is never superseded by
|
||||||
|
* a render (or by another assemble). It waits for the job in flight and runs before any queued render.
|
||||||
|
*/
|
||||||
|
assemble(job: AssembleJob): Promise<AssembleResult>;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
readonly mode: "worker" | "main";
|
readonly mode: "worker" | "main";
|
||||||
}
|
}
|
||||||
@@ -57,23 +63,38 @@ export interface RenderClientOptions {
|
|||||||
const defaultWorkerFactory = (): WorkerLike =>
|
const defaultWorkerFactory = (): WorkerLike =>
|
||||||
new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike;
|
new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike;
|
||||||
|
|
||||||
interface Slot {
|
interface RenderSlot {
|
||||||
|
kind: "render";
|
||||||
job: RenderJob;
|
job: RenderJob;
|
||||||
resolve: (r: RenderResult) => void;
|
resolve: (r: RenderResult) => void;
|
||||||
reject: (e: unknown) => void;
|
reject: (e: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Running extends Slot {
|
interface AssembleSlot {
|
||||||
|
kind: "assemble";
|
||||||
|
job: AssembleJob;
|
||||||
|
resolve: (r: AssembleResult) => void;
|
||||||
|
reject: (e: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Slot = RenderSlot | AssembleSlot;
|
||||||
|
|
||||||
|
type Running = Slot & {
|
||||||
/** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
|
/** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
|
||||||
superseded: boolean;
|
superseded: boolean;
|
||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const requestFor = (slot: Slot): WorkerRequest =>
|
||||||
|
slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders in a module worker. Only the newest request matters: a new call rejects the one in flight
|
* Renders in a module worker. Only the newest render matters: a new call rejects the render in flight
|
||||||
* (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the
|
* (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the
|
||||||
* running job finishes at most one job, the newest, runs. The handshake is the worker's `ready`
|
* running job finishes at most one render, the newest, runs. Assemble jobs are the exception: they
|
||||||
* message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback.
|
* queue in order, are never superseded, and go ahead of a queued render. The handshake is the
|
||||||
|
* worker's `ready` message; a start-up error rejects with WorkerUnavailable and there is no
|
||||||
|
* timer-based fallback.
|
||||||
*/
|
*/
|
||||||
export function createRenderClient(opts: RenderClientOptions = {}): RenderClient {
|
export function createRenderClient(opts: RenderClientOptions = {}): RenderClient {
|
||||||
const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts;
|
const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts;
|
||||||
@@ -82,7 +103,8 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
|||||||
let unavailable: WorkerUnavailable | null = null;
|
let unavailable: WorkerUnavailable | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let running: Running | null = null;
|
let running: Running | null = null;
|
||||||
let pending: Slot | null = null;
|
let pending: RenderSlot | null = null;
|
||||||
|
const assembles: AssembleSlot[] = [];
|
||||||
|
|
||||||
const stopWorker = () => {
|
const stopWorker = () => {
|
||||||
const w = worker;
|
const w = worker;
|
||||||
@@ -107,16 +129,25 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
|||||||
unavailable = err;
|
unavailable = err;
|
||||||
stopWorker();
|
stopWorker();
|
||||||
settleRunning((s) => s.reject(err));
|
settleRunning((s) => s.reject(err));
|
||||||
|
rejectQueued(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectQueued = (err: unknown) => {
|
||||||
pending?.reject(err);
|
pending?.reject(err);
|
||||||
pending = null;
|
pending = null;
|
||||||
|
for (const a of assembles.splice(0)) a.reject(err);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pump = () => {
|
const pump = () => {
|
||||||
if (!ready || !worker || running || !pending || disposed) return;
|
if (!ready || !worker || running || disposed) return;
|
||||||
const slot = pending;
|
let slot: Slot | null = assembles.shift() ?? null;
|
||||||
pending = null;
|
if (!slot) {
|
||||||
|
slot = pending;
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
if (!slot) return;
|
||||||
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
|
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
|
||||||
worker.postMessage({ type: "render", job: slot.job });
|
worker.postMessage(requestFor(slot));
|
||||||
};
|
};
|
||||||
|
|
||||||
const spawn = () => {
|
const spawn = () => {
|
||||||
@@ -136,8 +167,12 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
|||||||
ready = true;
|
ready = true;
|
||||||
pump();
|
pump();
|
||||||
} else if (msg.type === "result") {
|
} else if (msg.type === "result") {
|
||||||
if (running?.job.id !== msg.result.id) return;
|
if (running?.kind !== "render" || running.job.id !== msg.result.id) return;
|
||||||
settleRunning((s) => s.resolve(msg.result));
|
settleRunning((s) => (s as RenderSlot).resolve(msg.result));
|
||||||
|
pump();
|
||||||
|
} else if (msg.type === "assembled") {
|
||||||
|
if (running?.kind !== "assemble" || running.job.id !== msg.result.id) return;
|
||||||
|
settleRunning((s) => (s as AssembleSlot).resolve(msg.result));
|
||||||
pump();
|
pump();
|
||||||
} else if (msg.id === null) {
|
} else if (msg.id === null) {
|
||||||
if (!ready) fail(new WorkerUnavailable(msg.message));
|
if (!ready) fail(new WorkerUnavailable(msg.message));
|
||||||
@@ -170,11 +205,20 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
|||||||
if (unavailable) return Promise.reject(unavailable);
|
if (unavailable) return Promise.reject(unavailable);
|
||||||
return new Promise<RenderResult>((resolve, reject) => {
|
return new Promise<RenderResult>((resolve, reject) => {
|
||||||
pending?.reject(new Superseded());
|
pending?.reject(new Superseded());
|
||||||
if (running && !running.superseded) {
|
if (running && running.kind === "render" && !running.superseded) {
|
||||||
running.superseded = true;
|
running.superseded = true;
|
||||||
running.reject(new Superseded());
|
running.reject(new Superseded());
|
||||||
}
|
}
|
||||||
pending = { job, resolve, reject };
|
pending = { kind: "render", job, resolve, reject };
|
||||||
|
if (!worker) spawn();
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
assemble(job) {
|
||||||
|
if (disposed) return Promise.reject(new Error("The render client was disposed"));
|
||||||
|
if (unavailable) return Promise.reject(unavailable);
|
||||||
|
return new Promise<AssembleResult>((resolve, reject) => {
|
||||||
|
assembles.push({ kind: "assemble", job, resolve, reject });
|
||||||
if (!worker) spawn();
|
if (!worker) spawn();
|
||||||
pump();
|
pump();
|
||||||
});
|
});
|
||||||
@@ -184,8 +228,7 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
const err = new Error("The render client was disposed");
|
const err = new Error("The render client was disposed");
|
||||||
settleRunning((s) => s.reject(err));
|
settleRunning((s) => s.reject(err));
|
||||||
pending?.reject(err);
|
rejectQueued(err);
|
||||||
pending = null;
|
|
||||||
stopWorker();
|
stopWorker();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -212,20 +255,41 @@ const runOnMainThread: MainExecutor = async (job) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */
|
/** Assembles one image-only PDF on this thread. */
|
||||||
export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient {
|
export type MainAssembleExecutor = (job: AssembleJob) => Promise<AssembleResult>;
|
||||||
|
|
||||||
|
const assembleOnMainThread: MainAssembleExecutor = async (job) => (await import("./assembleJob")).runAssembleJob(job);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same interface and lanes as the worker client (latest-wins renders, never-superseded assembles),
|
||||||
|
* on the main thread. Nothing can interrupt it, so there is no watchdog.
|
||||||
|
*/
|
||||||
|
export function createMainThreadClient(
|
||||||
|
execute: MainExecutor = runOnMainThread,
|
||||||
|
executeAssemble: MainAssembleExecutor = assembleOnMainThread,
|
||||||
|
): RenderClient {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let running: (Slot & { superseded: boolean }) | null = null;
|
let running: (Slot & { superseded: boolean }) | null = null;
|
||||||
let pending: Slot | null = null;
|
let pending: RenderSlot | null = null;
|
||||||
|
const assembles: AssembleSlot[] = [];
|
||||||
|
|
||||||
const pump = async () => {
|
const pump = async () => {
|
||||||
if (running || !pending || disposed) return;
|
if (running || disposed) return;
|
||||||
const slot = { ...pending, superseded: false };
|
let next: Slot | null = assembles.shift() ?? null;
|
||||||
pending = null;
|
if (!next) {
|
||||||
|
next = pending;
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
if (!next) return;
|
||||||
|
const slot = { ...next, superseded: false };
|
||||||
running = slot;
|
running = slot;
|
||||||
try {
|
try {
|
||||||
const result = await execute(slot.job);
|
if (slot.kind === "render") {
|
||||||
if (!slot.superseded) slot.resolve(result);
|
const result = await execute(slot.job);
|
||||||
|
if (!slot.superseded) slot.resolve(result);
|
||||||
|
} else {
|
||||||
|
slot.resolve(await executeAssemble(slot.job));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!slot.superseded) slot.reject(e);
|
if (!slot.superseded) slot.reject(e);
|
||||||
}
|
}
|
||||||
@@ -239,11 +303,18 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
|
|||||||
if (disposed) return Promise.reject(new Error("The render client was disposed"));
|
if (disposed) return Promise.reject(new Error("The render client was disposed"));
|
||||||
return new Promise<RenderResult>((resolve, reject) => {
|
return new Promise<RenderResult>((resolve, reject) => {
|
||||||
pending?.reject(new Superseded());
|
pending?.reject(new Superseded());
|
||||||
if (running && !running.superseded) {
|
if (running && running.kind === "render" && !running.superseded) {
|
||||||
running.superseded = true;
|
running.superseded = true;
|
||||||
running.reject(new Superseded());
|
running.reject(new Superseded());
|
||||||
}
|
}
|
||||||
pending = { job, resolve, reject };
|
pending = { kind: "render", job, resolve, reject };
|
||||||
|
void pump();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
assemble(job) {
|
||||||
|
if (disposed) return Promise.reject(new Error("The render client was disposed"));
|
||||||
|
return new Promise<AssembleResult>((resolve, reject) => {
|
||||||
|
assembles.push({ kind: "assemble", job, resolve, reject });
|
||||||
void pump();
|
void pump();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -252,6 +323,7 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
|
|||||||
const err = new Error("The render client was disposed");
|
const err = new Error("The render client was disposed");
|
||||||
pending?.reject(err);
|
pending?.reject(err);
|
||||||
pending = null;
|
pending = null;
|
||||||
|
for (const a of assembles.splice(0)) a.reject(err);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -261,10 +333,15 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
|
|||||||
* which one is active (main thread: the preview is paused while typing).
|
* which one is active (main thread: the preview is paused while typing).
|
||||||
*/
|
*/
|
||||||
export function createAutoClient(
|
export function createAutoClient(
|
||||||
opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {},
|
opts: RenderClientOptions & { mainExecutor?: MainExecutor; mainAssembleExecutor?: MainAssembleExecutor } = {},
|
||||||
): RenderClient {
|
): RenderClient {
|
||||||
const primary = createRenderClient(opts);
|
const primary = createRenderClient(opts);
|
||||||
let main: RenderClient | null = null;
|
let main: RenderClient | null = null;
|
||||||
|
const fallback = () => {
|
||||||
|
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
|
||||||
|
primary.dispose();
|
||||||
|
return main;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
get mode() {
|
get mode() {
|
||||||
return main ? "main" : "worker";
|
return main ? "main" : "worker";
|
||||||
@@ -273,9 +350,14 @@ export function createAutoClient(
|
|||||||
if (main) return main.render(job);
|
if (main) return main.render(job);
|
||||||
return primary.render(job).catch((err) => {
|
return primary.render(job).catch((err) => {
|
||||||
if (!(err instanceof WorkerUnavailable)) throw err;
|
if (!(err instanceof WorkerUnavailable)) throw err;
|
||||||
main ??= createMainThreadClient(opts.mainExecutor);
|
return fallback().render(job);
|
||||||
primary.dispose();
|
});
|
||||||
return main.render(job);
|
},
|
||||||
|
assemble(job) {
|
||||||
|
if (main) return main.assemble(job);
|
||||||
|
return primary.assemble(job).catch((err) => {
|
||||||
|
if (!(err instanceof WorkerUnavailable)) throw err;
|
||||||
|
return fallback().assemble(job);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
|
|||||||
+22
-1
@@ -20,10 +20,31 @@ export interface RenderResult {
|
|||||||
ms: number;
|
ms: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkerRequest = { type: "render"; job: RenderJob };
|
/** One flattened page: a JPEG to embed full-bleed on a page of the given size. */
|
||||||
|
export interface AssemblePageData {
|
||||||
|
jpeg: Uint8Array;
|
||||||
|
widthPt: number;
|
||||||
|
heightPt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps JPEG pages into an image-only PDF. Needs no fonts, so it never waits on the render's setup. */
|
||||||
|
export interface AssembleJob {
|
||||||
|
id: number;
|
||||||
|
pages: AssemblePageData[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssembleResult {
|
||||||
|
id: number;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerRequest = { type: "render"; job: RenderJob } | { type: "assemble-images"; job: AssembleJob };
|
||||||
|
|
||||||
export type WorkerMessage =
|
export type WorkerMessage =
|
||||||
| { type: "ready" }
|
| { type: "ready" }
|
||||||
| { type: "result"; result: RenderResult }
|
| { type: "result"; result: RenderResult }
|
||||||
|
| { type: "assembled"; result: AssembleResult }
|
||||||
/** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */
|
/** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */
|
||||||
| { type: "error"; id: number | null; message: string; stack?: string };
|
| { type: "error"; id: number | null; message: string; stack?: string };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests.
|
// Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests.
|
||||||
import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
|
import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
|
||||||
import { initBrowserRendering } from "./browserInit";
|
import { initBrowserRendering } from "./browserInit";
|
||||||
|
import { runAssembleJob } from "./assembleJob";
|
||||||
import { renderInvoicePdf } from "./invoice";
|
import { renderInvoicePdf } from "./invoice";
|
||||||
import type { WorkerMessage, WorkerRequest } from "./jobs";
|
import type { WorkerMessage, WorkerRequest } from "./jobs";
|
||||||
|
|
||||||
@@ -21,10 +22,24 @@ const failure = (id: number | null, err: unknown): WorkerMessage => ({
|
|||||||
stack: err instanceof Error ? err.stack : undefined,
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handing a buffer over instead of copying it only works when the view covers all of it.
|
||||||
|
const whole = (b: Uint8Array) => b.byteOffset === 0 && b.byteLength === b.buffer.byteLength;
|
||||||
|
|
||||||
const started = initBrowserRendering();
|
const started = initBrowserRendering();
|
||||||
|
|
||||||
scope.onmessage = async (event) => {
|
scope.onmessage = async (event) => {
|
||||||
const request = event.data;
|
const request = event.data;
|
||||||
|
if (request?.type === "assemble-images") {
|
||||||
|
const { job } = request;
|
||||||
|
try {
|
||||||
|
const out = await runAssembleJob(job);
|
||||||
|
const bytes = whole(out.bytes) ? out.bytes : out.bytes.slice();
|
||||||
|
post({ type: "assembled", result: { ...out, bytes } }, [bytes.buffer]);
|
||||||
|
} catch (err) {
|
||||||
|
post(failure(job.id, err));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (request?.type !== "render") return;
|
if (request?.type !== "render") return;
|
||||||
const { job } = request;
|
const { job } = request;
|
||||||
try {
|
try {
|
||||||
@@ -32,9 +47,7 @@ scope.onmessage = async (event) => {
|
|||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
|
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
|
||||||
const ms = Math.round(performance.now() - t0);
|
const ms = Math.round(performance.now() - t0);
|
||||||
// Hand the buffer over instead of copying it; a view onto part of a larger buffer is copied first.
|
const bytes = whole(out.bytes) ? out.bytes : out.bytes.slice();
|
||||||
const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength;
|
|
||||||
const bytes = whole ? out.bytes : out.bytes.slice();
|
|
||||||
post(
|
post(
|
||||||
{ type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } },
|
{ type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } },
|
||||||
[bytes.buffer],
|
[bytes.buffer],
|
||||||
|
|||||||
@@ -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);
|
background-color: var(--cds-support-warning);
|
||||||
color: #161616;
|
color: #161616;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- export feedback ---------- */
|
||||||
|
.voiced-notice-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-export-split {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-diagnostics__check {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--cds-border-subtle-01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-diagnostics__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-diagnostics__label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-diagnostics__ms {
|
||||||
|
color: var(--cds-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-diagnostics__detail {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--cds-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Grid,
|
Grid,
|
||||||
InlineNotification,
|
InlineNotification,
|
||||||
|
Link,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
RadioButton,
|
RadioButton,
|
||||||
RadioButtonGroup,
|
RadioButtonGroup,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
|
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
||||||
|
|
||||||
const blankBank = (): BankAccount => ({
|
const blankBank = (): BankAccount => ({
|
||||||
id: null,
|
id: null,
|
||||||
@@ -52,6 +54,7 @@ export default function AppSettings({
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<Settings>({ ...settings });
|
const [form, setForm] = useState<Settings>({ ...settings });
|
||||||
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||||
const [editing, setEditing] = useState<BankAccount | null>(null);
|
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -474,6 +477,20 @@ export default function AppSettings({
|
|||||||
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
|
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
|
||||||
{busy ? "Saving…" : "Save settings"}
|
{busy ? "Saving…" : "Save settings"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<p className="voiced-muted" style={{ marginTop: "3rem", fontSize: "0.75rem" }}>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e: { preventDefault: () => void }) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDiagnosticsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diagnostics
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<DiagnosticsPanel open={diagnosticsOpen} onClose={() => setDiagnosticsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-31
@@ -4,6 +4,8 @@ import {
|
|||||||
InlineLoading,
|
InlineLoading,
|
||||||
InlineNotification,
|
InlineNotification,
|
||||||
Modal,
|
Modal,
|
||||||
|
OverflowMenu,
|
||||||
|
OverflowMenuItem,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -14,10 +16,13 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@carbon/react";
|
} from "@carbon/react";
|
||||||
import { Download, Close } from "@carbon/icons-react";
|
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { formatAmount, formatDate } from "../lib/format";
|
import { formatAmount, formatDate } from "../lib/format";
|
||||||
import { exportInvoicePdf } from "../lib/pdf";
|
import { createExportDeps } from "../lib/exportDeps";
|
||||||
|
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
|
||||||
|
import type { ExportMode } from "../lib/filename";
|
||||||
|
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||||
|
import ExportFeedback from "../components/ExportFeedback";
|
||||||
import type { InvoiceSummary, Settings } from "../lib/types";
|
import type { InvoiceSummary, Settings } from "../lib/types";
|
||||||
|
|
||||||
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
|
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
|
||||||
@@ -27,6 +32,14 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
|||||||
const [busyId, setBusyId] = useState<number | null>(null);
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
|
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
|
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
|
||||||
|
const [archived, setArchived] = useState<Record<number, boolean>>({});
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const exp = useInvoiceExport({
|
||||||
|
settings,
|
||||||
|
bank: null,
|
||||||
|
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
|
||||||
|
});
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -44,12 +57,53 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
|||||||
if (active) void load();
|
if (active) void load();
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
const onExport = async (id: number) => {
|
// Read each invoice's archive status in the background; it is a local call per row.
|
||||||
|
useEffect(() => {
|
||||||
|
let stop = false;
|
||||||
|
void (async () => {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (stop) return;
|
||||||
|
try {
|
||||||
|
const status = await api.archiveStatus(row.id);
|
||||||
|
if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived }));
|
||||||
|
} catch {
|
||||||
|
// Unknown stays unknown: the row just shows no tag.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
stop = true;
|
||||||
|
};
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
// Fetched now, not at list time: the row summary does not carry the archive fields.
|
||||||
|
const invoice = await api.getInvoice(id);
|
||||||
|
await exp.start(invoice, mode, source);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onArchive = async (id: number) => {
|
||||||
|
setBusyId(id);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const invoice = await api.getInvoice(id);
|
const invoice = await api.getInvoice(id);
|
||||||
await exportInvoicePdf(invoice, settings, null);
|
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
|
||||||
|
if (outcome.archived) {
|
||||||
|
setArchived((a) => ({ ...a, [id]: true }));
|
||||||
|
setNotice(`Invoice ${invoice.number} is archived.`);
|
||||||
|
} else {
|
||||||
|
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -79,6 +133,9 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
|||||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
|
||||||
|
<ExportFeedback exp={exp} />
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<InlineLoading description="Loading invoices" />
|
<InlineLoading description="Loading invoices" />
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
@@ -104,38 +161,50 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
|||||||
<TableCell>{row.clientName || "—"}</TableCell>
|
<TableCell>{row.clientName || "—"}</TableCell>
|
||||||
<TableCell>₹{formatAmount(row.total)}</TableCell>
|
<TableCell>₹{formatAmount(row.total)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.status === "cancelled" ? (
|
<div className="voiced-header-action">
|
||||||
<Tag type="red" size="sm">
|
{row.status === "cancelled" ? (
|
||||||
Cancelled
|
<Tag type="red" size="sm">
|
||||||
</Tag>
|
Cancelled
|
||||||
) : (
|
</Tag>
|
||||||
row.status
|
) : (
|
||||||
)}
|
row.status
|
||||||
|
)}
|
||||||
|
{archived[row.id] === false ? (
|
||||||
|
<Tag type="warm-gray" size="sm">
|
||||||
|
Not archived
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="voiced-header-action">
|
<div className="voiced-header-action">
|
||||||
<Button
|
{archived[row.id] === false ? (
|
||||||
kind="ghost"
|
<Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
|
||||||
size="sm"
|
{busyId === row.id ? "Working…" : "Render & archive"}
|
||||||
renderIcon={Download}
|
|
||||||
onClick={() => onExport(row.id)}
|
|
||||||
disabled={busyId === row.id}
|
|
||||||
>
|
|
||||||
{busyId === row.id ? "Exporting…" : "PDF"}
|
|
||||||
</Button>
|
|
||||||
{row.status === "issued" ? (
|
|
||||||
<Button
|
|
||||||
kind="ghost"
|
|
||||||
size="sm"
|
|
||||||
renderIcon={Close}
|
|
||||||
onClick={() => {
|
|
||||||
setReason("");
|
|
||||||
setCancelTarget(row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<OverflowMenu
|
||||||
|
aria-label={`Actions for invoice ${row.number}`}
|
||||||
|
iconDescription="Actions"
|
||||||
|
flipped
|
||||||
|
size="sm"
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</OverflowMenu>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
+67
-63
@@ -28,7 +28,6 @@ import {
|
|||||||
DocumentAdd,
|
DocumentAdd,
|
||||||
Download,
|
Download,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Renew,
|
|
||||||
Save,
|
Save,
|
||||||
TrashCan,
|
TrashCan,
|
||||||
} from "@carbon/icons-react";
|
} from "@carbon/icons-react";
|
||||||
@@ -51,12 +50,19 @@ import { computeLineAmount, computeTotals } from "../lib/invoice";
|
|||||||
import { amountInWords } from "../lib/numberToWords";
|
import { amountInWords } from "../lib/numberToWords";
|
||||||
import { gstinFullError } from "../lib/validators";
|
import { gstinFullError } from "../lib/validators";
|
||||||
import { addDays, formatAmount, todayIso } from "../lib/format";
|
import { addDays, formatAmount, todayIso } from "../lib/format";
|
||||||
import { exportInvoicePdf, modelFromAssets, prefsForInvoice } from "../lib/pdf";
|
import { modelFromAssets, prefsForInvoice } from "../lib/pdf";
|
||||||
import { formToPreviewInvoice } from "../lib/previewInvoice";
|
import { formToPreviewInvoice } from "../lib/previewInvoice";
|
||||||
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
|
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
|
||||||
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
|
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
|
||||||
import PageSetupControls from "../components/PageSetupControls";
|
import PageSetupControls from "../components/PageSetupControls";
|
||||||
import { useInvoicePdf } from "../hooks/useInvoicePdf";
|
import { useInvoicePdf } from "../hooks/useInvoicePdf";
|
||||||
|
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||||
|
import ExportButton, { ExportModePicker } from "../components/ExportButton";
|
||||||
|
import ExportFeedback from "../components/ExportFeedback";
|
||||||
|
import { createExportDeps } from "../lib/exportDeps";
|
||||||
|
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
|
||||||
|
import { loadExportMode } from "../lib/exportMode";
|
||||||
|
import type { ExportMode } from "../lib/filename";
|
||||||
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
|
|
||||||
@@ -138,10 +144,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
// Issued invoice shown read-only after a successful issue. Its number is consumed, so the
|
// Issued invoice shown read-only after a successful issue. Its number is consumed, so the
|
||||||
// form never issues again until the user starts a new one.
|
// form never issues again until the user starts a new one.
|
||||||
const [issued, setIssued] = useState<Invoice | null>(null);
|
const [issued, setIssued] = useState<Invoice | null>(null);
|
||||||
const [exporting, setExporting] = useState(false);
|
// What the backend recorded when the issued PDF was archived; null until that worked.
|
||||||
const [exportedPath, setExportedPath] = useState<string | null>(null);
|
const [archive, setArchive] = useState<{ sha256: string; fingerprint: string } | null>(null);
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [archiveError, setArchiveError] = useState<string | null>(null);
|
||||||
|
const [archiving, setArchiving] = useState(false);
|
||||||
|
const [exportMode, setExportMode] = useState<ExportMode>(loadExportMode);
|
||||||
const locked = issued !== null;
|
const locked = issued !== null;
|
||||||
|
// The issued invoice as the export flow needs it: with its archive fields once archived.
|
||||||
|
const exportable = useMemo<Invoice | null>(
|
||||||
|
() => (issued ? { ...issued, archivedPdfSha256: archive?.sha256 ?? issued.archivedPdfSha256, archivedFingerprint: archive?.fingerprint ?? issued.archivedFingerprint ?? null } : null),
|
||||||
|
[issued, archive],
|
||||||
|
);
|
||||||
|
|
||||||
// Drafts.
|
// Drafts.
|
||||||
const [draftId, setDraftId] = useState<number | null>(null);
|
const [draftId, setDraftId] = useState<number | null>(null);
|
||||||
@@ -392,8 +405,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
const clearIssued = () => {
|
const clearIssued = () => {
|
||||||
issuedRef.current = false;
|
issuedRef.current = false;
|
||||||
setIssued(null);
|
setIssued(null);
|
||||||
setExportedPath(null);
|
setArchive(null);
|
||||||
setExportError(null);
|
setArchiveError(null);
|
||||||
|
exp.dismiss();
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -517,19 +531,24 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
await refreshDrafts();
|
await refreshDrafts();
|
||||||
};
|
};
|
||||||
|
|
||||||
const runExport = async (invoice: Invoice) => {
|
const noteArchived = (invoice: Invoice) => {
|
||||||
setExporting(true);
|
if (invoice.archivedPdfSha256) {
|
||||||
setExportError(null);
|
setArchive({ sha256: invoice.archivedPdfSha256, fingerprint: invoice.archivedFingerprint ?? "" });
|
||||||
try {
|
setArchiveError(null);
|
||||||
const path = await exportInvoicePdf(invoice, settings, bank);
|
|
||||||
setExportedPath(path ?? null);
|
|
||||||
} catch (e) {
|
|
||||||
setExportError(String(e));
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exp = useInvoiceExport({ settings, bank, onInvoiceUpdated: noteArchived });
|
||||||
|
|
||||||
|
const retryArchive = async () => {
|
||||||
|
if (!exportable || archiving) return;
|
||||||
|
setArchiving(true);
|
||||||
|
const outcome = await renderAndArchive(exportable, createExportDeps({ settings, bank }));
|
||||||
|
if (outcome.archived) noteArchived(outcome.invoice);
|
||||||
|
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
|
||||||
|
setArchiving(false);
|
||||||
|
};
|
||||||
|
|
||||||
const onSave = async () => {
|
const onSave = async () => {
|
||||||
if (locked || saving) return;
|
if (locked || saving) return;
|
||||||
if (clientGstinError) {
|
if (clientGstinError) {
|
||||||
@@ -538,9 +557,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setExportedPath(null);
|
exp.dismiss();
|
||||||
setExportError(null);
|
const mode = loadExportMode();
|
||||||
let invoice: Invoice;
|
let outcome: Awaited<ReturnType<typeof issueAndArchive>>;
|
||||||
try {
|
try {
|
||||||
// Let a pending autosave land first so the issue call knows which draft to delete.
|
// Let a pending autosave land first so the issue call knows which draft to delete.
|
||||||
await chain.current;
|
await chain.current;
|
||||||
@@ -565,19 +584,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
draftId: draftIdRef.current,
|
draftId: draftIdRef.current,
|
||||||
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||||
};
|
};
|
||||||
invoice = await api.issueInvoice(input, activePrefs);
|
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
issuedRef.current = false;
|
issuedRef.current = false;
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The number is consumed from here on. A failed export must never lead to a second issue.
|
// The number is consumed from here on. A failed archive or export must never lead to a second issue.
|
||||||
setIssued(invoice);
|
setIssued(outcome.invoice);
|
||||||
|
if (outcome.archived) noteArchived(outcome.invoice);
|
||||||
|
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
|
||||||
detachDraft();
|
detachDraft();
|
||||||
api.listClients().then(setClients).catch(() => {});
|
api.listClients().then(setClients).catch(() => {});
|
||||||
void refreshDrafts();
|
void refreshDrafts();
|
||||||
await runExport(invoice);
|
// Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works.
|
||||||
|
await exp.start(outcome.invoice, mode, "original");
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -590,7 +612,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
});
|
});
|
||||||
|
|
||||||
const otherDrafts = drafts.filter((d) => d.id !== draftId);
|
const otherDrafts = drafts.filter((d) => d.id !== draftId);
|
||||||
const busy = saving || exporting;
|
const busy = saving || exp.busy || archiving;
|
||||||
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
|
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
|
||||||
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
|
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
|
||||||
|
|
||||||
@@ -637,9 +659,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
<Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}>
|
<Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}>
|
||||||
Save draft
|
Save draft
|
||||||
</Button>
|
</Button>
|
||||||
<Button renderIcon={Download} onClick={onSave} disabled={busy}>
|
<div className="voiced-export-split">
|
||||||
{saving ? "Saving…" : "Save & export PDF"}
|
<Button renderIcon={Download} onClick={onSave} disabled={busy} title={`Issues the invoice and exports the ${exportMode} PDF`}>
|
||||||
</Button>
|
{saving ? "Issuing…" : "Issue & export"}
|
||||||
|
</Button>
|
||||||
|
<ExportModePicker mode={exportMode} onChange={setExportMode} disabled={busy} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -673,48 +698,27 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
|||||||
onCloseButtonClick={() => setNoticeOpen(false)}
|
onCloseButtonClick={() => setNoticeOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{locked ? (
|
{locked && exportable ? (
|
||||||
<>
|
<>
|
||||||
{exportError ? (
|
{!exportable.archivedPdfSha256 ? (
|
||||||
<InlineNotification
|
<ActionableNotification
|
||||||
kind="error"
|
inline
|
||||||
|
kind="warning"
|
||||||
lowContrast
|
lowContrast
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
title={`Invoice ${issued.number} was issued, but the PDF export failed`}
|
title="Issued — PDF not archived yet"
|
||||||
subtitle={`${exportError} The number is already used; retry the export, do not issue again.`}
|
subtitle={`${archiveError ?? "The PDF was not archived."} The invoice is safe; it is not issued again. You can still export.`}
|
||||||
|
actionButtonLabel={archiving ? "Archiving…" : "Render & archive"}
|
||||||
|
onActionButtonClick={() => void retryArchive()}
|
||||||
/>
|
/>
|
||||||
) : exportedPath ? (
|
) : null}
|
||||||
<InlineNotification
|
<ExportFeedback exp={exp} />
|
||||||
kind="success"
|
|
||||||
lowContrast
|
|
||||||
hideCloseButton
|
|
||||||
title={`Invoice ${issued.number} issued`}
|
|
||||||
subtitle={exportedPath}
|
|
||||||
/>
|
|
||||||
) : exporting ? (
|
|
||||||
<InlineLoading description="Exporting PDF…" />
|
|
||||||
) : (
|
|
||||||
<InlineNotification
|
|
||||||
kind="info"
|
|
||||||
lowContrast
|
|
||||||
hideCloseButton
|
|
||||||
title={`Invoice ${issued.number} issued`}
|
|
||||||
subtitle="The PDF was not saved. Use Export again to save it."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
|
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
|
||||||
<Button
|
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
|
||||||
kind={exportError ? "primary" : "tertiary"}
|
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>
|
||||||
renderIcon={exportError ? Renew : Download}
|
|
||||||
onClick={() => void runExport(issued)}
|
|
||||||
disabled={exporting}
|
|
||||||
>
|
|
||||||
{exporting ? "Exporting…" : exportError ? "Retry export" : "Export again"}
|
|
||||||
</Button>
|
|
||||||
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={exporting}>
|
|
||||||
Duplicate as new
|
Duplicate as new
|
||||||
</Button>
|
</Button>
|
||||||
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={exporting}>
|
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={busy}>
|
||||||
New invoice
|
New invoice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user