From cedf4839bd0981f3f040268b2a0be4a119623b3d Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 05:47:19 +0530 Subject: [PATCH] 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. --- src-tauri/src/commands/invoice.rs | 4 +- src-tauri/src/commands/settings.rs | 26 +- src-tauri/src/db.rs | 25 +- src-tauri/src/models.rs | 5 + src/components/InvoicePreview.tsx | 192 --------------- src/components/PageSetupControls.tsx | 97 ++++++++ src/components/PdfPreview.tsx | 341 +++++++++++++++++++++++++++ src/hooks/useInvoicePdf.ts | 74 ++++++ src/lib/api.ts | 2 +- src/lib/pdf.tsx | 109 ++++----- src/lib/pdfProps.ts | 31 --- src/lib/pdfjs.ts | 50 ++++ src/lib/previewInvoice.ts | 98 ++++++++ src/lib/renderClient.ts | 12 + src/lib/streamPolyfill.ts | 25 ++ src/lib/types.ts | 4 + src/pdf/model/build.test.ts | 2 + src/pdf/render/client.test.ts | 230 ++++++++++++++++++ src/pdf/render/client.ts | 286 ++++++++++++++++++++++ src/pdf/render/jobs.ts | 29 +++ src/pdf/render/render.worker.ts | 50 ++++ src/pdf/templates/harness.ts | 2 + src/styles/carbon.scss | 316 +++++++++++-------------- src/views/NewInvoice.tsx | 74 +++--- 24 files changed, 1579 insertions(+), 505 deletions(-) delete mode 100644 src/components/InvoicePreview.tsx create mode 100644 src/components/PageSetupControls.tsx create mode 100644 src/components/PdfPreview.tsx create mode 100644 src/hooks/useInvoicePdf.ts delete mode 100644 src/lib/pdfProps.ts create mode 100644 src/lib/pdfjs.ts create mode 100644 src/lib/previewInvoice.ts create mode 100644 src/lib/renderClient.ts create mode 100644 src/lib/streamPolyfill.ts create mode 100644 src/pdf/render/client.test.ts create mode 100644 src/pdf/render/client.ts create mode 100644 src/pdf/render/jobs.ts create mode 100644 src/pdf/render/render.worker.ts diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index 4d584d0..203a21c 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -17,7 +17,7 @@ const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, clien tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words, bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at, doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at, - cancel_reason, archived_pdf_sha256"; + cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, '')"; fn map_invoice(row: &Row) -> rusqlite::Result { Ok(Invoice { @@ -55,6 +55,7 @@ fn map_invoice(row: &Row) -> rusqlite::Result { cancelled_at: row.get(31)?, cancel_reason: row.get(32)?, archived_pdf_sha256: row.get(33)?, + render_prefs: row.get(34)?, items: Vec::new(), }) } @@ -922,6 +923,7 @@ mod tests { .query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0)) .unwrap(); assert_eq!(prefs, "{\"theme\":\"plain\"}"); + assert_eq!(inv.render_prefs, prefs); } #[test] diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index de1e17f..bb8a006 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -6,7 +6,7 @@ use tauri::State; pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan, vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate, default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name, - signatory_designation"; + signatory_designation, render_prefs"; pub(crate) fn map_settings(row: &Row) -> rusqlite::Result { Ok(Settings { @@ -30,6 +30,7 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result { default_hsn_sac: row.get(17)?, signatory_name: row.get(18)?, signatory_designation: row.get(19)?, + render_prefs: row.get(20)?, }) } @@ -54,7 +55,7 @@ pub fn save_settings(state: State, settings: Settings) -> Result, settings: Settings) -> Result Migrations<'static> { - Migrations::new(vec![M::up(SCHEMA), M::up(M2)]) + Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3)]) } /// Open (creating if needed) the database at `path` and bring it to the latest schema. @@ -536,6 +541,22 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert!(backups_in(&backups).is_empty()); } + #[test] + fn render_prefs_column_defaults_to_empty_and_round_trips() { + let conn = open_in_memory().unwrap(); + let initial: String = conn + .query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(initial, ""); + + let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#; + conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap(); + let stored: String = conn + .query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(stored, json); + } + fn build_v1_database(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap(); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 8da6344..eec9bc3 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -23,6 +23,9 @@ pub struct Settings { pub default_hsn_sac: String, pub signatory_name: String, pub signatory_designation: String, + /// The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. + #[serde(default)] + pub render_prefs: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -194,6 +197,8 @@ pub struct Invoice { pub cancelled_at: Option, pub cancel_reason: String, pub archived_pdf_sha256: Option, + /// RenderPrefsV1 JSON frozen at issue; empty when none was stored. + pub render_prefs: String, pub items: Vec, } diff --git a/src/components/InvoicePreview.tsx b/src/components/InvoicePreview.tsx deleted file mode 100644 index ceec329..0000000 --- a/src/components/InvoicePreview.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { UNIT_LABELS } from "../lib/types"; -import { computeLineAmount } from "../lib/invoice"; -import { formatAmount, formatDate } from "../lib/format"; -import type { InvoicePdfProps } from "../lib/pdfProps"; - -const money = (n: number) => `₹${formatAmount(n)}`; - -/** On-screen preview that mirrors the exported PDF layout. */ -export function InvoicePreview(props: InvoicePdfProps) { - const { - vendor, - bank, - logoDataUri, - signatureDataUri, - number, - invoiceDate, - dueDate, - clientName, - clientAddress, - clientGstin, - poNumber, - placeOfSupplyStateCode, - items, - subtotal, - discount, - taxType, - taxRate, - cgst, - sgst, - igst, - total, - amountInWords, - notes, - } = props; - - const isTaxInvoice = taxType !== "none"; - - return ( -
-
-
-
- {logoDataUri ? : null} -
-

{vendor.vendorName || "Vendor"}

-

{vendor.vendorAddress}

- {vendor.vendorEmail ?

{vendor.vendorEmail}

: null} - {vendor.vendorPhone ?

{vendor.vendorPhone}

: null} - {vendor.vendorPan ?

PAN: {vendor.vendorPan}

: null} - {vendor.vendorGstin ?

GSTIN: {vendor.vendorGstin}

: null} -
-
-
-

{isTaxInvoice ? "Tax Invoice" : "Invoice"}

-
- Invoice No. - {number} -
-
- Date - {formatDate(invoiceDate)} -
- {dueDate ? ( -
- Due Date - {formatDate(dueDate)} -
- ) : null} -
-
- -
-
-

Billed To

