Files
Voiced/src/pdf/templates/harness.ts
T
xavierk 9e6df5ca8e 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.
2026-10-04 05:47:19 +05:30

305 lines
11 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { deflateSync } from "node:zlib";
import { computeTotals } from "../../lib/invoice";
import { amountInWords } from "../../lib/numberToWords";
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "../../lib/types";
import { buildRenderModel, type LogoInput, type RenderModel } from "../model/build";
import { DEFAULT_PREFS, type RenderPrefsV1 } from "../model/prefs";
import { renderInvoicePdf, type RenderInvoiceResult } from "../render/invoice";
import { parsePdfText, type RenderedPdf } from "../testing/pdfText";
import { setupPdfTest } from "../testing/setup";
/** Fixtures and helpers shared by the template harness and the sample renderer. */
export type LogoKind = "none" | "real" | "wide" | "square";
export interface FixtureSpec {
name: string;
vendor?: Partial<Settings>;
invoice?: Partial<Invoice>;
/** An item list, or a count of generated one-line items. */
items?: InvoiceItem[] | number;
logo?: LogoKind;
signature?: boolean;
/** Tax invoice with a registered supplier (default true; false gives the plain invoice). */
registered?: boolean;
}
export const FIXTURE_GSTIN = "29AAAAA0000A1Z5";
const FIXTURE_CLIENT_GSTIN = "29ABCDE1234F1Z5";
const baseVendor: Settings = {
vendorName: "Test Vendor",
vendorAddress: "1 Test Road, Bangalore - 560001",
vendorEmail: "vendor@example.com",
vendorPhone: "9000000000",
vendorPan: "ABCDE1234F",
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: "",
};
export const FIXTURE_BANK: BankAccount = {
id: 1,
label: "Primary",
bankName: "State Bank of India",
accountName: "Test Vendor",
accountNo: "000000000000",
branch: "000001",
ifsc: "TEST0000001",
isDefault: true,
};
export const makeItems = (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" } : {}),
}));
// --- tiny PNG writer: logos for the fixtures without a native image library or a binary fixture ----
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc32 = (buf: Buffer) => {
let c = 0xffffffff;
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type: string, data: Buffer) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
};
/** A w x h RGBA PNG: a navy block with a light stripe, so a wrongly stretched logo is visible. */
export function solidPng(w: number, h: number): Buffer {
const row = Buffer.alloc(1 + w * 4);
const raw = Buffer.alloc(row.length * h);
for (let y = 0; y < h; y++) {
raw[y * row.length] = 0;
for (let x = 0; x < w; x++) {
const stripe = Math.abs(x - y * (w / h)) < h * 0.12;
const o = y * row.length + 1 + x * 4;
raw[o] = stripe ? 120 : 24;
raw[o + 1] = stripe ? 160 : 52;
raw[o + 2] = stripe ? 240 : 120;
raw[o + 3] = 255;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8;
ihdr[9] = 6;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
const dataUri = (png: Buffer) => `data:image/png;base64,${png.toString("base64")}`;
const cache = new Map<string, LogoInput>();
export function fixtureLogo(kind: LogoKind): LogoInput | null {
if (kind === "none") return null;
let hit = cache.get(kind);
if (!hit) {
if (kind === "real") {
// 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect.
const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"));
hit = { dataUri: dataUri(png), width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
} else if (kind === "wide") {
// The trimmed Test Vendor ink box is 4.6:1.
hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100 };
} else {
hit = { dataUri: dataUri(solidPng(120, 120)), width: 120, height: 120 };
}
cache.set(kind, hit);
}
return hit;
}
let signature: string | null = null;
const signatureUri = () => (signature ??= dataUri(solidPng(320, 90)));
export interface BuiltFixture {
spec: FixtureSpec;
model: RenderModel;
invoice: Invoice;
}
/** A full `Invoice` read model (totals derived the way Rust does) pushed through buildRenderModel. */
export function buildFixture(spec: FixtureSpec): BuiltFixture {
const registered = spec.registered !== false;
const taxInvoice = registered;
const items = Array.isArray(spec.items) ? spec.items : makeItems(spec.items ?? 3, taxInvoice);
const taxType: TaxType = spec.invoice?.taxType ?? (taxInvoice ? "cgst_sgst" : "none");
const taxRate = spec.invoice?.taxRate ?? (taxInvoice ? 18 : 0);
const discount = spec.invoice?.discount ?? 0;
const totals = computeTotals(items, discount, taxType, taxRate);
const vendor: Settings = {
...baseVendor,
...(registered
? { vendorGstin: FIXTURE_GSTIN, gstRegistration: "regular" as const, signatoryName: "Test Vendor", signatoryDesignation: "Proprietor" }
: // A stale GSTIN left in the settings of an unregistered supplier must never print.
{ vendorGstin: "27ZZZZZ9999Z1Z9", gstRegistration: "unregistered" as const }),
...spec.vendor,
};
const pos = vendor.vendorStateCode;
const invoice: Invoice = {
id: 1,
number: "INV/2026-001",
seriesId: 1,
invoiceDate: "2026-09-15",
dueDate: "2026-10-15",
clientId: null,
clientName: "Acme Learning Pvt Ltd",
clientAddress: "42 MG Road\nBangalore 560001",
clientGstin: FIXTURE_CLIENT_GSTIN,
poNumber: "",
placeOfSupplyStateCode: taxInvoice ? pos : "",
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: taxInvoice ? "tax_invoice" : "invoice",
reverseCharge: false,
vendorSnapshot: "",
snapshotOrigin: "",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items,
...spec.invoice,
};
const model = buildRenderModel({
invoice,
vendor,
bank: FIXTURE_BANK,
logo: fixtureLogo(spec.logo ?? "real"),
signatureDataUri: spec.signature ? signatureUri() : null,
appVersion: "0.1.0",
});
return { spec, model, invoice };
}
const LONG_ADDRESS =
"Unit 4, Third Floor, The Grand Heritage Business Centre, Plot 1187-B, Old Airport Road Extension, Opposite the Municipal Corporation Ward Office, Domlur Second Stage, Bengaluru Urban District, Karnataka State, India - 560071; Registered office: Level 12, Tower C, Global Technology Park, Outer Ring Road, Marathahalli, Bengaluru - 560103";
/** The fixture matrix of the template harness. Every name is unique; the file name of a sample uses it. */
export const FIXTURES: FixtureSpec[] = [
{ name: "minimal", items: 1, logo: "none" },
{ name: "long-address", vendor: { vendorAddress: LONG_ADDRESS }, items: 4 },
{
name: "id-tokens",
vendor: {
vendorEmail: "Sample.longsurname.voiceover.studio.bangalore@examplestudios-india.co.in",
vendorPhone: "+91 97382 84459 / +91 80 4123 9988 (studio)",
vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt",
},
invoice: { clientName: "https://www.extremely-long-client-website-address.example.com/projects/2026/voice", clientGstin: "29ABCDE1234F1Z5", poNumber: "PO/2026/BANGALORE/0042/REV-3/LONG-REFERENCE" },
items: 3,
},
{ name: "rows-60", items: 60, invoice: { notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." }, signature: true },
{
name: "multiline",
items: [
{ description: "Voice-over for the onboarding course\nModule 1 - introduction\nModule 2 - safety basics\nIncludes two rounds of revisions", mode: "rate", rate: 1500, unit: "hour", quantity: 3, amount: 0, sortOrder: 0, hsnSac: "998397" },
{ description: "Studio hire", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 1, hsnSac: "997212" },
{ description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" },
],
},
{
name: "long-notes",
items: 30,
invoice: { notes: Array.from({ length: 18 }, (_, i) => `Note ${i + 1}: payment is due within thirty days of the invoice date; late payments attract interest at two percent per month.`).join("\n") },
},
{ name: "igst", invoice: { taxType: "igst", placeOfSupplyStateCode: "27", discount: 500 }, items: 5 },
{ name: "cgst-sgst", items: 5, invoice: { poNumber: "PO-2026-0042", notes: "Thank you for your business." } },
{ name: "utgst", vendor: { vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5" }, invoice: { placeOfSupplyStateCode: "04" }, items: 4 },
{ name: "unregistered", registered: false, items: 3 },
{ name: "cancelled", invoice: { status: "cancelled" }, items: 4, signature: true },
{ name: "logo-none", logo: "none", items: 3 },
{ name: "logo-wide", logo: "wide", items: 3 },
{ name: "logo-square", logo: "square", items: 3 },
{
name: "long-client",
invoice: {
clientName: "The Board of Trustees of the Mahatma Gandhi Memorial Institute of Education, Research and Rural Development (Registered Society)",
clientAddress: "Administrative Block, Level 3, Institutional Area, Sector 62\nNoida, Gautam Buddha Nagar, Uttar Pradesh - 201309",
},
items: 3,
},
];
export const PAGE_SIZE_IDS = ["A4", "LETTER"] as const;
export const prefsFor = (pageSize: RenderPrefsV1["pageSize"], over: Partial<RenderPrefsV1> = {}): RenderPrefsV1 => ({ ...DEFAULT_PREFS, pageSize, ...over });
export interface Rendered {
fixture: BuiltFixture;
prefs: RenderPrefsV1;
result: RenderInvoiceResult;
pdf: RenderedPdf;
}
let ready = false;
/** Registers fonts and installs the measurer once per process. */
export function ensureSetup(): void {
if (!ready) {
setupPdfTest();
ready = true;
}
}
export async function renderFixture(spec: FixtureSpec, prefs: RenderPrefsV1): Promise<Rendered> {
ensureSetup();
const fixture = buildFixture(spec);
const result = await renderInvoicePdf({ model: fixture.model, prefs });
const pdf = await parsePdfText(result.bytes);
return { fixture, prefs, result, pdf };
}