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:
2026-10-04 12:47:05 +05:30
parent dd85c3b3c5
commit 76533ad2c7
30 changed files with 3190 additions and 27 deletions
+575
View File
@@ -0,0 +1,575 @@
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} />
</>
);
}
@@ -0,0 +1,425 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { SLOT_ID } from "../../blocks/slots";
import { frameForPrefs, renderInvoicePdf } from "../../render/invoice";
import { loadCanvas, pagePng } from "../../testing/rasterize";
import { parsePdfText } from "../../testing/pdfText";
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
import { marginsFor } from "../contract";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { OPTIN_SLOTS } from "../slots.optin";
import { SLOTS } from "../slots.generated";
import { PAD_PREFIX, PAY_ID } from "./Layout";
import { highlighter } from "./highlighter";
import { planHighlighter } from "./plan";
import { BLACK, HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INDIGO, INK, YELLOW } from "./tokens";
ensureSetup();
// fontkit caches glyph objects per font for the process, and pdfkit creates a composite's components (the period and comma inside
// Poppins' colon and semicolon) at embed time with no code points (src/pdf/quirks.test.tsx, quirk 13). A first render without any
// period or comma, such as the 3-row sample at 100.00 an hour, then makes a later wrapped client name overflow its box by 3.6 pt
// (textkit's glyph-to-string mapping slips). The tests run in a fixed order, so warm Poppins with the harness's default invoice,
// whose amounts have both.
beforeAll(async () => {
await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" }));
});
const V = HIGHLIGHTER_VARIANT;
const slot = OPTIN_SLOTS.highlighter!;
type R = Awaited<ReturnType<typeof renderFixture>>;
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
const out: LayoutNode[] = [];
for (const p of layout) walk(p.nodes, (n) => out.push(n));
return out;
};
const onPage = (r: R, i: number) => nodes([r.result.core.layout[i]]);
const imageBoxes = (r: R) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
const byId = (r: R, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
const textLines = (root: LayoutNode) => {
const out: Array<{ text: string; x: number; right: number; y: number; h: number }> = [];
walk([root], (n) => {
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ text: l.text.trim(), x: l.x, right: l.x + l.w - (n.letterSpacing ?? 0), y: l.y, h: l.h });
});
return out;
};
const contentRight = (r: R) => {
const f = frameForPrefs(r.prefs);
return f.page.width - f.pad.right;
};
const optIn = (size: "A4" | "LETTER" = "A4") => prefsFor(size, { templateId: "highlighter", logo: { show: true } });
const hits = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0.5 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0.5;
/** The reference sample's shape: no due date, no address, the payment block's two lines, nothing the design has no slot for. */
const NAMES = ["Brand consultation", "Logo design", "Website design"];
const PLAIN_ITEMS = NAMES.map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i }));
async function reference(size: "A4" | "LETTER" = "A4"): Promise<{ r: R; items: Array<{ str: string; x0: number; x1: number; bottom: number }> }> {
const fixture = buildFixture({ name: "reference", registered: false, logo: "none", items: PLAIN_ITEMS, invoice: { dueDate: "", notes: "", clientName: "Olivia Wilson", clientAddress: "", clientGstin: "" } });
fixture.model.bank = { accountName: "Wealth", lines: [{ label: "Bank", value: "Wealth Banking" }, { label: "Account No.", value: "123 4567 8901" }] };
fixture.model.client.posLabel = null;
const prefs = prefsFor(size, { templateId: "highlighter" });
const result = await renderInvoicePdf({ model: fixture.model, prefs });
const pdf = await parsePdfText(result.bytes);
return { r: { fixture, prefs, result, pdf } as R, items: pdf.pages[0].items };
}
const find = (items: Array<{ str: string; x0: number; x1: number; bottom: number }>, text: string) => items.find((i) => i.str.trim().startsWith(text))!;
/** RGB of pixels of page `page` at 4x. */
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>, page = 1): Promise<number[][]> {
const lib = await loadCanvas();
if (!lib) return [];
const png = await pagePng(lib, bytes, page, 4);
const img = await lib.loadImage(png);
const c = lib.createCanvas(img.width, img.height);
const ctx = c.getContext("2d");
ctx.drawImage(img, 0, 0);
return points.map(([x, y]) => Array.from(ctx.getImageData(Math.round(x * 4), Math.round(y * 4), 1, 1).data).slice(0, 3));
}
const near = (c: number[], hex: string, tol = 8) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).every((w, i) => Math.abs(c[i] - w) <= tol);
describe("registry, catalog and fonts", () => {
it("is registered last, as a standalone template with its thumbnail on disk and the logo opt-in", () => {
expect(getTemplate("highlighter")).toBe(highlighter);
expect(highlighter.family).toBe("standalone");
expect(catalogEntry("highlighter").name).toBe("Highlighter");
expect(catalogEntry("highlighter").logoOptIn).toBe(true);
expect(existsSync(path.join(process.cwd(), "public", highlighter.thumbnail)), highlighter.thumbnail).toBe(true);
expect(Object.keys(TEMPLATES).at(-1)).toBe("highlighter");
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("is Poppins 400/700 and Montserrat 400 only, in the design's four colours", () => {
for (const [name, token] of Object.entries(HIGHLIGHTER_ROLES)) {
expect(["Poppins", "Montserrat"], name).toContain(token.face);
expect([400, 700], name).toContain(token.weight);
if (token.face === "Montserrat") expect(token.weight, name).toBe(400);
}
const dir = path.join(process.cwd(), "src/pdf/templates/highlighter");
const palette = [INK, INDIGO, YELLOW, BLACK].map((c) => c.toLowerCase());
for (const f of readdirSync(dir).filter((x) => /\.(ts|tsx)$/.test(x) && !/\.test\./.test(x))) {
const colours = readFileSync(path.join(dir, f), "utf8").match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
expect(colours.filter((c) => !palette.includes(c.toLowerCase())), f).toEqual([]);
}
expect(highlighter.decor.ink).toBe(INK);
});
});
describe("page geometry", () => {
it("template margins put the 3 pt frame's outer edge on the content box (479.4 pt wide on A4)", () => {
expect(marginsFor(highlighter, "template")).toEqual({ top: 50, right: 57.8, bottom: 36, left: 58.1 });
expect(marginsFor(highlighter, "narrow").left).toBe(36);
expect(frameForPrefs(prefsFor("A4", { templateId: "highlighter" })).contentWidth).toBeCloseTo(479.4, 0);
});
it("Letter keeps the physical margins and takes the missing height from the gaps and the empty rows", () => {
const a4 = frameForPrefs(prefsFor("A4", { templateId: "highlighter" }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "highlighter" }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
});
});
describe("header and fields", () => {
it("matches the design: title baseline 98 at x 60.3, the fields at the 34 pt pitch from 132.2, the payment block at 162.9 / 185.1", async () => {
const { items } = await reference();
const title = find(items, "Invoice");
expect(title.bottom).toBeCloseTo(98.0, 0);
expect(title.x0).toBeCloseTo(60.3, 0);
expect(find(items, "Date:").bottom).toBeCloseTo(132.2, 0);
expect(find(items, "Invoice number:").bottom).toBeCloseTo(166.0, 0);
expect(find(items, "Billed to:").bottom).toBeCloseTo(200.4, 0);
expect(find(items, "Billed to:").x0).toBeCloseTo(60.3, 0);
const pay = find(items, "Payment Method:");
expect(pay.bottom).toBeCloseTo(162.9, 0);
expect(pay.x0).toBeCloseTo(352.5, 0);
expect(find(items, "Account Name").bottom).toBeCloseTo(185.1, 0);
expect(find(items, "Account Name").bottom + 16.5).toBeCloseTo(find(items, "Bank Name").bottom, 0);
});
it("each field's value sits on a 0.75 pt indigo underline 5 pt after its label, ending at x 266 (the first three underlines at y 137.4 / 170.4 / 204.8)", async () => {
const { r } = await reference();
const meta = byId(r, META_ID);
const rules = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - 19.25) < 0.3 && Math.abs(n.box.x + n.box.w - 266) < 0.6 && n.box.y >= meta.box.y - 0.1);
expect(rules.length).toBeGreaterThanOrEqual(3);
const ys = [...new Set(rules.map((n) => Math.round((n.box.y + n.box.h) * 10) / 10))].sort((a, b) => a - b);
// The design's pitch is 33.8 / 34.4 pt; ours is a steady 34.1.
for (const [got, want] of [[ys[0], 137.8], [ys[1], 170.8], [ys[2], 205.2]] as const) expect(Math.abs(got - want), `underline ${got} vs ${want}`).toBeLessThan(1.3);
});
it("a long invoice number, client name or reference wraps inside its underline and the payment block stays beside it", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345/REV-3/LONG-REFERENCE-NUMBER", clientName: "The Board of Trustees of the Mahatma Gandhi Memorial Institute of Education" } }, prefsFor("A4", { templateId: "highlighter" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
const meta = byId(r, META_ID);
const pay = byId(r, PAY_ID);
expect(hits(meta.box, pay.box)).toBe(false);
expect(meta.box.x + meta.box.w).toBeLessThanOrEqual(pay.box.x + 0.5);
});
it("TAX INVOICE never reaches the vendor stack, on either size", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "t", items: 3, logo: "none", vendor: { vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt" } }, prefsFor(size, { templateId: "highlighter" }));
const title = textLines(byId(r, TITLE_ID));
const supplier = textLines(byId(r, SUPPLIER_ID));
expect(Math.max(...title.map((l) => l.right)), size).toBeLessThan(Math.min(...supplier.map((l) => l.x)) - 4);
expect(r.result.issues.filter((i) => i.severity === "error"), size).toEqual([]);
}
});
it("a cancelled invoice is marked in the vendor stack and closes with its sentence", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "cancelled")!, prefsFor("A4", { templateId: "highlighter" }));
expect(textLines(byId(r, SUPPLIER_ID)).some((l) => l.text === "CANCELLED")).toBe(true);
expect(r.pdf.pages[r.result.pages - 1].items.map((i) => i.str).join(" ")).toContain("This invoice has been cancelled.");
});
});
describe("logo", () => {
it("is an opt-in slot (the design has no placeholder): off by default, 150 x 50 at the top right", () => {
expect(SLOTS.highlighter).toBeNull();
expect(highlighter.logo.slot).toBe(slot);
expect(highlighter.logo.optIn).toBe(true);
expect(highlighter.logo.defaultOn).toBe(false);
expect(slot.lockup).toMatchObject({ x: 386, y: 50, w: 150, h: 50 });
expect(highlighter.logo.box).toEqual({ w: 150, h: 50 });
});
it("without the opt-in no logo is drawn, even when the invoice has one", async () => {
const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "highlighter" }));
expect(imageBoxes(r)).toHaveLength(0);
});
it("every opted-in logo fits the 150 x 50 slot, ends on the right edge at the slot's top, and the fields flow below it", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const logo of ["real", "wide", "square"] as const) {
const r = await renderFixture({ name: "o", items: 2, logo }, optIn(size));
const imgs = imageBoxes(r);
expect(imgs, `${size}/${logo}`).toHaveLength(1);
const [img] = imgs;
expect(img.box.w).toBeLessThanOrEqual(150.01);
expect(img.box.h).toBeLessThanOrEqual(50.01);
expect(img.box.x + img.box.w, `${size}/${logo} right edge`).toBeCloseTo(contentRight(r) - V.rightInset, 0);
expect(img.box.y, `${size}/${logo} top`).toBeCloseTo(frameForPrefs(r.prefs).pad.top, 0);
expect(byId(r, SUPPLIER_ID).box.y + byId(r, SUPPLIER_ID).box.h, `${size}/${logo}`).toBeLessThanOrEqual(byId(r, META_ID).box.y + 0.5);
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${logo}`).toEqual([]);
}
}
});
it("a tall logo is held to the slot's 50 pt height", async () => {
const { model } = buildFixture({ name: "tall", items: 2, logo: "square" });
model.logo = { ...model.logo!, aspect: 0.5, width: 60, height: 120, kind: "tall", includesName: false };
const result = await renderInvoicePdf({ model, prefs: optIn() });
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
const img = nodes(result.core.layout).find((n) => n.type === "image" && !n.decor)!;
expect(img.box.h).toBeLessThanOrEqual(50.01);
});
it("a wordmark replaces the vendor name text; a mark keeps it under the mark", async () => {
const stack = (r: R) => textLines(byId(r, SUPPLIER_ID)).map((l) => l.text);
expect(stack(await renderFixture({ name: "o", items: 2, logo: "real" }, optIn()))).not.toContain("Test Vendor");
expect(stack(await renderFixture({ name: "o", items: 2, logo: "square" }, optIn()))).toContain("Test Vendor");
});
});
describe("table", () => {
it("the bar is #FFE535 inside a 3 pt ink frame, 38.4 pt tall, with the headings centred over the design's columns (Qty x 277.4, Price x 360.8)", async () => {
const { r, items } = await reference();
const bar = byId(r, TABLE_HEAD_ID);
expect(bar.box.h).toBeCloseTo(V.table.barH, 0);
expect(bar.box.x).toBeCloseTo(58.1, 0);
expect(bar.box.x + bar.box.w).toBeCloseTo(537.5, 0);
const centre = (t: string) => {
const i = find(items, t);
return (i.x0 + i.x1) / 2;
};
expect(centre("Qty")).toBeCloseTo(277.4, 0);
expect(centre("Price")).toBeCloseTo(360.8, 0);
const [fill, frame, divider] = await colourAt(r.result.bytes, [[200, bar.box.y + 3 + 3], [200, bar.box.y + 1.5], [245.5, bar.box.y + 5]]);
expect(near(fill, YELLOW), `bar ${fill}`).toBe(true);
expect(near(frame, INK, 10), `frame ${frame}`).toBe(true);
expect(near(divider, INDIGO, 40), `divider ${divider}`).toBe(true);
});
it("the body follows 4.5 pt under the bar with its own 3 pt frame; dividers are hairlines, the sides and the last row's bottom are the frame", async () => {
const { r } = await reference();
const bar = byId(r, TABLE_HEAD_ID).box;
const row0 = byId(r, `${TABLE_ROW_PREFIX}0`).box;
expect(row0.y - (bar.y + bar.h)).toBeCloseTo(V.table.bodyGap + V.frame, 0);
expect(row0.x).toBeCloseTo(58.1, 0);
expect(row0.w).toBeCloseTo(479.4, 0);
const pads = onPage(r, 0).filter((n) => n.id?.startsWith(PAD_PREFIX));
expect(pads.length).toBeGreaterThan(0);
const last = pads[pads.length - 1].box;
const [side, hair, bottom] = await colourAt(r.result.bytes, [[59.6, row0.y + 10], [150, row0.y + row0.h - 0.4], [150, last.y + last.h - 1.5]]);
expect(near(side, INK, 10), `side ${side}`).toBe(true);
expect(near(hair, INDIGO, 60), `hairline ${hair}`).toBe(true);
expect(near(bottom, INK, 10), `bottom frame ${bottom}`).toBe(true);
});
it("the form look pads to 8 empty rows on A4 and 7 on Letter while the page holds them, and to none for a long invoice", async () => {
const a4 = await reference("A4");
const rowsA4 = onPage(a4.r, 0).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX) || n.id?.startsWith(PAD_PREFIX)).length;
expect(rowsA4).toBe(8);
const letter = await reference("LETTER");
expect(onPage(letter.r, 0).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX) || n.id?.startsWith(PAD_PREFIX)).length).toBe(7);
const many = await renderFixture({ name: "m", items: 12 }, prefsFor("A4", { templateId: "highlighter" }));
expect(nodes(many.result.core.layout).filter((n) => n.id?.startsWith(PAD_PREFIX))).toHaveLength(0);
// A GST invoice with its longer stack drops pad rows rather than spill the sign-off onto a second page when one page can hold it.
const gst = await renderFixture({ name: "g", items: 2, logo: "none", vendor: { vendorEmail: "", vendorPhone: "" }, invoice: { clientAddress: "" } }, prefsFor("A4", { templateId: "highlighter" }));
expect(gst.result.pages).toBe(1);
});
it("description left (9 pt in), qty centred, price and total right-aligned 9 pt inside their cells", async () => {
const { r } = await reference();
const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const row = textLines(byId(r, `${TABLE_ROW_PREFIX}0`));
const left = frameForPrefs(r.prefs).pad.left + V.frame;
expect(row[0].x).toBeCloseTo(left + V.table.descPad, 0);
const amount = row[row.length - 1];
expect(amount.right).toBeCloseTo(frameForPrefs(r.prefs).pad.left + plan.W - V.frame - V.table.numPad, 0);
});
it("repeats the bar on every continuation page, never leaves it alone, and closes the frame after the last row", async () => {
const r = await renderFixture({ name: "rows", items: 40 }, prefsFor("A4", { templateId: "highlighter" }));
expect(r.result.pages).toBeGreaterThan(1);
for (let p = 0; p < r.result.pages; p++) {
const rows = onPage(r, p).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX)).length;
const heads = onPage(r, p).filter((n) => n.id === TABLE_HEAD_ID).length;
if (rows > 0) expect(heads, `page ${p + 1}`).toBe(1);
if (heads > 0) expect(rows, `page ${p + 1}`).toBeGreaterThan(0);
}
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
});
});
describe("totals and closing", () => {
it("the totals box lines up with the table's price and total columns; only the total row's label cell is yellow", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "highlighter" }));
const last = r.result.core.layout.findIndex((p) => nodes([p]).some((n) => n.id === TOTALS_ID));
const totals = byId(r, TOTALS_ID, last);
const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const frame = frameForPrefs(r.prefs);
const box = onPage(r, last).filter((n) => n.type === "view" && n.box.y > totals.box.y && Math.abs(n.box.w - plan.tail.boxW) < 0.5).sort((a, b) => a.box.y - b.box.y)[0];
expect(box.box.x + box.box.w).toBeCloseTo(frame.pad.left + plan.W, 0);
const lines = textLines(totals);
expect(lines.map((l) => l.text)).toEqual(expect.arrayContaining(["Subtotal :", "Taxable value :", "CGST @ 9% :", "SGST @ 9% :", "total :"]));
const totalLabel = lines.find((l) => l.text === "total :")!;
const subLabel = lines.find((l) => l.text === "Subtotal :")!;
const [yellow, white] = await colourAt(r.result.bytes, [[totalLabel.x - 3, totalLabel.y + 8], [subLabel.x - 3, subLabel.y + 8]], last + 1);
expect(near(yellow, YELLOW), `total label ${yellow}`).toBe(true);
expect(near(white, "#FFFFFF"), `subtotal label ${white}`).toBe(true);
expect(lines.find((l) => l.text === r.fixture.model.totals.totalText)!.right).toBeCloseTo(box.box.x + box.box.w - V.frame - V.totals.valuePad, 0);
});
it("the amount in words stands at the left of the box, top-aligned with its first row, and never meets it, even with a long amount", async () => {
const r = await renderFixture({ name: "t", items: [{ id: 1, description: "Retainer", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 98765432.1, sortOrder: 0, hsnSac: "998397" }], logo: "none", invoice: { discount: 5000 } }, prefsFor("A4", { templateId: "highlighter" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
const at = r.result.core.layout.findIndex((p) => nodes([p]).some((n) => n.id === TOTALS_ID));
const totals = byId(r, TOTALS_ID, at);
const words = onPage(r, at).find((n) => n.id === SLOT_ID.amountInWords)!;
const box = onPage(r, at).filter((n) => n.type === "view" && n.box.y >= totals.box.y && n.box.h > 100).sort((a, b) => b.box.x - a.box.x)[0];
expect(words.box.x + words.box.w).toBeLessThanOrEqual(box.box.x + 0.5);
});
it("the sign-off is THANK YOU and the contact rows with icons at the left, the ruled lines at the right (212 pt, notes on them, the signature on the last)", async () => {
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true, invoice: { notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." } }, prefsFor("A4", { templateId: "highlighter" }));
const last = r.result.pages - 1;
const closing = byId(r, CLOSING_ID, last);
const lines = textLines(closing).map((l) => l.text);
expect(lines).toEqual(expect.arrayContaining(["THANK YOU", "9000000000", "vendor@example.com", "Payment due within 30 days."]));
expect(onPage(r, last).filter((n) => n.type === "svg" && n.box.y >= closing.box.y)).toHaveLength(3);
const sign = byId(r, SLOT_ID.signatory, last);
expect(sign.box.w).toBeCloseTo(V.closing.ruledW, 0);
expect(sign.box.x + sign.box.w).toBeCloseTo(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right - V.rightInset, 0);
const note = textLines(closing).find((l) => l.text === "Payment due within 30 days.")!;
expect(closing.box.x + note.x - closing.box.x).toBeGreaterThan(300);
});
it("long terms flow as a block of their own and keep the ruled lines bounded", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "highlighter" }));
const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
expect(plan.closing.notesFlow).toBe(true);
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
});
it("a long address wraps in the contact row and pushes nothing into the ruled lines", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-address")!, prefsFor("A4", { templateId: "highlighter" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
const left = textLines(closing).filter((l) => l.x < 300);
const right = textLines(closing).filter((l) => l.x >= 300);
for (const a of left) for (const b of right) expect(hits({ x: a.x, y: a.y, w: a.right - a.x, h: a.h }, { x: b.x, y: b.y, w: b.right - b.x, h: b.h }), `${a.text} / ${b.text}`).toBe(false);
});
});
describe("the GST slots", () => {
it("every GST slot has a home on a tax invoice, and the supplier's identifiers are on page 1 whatever the length", async () => {
const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "highlighter" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, `${SLOT_ID.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(true);
expect(onPage(r, 0).some((n) => n.id === `${SLOT_ID.gstin}-supplier`)).toBe(true);
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
for (const word of ["SUBTOTAL:", "TAXABLEVALUE:", "CGST@9%:", "SGST@9%:", "HSN/SAC:998397", "PLACEOFSUPPLY", "PAN:ABCDE1234F"]) expect(flat, word).toContain(word);
});
it("an unregistered invoice carries none of the GST slots", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "highlighter" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(false);
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error on either size", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const name of ["id-tokens", "long-address", "long-client", "long-notes"]) {
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: "highlighter" }));
expect(r.result.issues.filter((i) => i.severity === "error").map((i) => `${i.code}: ${i.message}`), `${size}/${name}`).toEqual([]);
}
}
});
it("the header's three columns never overlap, whatever the vendor and client", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor(size, { templateId: "highlighter" }));
const boxes = [byId(r, TITLE_ID), byId(r, SUPPLIER_ID), byId(r, META_ID), byId(r, PAY_ID)];
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) expect(hits(boxes[i].box, boxes[j].box), `${size}: ${boxes[i].id} / ${boxes[j].id}`).toBe(false);
}
});
});
describe("plan matches the laid-out blocks", () => {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: "highlighter" });
const frame = frameForPrefs(prefs);
// Logo-less (the opt-in is off), so the plan made here is the one the layout made.
const r = await renderFixture({ ...spec, logo: "none" } as FixtureSpec, prefs);
const heights = (id: string) => {
const hits: number[] = [];
for (const page of r.result.core.layout) walk(page.nodes, (n) => n.id === id && hits.push(n.box.h));
expect(hits).toHaveLength(1);
return hits[0];
};
const plan = planHighlighter(r.fixture.model, frame, V, null, false);
expect(plan.tailH - heights(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - heights(TOTALS_ID)).toBeLessThanOrEqual(24);
expect(plan.closingH - heights(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.closingH - heights(CLOSING_ID)).toBeLessThanOrEqual(24);
});
}
}
});
describe("design docs", () => {
it("records its implementation notes", () => {
const text = readFileSync(path.join(process.cwd(), "docs/templates/02-highlighter.md"), "utf8");
expect(text).toContain("## Implementation notes");
});
});
@@ -0,0 +1,40 @@
import type { ComponentType } from "react";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition, TemplateProps } from "../contract";
import { slotFor } from "../slots.optin";
import { HighlighterBody } from "./Layout";
import { HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INK } from "./tokens";
export { HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT } from "./tokens";
const slot = slotFor("highlighter");
/** The opt-in logo area (docs/templates/02-highlighter.md: the empty top right, 150 x 50, right-aligned at the vendor name). */
const area = slot?.lockup ?? { x: 386, y: 50, w: 150, h: 50 };
const HighlighterLayout: ComponentType<TemplateProps> = (props) => <HighlighterBody {...props} variant={HIGHLIGHTER_VARIANT} />;
HighlighterLayout.displayName = "HighlighterLayout";
export const highlighter: TemplateDefinition = {
...catalogEntry("highlighter"),
version: 1,
roles: HIGHLIGHTER_ROLES,
margins: HIGHLIGHTER_VARIANT.margins,
marginFloor: HIGHLIGHTER_VARIANT.marginFloor,
decor: { ink: INK },
chrome: {
color: INK,
rule: null,
role: HIGHLIGHTER_ROLES.footer,
leftWidth: 150,
},
logo: {
box: { w: area.w, h: area.h },
stackBelowTextWidth: 0,
defaultOn: false,
optIn: catalogEntry("highlighter").logoOptIn === true,
slotWidth: () => area.w,
slot,
available: () => ({ w: area.w, h: area.h }),
},
Layout: HighlighterLayout,
};
+305
View File
@@ -0,0 +1,305 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { drawnText, getMeasurer, measureToken } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import type { RenderModel } from "../../model/build";
import { resolveFace, type TypeToken } from "../../fonts/roles";
import type { PlacedLogo } from "../contract";
import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
import { leading, INDIGO, type HighlighterVariant } from "./tokens";
export const NOTES_FLOW_GAP = 14;
const FIT_AIR = 2;
/** A GSTIN is always 15 characters: the text the layout prints for the height estimate, which must not read the regulated field. */
const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
export const REVERSE_GAP = 4;
export const LABEL_GAP = 3;
/** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */
function idLines(role: TypeToken, text: string, width: number): number {
const f = fitSize(role, text, Math.max(1, width));
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)));
}
/** Tax rows read "CGST @ 9% :" like the design's "total :". */
export const taxLabel = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate} :` : `${label} :`);
export function signatureOptions(v: HighlighterVariant): SignatoryOptions {
const r = v.roles;
return { roles: { name: r.signName, designation: r.signDesignation, fine: r.fine }, align: "right", rule: { width: v.hair, color: INDIGO }, color: v.ink, imageHeight: v.closing.signatureImageH };
}
/** The table's description (+ HSN/SAC sub-line), qty, rate and amount cells. */
export function tableRows(model: RenderModel, v: HighlighterVariant, rateW: number): TableCellValue[][] {
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
});
}
/** The payment block: "Bank Name : ...", one bold line per bank field (the design's "label : value" pairs). */
export function paymentLines(model: RenderModel): string[] {
if (!model.bank) return [];
const names: Record<string, string> = { Bank: "Bank Name", "Account No.": "Account No" };
return [`Account Name : ${model.bank.accountName}`, ...model.bank.lines.map((l) => `${names[l.label] ?? l.label} : ${l.value}`)];
}
export interface FieldSpec {
label: string;
value: string;
}
/** The underlined fields in the design's order (Date, Invoice number), plus the Due date, then Billed to. */
export function fieldSpecs(model: RenderModel): FieldSpec[] {
const out: FieldSpec[] = [
{ label: "Date:", value: model.meta.date },
{ label: "Invoice number:", value: model.meta.number },
];
if (model.meta.dueDate) out.push({ label: "Due date:", value: model.meta.dueDate });
out.push({ label: "Billed to:", value: model.client.name });
return out;
}
/** The note lines wrapped to the ruled lines' width: one string per drawn line. */
export function ruledNoteLines(model: RenderModel, role: TypeToken, width: number): string[] {
const m = getMeasurer();
return model.notesLines.flatMap((n) => m.wrapLines(drawnText(role, n), width, { face: resolveFace(role.face), weight: role.weight, italic: false, size: role.size, letterSpacing: (role.tracking ?? 0) * role.size }));
}
/** The contact rows of the sign-off, with the address as one wrapping line. */
export function contactRows(model: RenderModel): Array<{ icon: "phone" | "email" | "location"; text: string }> {
const s = model.supplier;
const out: Array<{ icon: "phone" | "email" | "location"; text: string }> = [];
if (s.phone) out.push({ icon: "phone", text: s.phone });
if (s.email) out.push({ icon: "email", text: s.email });
const address = s.addressLines.join(", ");
if (address.trim()) out.push({ icon: "location", text: address });
return out;
}
export interface HighlighterPlan {
v: HighlighterVariant;
W: number;
head: { titleSize: number; titleW: number; stackW: number; showName: boolean; nameH: number; idsH: number; stackH: number; h: number };
fields: { specs: FieldSpec[]; labelW: number[]; valueW: number; heights: number[]; fieldsH: number; subLines: number; subH: number; leftW: number; payLines: string[]; payH: number; h: number };
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
padLeft: number[];
padRight: number[];
padY: number;
cw: { qty: number; rate: number; amount: number };
innerW: number;
table: ItemsTablePlan;
padRows: number;
padRowH: number;
groupH: number;
guardH: number;
tail: { boxW: number; labelW: number; valueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rows: number; boxH: number; wordsW: number; wordsH: number; reverseH: number; leftH: number; body: number };
closing: { notes: string[]; ruledRows: number; ruled: string[]; notesFlow: boolean; notesFlowH: number; leftW: number; contacts: Array<{ icon: "phone" | "email" | "location"; text: string; h: number }>; leftH: number; rightH: number; signH: number; h: number };
gaps: { bar: number; close: number };
tailH: number;
closingH: number;
}
/**
* Everything the layout must know before rendering: the title fitted to its zone, the underlined fields and the payment
* block, the table (its columns widen only where an amount needs it; the form look pads to 8 empty rows on A4 and 7 on Letter
* while they fit), the totals stack and the heights of the tail and the closing block, each a hair high rather than low.
*/
export function planHighlighter(model: RenderModel, frame: PageFrame, v: HighlighterVariant, logo: PlacedLogo | null, includesName: boolean): HighlighterPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const s = model.supplier;
const c = model.client;
const hd = v.head;
const fr = v.frame;
// --- header: the title at the left, the vendor's logo, name and identifiers at the right --------------------------------------
const stackW = Math.min(hd.stackW, Math.max(80, W - hd.minTitleW - hd.gap - v.inset - v.rightInset));
const titleW = Math.max(hd.minTitleW, W - stackW - v.rightInset - hd.gap - v.inset);
const tFit = fitSize(r.title, model.docTitle, titleW);
const showName = !(logo && includesName) && s.name.trim() !== "";
const nameH = showName ? textHeight(r.vendorName, s.name, stackW) : 0;
const gstinLines = slotHeights.gstinLines(model, "supplier");
const idsH = (gstinLines ? idLines(r.vendorId, GSTIN_SAMPLE, stackW) * leading(r.vendorId) : 0) + (s.pan ? idLines(r.vendorId, `PAN: ${s.pan}`, stackW) * leading(r.vendorId) : 0);
const cancelH = model.cancelled ? leading(r.vendorName) : 0;
const stackH = (logo ? logo.h : 0) + nameH + idsH + cancelH;
const headH = Math.max(hd.zoneH, stackH);
// --- fields (underlined) and the payment block ---------------------------------------------------------------------------------
const specs = fieldSpecs(model);
const f = v.fields;
const fieldLabelW = specs.map((sp) => Math.ceil(measureToken(m, r.label, sp.label)) + 1);
const valueWs = fieldLabelW.map((lw) => f.w - lw - f.labelGap);
const heights = specs.map((sp, i) => textLines(r.value, sp.value, valueWs[i] - f.valuePad) * leading(r.value) + v.hair);
const fieldsRows = heights.reduce((a, h) => a + h, 0) + f.rowGap * (specs.length - 1);
const leftW = W - v.pay.w - v.rightInset;
const subW = f.w;
let subH = 0;
for (const l of c.addressLines) subH += textHeight(r.sub, l, subW);
subH += slotHeights.gstinLines(model, "client") * leading(r.sub) + slotHeights.placeOfSupplyLines(model, r.sub, subW) * leading(r.sub);
if (c.poNumber) subH += textHeight(r.sub, `PO: ${c.poNumber}`, subW);
const fieldsH = fieldsRows + (subH > 0 ? f.subGap + subH : 0);
const payLines = paymentLines(model);
const payW = Math.min(v.pay.w, W);
const payH = payLines.length ? v.pay.top + leading(r.payHead) + payLines.reduce((h, x) => h + textHeight(r.payLine, x, payW), 0) : 0;
const blocksH = Math.max(fieldsH, payH);
// --- table -----------------------------------------------------------------------------------------------------------------
const t = v.table;
const innerW = W - 2 * fr;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + v.hair + 6;
const shortRate = model.items.map((i) => {
const cell = rateCell(i.rateText, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—";
});
const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const headerCells = ["Description", "Qty", "Price", "Total"];
const align: HighlighterPlan["align"] = ["left", "center", "right", "right"];
// Each cell but the last ends in a 0.75 pt rule that is part of its width.
const padLeft = [t.descPad, 0, 0, 0];
const padRight = [8 + v.hair, v.hair, t.numPad + v.hair, t.numPad];
const rows = tableRows(model, v, cw.rate - t.numPad - v.hair - 2);
const n = rows.length;
const baseLines = leading(r.tableBody);
const padYn = (t.rowH - v.hair - baseLines) / 2;
const planTable = (padY: number, tailH: number) =>
planItemsTable({ columns, gap: 0, width: innerW, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY: 0, border: v.hair, headBorder: 0, tailH, contentH: frame.contentHeight });
const groupH = t.barH + t.bodyGap + fr;
// The last row's bottom edge is the frame (3 pt) instead of a hairline.
const lastExtra = fr - v.hair;
// --- totals: a stack of two-cell rows, the Total row's label cell yellow; words and reverse charge at the left ----------------
const tt = model.totals;
const o = v.totals;
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal :", amount: tt.subtotalText }];
if (tt.discountText) stack.push({ label: "Discount :", amount: `- ${tt.discountText}` });
if (tt.taxableText) stack.push({ label: "Taxable value :", amount: tt.taxableText });
const slotRows = slotHeights.taxRowCount(model);
const taxTexts = slotHeights.taxRowTexts(model, taxLabel);
const labels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label), "total :"];
const amounts = [...stack.map((x) => x.amount), ...taxTexts.map((x) => x.amount)];
const labelNeed = Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)))) + 2 * o.labelPad + fr;
const valueNeed = Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)), measureToken(m, r.totalStrong, tt.totalText))) + o.valuePad + o.labelPad + 2;
// The box lines up with the table's columns: its label cell is the price column wide, its value cell the rest (the design's
// box starts 1.5 pt outside the price column's left rule).
const boxW0 = fr + cw.amount + cw.rate + o.boxOutside;
const labelW = Math.max(cw.rate, labelNeed);
const valueW = Math.max(boxW0 - 2 * fr - cw.rate, valueNeed);
const boxW = labelW + valueW + 2 * fr;
const totalRows = stack.length + slotRows + 1;
const boxH = 2 * fr + totalRows * o.rowH + (totalRows - 1) * v.hair;
const tailLeft = Math.max(0, W - boxW);
const wordsW = Math.max(60, tailLeft - o.wordsGap - v.inset);
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.words);
const leftH = wordsH || reverseH ? o.wordsTop + wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0;
const body = Math.max(boxH, leftH);
const tailH = o.gap + body;
// --- closing: THANK YOU and the contact rows at the left, the ruled lines (notes, then the signature) at the right -----------
const cl = v.closing;
const closeLeftW = W - v.rightInset - cl.ruledW - cl.colGap;
const textW = Math.max(60, closeLeftW - v.inset - cl.iconSize - cl.iconGap);
const contacts = contactRows(model).map((x) => ({ ...x, h: Math.max(cl.iconSize, (x.icon === "location" ? textLines(r.contact, x.text, textW) : idLines(r.contact, x.text, textW)) * leading(r.contact)) }));
const leftContentH = leading(r.thanks) + (contacts.length ? cl.thanksGap + contacts.reduce((h, x) => h + x.h, 0) + cl.contactGap * (contacts.length - 1) : 0);
const leftH2 = cl.leftTop + leftContentH;
const ruled = ruledNoteLines(model, r.note, cl.ruledW - 2);
const notesFlow = ruled.length > cl.ruledMax;
const ruledRows = notesFlow ? cl.ruledMin : Math.max(cl.ruledMin, ruled.length);
const signH = signatoryHeight(model, cl.ruledW, signatureOptions(v));
const rightH = ruledRows * cl.ruledH + signH;
const notesFlowH = notesFlow ? leading(r.wordsLabel) + LABEL_GAP + model.notesLines.reduce((h, l) => h + textHeight(r.note, l, W - v.inset - v.rightInset), 0) : 0;
const cancelledH = model.cancelled ? 10 + textHeight(r.note, CANCELLED_SENTENCE, W) : 0;
const bandH = Math.max(leftH2, rightH);
const closingFor = (g: { close: number }) => g.close + bandH + cancelledH;
// --- pad rows, gaps: the form look pads to N rows while the page holds them; the gaps and row padding give back Letter's height -----
const gapBarNominal = Math.max(v.gaps.bar.nominal, f.designH - blocksH);
const ranges0 = { bar: { nominal: gapBarNominal, min: v.gaps.bar.min }, close: v.gaps.close };
const table0 = planTable(padYn, 0);
const dataH = table0.rowHeights.reduce((a, x) => a + x, 0);
const padRowH = t.rowH;
const notesBlock = notesFlow ? NOTES_FLOW_GAP + notesFlowH : 0;
const headTop = headH + f.top;
const nominalNoClosing = (pad: number) => headTop + ranges0.bar.nominal + blocksH + groupH + dataH + pad * padRowH + lastExtra + tailH + notesBlock;
let padRows = Math.max(0, t.padTo[frame.page.id] - n);
const rowPadRange = (pad: number) => ({ nominal: 2 * padYn * (n + pad), min: 2 * t.padYMin * (n + pad) });
// What the gaps can give back while the table and totals stay on page 1 (the sign-off's own gap does not help them).
const slackBefore = (pad: number) => ranges0.bar.nominal - ranges0.bar.min + rowPadRange(pad).nominal - rowPadRange(pad).min;
const closeNominal = ranges0.close.nominal;
const closeSlack = ranges0.close.nominal - ranges0.close.min;
const closingAt = closingFor({ close: closeNominal });
// The pad rows are decoration, so they give way first: the most of them that still lets the whole invoice (sign-off included)
// sit on one page; failing that, the most that lets the table and totals sit on page 1 with only the sign-off moving on.
const wanted = padRows;
const fitsAll = (pad: number) => nominalNoClosing(pad) + closingAt - frame.contentHeight <= slackBefore(pad) + closeSlack;
const fitsBefore = (pad: number) => nominalNoClosing(pad) - frame.contentHeight <= slackBefore(pad);
while (padRows > 0 && !fitsAll(padRows)) padRows--;
if (!fitsAll(padRows)) {
padRows = wanted;
while (padRows > 0 && !fitsBefore(padRows)) padRows--;
}
const ranges = { ...ranges0, rowPad: rowPadRange(padRows) };
const closingNominal = closingFor({ close: ranges.close.nominal });
const nominalTotal = nominalNoClosing(padRows) + closingNominal;
const slackAll = slackBefore(padRows) + ranges.close.nominal - ranges.close.min;
const overflow = nominalTotal - frame.contentHeight;
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
const air = (over: number, slack: number) => Math.min(slack, over + FIT_AIR);
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on page 1
// and let only the closing block move to the next page (then its own gap stays nominal).
const { close: closeRange, ...beforeRanges } = ranges;
let g: Record<"bar" | "rowPad" | "close", number>;
if (overflow > 0 && overflow <= slackAll) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slackAll)));
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore(padRows)) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore(padRows)))), close: closeRange.nominal };
else g = elastic(ranges, frame.heightDeficit);
const rowCount = n + padRows;
const padY = rowCount ? g.rowPad / (2 * rowCount) : padYn;
const closingH = closingFor({ close: g.close });
const tailHigh = o.gap + body;
const table = planTable(padY, tailHigh + lastExtra);
const guardH = groupH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailHigh : 0);
return {
v,
W,
head: { titleSize: tFit.size, titleW, stackW, showName, nameH, idsH, stackH, h: headH },
fields: { specs, labelW: fieldLabelW, valueW: f.w, heights, fieldsH, subLines: subH > 0 ? 1 : 0, subH, leftW, payLines, payH, h: blocksH },
columns,
headerCells,
rows,
align,
padLeft,
padRight,
padY,
cw,
innerW,
table,
padRows,
padRowH: baseLines + 2 * padY + v.hair,
groupH,
guardH,
tail: { boxW, labelW, valueW, stack, slotRows, rows: totalRows, boxH, wordsW, wordsH, reverseH, leftH, body },
closing: { notes: model.notesLines, ruledRows, ruled, notesFlow, notesFlowH, leftW: closeLeftW, contacts, leftH: leftH2, rightH, signH, h: closingH },
gaps: { bar: g.bar, close: g.close },
tailH: tailHigh,
closingH,
};
}
+168
View File
@@ -0,0 +1,168 @@
import type { Sides } from "../../engine/geometry";
import { defineRoles, type TypeToken } from "../../fonts/roles";
import type { GapRange } from "../neutral/variant";
const P = "Poppins" as const;
const M = "Montserrat" as const;
/** Near-black ink: the title, labels, table heads, the 3 pt frames and the icons. */
export const INK = "#1C2120";
/** Deep indigo hairlines: the field underlines, the table grid and the ruled lines. */
export const INDIGO = "#423869";
/** Signal yellow: the table header bar and the total cell (the template's accent). */
export const YELLOW = "#FFE535";
/** The contact lines and the total label are pure black in the design. */
export const BLACK = "#000000";
/**
* Highlighter's type (docs/templates/02-highlighter.md): Poppins Bold for the title, table heads, bank lines, total
* and THANK YOU; Poppins Regular for the field labels and the table; Montserrat Regular for the contact lines.
* Poppins ascent 1.05, descent 0.35; Montserrat 0.968 / 0.251. A text line's baseline sits `ascent x size` under its top.
*/
export const HIGHLIGHTER_ROLES = defineRoles({
/** "Invoice" / "Tax Invoice": 45.68 pt, tracked -0.062 em; the line box is the design's 64 pt tall zone. */
title: { face: P, weight: 700, size: 45.68, leading: 64, tracking: -0.062, minSize: 22 },
vendorName: { face: P, weight: 700, size: 13, leading: 18.5, minSize: 9 },
vendorId: { face: P, weight: 400, size: 10, leading: 14, minSize: 7 },
/** "Date:", "Invoice number:", "Billed to:". The line box ends where the field's underline begins. */
label: { face: P, weight: 400, size: 13, leading: 18.5, tracking: -0.028 },
value: { face: P, weight: 400, size: 13, leading: 18.5, minSize: 9 },
/** The client's address lines under the Billed to field. */
sub: { face: P, weight: 400, size: 11, leading: 15, minSize: 8 },
payHead: { face: P, weight: 400, size: 13, leading: 23.25 },
payLine: { face: P, weight: 700, size: 12, leading: 16.5, minSize: 8 },
tableHead: { face: P, weight: 700, size: 13, leading: 20.3 },
tableBody: { face: P, weight: 400, size: 11, leading: 15 },
tableSub: { face: P, weight: 400, size: 9, leading: 12 },
totalLabel: { face: P, weight: 700, size: 12, leading: 17.7 },
totalValue: { face: P, weight: 400, size: 12, leading: 17.7 },
totalStrong: { face: P, weight: 700, size: 12, leading: 17.7 },
words: { face: P, weight: 400, size: 10.5, leading: 14 },
wordsLabel: { face: P, weight: 700, size: 10.5, leading: 14 },
note: { face: P, weight: 400, size: 10.5, leading: 14 },
thanks: { face: P, weight: 700, size: 25.01, leading: 30 },
contact: { face: M, weight: 400, size: 11.67, leading: 16, minSize: 8 },
signName: { face: P, weight: 700, size: 10.5, leading: 14.5 },
signDesignation: { face: P, weight: 400, size: 9.5, leading: 13 },
fine: { face: P, weight: 400, size: 8, leading: 11 },
footer: { face: P, weight: 400, size: 7 },
});
export type HighlighterRoles = typeof HIGHLIGHTER_ROLES;
/**
* Reference A4 numbers. The content box is the frame's outer edge (x 58.1 to 537.5, 479.4 wide: the design's strokes are
* centred on x 59.6 / 536.0, so the 3 pt frames reach 1.5 pt outside them); y values are the design's distances turned into
* flow gaps. Everything is relative to the content box, so the margin presets and Letter reuse the same tokens.
*/
export interface HighlighterVariant {
roles: HighlighterRoles;
ink: string;
margins: Sides;
marginFloor: Sides;
/** Text left of the frame's outer edge, and the right-hand column's inset from the right edge. */
inset: number;
rightInset: number;
/** The 3 pt frame and the 0.75 pt hairlines. */
frame: number;
hair: number;
head: {
/** The title's and the vendor stack's zone (the design's y 50 to 114). */
zoneH: number;
/** The vendor stack's width (x 352 to 536). */
stackW: number;
/** Space between the title and the stack at its narrowest. */
gap: number;
minTitleW: number;
};
fields: {
/** Title zone bottom to the first field. */
top: number;
/** Width of the label + underline row (x 60.3 to 266). */
w: number;
/** Label to the underline's start, and one field's bottom to the next field's top. */
labelGap: number;
rowGap: number;
/** The underline's text starts this far in. */
valuePad: number;
/** Billed-to underline to the first address line. */
subGap: number;
/** The design's distance from the first field's top to the table bar's top (the empty space the bar leaves). */
designH: number;
};
pay: { w: number; top: number };
table: {
/** The bar's outer height (frame included) and the gap under it to the body's top rule. */
barH: number;
barPadTop: number;
bodyGap: number;
descPad: number;
numPad: number;
cols: { qty: number; rate: number; amount: number };
/** Row height for one line, hairline included (the design's 30.65 pt pitch), and the empty rows the form look pads to. */
rowH: number;
padTo: { A4: number; LETTER: number };
padYMin: number;
};
totals: {
/** The gap under the table, the cell height inside the frame and how far the box's outer edge reaches left of the price column. */
gap: number;
rowH: number;
padY: number;
boxOutside: number;
labelPad: number;
valuePad: number;
wordsGap: number;
wordsTop: number;
};
closing: {
ruledW: number;
colGap: number;
thanksGap: number;
contactGap: number;
iconSize: number;
iconGap: number;
/** The ruled lines: row height, the text's padding above its baseline, and the lines the form always draws. */
ruledH: number;
ruledMin: number;
ruledMax: number;
leftTop: number;
signatureImageH: number;
};
gaps: {
/** Fields and payment block bottom to the table bar (the design leaves 64 pt). */
bar: GapRange;
/** The totals to the sign-off. */
close: GapRange;
};
}
export const HIGHLIGHTER_VARIANT: HighlighterVariant = {
roles: HIGHLIGHTER_ROLES,
ink: INK,
margins: { top: 50, right: 57.8, bottom: 36, left: 58.1 },
marginFloor: { top: 36, right: 36, bottom: 30, left: 36 },
inset: 2.2,
rightInset: 1.5,
frame: 3,
hair: 0.75,
head: { zoneH: 64, stackW: 183.5, gap: 12, minTitleW: 120 },
fields: { top: 4.55, w: 205.7, labelGap: 5, rowGap: 14.85, valuePad: 3, subGap: 8, designH: 149.65 },
pay: { w: 183.5, top: 30.7 },
table: {
barH: 38.4,
barPadTop: 6.05,
bodyGap: 4.5,
descPad: 9,
numPad: 9,
cols: { qty: 64, rate: 102.8, amount: 121.9 },
rowH: 30.65,
padTo: { A4: 8, LETTER: 7 },
padYMin: 4.5,
},
totals: { gap: 4.5, rowH: 25.1, padY: 3.7, boxOutside: 1.5, labelPad: 7, valuePad: 9, wordsGap: 8, wordsTop: 3 },
closing: { ruledW: 212.3, colGap: 24, thanksGap: 22.6, contactGap: 6.5, iconSize: 14.75, iconGap: 7.55, ruledH: 29.25, ruledMin: 3, ruledMax: 6, leftTop: 3.6, signatureImageH: 36 },
gaps: { bar: { nominal: 18, min: 14 }, close: { nominal: 24, min: 12 } },
};
export const leading = (t: TypeToken) => t.leading as number;