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,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 (
|
||||
<div className="inv-preview-wrap">
|
||||
<div className="inv-paper">
|
||||
<div className="inv-top">
|
||||
<div className="inv-vendor">
|
||||
{logoDataUri ? <img className="inv-vendor__logo" src={logoDataUri} alt="" /> : null}
|
||||
<div>
|
||||
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
|
||||
<p className="inv-vendor__line">{vendor.vendorAddress}</p>
|
||||
{vendor.vendorEmail ? <p className="inv-vendor__line">{vendor.vendorEmail}</p> : null}
|
||||
{vendor.vendorPhone ? <p className="inv-vendor__line">{vendor.vendorPhone}</p> : null}
|
||||
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
|
||||
{vendor.vendorGstin ? <p className="inv-vendor__line">GSTIN: {vendor.vendorGstin}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inv-meta">
|
||||
<h3 className="inv-meta__title">{isTaxInvoice ? "Tax Invoice" : "Invoice"}</h3>
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Invoice No.</span>
|
||||
<span className="inv-meta__value">{number}</span>
|
||||
</div>
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Date</span>
|
||||
<span className="inv-meta__value">{formatDate(invoiceDate)}</span>
|
||||
</div>
|
||||
{dueDate ? (
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Due Date</span>
|
||||
<span className="inv-meta__value">{formatDate(dueDate)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-split">
|
||||
<div className="inv-block" style={{ maxWidth: "60%" }}>
|
||||
<p className="inv-block__label">Billed To</p>
|
||||
<div className="inv-block__strong">{clientName || "—"}</div>
|
||||
{clientAddress ? <div style={{ whiteSpace: "pre-line" }}>{clientAddress}</div> : null}
|
||||
{clientGstin ? <div>GSTIN: {clientGstin}</div> : null}
|
||||
{placeOfSupplyStateCode ? <div>Place of Supply: {placeOfSupplyStateCode}</div> : null}
|
||||
</div>
|
||||
{poNumber ? (
|
||||
<div className="inv-block">
|
||||
<p className="inv-block__label">PO Number</p>
|
||||
<div className="inv-block__strong">{poNumber}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<table className="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th className="num">Unit Price</th>
|
||||
<th className="num">Qty</th>
|
||||
<th className="num">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
<td>{item.description || "—"}</td>
|
||||
<td className="num">
|
||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
|
||||
</td>
|
||||
<td className="num">{item.mode === "rate" ? item.quantity : 1}</td>
|
||||
<td className="num">{money(computeLineAmount(item))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="inv-totals">
|
||||
<div className="inv-totals__table">
|
||||
<div className="inv-totals__row">
|
||||
<span>Subtotal</span>
|
||||
<span>{money(subtotal)}</span>
|
||||
</div>
|
||||
{discount > 0 ? (
|
||||
<div className="inv-totals__row">
|
||||
<span>Discount</span>
|
||||
<span>- {money(discount)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{taxType === "cgst_sgst" ? (
|
||||
<>
|
||||
<div className="inv-totals__row">
|
||||
<span>CGST ({(taxRate / 2).toFixed(2)}%)</span>
|
||||
<span>{money(cgst)}</span>
|
||||
</div>
|
||||
<div className="inv-totals__row">
|
||||
<span>SGST ({(taxRate / 2).toFixed(2)}%)</span>
|
||||
<span>{money(sgst)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{taxType === "igst" ? (
|
||||
<div className="inv-totals__row">
|
||||
<span>IGST ({taxRate.toFixed(2)}%)</span>
|
||||
<span>{money(igst)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="inv-totals__row inv-totals__row--grand">
|
||||
<span>Total</span>
|
||||
<span>{money(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="inv-words">Amount in words: {amountInWords}</p>
|
||||
|
||||
{notes ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="inv-block__label">Notes</p>
|
||||
<div style={{ whiteSpace: "pre-line" }}>{notes}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="inv-bottom">
|
||||
<div className="inv-block" style={{ maxWidth: "55%" }}>
|
||||
{bank ? (
|
||||
<>
|
||||
<p className="inv-block__label">Pay To</p>
|
||||
<div className="inv-block__strong">{bank.accountName || vendor.vendorName}</div>
|
||||
<div>{bank.bankName}</div>
|
||||
{bank.accountNo ? <div>Account No.: {bank.accountNo}</div> : null}
|
||||
{bank.branch ? <div>Branch: {bank.branch}</div> : null}
|
||||
{bank.ifsc ? <div>IFSC: {bank.ifsc}</div> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="inv-signature">
|
||||
{signatureDataUri ? (
|
||||
<>
|
||||
<img className="inv-signature__img" src={signatureDataUri} alt="Signature" />
|
||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
||||
<p className="inv-fineprint">Computer-generated invoice; no signature is required.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-footer">
|
||||
<span>{vendor.vendorName}</span>
|
||||
<span>Generated with Voiced — a Bongbetic product</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="voiced-page-setup">
|
||||
<div className="voiced-page-setup__row">
|
||||
{templateSlot}
|
||||
<div className="voiced-page-setup__field">
|
||||
<span className="cds--label">Page size</span>
|
||||
<ContentSwitcher
|
||||
size="md"
|
||||
selectedIndex={Math.max(0, PAGES.findIndex((p) => p.id === prefs.pageSize))}
|
||||
onChange={(e) => !disabled && onChange({ ...prefs, pageSize: PAGES[e.index ?? 0].id })}
|
||||
>
|
||||
{PAGES.map((p) => (
|
||||
<Switch key={p.id} name={p.id} text={p.label} disabled={disabled} />
|
||||
))}
|
||||
</ContentSwitcher>
|
||||
</div>
|
||||
<div className="voiced-page-setup__field">
|
||||
<Select
|
||||
id="page-margins"
|
||||
labelText="Margins"
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
value={prefs.margins}
|
||||
onChange={(e) => onChange({ ...prefs, margins: e.target.value as MarginPreset })}
|
||||
>
|
||||
{MARGINS.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="voiced-page-setup__footer">
|
||||
{saving ? (
|
||||
<InlineLoading status="active" description="Saving…" />
|
||||
) : (
|
||||
<Button kind="tertiary" size="sm" disabled={isDefault} onClick={() => void saveDefault()}>
|
||||
{isDefault ? "Saved as default" : "Save as default"}
|
||||
</Button>
|
||||
)}
|
||||
<span className="voiced-muted voiced-page-setup__note">
|
||||
{error ?? "This choice is frozen into each invoice when it is issued."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PDFDocumentProxy | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const current = useRef<PDFDocumentProxy | null>(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<T>(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<HTMLDivElement>(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 <div ref={host} className="voiced-pdf-preview__page" data-page={index} style={{ width: cssWidth, height: cssHeight }} />;
|
||||
}
|
||||
|
||||
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<Zoom>("fit");
|
||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||
const [fitWidth, setFitWidth] = useState(0);
|
||||
const [live, setLive] = useState<number[]>([0]);
|
||||
const [current, setCurrent] = useState(1);
|
||||
const scroller = useRef<HTMLDivElement>(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<number>();
|
||||
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 (
|
||||
<div className={`voiced-pdf-viewer ${className ?? ""}`}>
|
||||
<div className="voiced-pdf-viewer__bar">
|
||||
<ContentSwitcher
|
||||
size="sm"
|
||||
className="voiced-pdf-viewer__zoom"
|
||||
selectedIndex={zoomIndex}
|
||||
onChange={(e) => setZoom(ZOOMS[e.index ?? 0].id)}
|
||||
>
|
||||
{ZOOMS.map((z) => (
|
||||
<Switch key={z.id} name={z.id} text={z.label} />
|
||||
))}
|
||||
</ContentSwitcher>
|
||||
<span className="voiced-pdf-viewer__page" aria-live="polite">
|
||||
{pageCount > 0 ? `Page ${current} of ${pageCount}` : "No pages"}
|
||||
</span>
|
||||
</div>
|
||||
<div ref={scroller} className="voiced-pdf-preview__scroll">
|
||||
{loadError ? (
|
||||
<InlineNotification lowContrast hideCloseButton kind="error" title="Cannot show the PDF" subtitle={loadError} />
|
||||
) : null}
|
||||
<div className="voiced-pdf-preview__pages">
|
||||
{doc && natural && cssWidth > 0
|
||||
? Array.from({ length: pageCount }, (_, i) => (
|
||||
<PageSlot
|
||||
key={i}
|
||||
doc={doc}
|
||||
index={i}
|
||||
live={liveSet.has(i) && renderScale > 0}
|
||||
renderScale={renderScale}
|
||||
cssWidth={cssWidth}
|
||||
cssHeight={cssHeight}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="voiced-pdf-preview">
|
||||
<div className="voiced-pdf-preview__status">
|
||||
<div className="voiced-pdf-preview__chips">
|
||||
{rendering ? <InlineLoading status="active" description="Rendering…" /> : null}
|
||||
{mode === "main" ? (
|
||||
<Tag type="gray" size="sm">
|
||||
Preview paused while typing
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="voiced-pdf-preview__actions">
|
||||
{issues.length > 0 ? (
|
||||
<Popover open={issuesOpen} align="bottom-end" dropShadow onRequestClose={() => setIssuesOpen(false)}>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Warning}
|
||||
aria-expanded={issuesOpen}
|
||||
onClick={() => setIssuesOpen((o) => !o)}
|
||||
>
|
||||
{errors > 0 ? `Issues (${issues.length}, ${errors} error${errors === 1 ? "" : "s"})` : `Issues (${issues.length})`}
|
||||
</Button>
|
||||
<PopoverContent className="voiced-pdf-issues">
|
||||
<ul className="voiced-pdf-issues__list">
|
||||
{issues.map((issue, i) => (
|
||||
<li key={i} className="voiced-pdf-issues__item">
|
||||
<div className="voiced-pdf-issues__head">
|
||||
<Tag type={issue.severity === "error" ? "red" : "gray"} size="sm" className={issue.severity === "error" ? undefined : "voiced-tag--warn"}>
|
||||
{issue.code}
|
||||
</Tag>
|
||||
<span className="voiced-muted">Page {issue.page}</span>
|
||||
</div>
|
||||
<p className="voiced-pdf-issues__message">{issue.message}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
hasIconOnly
|
||||
renderIcon={Maximize}
|
||||
iconDescription="Focus preview"
|
||||
tooltipPosition="left"
|
||||
disabled={!bytes}
|
||||
onClick={() => {
|
||||
setFocusOpen(true);
|
||||
onFocus?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <InlineNotification lowContrast hideCloseButton kind="error" title="Preview failed" subtitle={error} /> : null}
|
||||
|
||||
{bytes ? (
|
||||
<PdfViewer bytes={bytes} pages={pages} />
|
||||
) : (
|
||||
<div className="voiced-pdf-preview__empty">{rendering ? "Rendering the first preview…" : "The preview appears here."}</div>
|
||||
)}
|
||||
|
||||
<ComposedModal open={focusOpen} size="lg" className="voiced-pdf-focus" onClose={() => setFocusOpen(false)}>
|
||||
<ModalHeader title="Invoice preview" closeModal={() => setFocusOpen(false)} />
|
||||
<ModalBody hasScrollingContent={false}>{focusOpen && bytes ? <PdfViewer bytes={bytes} pages={pages} className="voiced-pdf-viewer--focus" /> : null}</ModalBody>
|
||||
</ComposedModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<InvoicePdfState>(INITIAL);
|
||||
const latestJob = useRef(0);
|
||||
const lastPrefsKey = useRef<string | null>(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;
|
||||
}
|
||||
+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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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<unknown>) => 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);
|
||||
});
|
||||
});
|
||||
@@ -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<RenderResult>;
|
||||
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<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<RenderResult>((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<RenderResult>;
|
||||
|
||||
// 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<void>((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<RenderResult>((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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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<WorkerRequest>) => 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)),
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+134
-182
@@ -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;
|
||||
}
|
||||
|
||||
+42
-32
@@ -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<RenderPrefsV1>(() => 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<InvoiceItem>) =>
|
||||
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
|
||||
<Column sm={4} md={8} lg={7}>
|
||||
<div style={{ position: "sticky", top: "3rem" }}>
|
||||
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
|
||||
<InvoicePreview {...previewProps} />
|
||||
<PageSetupControls prefs={activePrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
|
||||
<Suspense fallback={<InlineLoading description="Loading preview…" />}>
|
||||
<PdfPreview
|
||||
bytes={pdf.bytes}
|
||||
pages={pdf.pages}
|
||||
issues={pdf.issues}
|
||||
rendering={pdf.rendering}
|
||||
mode={pdf.mode}
|
||||
error={pdf.error}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Column>
|
||||
</Grid>
|
||||
|
||||
Reference in New Issue
Block a user