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.
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import type { ReactNode } from "react";
|
||||
import { pinned, type ColSpec } from "../../engine/columns";
|
||||
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
||||
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
|
||||
import { BottomSpacer } from "../../blocks/BottomSpacer";
|
||||
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
|
||||
import { fitSize, FitText } from "../../blocks/FitText";
|
||||
import { IdText } from "../../blocks/IdText";
|
||||
import { TableRows } from "../../blocks/ItemsTable";
|
||||
import { Row } from "../../blocks/Row";
|
||||
import { SafeTxt } from "../../blocks/SafeTxt";
|
||||
import { Slot } from "../../blocks/slots";
|
||||
import { TableGuard } from "../../blocks/TableGuard";
|
||||
import { Txt } from "../../blocks/Txt";
|
||||
import { AccentRule } from "../../decor/AccentRule";
|
||||
import type { TypeToken } from "../../fonts/roles";
|
||||
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 { LABEL_GAP, NOTES_FLOW_GAP, PARTY_GUTTER, payableLines, planMarble, REVERSE_GAP, signatureOptions, stepOf, taxLabel, type MarblePlan } from "./plan";
|
||||
import { RULE, ZEBRA, type MarbleVariant } from "./tokens";
|
||||
|
||||
/** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */
|
||||
const NOTES_HOLD = 70;
|
||||
|
||||
/** Layout-tree ids of the blocks that are Marble's own (the harness ids are in classic/ids.ts). */
|
||||
export const IDS_ID = "marble-ids";
|
||||
export const PAYABLE_ID = "marble-payable";
|
||||
export const BILL_ID = "marble-bill";
|
||||
|
||||
interface Ctx {
|
||||
model: TemplateProps["model"];
|
||||
plan: MarblePlan;
|
||||
v: MarbleVariant;
|
||||
logo: PlacedLogo | null;
|
||||
}
|
||||
|
||||
function Spacer({ h }: { h: number }) {
|
||||
return h > 0 ? <View style={{ height: h }} /> : null;
|
||||
}
|
||||
|
||||
function Rule({ ctx }: { ctx: Ctx }) {
|
||||
return <AccentRule width={ctx.plan.W} thickness={ctx.v.rule} color={RULE} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* One right-aligned line of tracked text. textkit adds the tracking after the last glyph too, so the box is
|
||||
* widened by one step and pulled back with a negative margin: the ink then ends on the edge.
|
||||
*/
|
||||
function RightText({ role, color, width, children }: { role: TypeToken; color: string; width: number; children: string }) {
|
||||
const step = stepOf(role);
|
||||
const fit = fitSize(role, children, width + step);
|
||||
if (!fit.fits) {
|
||||
return (
|
||||
<IdText role={role} color={color} width={width} align="right" fill>
|
||||
{children}
|
||||
</IdText>
|
||||
);
|
||||
}
|
||||
const sized = { ...role, size: fit.size };
|
||||
const s = stepOf(sized);
|
||||
return (
|
||||
<Txt role={sized} color={color} width={width + s} align="right" style={{ marginRight: -s }}>
|
||||
{children}
|
||||
</Txt>
|
||||
);
|
||||
}
|
||||
|
||||
// --- header -----------------------------------------------------------------------------------------------------
|
||||
|
||||
/** The vendor name (Lustria caps) or its logo; the design leaves this corner empty. */
|
||||
function Brand({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v, logo } = ctx;
|
||||
const h = plan.head;
|
||||
const hd = v.head;
|
||||
return (
|
||||
<View id={SUPPLIER_ID} style={{ ...pinned(h.leftW), marginLeft: v.inset.left } as never}>
|
||||
{logo ? (
|
||||
<View style={{ ...pinned(h.leftW), height: Math.max(hd.logoH, h.logoTop + logo.h) } as never}>
|
||||
<Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: h.logoTop, objectFit: "contain" } as never} />
|
||||
</View>
|
||||
) : null}
|
||||
{h.showName ? (
|
||||
<View style={{ ...pinned(h.leftW), marginTop: logo ? hd.nameGap : hd.nameTop } as never}>
|
||||
<AvailableWidthProvider width={h.leftW}>
|
||||
<SafeTxt role={v.roles.brand} color={v.ink}>
|
||||
{model.supplier.name}
|
||||
</SafeTxt>
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const h = plan.head;
|
||||
const r = v.roles;
|
||||
return (
|
||||
<View style={{ ...pinned(h.stackW), paddingTop: v.head.titleTop } as never}>
|
||||
<FitText id={TITLE_ID} role={{ ...r.title, size: h.titleSize }} color={v.ink} width={h.stackW + stepOf({ ...r.title, size: h.titleSize })} align="right" style={{ marginRight: -stepOf({ ...r.title, size: h.titleSize }) }}>
|
||||
{model.docTitle}
|
||||
</FitText>
|
||||
<View id={META_ID} style={{ ...pinned(h.stackW), marginTop: v.head.titleGap } as never}>
|
||||
<AvailableWidthProvider width={h.stackW}>
|
||||
{h.lines.map((l, i) => (
|
||||
<RightText key={i} role={r.meta} color={l.kind === "cancelled" ? CANCELLED_INK : v.ink} width={h.stackW}>
|
||||
{l.text}
|
||||
</RightText>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow({ ctx }: { ctx: Ctx }) {
|
||||
const { plan } = ctx;
|
||||
return (
|
||||
<Row widths={[{ fill: true }, { fixed: plan.head.stackW }]} width={plan.W} style={{ minHeight: plan.head.h }}>
|
||||
<Brand ctx={ctx} />
|
||||
<Stack ctx={ctx} />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
// --- parties ----------------------------------------------------------------------------------------------------
|
||||
|
||||
function ClientBlock({ ctx, width }: { ctx: Ctx; width: number }) {
|
||||
const { model, v } = ctx;
|
||||
const r = v.roles;
|
||||
const c = model.client;
|
||||
return (
|
||||
<View id={BILL_ID} style={pinned(width) as never}>
|
||||
<AvailableWidthProvider width={width}>
|
||||
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
|
||||
Bill to:
|
||||
</Txt>
|
||||
<SafeTxt role={r.body} color={v.ink}>
|
||||
{c.name}
|
||||
</SafeTxt>
|
||||
{c.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
<Slot.Gstin model={model} party="client" role={r.body} color={v.ink} />
|
||||
<Slot.PlaceOfSupply model={model} role={r.body} color={v.ink} />
|
||||
{c.poNumber ? <SafeTxt role={r.body} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt> : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PayableBlock({ ctx, width }: { ctx: Ctx; width: number }) {
|
||||
const { model, v } = ctx;
|
||||
const r = v.roles;
|
||||
return (
|
||||
<View id={PAYABLE_ID} style={pinned(width) as never}>
|
||||
<AvailableWidthProvider width={width}>
|
||||
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
|
||||
Payable to:
|
||||
</Txt>
|
||||
{payableLines(model).map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function IdsBlock({ ctx, width }: { ctx: Ctx; width: number }) {
|
||||
const { model, v } = ctx;
|
||||
const r = v.roles;
|
||||
const s = model.supplier;
|
||||
return (
|
||||
<View id={IDS_ID} style={pinned(width) as never}>
|
||||
<AvailableWidthProvider width={width}>
|
||||
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
|
||||
Supplier:
|
||||
</Txt>
|
||||
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
|
||||
{s.pan ? (
|
||||
<IdText role={r.body} color={v.ink}>
|
||||
{`PAN: ${s.pan}`}
|
||||
</IdText>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Parties({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
const cols = plan.info.cols;
|
||||
const text = cols.map((w) => Math.max(40, w - PARTY_GUTTER));
|
||||
const blocks: ReactNode[] = [<ClientBlock key="a" ctx={ctx} width={text[0]} />];
|
||||
let k = 1;
|
||||
if (plan.info.hasBank) blocks.push(<PayableBlock key="b" ctx={ctx} width={text[k++]} />);
|
||||
if (plan.info.hasIds) blocks.push(<IdsBlock key="c" ctx={ctx} width={text[k++]} />);
|
||||
const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w }));
|
||||
return (
|
||||
<View style={{ ...pinned(plan.W), paddingLeft: v.inset.left } as never}>
|
||||
<Row widths={specs} width={plan.W - v.inset.left}>
|
||||
{blocks}
|
||||
</Row>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- totals -----------------------------------------------------------------------------------------------------
|
||||
|
||||
function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) {
|
||||
const { plan, v } = ctx;
|
||||
const t = plan.tail;
|
||||
const r = v.roles;
|
||||
const labelW = t.boxW - t.valueW;
|
||||
return (
|
||||
<Row widths={[{ fixed: labelW }, { fixed: t.valueW }]} width={t.boxW} style={v.totals.rowGap ? { marginBottom: v.totals.rowGap } : undefined}>
|
||||
<Txt role={r.totalLabel} color={v.ink} width={labelW}>
|
||||
{label}
|
||||
</Txt>
|
||||
<RightText role={r.totalValue} color={v.ink} width={t.valueW}>
|
||||
{amount}
|
||||
</RightText>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalsBox({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const t = plan.tail;
|
||||
return (
|
||||
<View style={pinned(t.boxW) as never}>
|
||||
<AvailableWidthProvider width={t.boxW}>
|
||||
{t.stack.map((row, i) => (
|
||||
<StackRow 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) => <StackRow ctx={ctx} label={row.label} amount={row.amount} />} />
|
||||
<View style={{ marginTop: v.totals.dueGap, ...pinned(t.boxW) } as never}>
|
||||
<RightText role={{ ...v.roles.due, size: t.dueSize }} color={v.ink} width={t.boxW}>
|
||||
{t.dueText}
|
||||
</RightText>
|
||||
</View>
|
||||
</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={plan.gaps.tail} />
|
||||
<Row widths={[{ fill: true }, { fixed: t.boxW }]} gap={o.leftGap} width={plan.W - o.rightInset}>
|
||||
<View style={{ ...pinned(t.leftW), marginLeft: v.inset.left } as never}>
|
||||
<AvailableWidthProvider width={t.leftW}>
|
||||
<Slot.AmountInWords model={model} role={r.small} labelRole={r.smallLabel} color={v.ink} />
|
||||
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
<TotalsBox ctx={ctx} />
|
||||
</Row>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- closing ----------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Notes: a label held with the first line, every line unbreakable (a cut Text would leave a zero-line node). */
|
||||
function Notes({ 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.smallLabel} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}>
|
||||
Notes:
|
||||
</Txt>
|
||||
<SafeTxt role={r.small} color={v.ink}>
|
||||
{lines[0]}
|
||||
</SafeTxt>
|
||||
</View>
|
||||
{lines.slice(1).map((line, i) => (
|
||||
<SafeTxt key={i} role={r.small} color={v.ink} wrap={false}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Closing({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const cl = v.closing;
|
||||
const c = plan.closing;
|
||||
const opts = signatureOptions(v);
|
||||
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={[{ fill: true }, { fixed: cl.signW }]} gap={cl.gap} width={plan.W - v.totals.rightInset}>
|
||||
<View style={{ ...pinned(c.notesW), marginLeft: v.inset.left } as never}>{c.notesBeside ? <Notes ctx={ctx} width={c.notesW} /> : null}</View>
|
||||
<Slot.Signatory model={model} width={cl.signW} {...opts} />
|
||||
</Row>
|
||||
{model.cancelled ? (
|
||||
<Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10, marginLeft: v.inset.left }}>
|
||||
{CANCELLED_SENTENCE}
|
||||
</Txt>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- the layout -------------------------------------------------------------------------------------------------
|
||||
|
||||
export function MarbleBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: MarbleVariant }) {
|
||||
const plan = planMarble(model, frame, v, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v, logo };
|
||||
const r = v.roles;
|
||||
const t = v.table;
|
||||
const notesFlow: ReactNode =
|
||||
plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
|
||||
<>
|
||||
{/* 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. */}
|
||||
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.inset.left }}>
|
||||
<Notes ctx={ctx} width={plan.W - v.inset.left - v.totals.rightInset} />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FirstPageHeader pad={frame.pad}>
|
||||
<HeaderRow ctx={ctx} />
|
||||
<Spacer h={plan.gaps.headRule} />
|
||||
<Rule ctx={ctx} />
|
||||
<Spacer h={plan.gaps.infoTop} />
|
||||
<Parties ctx={ctx} />
|
||||
<Spacer h={plan.gaps.infoBottom} />
|
||||
<Rule ctx={ctx} />
|
||||
</FirstPageHeader>
|
||||
|
||||
<Spacer h={plan.gaps.band} />
|
||||
|
||||
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
|
||||
<View style={pinned(plan.W) as never}>
|
||||
<TableRows
|
||||
columns={plan.columns}
|
||||
gap={0}
|
||||
width={plan.W}
|
||||
header={plan.headerCells}
|
||||
rows={plan.rows}
|
||||
roles={{ head: r.tableHead, body: r.tableBody }}
|
||||
align={plan.align}
|
||||
padX={0}
|
||||
padLeft={plan.padLeft}
|
||||
padRight={plan.padRight}
|
||||
padY={plan.padY}
|
||||
headPadY={0}
|
||||
border={0}
|
||||
headBorder={0}
|
||||
headColor={v.ink}
|
||||
rowStyle={(i) => (i % 2 === 0 ? { backgroundColor: ZEBRA } : {})}
|
||||
headCellStyle={(i) => (i === 0 ? { marginLeft: -(t.descInset - t.headDescInset) } : undefined)}
|
||||
headerTail={<Spacer h={t.headGap} />}
|
||||
headerTailH={t.headGap}
|
||||
tailH={plan.tailH}
|
||||
tail={<Tail ctx={ctx} />}
|
||||
/>
|
||||
</View>
|
||||
{notesFlow}
|
||||
<Closing ctx={ctx} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user