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:
+1
-1
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user