Classic now renders through buildRenderModel, computeFrame, the template and renderCore, with S.No and HSN/SAC columns, GST slots only through checked Slot components, taxable value, reverse-charge line, cancelled tag, continuation band and Page n of N. Letter absorbs its height deficit in elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item sweep and asserts zero audit errors, repeated headers, no split blocks, rupee extraction and deterministic output. The legacy InvoiceDocument is removed.
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
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 { api } from "./api";
|
|
import { imageSizeFromDataUri } from "./imageSize";
|
|
import type { InvoicePdfProps } from "./pdfProps";
|
|
import type { BankAccount, Invoice, Settings } from "./types";
|
|
|
|
function bytesToBase64(bytes: Uint8Array): string {
|
|
let bin = "";
|
|
const step = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += step) bin += String.fromCharCode(...bytes.subarray(i, i + step));
|
|
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.
|
|
*/
|
|
export async function renderInvoicePdf(
|
|
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({
|
|
invoice,
|
|
vendor: settings,
|
|
bank,
|
|
logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null,
|
|
signatureDataUri,
|
|
appVersion: pkg.version,
|
|
});
|
|
return renderModelPdf({ model, prefs });
|
|
}
|
|
|
|
/** 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.saveBinaryFile(path, bytesToBase64(bytes));
|
|
return path;
|
|
}
|
|
|
|
export async function exportInvoicePdf(
|
|
invoice: Invoice,
|
|
settings: Settings,
|
|
bank: BankAccount | null,
|
|
prefs: RenderPrefsV1 = DEFAULT_PREFS,
|
|
): Promise<string | null> {
|
|
const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
|
|
return savePdfBytes(bytes, invoice.number);
|
|
}
|