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 3a99b7284b
commit 9e6df5ca8e
24 changed files with 1579 additions and 505 deletions
+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);