-
{clientName || "—"}
- {clientAddress ?
{clientAddress}
: null} - {clientGstin ?
GSTIN: {clientGstin}
: null} - {placeOfSupplyStateCode ?
Place of Supply: {placeOfSupplyStateCode}
: null} -
- {poNumber ? ( -
-

PO Number

-
{poNumber}
-
- ) : null} -
- - - - - - - - - - - - {items.map((item, index) => ( - - - - - - - ))} - -
DescriptionUnit PriceQtyTotal
{item.description || "—"} - {item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"} - {item.mode === "rate" ? item.quantity : 1}{money(computeLineAmount(item))}
- -
-
-
- Subtotal - {money(subtotal)} -
- {discount > 0 ? ( -
- Discount - - {money(discount)} -
- ) : null} - {taxType === "cgst_sgst" ? ( - <> -
- CGST ({(taxRate / 2).toFixed(2)}%) - {money(cgst)} -
-
- SGST ({(taxRate / 2).toFixed(2)}%) - {money(sgst)} -
- - ) : null} - {taxType === "igst" ? ( -
- IGST ({taxRate.toFixed(2)}%) - {money(igst)} -
- ) : null} -
- Total - {money(total)} -
-
-
- -

Amount in words: {amountInWords}

- - {notes ? ( -
-

Notes

-
{notes}
-
- ) : null} - -
-
- {bank ? ( - <> -

Pay To

-
{bank.accountName || vendor.vendorName}
-
{bank.bankName}
- {bank.accountNo ?
Account No.: {bank.accountNo}
: null} - {bank.branch ?
Branch: {bank.branch}
: null} - {bank.ifsc ?
IFSC: {bank.ifsc}
: null} - - ) : null} -
-
- {signatureDataUri ? ( - <> - Signature -
{vendor.vendorName}
- - ) : ( - <> -
{vendor.vendorName}
-

Computer-generated invoice; no signature is required.

- - )} -
-
- -
- {vendor.vendorName} - Generated with Voiced — a Bongbetic product -
-
-
- ); -} diff --git a/src/components/PageSetupControls.tsx b/src/components/PageSetupControls.tsx new file mode 100644 index 0000000..658c273 --- /dev/null +++ b/src/components/PageSetupControls.tsx @@ -0,0 +1,97 @@ +import { useState, type ReactNode } from "react"; +import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch } from "@carbon/react"; +import { api } from "../lib/api"; +import type { Settings } from "../lib/types"; +import type { MarginPreset, PageId } from "../pdf/engine/geometry"; +import { parsePrefs, prefsToJson, type RenderPrefsV1 } from "../pdf/model/prefs"; + +const PAGES: { id: PageId; label: string }[] = [ + { id: "A4", label: "A4" }, + { id: "LETTER", label: "US Letter" }, +]; + +const MARGINS: { id: MarginPreset; label: string }[] = [ + { id: "template", label: "Template default" }, + { id: "narrow", label: "Narrow (0.5″)" }, + { id: "normal", label: "Normal (0.75″)" }, +]; + +interface Props { + prefs: RenderPrefsV1; + onChange: (prefs: RenderPrefsV1) => void; + /** Live settings; `renderPrefs` is the saved default. */ + settings: Settings; + /** Called after the default was saved so the app reloads its settings. */ + onSaved: () => void; + disabled?: boolean; + /** Where a TemplatePicker goes once there is more than one template. */ + templateSlot?: ReactNode; +} + +/** Page size and margins for the invoice being edited, plus "Save as default" for new invoices. */ +export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled, templateSlot }: Props) { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const saved = prefsToJson(parsePrefs(settings.renderPrefs)); + const isDefault = prefsToJson(prefs) === saved; + + const saveDefault = async () => { + setSaving(true); + setError(null); + try { + await api.saveSettings({ ...settings, renderPrefs: prefsToJson(prefs) }); + onSaved(); + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {templateSlot} +
+ Page size + p.id === prefs.pageSize))} + onChange={(e) => !disabled && onChange({ ...prefs, pageSize: PAGES[e.index ?? 0].id })} + > + {PAGES.map((p) => ( + + ))} + +
+
+ +
+
+
+ {saving ? ( + + ) : ( + + )} + + {error ?? "This choice is frozen into each invoice when it is issued."} + +
+
+ ); +} diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx new file mode 100644 index 0000000..0e8981a --- /dev/null +++ b/src/components/PdfPreview.tsx @@ -0,0 +1,341 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button, ComposedModal, ContentSwitcher, InlineLoading, InlineNotification, ModalBody, ModalHeader, Popover, PopoverContent, Switch, Tag } from "@carbon/react"; +import { Maximize, Warning } from "@carbon/icons-react"; +import { cancelPageRender, closePdf, isRenderCancelled, openPdf, renderPageToCanvas, type PDFDocumentProxy } from "../lib/pdfjs"; +import type { AuditIssue } from "../pdf/engine/audit"; + +export interface PdfPreviewProps { + bytes: Uint8Array | null; + pages: number; + issues: AuditIssue[]; + rendering: boolean; + /** "main": rendering runs on the main thread, so the preview only updates when typing pauses. */ + mode: "worker" | "main"; + error: string | null; + /** Adds a "Focus preview" button (the modal itself is built in). */ + onFocus?: () => void; +} + +type Zoom = "fit" | "100" | "150"; +const ZOOMS: { id: Zoom; label: string }[] = [ + { id: "fit", label: "Fit width" }, + { id: "100", label: "100%" }, + { id: "150", label: "150%" }, +]; + +/** Gap between pages and padding around them, in CSS px (matches .voiced-pdf-preview__pages). */ +const PAGE_GAP = 16; +const PAD = 16; +const MAX_LIVE_CANVASES = 3; +/** Backing store is never below 2x the CSS size: at fit-width the text would be blurry at 1x. */ +const MIN_BACKING = 2; +const CSS_PX_PER_PT = 96 / 72; + +function useDocument(bytes: Uint8Array | null) { + const [doc, setDoc] = useState(null); + const [loadError, setLoadError] = useState(null); + const current = useRef(null); + + useEffect(() => { + if (!bytes) return; + let stale = false; + openPdf(bytes).then( + (opened) => { + if (stale) { + void closePdf(opened); + return; + } + const previous = current.current; + current.current = opened; + setDoc(opened); + setLoadError(null); + // The old canvases are plain bitmaps, so the old document can go once the slots re-render. + if (previous) setTimeout(() => void closePdf(previous), 500); + }, + (err: unknown) => { + if (!stale) setLoadError(err instanceof Error ? err.message : String(err)); + }, + ); + return () => { + stale = true; + }; + }, [bytes]); + + useEffect( + () => () => { + if (current.current) void closePdf(current.current); + current.current = null; + }, + [], + ); + + return { doc, loadError }; +} + +function useDebounced(value: T, ms: number): T { + const [v, setV] = useState(value); + useEffect(() => { + const t = setTimeout(() => setV(value), ms); + return () => clearTimeout(t); + }, [value, ms]); + return v; +} + +interface SlotProps { + doc: PDFDocumentProxy; + index: number; + live: boolean; + renderScale: number; + cssWidth: number; + cssHeight: number; +} + +/** + * One page. A live slot renders into an offscreen canvas and swaps it in when done, so a re-render + * never blanks the page; a slot that is not live holds no canvas at all. + */ +function PageSlot({ doc, index, live, renderScale, cssWidth, cssHeight }: SlotProps) { + const host = useRef(null); + const key = useRef({}).current; + + useEffect(() => { + const el = host.current; + if (!el) return; + if (!live) { + const old = el.firstElementChild as HTMLCanvasElement | null; + el.replaceChildren(); + if (old) old.width = old.height = 0; + return; + } + let dead = false; + const off = document.createElement("canvas"); + off.className = "voiced-pdf-preview__canvas"; + (async () => { + try { + const page = await doc.getPage(index + 1); + await renderPageToCanvas(page, off, renderScale, key); + if (dead) return; + const old = el.firstElementChild as HTMLCanvasElement | null; + el.replaceChildren(off); + if (old) old.width = old.height = 0; + } catch (err) { + if (!dead && !isRenderCancelled(err)) console.error("PDF page render failed", err); + } + })(); + return () => { + dead = true; + cancelPageRender(key); + }; + }, [doc, index, live, renderScale, key]); + + return
; +} + +interface ViewerProps { + bytes: Uint8Array | null; + /** Page count from the render, shown until the document has opened. */ + pages: number; + className?: string; +} + +/** Zoom control, page indicator and the scrolling stack of page canvases. */ +function PdfViewer({ bytes, pages, className }: ViewerProps) { + const { doc, loadError } = useDocument(bytes); + const [zoom, setZoom] = useState("fit"); + const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); + const [fitWidth, setFitWidth] = useState(0); + const [live, setLive] = useState([0]); + const [current, setCurrent] = useState(1); + const scroller = useRef(null); + + useEffect(() => { + if (!doc) return; + let stale = false; + void doc.getPage(1).then((p) => { + const v = p.getViewport({ scale: 1 }); + if (!stale) setNatural({ w: v.width, h: v.height }); + }); + return () => { + stale = true; + }; + }, [doc]); + + useEffect(() => { + const el = scroller.current; + if (!el) return; + const measure = () => setFitWidth(Math.max(0, el.clientWidth - 2 * PAD)); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const pageCount = doc?.numPages ?? pages; + const cssWidth = natural ? (zoom === "fit" ? fitWidth : natural.w * CSS_PX_PER_PT * (Number(zoom) / 100)) : 0; + const cssHeight = natural ? (cssWidth * natural.h) / natural.w : 0; + const settledWidth = useDebounced(cssWidth, 150); + const renderScale = natural && settledWidth > 0 ? (settledWidth / natural.w) * Math.max(MIN_BACKING, window.devicePixelRatio || 1) : 0; + + // Which pages are near the viewport; at most MAX_LIVE_CANVASES of them hold a canvas. + useEffect(() => { + const root = scroller.current; + if (!root || !doc || !natural) return; + const visible = new Set(); + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) { + const n = Number((e.target as HTMLElement).dataset.page); + if (e.isIntersecting) visible.add(n); + else visible.delete(n); + } + const next = [...visible].sort((a, b) => a - b).slice(0, MAX_LIVE_CANVASES); + setLive((prev) => (prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : next)); + }, + { root, rootMargin: "200px 0px" }, + ); + root.querySelectorAll("[data-page]").forEach((el) => io.observe(el)); + return () => io.disconnect(); + }, [doc, natural, pageCount, cssHeight]); + + // Pages are one size, so the page under the middle of the viewport follows from the scroll offset. + useEffect(() => { + const root = scroller.current; + if (!root || cssHeight <= 0) return; + let frame = 0; + const update = () => { + frame = 0; + const middle = root.scrollTop + root.clientHeight / 2 - PAD; + const n = Math.floor(middle / (cssHeight + PAGE_GAP)) + 1; + setCurrent(Math.min(Math.max(n, 1), Math.max(pageCount, 1))); + }; + const onScroll = () => { + if (!frame) frame = requestAnimationFrame(update); + }; + update(); + root.addEventListener("scroll", onScroll, { passive: true }); + return () => { + root.removeEventListener("scroll", onScroll); + if (frame) cancelAnimationFrame(frame); + }; + }, [cssHeight, pageCount]); + + const liveSet = useMemo(() => new Set(live), [live]); + const zoomIndex = ZOOMS.findIndex((z) => z.id === zoom); + + return ( +
+
+ setZoom(ZOOMS[e.index ?? 0].id)} + > + {ZOOMS.map((z) => ( + + ))} + + + {pageCount > 0 ? `Page ${current} of ${pageCount}` : "No pages"} + +
+
+ {loadError ? ( + + ) : null} +
+ {doc && natural && cssWidth > 0 + ? Array.from({ length: pageCount }, (_, i) => ( + 0} + renderScale={renderScale} + cssWidth={cssWidth} + cssHeight={cssHeight} + /> + )) + : null} +
+
+
+ ); +} + +export default function PdfPreview({ bytes, pages, issues, rendering, mode, error, onFocus }: PdfPreviewProps) { + const [issuesOpen, setIssuesOpen] = useState(false); + const [focusOpen, setFocusOpen] = useState(false); + const errors = issues.filter((i) => i.severity === "error").length; + + return ( +
+
+
+ {rendering ? : null} + {mode === "main" ? ( + + Preview paused while typing + + ) : null} +
+
+ {issues.length > 0 ? ( + setIssuesOpen(false)}> + + +
    + {issues.map((issue, i) => ( +
  • +
    + + {issue.code} + + Page {issue.page} +
    +

    {issue.message}

    +
  • + ))} +
+
+
+ ) : null} +
+
+ + {error ? : null} + + {bytes ? ( + + ) : ( +
{rendering ? "Rendering the first preview…" : "The preview appears here."}
+ )} + + setFocusOpen(false)}> + setFocusOpen(false)} /> + {focusOpen && bytes ? : null} + +
+ ); +} diff --git a/src/hooks/useInvoicePdf.ts b/src/hooks/useInvoicePdf.ts new file mode 100644 index 0000000..636e4c5 --- /dev/null +++ b/src/hooks/useInvoicePdf.ts @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from "react"; +import { getRenderClient, nextRenderJobId } from "../lib/renderClient"; +import type { AuditIssue } from "../pdf/engine/audit"; +import type { RenderModel } from "../pdf/model/build"; +import type { RenderPrefsV1 } from "../pdf/model/prefs"; +import { Superseded } from "../pdf/render/client"; + +/** Typing pauses this long before the model is re-rendered; the render itself takes about 1 s. */ +export const MODEL_DEBOUNCE_MS = 800; + +export interface InvoicePdfState { + bytes: Uint8Array | null; + pages: number; + issues: AuditIssue[]; + rendering: boolean; + error: string | null; + mode: "worker" | "main"; + lastMs: number | null; +} + +const INITIAL: InvoicePdfState = { bytes: null, pages: 0, issues: [], rendering: false, error: null, mode: "worker", lastMs: null }; + +/** + * Live PDF for a render model. Model changes are debounced; changes to the prefs render at once. A new + * request cancels the one in flight, the previous bytes stay in place while the next render runs, and + * a null model never renders (the last PDF stays). Pass a memoised model: a new object means a change. + */ +export function useInvoicePdf({ model, prefs }: { model: RenderModel | null; prefs: RenderPrefsV1 }): InvoicePdfState { + const [state, setState] = useState(INITIAL); + const latestJob = useRef(0); + const lastPrefsKey = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const prefsKey = JSON.stringify(prefs); + + useEffect(() => { + if (!model) return; + const prefsChanged = lastPrefsKey.current !== prefsKey; + lastPrefsKey.current = prefsKey; + + const start = () => { + const id = nextRenderJobId(); + latestJob.current = id; + setState((s) => ({ ...s, rendering: true })); + const client = getRenderClient(); + client.render({ id, model, prefs: JSON.parse(prefsKey) as RenderPrefsV1 }).then( + (res) => { + if (!mounted.current || latestJob.current !== id) return; + setState({ bytes: res.bytes, pages: res.pages, issues: res.issues, rendering: false, error: null, mode: client.mode, lastMs: res.ms }); + }, + (err: unknown) => { + if (!mounted.current || latestJob.current !== id) return; + // Superseded while still the newest here means another caller (export) took the worker; keep + // the old PDF and let the next model or prefs change render again. + const error = err instanceof Superseded ? null : err instanceof Error ? err.message : String(err); + setState((s) => ({ ...s, rendering: false, error: error ?? s.error, mode: client.mode })); + }, + ); + }; + + if (prefsChanged) start(); + const timer = prefsChanged ? undefined : setTimeout(start, MODEL_DEBOUNCE_MS); + return () => { + if (timer) clearTimeout(timer); + }; + }, [model, prefsKey]); + + return state; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index c1dbe52..c9f4c0b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -28,7 +28,7 @@ export const api = { deleteClient: (id: number) => invoke("delete_client", { id }), peekNextInvoiceNumber: () => invoke("peek_next_invoice_number"), - issueInvoice: (input: InvoiceInput, renderPrefs: Record) => + issueInvoice: (input: InvoiceInput, renderPrefs: object) => invoke("issue_invoice", { input, renderPrefs }), cancelInvoice: (id: number, reason: string) => invoke("cancel_invoice", { id, reason }), diff --git a/src/lib/pdf.tsx b/src/lib/pdf.tsx index 562939c..ee76b95 100644 --- a/src/lib/pdf.tsx +++ b/src/lib/pdf.tsx @@ -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 { - // 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 { - 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 { + 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 { + 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 { const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs); return savePdfBytes(bytes, invoice.number); diff --git a/src/lib/pdfProps.ts b/src/lib/pdfProps.ts deleted file mode 100644 index 243f2aa..0000000 --- a/src/lib/pdfProps.ts +++ /dev/null @@ -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; -} diff --git a/src/lib/pdfjs.ts b/src/lib/pdfjs.ts new file mode 100644 index 0000000..6307a45 --- /dev/null +++ b/src/lib/pdfjs.ts @@ -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 { + return getDocument({ data: bytes.slice(), disableFontFace: true }).promise; +} + +/** Releases the document and its worker resources. */ +export function closePdf(doc: PDFDocumentProxy): Promise { + return doc.loadingTask.destroy(); +} + +const tasks = new WeakMap(); + +/** 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 { + 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"; diff --git a/src/lib/previewInvoice.ts b/src/lib/previewInvoice.ts new file mode 100644 index 0000000..9cbebdd --- /dev/null +++ b/src/lib/previewInvoice.ts @@ -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, + }; +} diff --git a/src/lib/renderClient.ts b/src/lib/renderClient.ts new file mode 100644 index 0000000..1250949 --- /dev/null +++ b/src/lib/renderClient.ts @@ -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; diff --git a/src/lib/streamPolyfill.ts b/src/lib/streamPolyfill.ts new file mode 100644 index 0000000..1bd2552 --- /dev/null +++ b/src/lib/streamPolyfill.ts @@ -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 + | undefined; + if (!proto || Symbol.asyncIterator in proto) return; + proto[Symbol.asyncIterator] = function (this: ReadableStream) { + 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; + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 28318d2..f678741 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[]; } diff --git a/src/pdf/model/build.test.ts b/src/pdf/model/build.test.ts index ae035ba..53cfd9c 100644 --- a/src/pdf/model/build.test.ts +++ b/src/pdf/model/build.test.ts @@ -24,6 +24,7 @@ const vendor: Settings = { defaultHsnSac: "", signatoryName: "", signatoryDesignation: "", + renderPrefs: "", }; const bank: BankAccount = { @@ -77,6 +78,7 @@ const base: Invoice = { cancelledAt: null, cancelReason: "", archivedPdfSha256: null, + renderPrefs: "", items, }; diff --git a/src/pdf/render/client.test.ts b/src/pdf/render/client.test.ts new file mode 100644 index 0000000..0b1460d --- /dev/null +++ b/src/pdf/render/client.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createAutoClient, + createMainThreadClient, + createRenderClient, + RenderFailed, + RenderTimeout, + Superseded, + WorkerUnavailable, + type WorkerLike, +} from "./client"; +import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; + +class FakeWorker implements WorkerLike { + onmessage: WorkerLike["onmessage"] = null; + onerror: WorkerLike["onerror"] = null; + sent: WorkerRequest[] = []; + terminated = false; + postMessage(message: WorkerRequest) { + this.sent.push(message); + } + terminate() { + this.terminated = true; + } + emit(data: WorkerMessage) { + this.onmessage?.({ data }); + } + ready() { + this.emit({ type: "ready" }); + } + reply(id: number) { + this.emit({ type: "result", result: result(id) }); + } +} + +const result = (id: number): RenderResult => ({ + id, + bytes: new Uint8Array([id]), + pages: 1, + issues: [], + fingerprint: `fp${id}`, + ms: 5, +}); +const job = (id: number): RenderJob => ({ id, model: {} as RenderJob["model"], prefs: {} as RenderJob["prefs"] }); + +function setup(timeoutMs = 1000) { + const workers: FakeWorker[] = []; + const client = createRenderClient({ + timeoutMs, + workerFactory: () => { + const w = new FakeWorker(); + workers.push(w); + return w; + }, + }); + return { client, workers }; +} + +const settled = (p: Promise) => p.then(() => "ok", (e) => e); + +describe("RenderClient", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("sends nothing until the worker is ready, then resolves with the result", async () => { + const { client, workers } = setup(); + const p = client.render(job(1)); + expect(workers).toHaveLength(1); + expect(workers[0].sent).toHaveLength(0); + workers[0].ready(); + expect(workers[0].sent.map((m) => m.job.id)).toEqual([1]); + workers[0].reply(1); + expect((await p).fingerprint).toBe("fp1"); + }); + + it("a new call supersedes the one in flight, and only the newest queued job runs next", async () => { + const s = setup(); + const a = s.client.render(job(1)); + s.workers[0].ready(); + const b = s.client.render(job(2)); + const c = s.client.render(job(3)); + expect(await settled(a)).toBeInstanceOf(Superseded); + expect(await settled(b)).toBeInstanceOf(Superseded); + // The worker is still busy with job 1: nothing else was posted. + expect(s.workers[0].sent.map((m) => m.job.id)).toEqual([1]); + s.workers[0].reply(1); // result of the superseded job is discarded + expect(s.workers[0].sent.map((m) => m.job.id)).toEqual([1, 3]); + s.workers[0].reply(3); + expect((await c).id).toBe(3); + }); + + it("rejects the superseded in-flight promise immediately", async () => { + const { client, workers } = setup(); + const a = client.render(job(1)); + workers[0].ready(); + const b = client.render(job(2)); + expect(await settled(a)).toBeInstanceOf(Superseded); + workers[0].reply(1); + workers[0].reply(2); + expect((await b).id).toBe(2); + }); + + it("surfaces a render error from the worker and keeps going", async () => { + const { client, workers } = setup(); + const a = client.render(job(1)); + workers[0].ready(); + workers[0].emit({ type: "error", id: 1, message: "boom", stack: "at worker" }); + const err = await settled(a); + expect(err).toBeInstanceOf(RenderFailed); + expect((err as Error).message).toBe("boom"); + const b = client.render(job(2)); + workers[0].reply(2); + expect((await b).id).toBe(2); + }); + + it("watchdog: terminates, respawns, rejects with RenderTimeout, then serves the next job", async () => { + const { client, workers } = setup(1000); + const a = client.render(job(1)); + workers[0].ready(); + vi.advanceTimersByTime(1000); + expect(await settled(a)).toBeInstanceOf(RenderTimeout); + expect(workers[0].terminated).toBe(true); + expect(workers).toHaveLength(2); + + const b = client.render(job(2)); + workers[1].ready(); + expect(workers[1].sent.map((m) => m.job.id)).toEqual([2]); + workers[1].reply(2); + expect((await b).id).toBe(2); + }); + + it("watchdog on a superseded job still replaces the worker and then runs the queued job", async () => { + const { client, workers } = setup(1000); + const a = client.render(job(1)); + workers[0].ready(); + const b = client.render(job(2)); + expect(await settled(a)).toBeInstanceOf(Superseded); + vi.advanceTimersByTime(1000); + expect(workers[0].terminated).toBe(true); + workers[1].ready(); + expect(workers[1].sent.map((m) => m.job.id)).toEqual([2]); + workers[1].reply(2); + expect((await b).id).toBe(2); + }); + + it("a start-up error before ready rejects with WorkerUnavailable, and so do later calls", async () => { + const { client, workers } = setup(); + const a = client.render(job(1)); + workers[0].emit({ type: "error", id: null, message: "fonts missing" }); + const err = await settled(a); + expect(err).toBeInstanceOf(WorkerUnavailable); + expect((err as Error).message).toContain("fonts missing"); + expect(workers[0].terminated).toBe(true); + expect(await settled(client.render(job(2)))).toBeInstanceOf(WorkerUnavailable); + expect(workers).toHaveLength(1); + }); + + it("a worker script error before ready is WorkerUnavailable; there is no timer fallback", async () => { + const { client, workers } = setup(1000); + const a = client.render(job(1)); + // A silent worker that never says ready just waits: no timer turns that into a failure. + vi.advanceTimersByTime(60_000); + let done = false; + void settled(a).then(() => (done = true)); + await Promise.resolve(); + expect(done).toBe(false); + workers[0].onerror?.({ message: "SyntaxError" }); + expect(await settled(a)).toBeInstanceOf(WorkerUnavailable); + }); + + it("a factory that throws is WorkerUnavailable", async () => { + const client = createRenderClient({ + workerFactory: () => { + throw new Error("no Worker here"); + }, + }); + expect(await settled(client.render(job(1)))).toBeInstanceOf(WorkerUnavailable); + }); + + it("dispose terminates the worker and rejects what is outstanding", async () => { + const { client, workers } = setup(); + const a = client.render(job(1)); + workers[0].ready(); + const b = client.render(job(2)); + client.dispose(); + expect(workers[0].terminated).toBe(true); + expect(await settled(a)).toBeInstanceOf(Superseded); + expect(String(await settled(b))).toContain("disposed"); + expect(String(await settled(client.render(job(3))))).toContain("disposed"); + }); +}); + +describe("main-thread client and fallback", () => { + it("runs jobs serially with latest-wins", async () => { + const gates: Array<() => void> = []; + const seen: number[] = []; + const client = createMainThreadClient( + (j) => + new Promise((resolve) => { + seen.push(j.id); + gates.push(() => resolve(result(j.id))); + }), + ); + expect(client.mode).toBe("main"); + const a = client.render(job(1)); + const b = client.render(job(2)); + const c = client.render(job(3)); + expect(await settled(a)).toBeInstanceOf(Superseded); + expect(await settled(b)).toBeInstanceOf(Superseded); + gates[0](); + await Promise.resolve(); + await Promise.resolve(); + expect(seen).toEqual([1, 3]); + gates[1](); + expect((await c).id).toBe(3); + }); + + it("the auto client moves to the main thread when the worker cannot start", async () => { + const client = createAutoClient({ + workerFactory: () => { + throw new Error("no worker"); + }, + mainExecutor: async (j) => result(j.id), + }); + expect(client.mode).toBe("worker"); + expect((await client.render(job(7))).id).toBe(7); + expect(client.mode).toBe("main"); + expect((await client.render(job(8))).id).toBe(8); + }); +}); diff --git a/src/pdf/render/client.ts b/src/pdf/render/client.ts new file mode 100644 index 0000000..7f4f1ab --- /dev/null +++ b/src/pdf/render/client.ts @@ -0,0 +1,286 @@ +import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs"; + +/** A newer render call replaced this one before it finished (or before it started). */ +export class Superseded extends Error { + constructor() { + super("Render superseded by a newer request"); + this.name = "Superseded"; + } +} + +/** The watchdog fired: the worker was terminated and replaced. */ +export class RenderTimeout extends Error { + constructor(ms: number) { + super(`The PDF render did not finish within ${Math.round(ms / 1000)} s and was stopped`); + this.name = "RenderTimeout"; + } +} + +/** The worker could not start (script, WASM or font failure). Rendering must move to the main thread. */ +export class WorkerUnavailable extends Error { + constructor(detail: string) { + super(`The render worker could not start: ${detail}`); + this.name = "WorkerUnavailable"; + } +} + +/** The render itself threw inside the worker. */ +export class RenderFailed extends Error { + constructor(message: string, workerStack?: string) { + super(message); + this.name = "RenderFailed"; + if (workerStack) this.stack = workerStack; + } +} + +/** The part of Worker the client uses, so tests can supply a fake. */ +export interface WorkerLike { + postMessage(message: WorkerRequest): void; + terminate(): void; + onmessage: ((event: { data: WorkerMessage }) => void) | null; + onerror: ((event: { message?: string }) => void) | null; +} + +export interface RenderClient { + /** Resolves with the newest request's result; an older call rejects with Superseded. */ + render(job: RenderJob): Promise; + dispose(): void; + readonly mode: "worker" | "main"; +} + +export interface RenderClientOptions { + workerFactory?: () => WorkerLike; + /** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */ + timeoutMs?: number; +} + +const defaultWorkerFactory = (): WorkerLike => + new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike; + +interface Slot { + job: RenderJob; + resolve: (r: RenderResult) => void; + reject: (e: unknown) => void; +} + +interface Running extends Slot { + /** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */ + superseded: boolean; + timer: ReturnType; +} + +/** + * Renders in a module worker. Only the newest request matters: a new call rejects the one in flight + * (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the + * running job finishes at most one job, the newest, runs. The handshake is the worker's `ready` + * message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback. + */ +export function createRenderClient(opts: RenderClientOptions = {}): RenderClient { + const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts; + let worker: WorkerLike | null = null; + let ready = false; + let unavailable: WorkerUnavailable | null = null; + let disposed = false; + let running: Running | null = null; + let pending: Slot | null = null; + + const stopWorker = () => { + const w = worker; + worker = null; + ready = false; + if (w) { + w.onmessage = null; + w.onerror = null; + w.terminate(); + } + }; + + const settleRunning = (outcome: (slot: Running) => void) => { + const cur = running; + if (!cur) return; + running = null; + clearTimeout(cur.timer); + if (!cur.superseded) outcome(cur); + }; + + const fail = (err: WorkerUnavailable) => { + unavailable = err; + stopWorker(); + settleRunning((s) => s.reject(err)); + pending?.reject(err); + pending = null; + }; + + const pump = () => { + if (!ready || !worker || running || !pending || disposed) return; + const slot = pending; + pending = null; + running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) }; + worker.postMessage({ type: "render", job: slot.job }); + }; + + const spawn = () => { + let w: WorkerLike; + try { + w = workerFactory(); + } catch (e) { + fail(new WorkerUnavailable(e instanceof Error ? e.message : String(e))); + return; + } + worker = w; + ready = false; + w.onmessage = (event) => { + if (w !== worker) return; + const msg = event.data; + if (msg.type === "ready") { + ready = true; + pump(); + } else if (msg.type === "result") { + if (running?.job.id !== msg.result.id) return; + settleRunning((s) => s.resolve(msg.result)); + pump(); + } else if (msg.id === null) { + if (!ready) fail(new WorkerUnavailable(msg.message)); + } else if (running?.job.id === msg.id) { + settleRunning((s) => s.reject(new RenderFailed(msg.message, msg.stack))); + pump(); + } + }; + w.onerror = (event) => { + if (w !== worker) return; + const detail = event.message || "script error"; + if (!ready) return fail(new WorkerUnavailable(detail)); + // A crash mid-job: replace the worker so later jobs have a live one. + settleRunning((s) => s.reject(new RenderFailed(detail))); + stopWorker(); + spawn(); + }; + }; + + function onTimeout() { + settleRunning((s) => s.reject(new RenderTimeout(timeoutMs))); + stopWorker(); + spawn(); + } + + return { + mode: "worker", + render(job) { + if (disposed) return Promise.reject(new Error("The render client was disposed")); + if (unavailable) return Promise.reject(unavailable); + return new Promise((resolve, reject) => { + pending?.reject(new Superseded()); + if (running && !running.superseded) { + running.superseded = true; + running.reject(new Superseded()); + } + pending = { job, resolve, reject }; + if (!worker) spawn(); + pump(); + }); + }, + dispose() { + if (disposed) return; + disposed = true; + const err = new Error("The render client was disposed"); + settleRunning((s) => s.reject(err)); + pending?.reject(err); + pending = null; + stopWorker(); + }, + }; +} + +/** Renders one job on this thread. */ +export type MainExecutor = (job: RenderJob) => Promise; + +// Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable. +const runOnMainThread: MainExecutor = async (job) => { + const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]); + await initBrowserRendering(); + // Let pending input and paint run before the long synchronous layout. + await new Promise((r) => setTimeout(r, 0)); + const t0 = performance.now(); + const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs }); + return { + id: job.id, + bytes: out.bytes, + pages: out.pages, + issues: out.issues, + fingerprint: out.fingerprint, + ms: Math.round(performance.now() - t0), + }; +}; + +/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */ +export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient { + let disposed = false; + let running: (Slot & { superseded: boolean }) | null = null; + let pending: Slot | null = null; + + const pump = async () => { + if (running || !pending || disposed) return; + const slot = { ...pending, superseded: false }; + pending = null; + running = slot; + try { + const result = await execute(slot.job); + if (!slot.superseded) slot.resolve(result); + } catch (e) { + if (!slot.superseded) slot.reject(e); + } + running = null; + void pump(); + }; + + return { + mode: "main", + render(job) { + if (disposed) return Promise.reject(new Error("The render client was disposed")); + return new Promise((resolve, reject) => { + pending?.reject(new Superseded()); + if (running && !running.superseded) { + running.superseded = true; + running.reject(new Superseded()); + } + pending = { job, resolve, reject }; + void pump(); + }); + }, + dispose() { + disposed = true; + const err = new Error("The render client was disposed"); + pending?.reject(err); + pending = null; + }, + }; +} + +/** + * Worker first; the first WorkerUnavailable switches to the main thread for good. `mode` tells the UI + * which one is active (main thread: the preview is paused while typing). + */ +export function createAutoClient( + opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {}, +): RenderClient { + const primary = createRenderClient(opts); + let main: RenderClient | null = null; + return { + get mode() { + return main ? "main" : "worker"; + }, + render(job) { + if (main) return main.render(job); + return primary.render(job).catch((err) => { + if (!(err instanceof WorkerUnavailable)) throw err; + main ??= createMainThreadClient(opts.mainExecutor); + primary.dispose(); + return main.render(job); + }); + }, + dispose() { + primary.dispose(); + main?.dispose(); + }, + }; +} diff --git a/src/pdf/render/jobs.ts b/src/pdf/render/jobs.ts new file mode 100644 index 0000000..35fae2c --- /dev/null +++ b/src/pdf/render/jobs.ts @@ -0,0 +1,29 @@ +import type { AuditIssue } from "../engine/audit"; +import type { RenderModel } from "../model/build"; +import type { RenderPrefsV1 } from "../model/prefs"; + +// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps. + +export interface RenderJob { + id: number; + model: RenderModel; + prefs: RenderPrefsV1; +} + +export interface RenderResult { + id: number; + bytes: Uint8Array; + pages: number; + issues: AuditIssue[]; + fingerprint: string; + /** Wall time of the render itself, measured where it ran. */ + ms: number; +} + +export type WorkerRequest = { type: "render"; job: RenderJob }; + +export type WorkerMessage = + | { type: "ready" } + | { type: "result"; result: RenderResult } + /** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */ + | { type: "error"; id: number | null; message: string; stack?: string }; diff --git a/src/pdf/render/render.worker.ts b/src/pdf/render/render.worker.ts new file mode 100644 index 0000000..bfeba85 --- /dev/null +++ b/src/pdf/render/render.worker.ts @@ -0,0 +1,50 @@ +// Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests. +import { installStreamAsyncIterator } from "../../lib/streamPolyfill"; +import { initBrowserRendering } from "./browserInit"; +import { renderInvoicePdf } from "./invoice"; +import type { WorkerMessage, WorkerRequest } from "./jobs"; + +interface WorkerScope { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: WorkerMessage, options?: { transfer: Transferable[] }): void; +} +const scope = self as unknown as WorkerScope; + +installStreamAsyncIterator(); + +const post = (message: WorkerMessage, transfer: Transferable[] = []) => scope.postMessage(message, { transfer }); + +const failure = (id: number | null, err: unknown): WorkerMessage => ({ + type: "error", + id, + message: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, +}); + +const started = initBrowserRendering(); + +scope.onmessage = async (event) => { + const request = event.data; + if (request?.type !== "render") return; + const { job } = request; + try { + await started.catch(() => initBrowserRendering()); + const t0 = performance.now(); + const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs }); + const ms = Math.round(performance.now() - t0); + // Hand the buffer over instead of copying it; a view onto part of a larger buffer is copied first. + const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength; + const bytes = whole ? out.bytes : out.bytes.slice(); + post( + { type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } }, + [bytes.buffer], + ); + } catch (err) { + post(failure(job.id, err)); + } +}; + +started.then( + () => post({ type: "ready" }), + (err) => post(failure(null, err)), +); diff --git a/src/pdf/templates/harness.ts b/src/pdf/templates/harness.ts index 4241bd8..6b65f99 100644 --- a/src/pdf/templates/harness.ts +++ b/src/pdf/templates/harness.ts @@ -50,6 +50,7 @@ const baseVendor: Settings = { defaultHsnSac: "", signatoryName: "", signatoryDesignation: "", + renderPrefs: "", }; export const FIXTURE_BANK: BankAccount = { @@ -210,6 +211,7 @@ export function buildFixture(spec: FixtureSpec): BuiltFixture { cancelledAt: null, cancelReason: "", archivedPdfSha256: null, + renderPrefs: "", items, ...spec.invoice, }; diff --git a/src/styles/carbon.scss b/src/styles/carbon.scss index a8070de..f69f935 100644 --- a/src/styles/carbon.scss +++ b/src/styles/carbon.scss @@ -186,218 +186,170 @@ body { color: var(--cds-text-secondary); } -/* ---------- Invoice preview ("paper") ---------- */ -.inv-preview-wrap { +/* ---------- PDF preview column ---------- */ +.voiced-page-setup { + margin-bottom: 1rem; +} + +.voiced-page-setup__row { display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1rem; +} + +.voiced-page-setup__field { + min-width: 11rem; + flex: 1 1 11rem; +} + +.voiced-page-setup__field .cds--label { + display: block; + margin-bottom: 0.5rem; +} + +.voiced-page-setup__footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + margin-top: 0.75rem; +} + +.voiced-page-setup__note { + font-size: 0.75rem; + flex: 1 1 12rem; +} + +.voiced-pdf-preview { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 0; +} + +.voiced-pdf-preview__status { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.25rem 1rem; + min-height: 2rem; +} + +.voiced-pdf-preview__chips, +.voiced-pdf-preview__actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.voiced-pdf-preview__empty { + display: flex; + align-items: center; justify-content: center; + height: 12rem; + background: var(--cds-layer-accent); + color: var(--cds-text-secondary); } -.inv-paper { - background: #ffffff; - color: #161616; - width: 100%; - max-width: 820px; - padding: 40px 44px; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); - font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif; - font-size: 13px; - line-height: 1.5; - box-sizing: border-box; -} - -.inv-top { - display: grid; - grid-template-columns: minmax(0, 1fr) 220px; - gap: 24px; - align-items: start; -} - -.inv-vendor { +.voiced-pdf-viewer { display: flex; - gap: 16px; - align-items: flex-start; + flex-direction: column; + gap: 0.5rem; min-width: 0; } -.inv-vendor > div { - flex: 1 1 0; - min-width: 0; -} - -.inv-vendor__logo { - max-height: 56px; - max-width: 220px; - object-fit: contain; -} - -.inv-vendor__name { - font-size: 22px; - font-weight: 600; - margin: 0 0 4px; -} - -.inv-vendor__line { - margin: 0; - white-space: pre-line; - overflow-wrap: anywhere; -} - -.inv-meta { - text-align: right; - min-width: 0; -} - -.inv-meta__title { - font-size: 20px; - font-weight: 600; - letter-spacing: 0.08em; - margin: 0 0 8px; - text-transform: uppercase; -} - -.inv-meta__row { +.voiced-pdf-viewer__bar { display: flex; + flex-wrap: wrap; + align-items: center; justify-content: space-between; - gap: 16px; + gap: 0.5rem 1rem; } -.inv-meta__label { - color: #525252; +.voiced-pdf-viewer__zoom { + flex: 0 1 18rem; } -.inv-meta__value { - font-weight: 600; -} - -.inv-split { - display: flex; - justify-content: space-between; - gap: 24px; - margin-top: 28px; -} - -.inv-block { - min-width: 0; -} - -.inv-block__label { - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #525252; - margin: 0 0 4px; -} - -.inv-block__strong { - font-weight: 600; -} - -.inv-table { - width: 100%; - border-collapse: collapse; - margin-top: 28px; -} - -.inv-table th { - text-align: left; - font-size: 11px; - letter-spacing: 0.06em; - text-transform: uppercase; - color: #525252; - border-bottom: 1.5px solid #161616; - padding: 6px 8px; -} - -.inv-table td { - padding: 8px; - border-bottom: 1px solid #e0e0e0; - vertical-align: top; -} - -.inv-table .num { - text-align: right; +.voiced-pdf-viewer__page { + font-size: 0.875rem; + color: var(--cds-text-secondary); white-space: nowrap; } -.inv-totals { - margin-top: 16px; +.voiced-pdf-preview__scroll { + height: calc(100vh - 25rem); + min-height: 20rem; + overflow: auto; + background: var(--cds-layer-accent); +} + +.voiced-pdf-viewer--focus .voiced-pdf-preview__scroll { + height: calc(100vh - 12rem); +} + +/* The wrapper is as wide as the widest page so a zoomed page scrolls instead of being clipped. */ +.voiced-pdf-preview__pages { + box-sizing: border-box; display: flex; - justify-content: flex-end; + flex-direction: column; + align-items: center; + gap: 16px; + min-width: 100%; + width: max-content; + padding: 16px; } -.inv-totals__table { - width: 320px; +.voiced-pdf-preview__page { + flex: none; + background: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); } -.inv-totals__row { - display: flex; - justify-content: space-between; - padding: 4px 0; -} - -.inv-totals__row--grand { - border-top: 1.5px solid #161616; - margin-top: 4px; - padding-top: 8px; - font-size: 16px; - font-weight: 600; -} - -.inv-words { - margin-top: 16px; - font-style: italic; -} - -.inv-bottom { - display: flex; - justify-content: space-between; - gap: 24px; - margin-top: 32px; - align-items: flex-end; -} - -.inv-signature { - text-align: right; -} - -.inv-signature__img { - max-height: 64px; - max-width: 220px; - object-fit: contain; +.voiced-pdf-preview__canvas { display: block; - margin-left: auto; + width: 100%; + height: 100%; } -.inv-signature__name { - margin-top: 6px; - font-weight: 600; - border-top: 1px solid #8d8d8d; - padding-top: 4px; - display: inline-block; - min-width: 160px; +.voiced-pdf-focus .cds--modal-container { + width: 96vw; + max-width: 96vw; + height: 94vh; + max-height: 94vh; } -.inv-fineprint { - margin-top: 8px; - font-size: 10px; - color: #6f6f6f; - text-align: right; +.voiced-pdf-issues { + width: 22rem; + max-width: 80vw; + max-height: 20rem; + overflow: auto; + padding: 0.75rem 1rem; } -.inv-footer { - margin-top: 28px; - padding-top: 10px; - border-top: 1px solid #e0e0e0; - font-size: 10px; - color: #6f6f6f; +.voiced-pdf-issues__list { + list-style: none; + margin: 0; + padding: 0; +} + +.voiced-pdf-issues__item + .voiced-pdf-issues__item { + margin-top: 0.75rem; +} + +.voiced-pdf-issues__head { display: flex; - justify-content: space-between; - gap: 12px; + align-items: center; + gap: 0.5rem; } -@media print { - .inv-paper { - box-shadow: none; - max-width: none; - } +.voiced-pdf-issues__message { + margin: 0.25rem 0 0; + font-size: 0.875rem; +} + +.voiced-tag--warn.cds--tag { + background-color: var(--cds-support-warning); + color: #161616; } diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index d8e9a5c..3a25fd1 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActionableNotification, Button, @@ -51,12 +51,17 @@ import { computeLineAmount, computeTotals } from "../lib/invoice"; import { amountInWords } from "../lib/numberToWords"; import { gstinFullError } from "../lib/validators"; import { addDays, formatAmount, todayIso } from "../lib/format"; -import { exportInvoicePdf } from "../lib/pdf"; +import { exportInvoicePdf, modelFromAssets, prefsForInvoice } from "../lib/pdf"; +import { formToPreviewInvoice } from "../lib/previewInvoice"; import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft"; import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts"; -import { InvoicePreview } from "../components/InvoicePreview"; +import PageSetupControls from "../components/PageSetupControls"; +import { useInvoicePdf } from "../hooks/useInvoicePdf"; +import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs"; import { ImagePicker } from "../components/ImagePicker"; -import type { InvoicePdfProps } from "../lib/pdfProps"; + +// pdf.js is only needed once a preview exists, so it loads on demand. +const PdfPreview = lazy(() => import("../components/PdfPreview")); interface NewInvoiceProps { settings: Settings; @@ -75,7 +80,7 @@ const formatStamp = (iso: string) => { return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`; }; -export default function NewInvoice({ settings, active, onActivate }: NewInvoiceProps) { +export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) { const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac }); const blankState = (): InvoiceFormState => { const t = todayIso(); @@ -330,32 +335,25 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP return () => window.removeEventListener("beforeunload", onBeforeUnload); }, [unsaved]); - const previewProps: InvoicePdfProps = { - vendor: settings, - bank, - logoDataUri: logoUri, - signatureDataUri: signatureUri, - number: issued?.number ?? (number || "DRAFT"), - invoiceDate, - dueDate, - clientName, - clientAddress, - clientGstin, - poNumber, - placeOfSupplyStateCode: placeOfSupply, - items, - subtotal: totals.subtotal, - discount: totals.discount, - taxType: effectiveTaxType, - taxRate, - cgst: totals.cgst, - sgst: totals.sgst, - igst: totals.igst, - total: totals.total, - amountInWords: words, - notes, - }; + // Page setup for the invoice being edited; an issued invoice shows what was frozen at issue. + const [prefs, setPrefs] = useState(() => parsePrefs(settings.renderPrefs)); + useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]); + const activePrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]); + // The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped + // while this view is hidden (it stays mounted) so typing elsewhere never starts a render. + const previewModel = useMemo(() => { + if (!active) return null; + const invoice = + issued ?? + formToPreviewInvoice( + { invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items }, + number || "DRAFT", + settings, + bank, + ); + return modelFromAssets(invoice, settings, bank, logoUri, signatureUri); + }, [active, issued, invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items, number, settings, bank, logoUri, signatureUri]); const updateItem = (index: number, patch: Partial) => setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); @@ -567,7 +565,7 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP draftId: draftIdRef.current, items: items.map((item, index) => ({ ...item, sortOrder: index })), }; - invoice = await api.issueInvoice(input, {}); + invoice = await api.issueInvoice(input, activePrefs); } catch (e) { issuedRef.current = false; setError(String(e)); @@ -593,6 +591,8 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP const otherDrafts = drafts.filter((d) => d.id !== draftId); const busy = saving || exporting; + // Yield the shared render worker to the export; the model changes back when it is done and re-renders. + const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs }); return ( <> @@ -1031,7 +1031,17 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP

Preview

- + + }> + +