Replace the HTML preview with a worker-rendered PDF preview

Rendering moves into a module Web Worker behind a latest-wins RenderClient
with a watchdog and a handshake-based main-thread fallback. The preview
shows the real PDF through pdf.js at 2x with zoom, page indicator, audit
issues popover and a focus modal. A4/Letter and margin controls are frozen
into each invoice at issue and can be saved as the default (migration M3).
react-pdf and pdf.js are lazy-loaded; the main chunk shrinks from 1.8 MB
to 0.5 MB. The HTML twin and its styles are removed.
This commit is contained in:
2026-10-04 05:47:19 +05:30
parent a014a907e4
commit cedf4839bd
24 changed files with 1579 additions and 505 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ export const api = {
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
issueInvoice: (input: InvoiceInput, renderPrefs: Record<string, unknown>) =>
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
cancelInvoice: (id: number, reason: string) =>
invoke<Invoice>("cancel_invoice", { id, reason }),
+47 -62
View File
@@ -1,13 +1,13 @@
import { save } from "@tauri-apps/plugin-dialog";
import pkg from "../../package.json";
import { DEFAULT_PREFS, type RenderPrefsV1 } from "../pdf/model/prefs";
import { buildRenderModel } from "../pdf/model/build";
import { bankFromSnapshot, vendorFromSnapshot } from "../pdf/model/vendor";
import { initBrowserRendering } from "../pdf/render/browserInit";
import { renderInvoicePdf as renderModelPdf, type RenderInvoiceResult } from "../pdf/render/invoice";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
import { vendorFromSnapshot } from "../pdf/model/vendor";
import { Superseded } from "../pdf/render/client";
import type { RenderResult } from "../pdf/render/jobs";
import { api } from "./api";
import { imageSizeFromDataUri } from "./imageSize";
import type { InvoicePdfProps } from "./pdfProps";
import { getRenderClient, nextRenderJobId } from "./renderClient";
import type { BankAccount, Invoice, Settings } from "./types";
function bytesToBase64(bytes: Uint8Array): string {
@@ -17,68 +17,21 @@ function bytesToBase64(bytes: Uint8Array): string {
return btoa(bin);
}
/** Flat props for the on-screen preview; the PDF itself is built by renderInvoicePdf. */
export async function buildPdfProps(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<InvoicePdfProps> {
// Re-exports use the vendor details frozen at issue; live settings are only a fallback for
// legacy rows without a snapshot.
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath
? await api.readAssetDataUri(invoice.signaturePath)
: null;
const resolvedBank = bankFromSnapshot(invoice.bankSnapshot, bank);
return {
vendor,
bank: resolvedBank,
logoDataUri,
signatureDataUri,
number: invoice.number,
invoiceDate: invoice.invoiceDate,
dueDate: invoice.dueDate,
clientName: invoice.clientName,
clientAddress: invoice.clientAddress,
clientGstin: invoice.clientGstin,
poNumber: invoice.poNumber,
placeOfSupplyStateCode: invoice.placeOfSupplyStateCode,
items: invoice.items,
subtotal: invoice.subtotal,
discount: invoice.discount,
taxType: invoice.taxType,
taxRate: invoice.taxRate,
cgst: invoice.cgstAmount,
sgst: invoice.sgstAmount,
igst: invoice.igstAmount,
total: invoice.total,
amountInWords: invoice.amountInWords,
notes: invoice.notes,
};
}
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
/**
* Renders an issued invoice through the engine: model (supplier and bank from the snapshots frozen at
* issue, live settings only as a fallback) -> frame -> template -> PDF bytes, with the layout audit
* issues and fingerprint.
* The render model for an invoice whose images are already loaded (the live preview caches them).
* Supplier and bank come from the snapshots frozen at issue; live settings are only a fallback.
*/
export async function renderInvoicePdf(
export function modelFromAssets(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
prefs: RenderPrefsV1 = DEFAULT_PREFS,
): Promise<RenderInvoiceResult> {
await initBrowserRendering();
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
const model = buildRenderModel({
logoUri: string | null,
signatureDataUri: string | null,
): RenderModel {
return buildRenderModel({
invoice,
vendor: settings,
bank,
@@ -86,7 +39,39 @@ export async function renderInvoicePdf(
signatureDataUri,
appVersion: pkg.version,
});
return renderModelPdf({ model, prefs });
}
/** Loads the invoice's logo and signature, then builds its render model. */
export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise<RenderModel> {
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
return modelFromAssets(invoice, settings, bank, logoUri, signatureDataUri);
}
/** Page setup for an invoice: what was frozen at issue, else the user's current default. */
export const prefsForInvoice = (invoice: Invoice, settings: Settings): RenderPrefsV1 =>
parsePrefs(invoice.renderPrefs || settings.renderPrefs);
/**
* Renders an issued invoice to PDF bytes through the shared render client (worker, or the main
* thread if the worker cannot start), with the layout audit issues and fingerprint.
*/
export async function renderInvoicePdf(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
): Promise<RenderResult> {
const model = await buildModelForInvoice(invoice, settings, bank);
// The live preview shares the client; if it takes the worker first this render is superseded, so go again.
for (let attempt = 0; ; attempt++) {
try {
return await getRenderClient().render({ id: nextRenderJobId(), model, prefs });
} catch (err) {
if (!(err instanceof Superseded) || attempt >= 4) throw err;
}
}
}
/** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */
@@ -105,7 +90,7 @@ export async function exportInvoicePdf(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
prefs: RenderPrefsV1 = DEFAULT_PREFS,
prefs?: RenderPrefsV1,
): Promise<string | null> {
const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
return savePdfBytes(bytes, invoice.number);
-31
View File
@@ -1,31 +0,0 @@
import type { BankAccount, InvoiceItem, Settings, TaxType } from "./types";
/**
* The flat invoice props the on-screen preview (components/InvoicePreview.tsx) renders from. The PDF no
* longer uses them: it goes invoice -> buildRenderModel -> template (see lib/pdf.tsx).
*/
export interface InvoicePdfProps {
vendor: Settings;
bank: BankAccount | null;
logoDataUri: string | null;
signatureDataUri: string | null;
number: string;
invoiceDate: string;
dueDate: string;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
items: InvoiceItem[];
subtotal: number;
discount: number;
taxType: TaxType;
taxRate: number;
cgst: number;
sgst: number;
igst: number;
total: number;
amountInWords: string;
notes: string;
}
+50
View File
@@ -0,0 +1,50 @@
import { getDocument, GlobalWorkerOptions, type PDFDocumentProxy, type PDFPageProxy, type RenderTask } from "pdfjs-dist";
import { installStreamAsyncIterator } from "./streamPolyfill";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
installStreamAsyncIterator();
export type { PDFDocumentProxy, PDFPageProxy };
/**
* Opens PDF bytes. The bytes are copied because pdf.js transfers the buffer to its worker, which
* would detach the caller's array. Glyphs are drawn as paths (`disableFontFace`) so the preview looks
* the same on WebKitGTK and WebView2. (pdf.js 6 dropped `isEvalSupported`; it no longer evaluates font code.)
*/
export function openPdf(bytes: Uint8Array): Promise<PDFDocumentProxy> {
return getDocument({ data: bytes.slice(), disableFontFace: true }).promise;
}
/** Releases the document and its worker resources. */
export function closePdf(doc: PDFDocumentProxy): Promise<void> {
return doc.loadingTask.destroy();
}
const tasks = new WeakMap<object, RenderTask>();
/** Cancels whatever render is running for `slot` (a canvas, or any object the caller keys on). */
export function cancelPageRender(slot: object): void {
tasks.get(slot)?.cancel();
tasks.delete(slot);
}
/**
* Draws `page` into `canvas` at `scale` device pixels per PDF point, sizing the backing store to
* match. Starting a render for a slot cancels its previous one; a cancelled render rejects with
* pdf.js's RenderingCancelledException (see isRenderCancelled).
*/
export async function renderPageToCanvas(page: PDFPageProxy, canvas: HTMLCanvasElement, scale: number, slot: object = canvas): Promise<void> {
cancelPageRender(slot);
const viewport = page.getViewport({ scale });
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const task = page.render({ canvas, viewport });
tasks.set(slot, task);
try {
await task.promise;
} finally {
if (tasks.get(slot) === task) tasks.delete(slot);
}
}
export const isRenderCancelled = (err: unknown): boolean => (err as { name?: string } | null)?.name === "RenderingCancelledException";
+98
View File
@@ -0,0 +1,98 @@
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "./types";
import { computeLineAmount, computeTotals } from "./invoice";
import { amountInWords } from "./numberToWords";
/** The form fields the preview reads. */
export interface PreviewForm {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupply: string;
discount: number;
taxRate: number;
reverseCharge: boolean;
signaturePath: string | null;
notes: string;
items: InvoiceItem[];
}
/**
* The tax treatment Rust derives from the supplier state and the place of supply
* (src-tauri/src/gst.rs); the form shows and sends the same value.
*/
export function deriveTaxType(settings: Settings, placeOfSupply: string): TaxType {
if (settings.gstRegistration === "unregistered") return "none";
return !placeOfSupply || placeOfSupply === settings.vendorStateCode ? "cgst_sgst" : "igst";
}
/** Mirrors vendor_snapshot() in src-tauri/src/commands/invoice.rs. */
export function vendorSnapshotJson(settings: Settings): string {
return JSON.stringify({
vendorName: settings.vendorName,
vendorAddress: settings.vendorAddress,
vendorEmail: settings.vendorEmail,
vendorPhone: settings.vendorPhone,
vendorPan: settings.vendorPan,
vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(),
vendorStateCode: settings.vendorStateCode,
logoPath: settings.logoPath,
signaturePath: settings.signaturePath,
gstRegistration: settings.gstRegistration,
signatoryName: settings.signatoryName,
signatoryDesignation: settings.signatoryDesignation,
});
}
/**
* A draft-status Invoice built from the live form, so the real renderer can draw it before anything
* is issued. Totals use the same paise rounding Rust stores. `number` is the next number (a peek),
* not yet consumed.
*/
export function formToPreviewInvoice(form: PreviewForm, number: string, settings: Settings, bank: BankAccount | null): Invoice {
const taxType = deriveTaxType(settings, form.placeOfSupply);
const registered = settings.gstRegistration !== "unregistered";
const totals = computeTotals(form.items, form.discount, taxType, form.taxRate);
const items = form.items.map((item, i) => ({ ...item, amount: computeLineAmount(item), sortOrder: i }));
return {
id: 0,
number,
seriesId: null,
invoiceDate: form.invoiceDate,
dueDate: form.dueDate,
clientId: form.clientId,
clientName: form.clientName,
clientAddress: form.clientAddress,
clientGstin: form.clientGstin,
poNumber: form.poNumber,
placeOfSupplyStateCode: form.placeOfSupply,
subtotal: totals.subtotal,
discount: totals.discount,
taxType,
taxRate: form.taxRate,
cgstAmount: totals.cgst,
sgstAmount: totals.sgst,
igstAmount: totals.igst,
total: totals.total,
amountInWords: amountInWords(totals.total),
bankAccountId: bank?.id ?? null,
bankSnapshot: bank ? JSON.stringify(bank) : "",
signaturePath: form.signaturePath,
notes: form.notes,
status: "draft",
createdAt: "",
updatedAt: "",
docType: registered ? "tax_invoice" : "invoice",
reverseCharge: registered && form.reverseCharge,
vendorSnapshot: vendorSnapshotJson(settings),
snapshotOrigin: "issued",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items,
};
}
+12
View File
@@ -0,0 +1,12 @@
import { createAutoClient, type RenderClient } from "../pdf/render/client";
let client: RenderClient | null = null;
let lastId = 0;
/** The one render client (and so one worker) shared by the preview and export. */
export function getRenderClient(): RenderClient {
client ??= createAutoClient();
return client;
}
export const nextRenderJobId = (): number => ++lastId;
+25
View File
@@ -0,0 +1,25 @@
/**
* WebKitGTK (2.50) lacks ReadableStream.prototype[Symbol.asyncIterator], which pdf.js uses in
* getTextContent. Installs a minimal version when it is missing; a no-op elsewhere.
*/
export function installStreamAsyncIterator(): void {
const proto = (globalThis as { ReadableStream?: { prototype: object } }).ReadableStream?.prototype as
| Record<symbol, unknown>
| undefined;
if (!proto || Symbol.asyncIterator in proto) return;
proto[Symbol.asyncIterator] = function (this: ReadableStream<unknown>) {
const reader = this.getReader();
const iterator = {
next: () => reader.read(),
return: async () => {
await reader.cancel().catch(() => {});
reader.releaseLock();
return { done: true as const, value: undefined };
},
[Symbol.asyncIterator]() {
return iterator;
},
};
return iterator;
};
}
+4
View File
@@ -23,6 +23,8 @@ export interface Settings {
defaultHsnSac: string;
signatoryName: string;
signatoryDesignation: string;
/** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */
renderPrefs: string;
}
export interface BankAccount {
@@ -132,6 +134,8 @@ export interface Invoice {
cancelledAt: string | null;
cancelReason: string;
archivedPdfSha256: string | null;
/** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */
renderPrefs: string;
items: InvoiceItem[];
}