Files
Voiced/src/pdf/templates/highlighter/Layout.tsx
T
xavierk 76533ad2c7 Add the Marble and Highlighter templates, completing the 13 reference templates
Marble uses a flat-to-gradient band in place of the marble photo and bundles Lustria (OFL).
Both logo slots are opt-in. Quirk 13 documents a fontkit glyph-cache issue that the
tests work around with warm-up renders; the engine fix follows separately.
2026-10-04 12:47:05 +05:30

576 lines
20 KiB
TypeScript

import { Image, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { colsDetailed, pinned } from "../../engine/columns";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
import { FitText } from "../../blocks/FitText";
import { IdText } from "../../blocks/IdText";
import { Cell, Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { pdfFlags } from "../../blocks/pdfProps";
import { Slot } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Txt } from "../../blocks/Txt";
import { ContactIcon } from "../../decor/CircledIcon";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import type { PlacedLogo, TemplateProps } from "../contract";
import { includesName } from "../logoName";
import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
import type { CellPart, TableCellValue } from "../../blocks/ItemsTable";
import { LABEL_GAP, NOTES_FLOW_GAP, paymentLines, planHighlighter, REVERSE_GAP, signatureOptions, taxLabel, type HighlighterPlan } from "./plan";
import { BLACK, INDIGO, YELLOW, type HighlighterVariant } from "./tokens";
/** Layout-tree ids of the blocks that are Highlighter's own (the harness ids are in classic/ids.ts). */
export const PAY_ID = "highlighter-pay";
export const PAD_PREFIX = "highlighter-pad-";
/** A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */
const NOTES_HOLD = 70;
interface Ctx {
model: TemplateProps["model"];
plan: HighlighterPlan;
v: HighlighterVariant;
logo: PlacedLogo | null;
}
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
// --- header -----------------------------------------------------------------------------------------------------
function Title({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const h = plan.head;
return (
<View style={{ ...pinned(h.titleW), marginLeft: v.inset } as never}>
<FitText id={TITLE_ID} role={{ ...v.roles.title, size: h.titleSize }} color={v.ink} width={h.titleW}>
{model.docTitle}
</FitText>
</View>
);
}
/** The vendor at the top right: the logo (right-aligned at the slot's top), the name, GSTIN and PAN. */
function Vendor({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const h = plan.head;
const r = v.roles;
const s = model.supplier;
return (
<View id={SUPPLIER_ID} style={pinned(h.stackW) as never}>
<AvailableWidthProvider width={h.stackW}>
{logo ? <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginLeft: Math.max(0, h.stackW - logo.w), objectFit: "contain" } as never} /> : null}
{h.showName ? (
<SafeTxt role={r.vendorName} color={v.ink} align="right">
{s.name}
</SafeTxt>
) : null}
<Slot.Gstin model={model} party="supplier" role={r.vendorId} color={v.ink} align="right" />
{s.pan ? (
<IdText role={r.vendorId} color={v.ink} align="right" fill>
{`PAN: ${s.pan}`}
</IdText>
) : null}
{model.cancelled ? (
<Txt role={r.vendorName} color={CANCELLED_INK} width={h.stackW} align="right">
CANCELLED
</Txt>
) : null}
</AvailableWidthProvider>
</View>
);
}
function HeaderRow({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
return (
<Row widths={[{ fill: true }, { fixed: plan.head.stackW + v.rightInset }]} width={plan.W} style={{ minHeight: plan.head.h }}>
<Title ctx={ctx} />
<Vendor ctx={ctx} />
</Row>
);
}
// --- fields and payment -----------------------------------------------------------------------------------------
function Field({ ctx, index }: { ctx: Ctx; index: number }) {
const { plan, v } = ctx;
const f = v.fields;
const spec = plan.fields.specs[index];
const labelW = plan.fields.labelW[index];
const valueW = f.w - labelW - f.labelGap;
return (
<View style={{ marginTop: index > 0 ? f.rowGap : 0 }}>
<Row widths={[{ fixed: labelW }, { fixed: valueW }]} gap={f.labelGap} width={f.w}>
<Txt role={v.roles.label} color={v.ink} width={labelW}>
{spec.label}
</Txt>
<View style={{ ...pinned(valueW), borderBottomWidth: v.hair, borderBottomColor: INDIGO } as never}>
<AvailableWidthProvider width={valueW - f.valuePad}>
<View style={{ marginLeft: f.valuePad }}>
<SafeTxt role={v.roles.value} color={v.ink}>
{spec.value}
</SafeTxt>
</View>
</AvailableWidthProvider>
</View>
</Row>
</View>
);
}
function Fields({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
const f = v.fields;
return (
<View id={META_ID} style={pinned(f.w) as never}>
{plan.fields.specs.map((_, i) => (
<Field key={i} ctx={ctx} index={i} />
))}
{plan.fields.subH > 0 ? (
<View style={{ ...pinned(f.w), marginTop: f.subGap } as never}>
<AvailableWidthProvider width={f.w}>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.sub} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.sub} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.sub} color={v.ink} />
{c.poNumber ? <SafeTxt role={r.sub} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt> : null}
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function Payment({ ctx }: { ctx: Ctx }) {
const { model, v } = ctx;
const r = v.roles;
const lines = paymentLines(model);
if (lines.length === 0) return <View />;
const w = v.pay.w;
return (
<View id={PAY_ID} style={{ ...pinned(w), marginTop: v.pay.top } as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.payHead} color={v.ink} width={w}>
Payment Method:
</Txt>
{lines.map((line, i) => (
<SafeTxt key={i} role={r.payLine} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
// --- table ------------------------------------------------------------------------------------------------------
const partsOf = (value: TableCellValue): CellPart[] => (Array.isArray(value) ? (value.length ? value : [""]) : [value]);
/** One table line inside the 3 pt frame: the frame's sides are the line's own borders, the dividers are its cells'. */
function Line({
ctx,
id,
last,
flags,
minH,
children,
}: {
ctx: Ctx;
id: string;
last: boolean;
flags: ReturnType<typeof pdfFlags>;
minH?: number;
children: ReactNode;
}) {
const { plan, v } = ctx;
return (
<View
id={id}
{...flags}
style={
{
...pinned(plan.W),
borderLeftWidth: v.frame,
borderRightWidth: v.frame,
borderLeftColor: v.ink,
borderRightColor: v.ink,
borderBottomWidth: last ? v.frame : v.hair,
borderBottomColor: last ? v.ink : INDIGO,
} as never
}
>
<Row widths={plan.columns} width={plan.innerW} align="stretch" style={minH ? { minHeight: minH } : undefined}>
{children}
</Row>
</View>
);
}
function colInner(ctx: Ctx, i: number): number {
const { plan, v } = ctx;
const w = plan.table.widths[i];
const last = i === plan.columns.length - 1;
return Math.max(0, w - plan.padLeft[i] - (plan.padRight[i] - (last ? 0 : v.hair)) - (last ? 0 : v.hair));
}
function BodyCell({ ctx, i, children }: { ctx: Ctx; i: number; children?: ReactNode }) {
const { plan, v } = ctx;
const last = i === plan.columns.length - 1;
return (
<Cell
padding={{ top: plan.padY, bottom: plan.padY, left: plan.padLeft[i], right: plan.padRight[i] - (last ? 0 : v.hair) }}
borderRight={last ? 0 : v.hair}
style={{ flexGrow: 1, flexBasis: "auto", ...(last ? {} : { borderRightColor: INDIGO }) }}
>
{children}
</Cell>
);
}
function DataRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) {
const { plan, v } = ctx;
const r = v.roles;
const cells = plan.rows[index];
return (
<Line ctx={ctx} id={`${TABLE_ROW_PREFIX}${index}`} last={lastRow} flags={pdfFlags({ wrap: false, minPresenceAhead: lastRow ? plan.tailH + (v.frame - v.hair) : undefined })}>
{cells.map((value, i) => (
<BodyCell key={i} ctx={ctx} i={i}>
{partsOf(value).map((part, li) => {
const text = typeof part === "string" ? part : part.text;
const role = typeof part === "string" ? r.tableBody : (part.role as typeof r.tableBody);
return i === 0 && typeof part === "string" ? (
<SafeTxt key={li} role={role} color={v.ink}>
{text}
</SafeTxt>
) : (
<Txt key={li} role={role} color={v.ink} width={colInner(ctx, i)} align={plan.align[i]}>
{text}
</Txt>
);
})}
</BodyCell>
))}
</Line>
);
}
function PadRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) {
const { plan, v } = ctx;
return (
<Line ctx={ctx} id={`${PAD_PREFIX}${index}`} last={lastRow} flags={pdfFlags({ wrap: false, minPresenceAhead: lastRow ? plan.tailH + (v.frame - v.hair) : undefined })} minH={plan.padRowH - v.hair}>
{plan.columns.map((_, i) => (
<BodyCell key={i} ctx={ctx} i={i} />
))}
</Line>
);
}
/** The header group, repeated on every page: the yellow bar, the gap, and the body's 3 pt top rule. */
function HeaderGroup({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const t = v.table;
const r = v.roles;
const fr = v.frame;
return (
<View {...pdfFlags({ fixed: true })} style={pinned(plan.W) as never}>
<View
id={TABLE_HEAD_ID}
style={
{
...pinned(plan.W),
backgroundColor: YELLOW,
borderWidth: fr,
borderColor: v.ink,
} as never
}
>
<Row widths={plan.columns} width={plan.innerW} align="stretch">
{plan.headerCells.map((text, i) => {
const last = i === plan.columns.length - 1;
return (
<Cell
key={i}
padding={{ top: t.barPadTop, bottom: t.barPadTop, left: 0, right: 0 }}
borderRight={last ? 0 : v.hair}
style={{ flexGrow: 1, flexBasis: "auto", ...(last ? {} : { borderRightColor: INDIGO }) }}
>
<Txt role={r.tableHead} color={v.ink} width={Math.max(0, plan.table.widths[i] - (last ? 0 : v.hair))} align="center">
{text}
</Txt>
</Cell>
);
})}
</Row>
</View>
<Spacer h={t.bodyGap} />
<View style={{ ...pinned(plan.W), height: fr, backgroundColor: v.ink } as never} />
</View>
);
}
function Table({ ctx }: { ctx: Ctx }) {
const { plan } = ctx;
const n = plan.rows.length;
return (
<AvailableWidthProvider width={plan.W}>
<View style={pinned(plan.W) as never}>
<HeaderGroup ctx={ctx} />
{plan.rows.map((_, i) => (
<DataRow key={i} ctx={ctx} index={i} lastRow={i === n - 1 && plan.padRows === 0} />
))}
{Array.from({ length: plan.padRows }, (_, i) => (
<PadRow key={i} ctx={ctx} index={i} lastRow={i === plan.padRows - 1} />
))}
<Tail ctx={ctx} />
</View>
</AvailableWidthProvider>
);
}
// --- totals -----------------------------------------------------------------------------------------------------
function TotalRow({ ctx, label, amount, total }: { ctx: Ctx; label: string; amount: string; total?: boolean }) {
const { plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
const o = v.totals;
const pad = { paddingTop: o.padY, paddingBottom: o.padY };
return (
<View style={{ flexDirection: "row", ...pinned(t.boxW - 2 * v.frame), ...(total ? {} : { borderBottomWidth: v.hair, borderBottomColor: INDIGO }) } as never}>
<View
style={
{
...pinned(t.labelW),
...pad,
paddingLeft: o.labelPad,
borderRightWidth: v.frame,
borderRightColor: v.ink,
...(total ? { backgroundColor: YELLOW } : {}),
} as never
}
>
<Txt role={r.totalLabel} color={BLACK} width={t.labelW - v.frame - o.labelPad}>
{label}
</Txt>
</View>
<View style={{ ...pinned(t.valueW), ...pad, paddingRight: o.valuePad } as never}>
<Txt role={total ? r.totalStrong : r.totalValue} color={v.ink} width={t.valueW - o.valuePad} align="right">
{amount}
</Txt>
</View>
</View>
);
}
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
return (
<View style={{ ...pinned(t.boxW), borderWidth: v.frame, borderColor: v.ink } as never}>
<AvailableWidthProvider width={t.boxW - 2 * v.frame}>
{t.stack.map((row, i) => (
<TotalRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
))}
<Slot.TaxRows model={model} role={v.roles.totalLabel} color={v.ink} amountWidth={t.valueW} format={taxLabel} renderRow={(row) => <TotalRow ctx={ctx} label={row.label} amount={row.amount} />} />
<TotalRow ctx={ctx} label="total :" amount={model.totals.totalText} total />
</AvailableWidthProvider>
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
const o = v.totals;
return (
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
<Spacer h={o.gap} />
<Row widths={[{ fill: true }, { fixed: t.boxW }]} width={plan.W}>
<View style={{ ...pinned(t.wordsW), marginLeft: v.inset, paddingTop: t.leftH ? o.wordsTop : 0 } as never}>
<AvailableWidthProvider width={t.wordsW}>
<Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={v.ink} />
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.words} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null}
</AvailableWidthProvider>
</View>
<TotalsBox ctx={ctx} />
</Row>
</View>
);
}
// --- closing ----------------------------------------------------------------------------------------------------
/** Notes in flow mode (too long for the ruled lines): a label held with the first line, every line unbreakable. */
function NotesFlow({ ctx, width }: { ctx: Ctx; width: number }) {
const { plan, v } = ctx;
const r = v.roles;
const lines = plan.closing.notes;
if (lines.length === 0) return null;
return (
<View style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<View wrap={false}>
<Txt role={r.wordsLabel} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}>
Notes:
</Txt>
<SafeTxt role={r.note} color={v.ink}>
{lines[0]}
</SafeTxt>
</View>
{lines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.note} color={v.ink} wrap={false}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function SignOff({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const cl = v.closing;
const c = plan.closing;
const w = c.leftW - v.inset;
return (
<View style={{ ...pinned(c.leftW), paddingLeft: v.inset, paddingTop: cl.leftTop } as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.thanks} color={v.ink} width={w}>
THANK YOU
</Txt>
<View style={{ marginTop: c.contacts.length ? cl.thanksGap : 0 }}>
{c.contacts.map((x, i) => (
<View key={i} style={{ marginTop: i > 0 ? cl.contactGap : 0 }}>
<Row widths={[{ fixed: cl.iconSize }, { fill: true }]} gap={cl.iconGap} width={w}>
<View style={{ marginTop: 0.6 }}>
<ContactIcon name={x.icon} size={cl.iconSize} color={v.ink} />
</View>
{x.icon === "location" ? (
<SafeTxt role={r.contact} color={BLACK}>
{x.text}
</SafeTxt>
) : (
// A phone number or e-mail is an identifier: one fitted line (shrunk, then split at "@ . / -"), never a wrapped one.
<IdText role={r.contact} color={BLACK}>
{x.text}
</IdText>
)}
</Row>
</View>
))}
</View>
</AvailableWidthProvider>
</View>
);
}
/** The design's ruled lines: the notes written on them (a line per wrapped line), then the signature on the last one. */
function Ruled({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const cl = v.closing;
const c = plan.closing;
const opts = signatureOptions(v);
return (
<View style={pinned(cl.ruledW) as never}>
<AvailableWidthProvider width={cl.ruledW}>
{Array.from({ length: c.ruledRows }, (_, i) => (
<View
key={i}
style={
{
...pinned(cl.ruledW),
minHeight: cl.ruledH,
paddingTop: cl.ruledH - v.hair - (r.note.leading as number),
borderBottomWidth: v.hair,
borderBottomColor: INDIGO,
} as never
}
>
{!c.notesFlow && c.ruled[i] !== undefined ? (
<FitText role={r.note} color={v.ink} width={cl.ruledW - 2}>
{c.ruled[i]}
</FitText>
) : null}
</View>
))}
<Slot.Signatory model={model} width={cl.ruledW} {...opts} />
</AvailableWidthProvider>
</View>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const cl = v.closing;
const c = plan.closing;
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={pinned(plan.W) as never}>
<AvailableWidthProvider width={plan.W}>
<Spacer h={plan.gaps.close} />
<Row widths={[{ fixed: c.leftW }, { fixed: cl.ruledW }]} gap={cl.colGap} width={plan.W - v.rightInset}>
<SignOff ctx={ctx} />
<Ruled ctx={ctx} />
</Row>
{model.cancelled ? (
<Txt role={v.roles.note} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10, marginLeft: v.inset }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- the layout -------------------------------------------------------------------------------------------------
export function HighlighterBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: HighlighterVariant }) {
const plan = planHighlighter(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const notesFlow: ReactNode = !plan.closing.notesFlow ? null : (
<>
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.inset }}>
<NotesFlow ctx={ctx} width={plan.W - v.inset - v.rightInset} />
</View>
</>
);
return (
<>
<FirstPageHeader pad={frame.pad}>
<HeaderRow ctx={ctx} />
<Spacer h={v.fields.top} />
<Row widths={[{ fixed: plan.fields.leftW }, { fixed: v.pay.w + v.rightInset }]} width={plan.W}>
<View style={{ ...pinned(plan.fields.leftW), paddingLeft: v.inset } as never}>
<Fields ctx={ctx} />
</View>
<Payment ctx={ctx} />
</Row>
</FirstPageHeader>
<Spacer h={plan.gaps.bar} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<Table ctx={ctx} />
{notesFlow}
<Closing ctx={ctx} />
</>
);
}