Add Ledger and Band template families with decor primitives

Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from
the reference PDFs with grid and band tables that survive page breaks,
full-bleed fixed bands, pennant tabs, gradient corner swooshes with
keep-outs, Carbon icon circles, and the user's logo in the measured slot
(knockout or white chip on the dark Slate band). Adds static Poppins
500-700 and Inter fonts. Thumbnails regenerated for all eight templates;
the item-count sweeps run in their own test file.
This commit is contained in:
2026-10-04 08:34:18 +05:30
parent aa7a407944
commit 9de9a6ffee
57 changed files with 4078 additions and 217 deletions
+457
View File
@@ -0,0 +1,457 @@
import { Image, View } from "@react-pdf/renderer";
import { type ReactNode } from "react";
import { FILL, pinned } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { getMeasurer, measureToken } from "../../engine/measure";
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader, type DecorItem } from "../../blocks/FirstPageHeader";
import { 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 { cornerSwoosh } from "../../decor/CornerSwoosh";
import { fullBleedBand } from "../../decor/FullBleedBand";
import { CHIP_PADDING } from "../../engine/logo";
import type { RenderModel } from "../../model/build";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import type { PlacedLogo, TemplateProps } from "../contract";
import { includesName } from "../logoName";
import { bankLines, CANCELLED_INK, CANCELLED_SENTENCE, CLOSING_GAP } from "../neutral/plan";
import { CLOSING_COLUMN_GAP, HEADING_AFTER, planBand, REVERSE_GAP, signatureOptions, STACK_RULE, WORDS_GAP, type BandPlan, type MetaItem } from "./plan";
import type { BandVariant } from "./variant";
interface Ctx {
model: RenderModel;
plan: BandPlan;
v: BandVariant;
logo: PlacedLogo | null;
}
const WHITE = "#FFFFFF";
/** Montserrat's ascent as a share of the size: where the baseline sits below a line's top. */
const MONTSERRAT_ASCENT = 0.968;
const NOTES_UNDER_PAY = 10;
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
// --- header ---------------------------------------------------------------------------------------------
function LogoAndName({ ctx, nameColor }: { ctx: Ctx; nameColor?: string }) {
const { model, plan, v, logo } = ctx;
const h = plan.header;
const zoneTop = v.brandZone.top;
return (
<>
{logo ? (
<Image
src={logo.dataUri}
style={{ ...pinned(logo.w), height: logo.h, marginTop: zoneTop + h.logoTop, objectFit: "contain", ...(logo.placement?.variant === "chip" ? { marginLeft: 0 } : {}) } as never}
/>
) : null}
{h.showName ? (
<View style={{ ...pinned(h.nameW), marginLeft: logo ? v.markGap : 0, marginTop: h.nameTop } as never}>
<AvailableWidthProvider width={h.nameW}>
<SafeTxt role={v.roles.brand} color={nameColor ?? v.ink}>
{model.supplier.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : null}
</>
);
}
function SlateHeader({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const bands = v.bands as NonNullable<BandVariant["bands"]>;
const h = plan.header;
return (
<>
{/* minHeight: the bands are drawn from rowH, and without a logo the name and number alone are shorter. */}
<View style={{ flexDirection: "row", ...pinned(plan.W), minHeight: h.rowH } as never}>
<LogoAndName ctx={ctx} nameColor={WHITE} />
<View style={FILL} />
<View style={{ ...pinned(h.numW), marginTop: h.numTop } as never}>
<Txt role={v.roles.bandNumber} color={WHITE} width={h.numW} align="right">
{`NO. ${model.meta.number}`}
</Txt>
</View>
</View>
<Spacer h={plan.gaps.top} />
<Spacer h={bands.titlePadTop} />
<FitText id={TITLE_ID} role={v.roles.title} color={bands.titleText} width={plan.W} minSize={18}>
{model.docTitle}
</FitText>
<Spacer h={bands.titlePadBottom} />
</>
);
}
function TealHeader({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
return (
<>
<View style={{ flexDirection: "row", ...pinned(plan.W), minHeight: plan.header.rowH } as never}>
<LogoAndName ctx={ctx} />
</View>
<Spacer h={plan.gaps.titleGap} />
<FitText id={TITLE_ID} role={v.roles.title} color={v.ink} width={plan.header.titleW} minSize={18}>
{model.docTitle}
</FitText>
</>
);
}
function CancelledTag({ ctx }: { ctx: Ctx }) {
const textW = Math.ceil(measureToken(getMeasurer(), ctx.v.roles.label, "Cancelled")) + 2;
const boxW = textW + 12 + 1.6;
return (
<View style={{ borderWidth: 0.8, borderColor: CANCELLED_INK, paddingVertical: 2, paddingHorizontal: 6, marginBottom: 6, ...pinned(boxW) } as never}>
<Txt role={ctx.v.roles.label} color={CANCELLED_INK} width={textW}>
Cancelled
</Txt>
</View>
);
}
function MetaColumn({ ctx, items, labelW }: { ctx: Ctx; items: MetaItem[]; labelW: number }) {
const { v } = ctx;
return (
<View>
{items.map((item, i) =>
item.label ? (
<Row key={i} widths={[{ fixed: labelW }, { fill: true }]}>
{/* The 12 pt label shares the baseline of the 13 pt value in the design: it starts one ascent difference lower. */}
<Txt role={v.roles.label} color={v.ink} width={labelW} style={{ marginTop: (v.roles.dateValue.size - v.roles.label.size) * MONTSERRAT_ASCENT }}>
{item.label}
</Txt>
<SafeTxt role={v.roles.dateValue} color={v.ink}>
{item.value}
</SafeTxt>
</Row>
) : (
<SafeTxt key={i} role={v.roles.dateValue} color={v.text} style={{}}>
{item.value}
</SafeTxt>
),
)}
</View>
);
}
function MetaBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
return (
<View id={META_ID} style={pinned(plan.W) as never}>
{model.cancelled ? <CancelledTag ctx={ctx} /> : null}
<Row widths={[{ fixed: plan.leftW }, { fill: true }]} gap={v.partyGap}>
<MetaColumn ctx={ctx} items={plan.meta.left} labelW={plan.meta.labelWLeft} />
<MetaColumn ctx={ctx} items={plan.meta.right} labelW={plan.meta.labelWRight} />
</Row>
</View>
);
}
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
return (
<View style={pinned(plan.leftW) as never}>
<Txt role={r.label} color={v.ink} width={plan.leftW} style={{ marginBottom: v.headingGap }}>
Billed 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} />
</View>
);
}
function FromBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
<View id={SUPPLIER_ID} style={pinned(plan.rightW) as never}>
<Txt role={r.label} color={v.ink} width={plan.rightW} style={{ marginBottom: v.headingGap }}>
From:
</Txt>
<SafeTxt role={r.body} color={v.ink}>
{s.name}
</SafeTxt>
{s.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.ink}>
{line}
</SafeTxt>
))}
{s.phone ? (
<IdText role={r.body} color={v.ink}>
{s.phone}
</IdText>
) : null}
{s.email ? (
<IdText role={r.body} color={v.ink}>
{s.email}
</IdText>
) : null}
{s.pan ? <IdText role={r.body} color={v.ink}>{`PAN: ${s.pan}`}</IdText> : null}
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
</View>
);
}
// --- totals ---------------------------------------------------------------------------------------------
function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) {
const { plan, v } = ctx;
const t = plan.totals;
return (
<Row widths={[{ fill: true }, { fixed: t.amountW }]} style={{ marginBottom: v.totals.rowGap }}>
<Txt role={v.roles.totalLabel} color={v.text} width={t.labelW}>
{label}
</Txt>
<Txt role={v.roles.totalValue} color={v.text} width={t.amountW} align="right">
{amount}
</Txt>
</Row>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const t = plan.totals;
return (
<View id={TOTALS_ID} wrap={false}>
<View style={{ marginTop: plan.gaps.tail }}>
<Row widths={[{ fill: true }, { fixed: t.w }]}>
<View />
<View style={pinned(t.w) as never}>
<AvailableWidthProvider width={t.w}>
{t.stack.map((row, i) => (
<StackRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
))}
<Slot.TaxRows
model={model}
role={r.totalLabel}
amountRole={r.totalValue}
color={v.text}
amountWidth={t.amountW}
labelGap={0}
rowStyle={{ marginBottom: v.totals.rowGap }}
format={(label, rate) => (rate ? `${label} @ ${rate}` : label)}
/>
<View style={{ marginTop: STACK_RULE.before, borderTopWidth: STACK_RULE.thickness, borderTopColor: v.ruleColor, marginBottom: STACK_RULE.after }} />
</AvailableWidthProvider>
</View>
</Row>
<Row widths={[{ fixed: v.totals.dash.w }, { fill: true }, { fixed: t.w }]}>
<AccentRule width={v.totals.dash.w} thickness={v.totals.dash.h} color={v.accent} style={{ marginTop: MONTSERRAT_ASCENT * r.grand.size - v.totals.dash.h / 2 }} />
<View />
<Row widths={[{ fill: true }, { fixed: t.amountW }]} width={t.w}>
<Txt role={r.grand} color={v.text} width={t.labelW}>
Total
</Txt>
<Txt role={r.grandValue} color={v.text} width={t.amountW} align="right">
{model.totals.totalText}
</Txt>
</Row>
</Row>
{plan.wordsH > 0 ? (
<View style={{ marginTop: WORDS_GAP }}>
<Slot.AmountInWords model={model} role={r.small} labelRole={r.wordsLabel} color={v.text} />
</View>
) : null}
{plan.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.text} style={{ marginTop: REVERSE_GAP }} /> : null}
</View>
</View>
);
}
// --- notes and closing --------------------------------------------------------------------------------------
function NotesBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
if (model.notesLines.length === 0) return null;
return (
<View style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
{/* The label travels with the first line, and no paragraph splits (a cut Text leaves a zero-line node). */}
<View wrap={false}>
<Txt role={r.label} color={v.ink} width={width} style={{ marginBottom: HEADING_AFTER }}>
Note:
</Txt>
<SafeTxt role={r.body} color={v.text}>
{model.notesLines[0]}
</SafeTxt>
</View>
{model.notesLines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.text} wrap={false}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function PayBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
if (!model.bank) return null;
return (
<View style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<Txt role={v.roles.label} color={v.ink} width={width} style={{ marginBottom: HEADING_AFTER }}>
Payment details:
</Txt>
{bankLines(model).map((line, i) => (
<SafeTxt key={i} role={v.roles.small} color={v.text}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const slate = v.id === "slate-band";
const opts = signatureOptions(v);
const sign = <Slot.Signatory model={model} width={plan.closingRightW} {...opts} />;
const notes = plan.notesInClosing ? <NotesBlock ctx={ctx} width={plan.notesLeftW} /> : null;
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ marginLeft: v.inset, paddingTop: CLOSING_GAP, ...pinned(plan.ruleW) } as never}>
<AvailableWidthProvider width={plan.ruleW}>
<Row widths={[{ fill: true }, { fixed: plan.closingRightW }]} gap={CLOSING_COLUMN_GAP}>
{slate ? (
<View>
<PayBlock ctx={ctx} width={plan.closingLeftW} />
{notes ? <View style={{ marginTop: model.bank ? NOTES_UNDER_PAY : 0 }}>{notes}</View> : null}
</View>
) : (
<View>{notes}</View>
)}
{slate ? (
<View style={pinned(plan.closingRightW) as never}>{sign}</View>
) : (
<View style={pinned(plan.closingRightW) as never}>
<PayBlock ctx={ctx} width={plan.closingRightW} />
<View style={{ marginTop: model.bank ? 14 : 0 }}>{sign}</View>
</View>
)}
</Row>
{model.cancelled ? (
<Txt role={r.small} color={CANCELLED_INK} width={plan.ruleW} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- the layout ------------------------------------------------------------------------------------------------
/** First-page-only art: Slate's two full-bleed bands (and the white chip behind a logo without a knockout), Teal's top-right swoosh. */
function firstPageDecor(ctx: Ctx, frame: TemplateProps["frame"]): DecorItem[] {
const { plan, v, logo } = ctx;
const items: DecorItem[] = [];
if (v.bands) {
items.push(fullBleedBand(frame, { top: 0, height: plan.header.topBandH, fill: v.bands.top }));
items.push(fullBleedBand(frame, { top: plan.header.topBandH, height: plan.header.titleBandH, fill: v.bands.title }));
if (logo && logo.placement?.variant === "chip") {
const pad = CHIP_PADDING;
const y = frame.pad.top + v.brandZone.top + plan.header.logoTop - pad;
items.push({
x: frame.pad.left - pad,
y,
width: logo.w + 2 * pad,
height: logo.h + 2 * pad,
children: <View style={{ width: logo.w + 2 * pad, height: logo.h + 2 * pad, backgroundColor: WHITE, borderRadius: 3 }} />,
});
}
} else {
items.push(cornerSwoosh(frame, "top-right"));
}
return items;
}
export function BandLayout({ model, frame, logo, prefs, variant }: TemplateProps & { variant: BandVariant }) {
const plan = planBand(model, frame, variant, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v: variant, logo };
const v = variant;
const r = v.roles;
let notes: ReactNode = null;
if (model.notesLines.length > 0 && !plan.notesInClosing) {
notes = (
<View style={{ marginTop: plan.gaps.notes, marginLeft: v.inset, ...pinned(plan.ruleW) } as never}>
<NotesBlock ctx={ctx} width={plan.ruleW} />
</View>
);
}
return (
<>
<FirstPageHeader pad={frame.pad} decor={firstPageDecor(ctx, frame)}>
{v.bands ? <SlateHeader ctx={ctx} /> : <TealHeader ctx={ctx} />}
<Spacer h={plan.gaps.meta} />
<MetaBlock ctx={ctx} />
<Spacer h={plan.gaps.party} />
<Row widths={[{ fixed: plan.leftW }, { fill: true }]} gap={v.partyGap}>
<ClientBlock ctx={ctx} />
<FromBlock ctx={ctx} />
</Row>
</FirstPageHeader>
<Spacer h={plan.gaps.table} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={{ marginLeft: v.inset, ...pinned(plan.ruleW) } as never}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.ruleW}
header={plan.headerCells}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={0}
padY={v.padY}
headPadY={v.headPadY}
border={v.rule}
borderColor={v.ruleColor}
headBorder={0}
headColor={v.text}
bleed={{ left: plan.bleedLeft, width: frame.page.width, bg: v.headBand }}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{notes}
<Closing ctx={ctx} />
</>
);
}
+322
View File
@@ -0,0 +1,322 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { swooshKeepOuts } from "../../decor/CornerSwoosh";
import { CHIP_PADDING } from "../../engine/logo";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { frameForPrefs } from "../../render/invoice";
import { loadCanvas, pagePng } from "../../testing/rasterize";
import { SLOT_ID } from "../../blocks/slots";
import { catalogEntry } from "../catalog";
import { marginsFor } from "../contract";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { SLOTS } from "../slots.generated";
import { planBand } from "./plan";
import { slateBand, SLATE_VARIANT } from "./slate";
import { tealSwoosh, TEAL_VARIANT } from "./teal";
ensureSetup();
const BAND = [slateBand, tealSwoosh];
const VARIANTS = { "slate-band": SLATE_VARIANT, "teal-swoosh": TEAL_VARIANT } as const;
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
const out: LayoutNode[] = [];
for (const p of layout) walk(p.nodes, (n) => out.push(n));
return out;
};
const withLogo = { name: "logo", items: 2, logo: "real" as const };
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
/** The colour of one pixel of page 1, at 4x. */
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Promise<number[][]> {
const lib = await loadCanvas();
if (!lib) return [];
const png = await pagePng(lib, bytes, 1, 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 isWhite = ([r, g, b]: number[]) => r > 245 && g > 245 && b > 245;
const isSlate = ([r, g, b]: number[]) => Math.abs(r - 0x4d) < 6 && Math.abs(g - 0x58) < 6 && Math.abs(b - 0x69) < 6;
describe("registry and catalog", () => {
it("lists Slate Band and Teal Swoosh in the Band family, with their thumbnails on disk", () => {
for (const t of BAND) {
expect(getTemplate(t.id)).toBe(t);
expect(t.family).toBe("band");
expect(catalogEntry(t.id).name).toBe(t.name);
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
}
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh"]);
});
it("both are Montserrat throughout, and their slots are the measured reference ones", () => {
for (const t of BAND) for (const [name, token] of Object.entries(t.roles)) expect(token.face, `${t.id}.${name}`).toBe("Montserrat");
expect(slateBand.logo.slot).toBe(SLOTS["slate-band"]);
expect(tealSwoosh.logo.slot).toBe(SLOTS["teal-swoosh"]);
expect(slateBand.logo.slot?.darkSurface).toBe(true);
expect(tealSwoosh.logo.slot?.darkSurface).toBe(false);
expect(slateBand.roles.title).toMatchObject({ weight: 800, size: 30 });
});
});
describe("page geometry", () => {
it("template margins", () => {
expect(marginsFor(slateBand, "template")).toEqual({ top: 55.75, right: 59.55, bottom: 20, left: 59.55 });
expect(marginsFor(tealSwoosh, "template")).toEqual({ top: 55, right: 59.55, bottom: 24, left: 59.55 });
});
it("the flow stays above the footer rule (Slate) and the bottom-left swoosh (Teal) on every preset and size", () => {
for (const margins of ["template", "narrow", "normal"] as const) {
for (const size of ["A4", "LETTER"] as const) {
expect(frameForPrefs(prefsFor(size, { templateId: "slate-band", margins })).pad.bottom, `slate ${margins}/${size}`).toBeGreaterThanOrEqual(86.7 + 12 - 1e-6);
expect(frameForPrefs(prefsFor(size, { templateId: "teal-swoosh", margins })).pad.bottom, `teal ${margins}/${size}`).toBeGreaterThanOrEqual(110 - 1e-6);
}
}
});
it("Letter keeps the physical margins", () => {
for (const t of BAND) {
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
}
});
it("the table text is inset 9.8 pt and the header band bleeds to both page edges, on every page", async () => {
for (const t of BAND) {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "p", items: 30, logo: "none" }, prefsFor(size, { templateId: t.id }));
const frame = frameForPrefs(r.prefs);
expect(r.result.pages).toBeGreaterThan(1);
r.result.core.layout.forEach((page, i) => {
const heads = nodes([page]).filter((n) => n.id === "tbl-head");
if (heads.length === 0) return;
expect(heads[0].box.x, `${t.id}/${size} p${i + 1} head x`).toBeCloseTo(frame.pad.left + 9.8, 1);
// The fixed group around the header row is the band: page edge to page edge.
const band = nodes([page]).find((n) => n.type === "view" && n.fixed && n.box.x < 0.5 && n.box.w > frame.page.width - 0.5 && n.box.h > 40);
expect(band, `${t.id}/${size} p${i + 1} band`).toBeTruthy();
});
}
}
});
});
describe("Slate Band: dark band and logo", () => {
it("the two full-bleed bands are page 1 only, from the page top, as wide as the page", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor(size, { templateId: "slate-band" }));
const decor = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.decor && n.type === "view" && n.box.x < 0.5 && n.box.w > frameForPrefs(r.prefs).page.width - 0.5 && n.box.h > 20);
expect(decor(0).length, size).toBeGreaterThanOrEqual(2);
expect(Math.min(...decor(0).map((n) => n.box.y)), size).toBeCloseTo(0, 1);
// 103.5 pt of slate, then 53.8 of blue, at the design's margins.
const heights = decor(0).map((n) => n.box.h).sort((a, b) => a - b);
expect(heights[0]).toBeGreaterThan(50);
// 103.5 on A4; Letter takes up to 8.5 pt of the band's bottom padding.
expect(heights[heights.length - 1]).toBeGreaterThan(94);
expect(decor(1).filter((n) => n.box.y < 5), `${size} no band on page 2`).toHaveLength(0);
}
});
it("a logo with a white knockout draws the knockout on the band", async () => {
const lib = await loadCanvas();
const r = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" }));
const [img] = imageBoxes(r);
expect(imageBoxes(r)).toHaveLength(1);
const slot = SLOTS["slate-band"]!.lockup!;
expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6);
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.01);
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.01);
if (!lib) return;
const [centre, beside] = await colourAt(r.result.bytes, [[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2], [img.box.x - 3, img.box.y + img.box.h / 2]]);
expect(isWhite(centre), `knockout ink ${centre}`).toBe(true);
expect(isSlate(beside), `band beside the logo ${beside}`).toBe(true);
});
it("a logo without alpha sits on a white chip, padded by CHIP_PADDING, still inside the band", async () => {
const lib = await loadCanvas();
const r = await renderFixture({ ...withLogo, logo: "wide" }, prefsFor("A4", { templateId: "slate-band" }));
const [img] = imageBoxes(r);
if (!lib) return;
const [inChip, outside, centre] = await colourAt(r.result.bytes, [
[img.box.x - CHIP_PADDING + 1, img.box.y + img.box.h / 2],
[img.box.x - CHIP_PADDING - 3, img.box.y + img.box.h / 2],
[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2],
]);
expect(isWhite(inChip), `chip ${inChip}`).toBe(true);
expect(isSlate(outside), `band outside the chip ${outside}`).toBe(true);
// The print image (navy) is what is drawn on the chip, not a white knockout.
expect(isWhite(centre) || centre[2] > centre[0], `print ink ${centre}`).toBe(true);
});
it("a mark keeps the business name beside it, in white; a wordmark replaces the name", async () => {
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "slate-band" }));
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1);
const wide = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" }));
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(0);
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "slate-band" }));
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1);
// The number sits in the band, right-aligned to the content edge.
const no = none.pdf.pages[0].items.find((i) => i.str.startsWith("NO."))!;
expect(no.bottom).toBeLessThan(110);
expect(no.x1).toBeLessThanOrEqual(frameForPrefs(none.prefs).page.width - frameForPrefs(none.prefs).pad.right + 0.6);
});
it("the contact footer is on every page", async () => {
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "slate-band" }));
expect(r.result.pages).toBeGreaterThan(1);
for (const [i, page] of r.pdf.pages.entries()) {
const text = page.items.map((t) => t.str).join(" ");
expect(text, `page ${i + 1} phone`).toContain("9000000000");
expect(text, `page ${i + 1} e-mail`).toContain("vendor@example.com");
}
});
});
describe("Teal Swoosh: corner art and keep-outs", () => {
it("the bottom-left swoosh repeats on every page; the top-right one is page 1 only", async () => {
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
const frame = frameForPrefs(r.prefs);
const svgs = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.type === "svg" && n.decor);
expect(svgs(0).length).toBe(2);
for (let i = 1; i < r.result.pages; i++) {
const s = svgs(i);
expect(s, `page ${i + 1}`).toHaveLength(1);
expect(s[0].box.x).toBeCloseTo(0, 1);
expect(s[0].box.y + s[0].box.h).toBeCloseTo(frame.page.height, 1);
}
});
it("keep-outs: bottom-left on every page, top-right on page 1 only; both clusters on Letter too", () => {
for (const size of ["A4", "LETTER"] as const) {
const frame = frameForPrefs(prefsFor(size, { templateId: "teal-swoosh" }));
const k = tealSwoosh.decor.keepOuts!(frame);
const bl = swooshKeepOuts(frame, "bottom-left");
const tr = swooshKeepOuts(frame, "top-right");
expect(k).toHaveLength(bl.length + tr.length);
expect(k.filter((r) => r.page === undefined)).toHaveLength(bl.length);
expect(k.filter((r) => r.page === 1)).toHaveLength(tr.length);
}
});
it("no flowing text or image enters the keep-outs, with a long vendor name, 60 rows, on A4 and Letter", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of [FIXTURES.find((f) => f.name === "id-tokens")!, FIXTURES.find((f) => f.name === "rows-60")!, FIXTURES.find((f) => f.name === "long-address")!] satisfies FixtureSpec[]) {
const r = await renderFixture(spec, prefsFor(size, { templateId: "teal-swoosh" }));
const keep = r.result.issues.filter((i) => i.code === "keepout");
expect(keep.map((i) => i.message), `${size}/${spec.name}`).toEqual([]);
}
}
});
it("the keep-out audit does fire for a block placed in the swoosh", async () => {
// Teal with page margins pulled in so the closing block would reach the swoosh: the audit must say so.
const r = await renderFixture({ name: "k", items: 2, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
const frame = frameForPrefs(r.prefs);
const { auditLayout } = await import("../../engine/audit");
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
const inside = { x: 0, y: closing.box.y - 1, w: frame.page.width, h: closing.box.h + 2 };
expect(auditLayout({ pages: r.result.core.layout }, frame, { keepOuts: [inside] }).some((i) => i.code === "keepout")).toBe(true);
});
it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => {
const slot = SLOTS["teal-swoosh"]!.lockup!;
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "teal-swoosh" }));
const [img] = imageBoxes(wide);
expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6);
expect(img.box.y).toBeGreaterThanOrEqual(slot.y - 0.6);
expect(img.box.x + img.box.w).toBeLessThanOrEqual(slot.x + slot.w + 0.6);
expect(img.box.y + img.box.h).toBeLessThanOrEqual(slot.y + slot.h + 0.6);
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(0);
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "teal-swoosh" }));
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1);
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1);
});
it("the continuation band and footer stay out of the swoosh on page 2", async () => {
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
const band = r.pdf.pages[1].items.find((t) => t.str.startsWith("Tax Invoice"))!;
expect(band.x0).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 0);
const footer = r.pdf.pages[1].items.filter((t) => t.top > 790);
for (const t of footer) expect(t.x0, t.str).toBeGreaterThan(190);
});
});
describe("the TOTAL row and the totals stack", () => {
it("TOTAL carries the accent dash and every tax row sits above it", async () => {
for (const t of BAND) {
const r = await renderFixture({ name: "t", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id }));
const text = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str));
// Tracked labels are extracted letter by letter, so compare without spaces.
const flat = text.join("").replace(/\s+/g, "").toUpperCase();
expect(flat, t.id).toContain("TOTAL");
for (const word of ["SUBTOTAL", "CGST@9%", "SGST@9%", "TAXABLEVALUE"]) expect(flat, `${t.id} ${word}`).toContain(word);
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.amountInWords, SLOT_ID.signatory]) expect(ids.has(id), `${t.id} #${id}`).toBe(true);
}
});
it("an unregistered invoice carries none of the GST slots", async () => {
for (const t of BAND) {
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: t.id }));
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), `${t.id} #${id}`).toBe(false);
}
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error", async () => {
for (const t of BAND) {
for (const size of ["A4", "LETTER"] as const) {
for (const name of ["id-tokens", "long-address", "long-client"]) {
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: t.id }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${t.id}/${size}/${name}`).toEqual([]);
}
}
}
});
});
describe("plan matches the laid-out blocks", () => {
for (const t of BAND) {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${t.id}/${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: t.id });
const frame = frameForPrefs(prefs);
// Logo-less, so the plan made here is the one the layout made (a logo only changes the header).
const r = await renderFixture({ ...spec, logo: "none" }, prefs);
const find = (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 = planBand(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false);
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(15 + plan.gaps.tail);
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(15);
});
}
}
}
});
describe("design docs", () => {
it("each Band doc records its implementation notes", () => {
for (const f of ["09-slate-band.md", "10-teal-swoosh.md"]) {
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
expect(text, f).toContain("## Implementation notes");
}
});
});
void buildFixture;
+11
View File
@@ -0,0 +1,11 @@
import type { ComponentType } from "react";
import type { TemplateProps } from "../contract";
import { BandLayout } from "./Layout";
import type { BandVariant } from "./variant";
/** The family component bound to one variant's tokens, as the TemplateDefinition's Layout. */
export function BandLayoutFor(variant: BandVariant): ComponentType<TemplateProps> {
const Layout = (props: TemplateProps) => <BandLayout {...props} variant={variant} />;
Layout.displayName = `BandLayout(${variant.id})`;
return Layout;
}
+299
View File
@@ -0,0 +1,299 @@
import { type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import type { TypeToken } from "../../fonts/roles";
import type { RenderModel } from "../../model/build";
import type { PlacedLogo } from "../contract";
import { bankLines, CANCELLED_SENTENCE, CLOSING_GAP, elastic, rateCell } from "../neutral/plan";
import type { BandVariant } from "./variant";
export const WORDS_GAP = 12;
export const REVERSE_GAP = 4;
export const HEADING_AFTER = 4;
/** Space between the closing block's two columns. */
export const CLOSING_COLUMN_GAP = 16;
/** Slate: space between a mark and the business name in the dark band. */
const NAME_AFTER_LOGO = 7.8;
/** Space above and below the thin rule that separates the stack from TOTAL. */
export const STACK_RULE = { before: 2, after: 8, thickness: 0.75 };
const MIN_NAME_W = 40;
const round2 = (n: number) => Math.round(n * 100) / 100;
const leading = (t: TypeToken) => t.leading as number;
export interface MetaItem {
label?: string;
value: string;
}
export function metaColumns(model: RenderModel, v: BandVariant): { left: MetaItem[]; right: MetaItem[] } {
const date: MetaItem = { label: "Date:", value: model.meta.date };
const due = model.meta.dueDate ? { label: "Due:", value: model.meta.dueDate } : null;
const po = model.client.poNumber ? { label: "PO No:", value: model.client.poNumber } : null;
if (v.id === "slate-band") return { left: [date, ...(po ? [po] : [])], right: due ? [due] : [] };
// Teal prints the number in the right column of the date row.
const no = { value: `${model.meta.numberLabel === "Invoice No." ? "NO." : model.meta.numberLabel} ${model.meta.number}` };
return { left: [date, ...(due ? [due] : [])], right: [no, ...(po ? [{ value: `PO: ${po.value}` }] : [])] };
}
export function signatureOptions(v: BandVariant): SignatoryOptions {
const r = v.roles;
return {
roles: { name: r.bodyStrong, designation: r.small, fine: r.fine },
align: "left",
rule: { width: v.rule > 1 ? 0.75 : v.rule, color: v.ruleColor },
color: v.text,
imageHeight: v.signatureImageH,
};
}
export interface BandPlan {
v: BandVariant;
W: number;
/** Table (rows, rules, totals) width: the content width less the inset on both sides. */
ruleW: number;
/** Page edge to the table's left edge (the header band's bleed). */
bleedLeft: number;
gaps: { top: number; titleGap: number; titleBottom: number; meta: number; party: number; table: number; tail: number; notes: number };
header: {
/** Height of the logo / name / number row. */
rowH: number;
logoTop: number;
nameW: number;
nameH: number;
nameTop: number;
numW: number;
numTop: number;
showName: boolean;
titleW: number;
/** Slate: heights of the dark band and the title band, from the page top. */
topBandH: number;
titleBandH: number;
};
meta: { left: MetaItem[]; right: MetaItem[]; labelWLeft: number; labelWRight: number; rows: number; h: number };
leftW: number;
rightW: number;
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
table: ItemsTablePlan;
guardH: number;
totals: { w: number; labelW: number; amountW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rowH: number; stackH: number; grandH: number };
wordsH: number;
reverseH: number;
tailH: number;
notesInClosing: boolean;
notesLeftW: number;
closingLeftW: number;
closingRightW: number;
payH: number;
signH: number;
closingH: number;
}
/** Everything the layout must know before rendering, including the presence values (each a hair high, never low). */
export function planBand(model: RenderModel, frame: PageFrame, v: BandVariant, logo: PlacedLogo | null, includesName: boolean): BandPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const ruleW = round2(W - 2 * v.inset);
const hsn = Slot.HsnSac(model);
// --- header row ----------------------------------------------------------------------------------------
const zone = v.brandZone;
const logoTop = logo ? (logo.slotOffset?.y ?? 0) : 0;
const showName = !(logo && includesName) && model.supplier.name.trim() !== "";
const markW = logo?.w ?? 0;
const slate = v.id === "slate-band";
const numText = `NO. ${model.meta.number}`;
const numW = slate ? Math.ceil(measureToken(m, r.bandNumber, numText)) + 2 : 0;
const limit = v.brandRightLimit !== null ? v.brandRightLimit - frame.pad.left : W;
const rightEdge = slate ? W - numW - 16 : Math.min(W, limit);
const nameLeft = markW > 0 ? markW + (showName ? v.markGap : 0) : 0;
let nameW = 0;
let nameH = 0;
if (showName) {
const natural = Math.ceil(measureToken(m, r.brand, model.supplier.name)) + 1;
nameW = Math.max(MIN_NAME_W, Math.min(natural, rightEdge - nameLeft));
nameH = textHeight(r.brand, model.supplier.name, nameW);
}
const logoBottom = zone.top + (logo ? logoTop + logo.h : 0);
const rowH = Math.max(zone.top + zone.h, logoBottom, slate ? nameH : zone.top + nameH);
const nameTop = slate ? (rowH - nameH) / 2 : logo ? zone.top + Math.max(0, logoTop + (logo.h - nameH) / 2) : zone.top + Math.max(0, (zone.h - nameH) / 2);
const numTop = slate ? (rowH - leading(r.bandNumber)) / 2 : 0;
const titleW = Math.min(W, Math.ceil(measureToken(m, r.title, model.docTitle)) + 1);
// --- meta and parties ---------------------------------------------------------------------------------
const cols = metaColumns(model, v);
const labelOf = (items: MetaItem[]) => {
const labelled = items.filter((i) => i.label);
return labelled.length ? Math.ceil(Math.max(...labelled.map((i) => measureToken(m, r.label, i.label as string)))) + v.labelGap : 0;
};
const metaRows = Math.max(cols.left.length, cols.right.length);
const metaH = metaRows * leading(r.dateValue);
const leftW = round2(W * v.partyLeftShare);
const rightW = round2(W - leftW - v.partyGap);
const s = model.supplier;
const c = model.client;
let clientH = leading(r.label) + v.headingGap + textHeight(r.body, c.name, leftW);
for (const l of c.addressLines) clientH += textHeight(r.body, l, leftW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, leftW) * leading(r.body);
let fromH = leading(r.label) + v.headingGap + textHeight(r.body, s.name, rightW);
for (const l of s.addressLines) fromH += textHeight(r.body, l, rightW);
fromH += ([s.phone, s.email, s.pan].filter(Boolean).length + slotHeights.gstinLines(model, "supplier")) * leading(r.body);
const partyH = Math.max(clientH, fromH);
// --- table: description | qty | price | total ------------------------------------------------------------
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, [headText]))) + 4 + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const cw = {
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "QTY"),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "PRICE"),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"];
const align: BandPlan["align"] = ["left", "center", "right", "right"];
const rows: TableCellValue[][] = model.items.map((item, i) => {
const rate = rateCell(item.rateText, cw.rate - 4, 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];
});
// --- totals stack and TOTAL row ---------------------------------------------------------------------------
const t = model.totals;
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: t.subtotalText }];
if (t.discountText) stack.push({ label: "Discount", amount: `- ${t.discountText}` });
if (t.taxableText) stack.push({ label: "Taxable value", amount: t.taxableText });
const slotRows = slotHeights.taxRowCount(model);
const labelTexts = ["Subtotal", "Discount", "Taxable value", "UTGST @ 14%"];
const amountW = Math.max(v.totals.amountW, Math.ceil(Math.max(widest(r.totalValue, [t.subtotalText, t.totalText, t.discountText ? `- ${t.discountText}` : "", t.taxableText ?? ""]), widest(r.grandValue, [t.totalText]))) + 2);
const labelW = Math.max(v.totals.labelW, Math.ceil(Math.max(widest(r.totalLabel, labelTexts), widest(r.grand, ["Total"]))) + 8);
const totalsW = Math.min(labelW + amountW, ruleW - v.totals.dash.w - 16);
const stackRowH = Math.max(leading(r.totalLabel), leading(r.totalValue)) + v.totals.rowGap;
const stackH = (stack.length + slotRows) * stackRowH;
const grandH = Math.max(leading(r.grand), leading(r.grandValue));
const wordsH = slotHeights.amountInWords(model, ruleW, { role: r.small, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, ruleW, r.small);
const tailBody = stackH + STACK_RULE.before + STACK_RULE.thickness + STACK_RULE.after + grandH + 1 + (wordsH ? WORDS_GAP + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
// --- closing -----------------------------------------------------------------------------------------------
const closingRightW = Math.min(v.closingRightW, Math.floor(W * 0.5));
const closingLeftW = ruleW - closingRightW - CLOSING_COLUMN_GAP;
const lines = bankLines(model);
let payH = 0;
if (model.bank) {
payH = leading(r.label) + HEADING_AFTER;
for (const l of lines) payH += textHeight(r.small, l, slate ? closingLeftW : closingRightW);
}
const signH = signatoryHeight(model, closingRightW, signatureOptions(v));
const notesLeftW = closingLeftW;
const notesH = model.notesLines.length ? leading(r.label) + HEADING_AFTER + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, notesLeftW), 0) : 0;
const notesInClosing = notesH > 0 && notesH <= v.notesBesideMax;
const NOTES_UNDER_PAY = 10;
const leftH = slate ? payH + (notesInClosing ? (payH ? NOTES_UNDER_PAY : 0) + notesH : 0) : notesInClosing ? notesH : 0;
const rightH = slate ? signH : payH + (payH ? 14 : 0) + signH;
let closingH = CLOSING_GAP + Math.max(leftH, rightH) + 1.5;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
// --- the header's vertical geometry and the elastic gaps ---------------------------------------------------
const ownNotes = model.notesLines.length > 0 && !notesInClosing;
const ownNotesH = ownNotes ? leading(r.label) + HEADING_AFTER + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, ruleW), 0) : 0;
const bands = v.bands;
const ranges = {
top: bands ? bands.topPadBottom : { nominal: 0, min: 0 },
titleGap: v.titleGap,
meta: v.gaps.meta,
party: v.gaps.party,
table: v.gaps.table,
tail: v.gaps.tail,
notes: v.gaps.notes,
};
const titleBandH = bands ? bands.titlePadTop + leading(r.title) + bands.titlePadBottom : 0;
const tailNominal = ranges.tail.nominal + tailBody;
const table0 = planTable(v, ruleW, columns, headerCells, rows, frame, tailNominal);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, b) => a + b, 0);
const headerBlock = slate
? rowH + ranges.top.nominal + titleBandH
: rowH + ranges.titleGap.nominal + leading(r.title);
const nominalTotal =
headerBlock + ranges.meta.nominal + metaH + ranges.party.nominal + partyH + ranges.table.nominal + tableTotal + tailNominal + (ownNotes ? ranges.notes.nominal + ownNotesH : 0) + closingH;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
const overflow = nominalTotal - frame.contentHeight;
const deficit = Math.max(frame.heightDeficit, overflow > 0 && overflow <= slack ? overflow : 0);
const g = elastic(ranges, deficit);
const tailH = g.tail + tailBody;
const table = planTable(v, ruleW, columns, headerCells, rows, frame, tailH);
// Rows are unbreakable, so the guard asks for the whole first row (the shared 3-line cap leaves a header alone
// above a taller row); only a row beyond 40% of the page is capped, so it cannot push the table off every page.
// A one-row table has no previous row to carry the last row's own presence value (the row is first in its
// container, where react-pdf never breaks before it), so the guard also holds the tail.
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (rows.length === 1 ? tailH : 0);
return {
v,
W,
ruleW,
bleedLeft: round2(frame.pad.left + v.inset),
gaps: { top: g.top, titleGap: g.titleGap, titleBottom: bands?.titlePadBottom ?? 0, meta: g.meta, party: g.party, table: g.table, tail: g.tail, notes: g.notes },
header: {
rowH,
logoTop,
nameW,
nameH,
nameTop,
numW,
numTop,
showName,
titleW,
topBandH: slate ? round2(frame.pad.top + rowH + g.top) : 0,
titleBandH,
},
meta: { ...cols, labelWLeft: labelOf(cols.left), labelWRight: labelOf(cols.right), rows: metaRows, h: metaH },
leftW,
rightW,
columns,
headerCells,
rows,
align,
table,
guardH,
totals: { w: totalsW, labelW: totalsW - amountW, amountW, stack, slotRows, rowH: stackRowH, stackH, grandH },
wordsH,
reverseH,
tailH,
notesInClosing,
notesLeftW,
closingLeftW,
closingRightW,
payH,
signH,
closingH,
};
}
function planTable(v: BandVariant, ruleW: number, columns: ColSpec[], header: string[], rows: TableCellValue[][], frame: PageFrame, tailH: number): ItemsTablePlan {
const r = v.roles;
return planItemsTable({
columns,
gap: 0,
width: ruleW,
header,
rows,
measurer: getMeasurer(),
roles: { head: r.tableHead, body: r.tableBody },
padX: 0,
padY: v.padY,
headPadY: v.headPadY,
border: v.rule,
headBorder: 0,
tailH,
contentH: frame.contentHeight,
});
}
+178
View File
@@ -0,0 +1,178 @@
import { View } from "@react-pdf/renderer";
import { defineRoles } from "../../fonts/roles";
import { FitText } from "../../blocks/FitText";
import { Txt } from "../../blocks/Txt";
import type { DecorItem } from "../../blocks/FirstPageHeader";
import { CircledIcon } from "../../decor/CircledIcon";
import type { IconName } from "../../decor/icons.generated";
import type { RenderModel } from "../../model/build";
import type { PageFrame } from "../../engine/geometry";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { BandLayoutFor } from "./layoutFor";
import type { BandRoles, BandVariant } from "./variant";
const F = "Montserrat" as const;
const SLATE = "#4D5869";
const BLUE = "#0278B2";
const RULE = "#C1C5CE";
/**
* Slate Band's type: Montserrat 400/500/700/800 (docs/templates/09-slate-band.md). Ascent 0.968, descent 0.251.
* Tracked caps are labels, never data; data text stays at or under 0.05 em.
*/
export const SLATE_ROLES = defineRoles({
title: { face: F, weight: 800, size: 30, leading: 36, tracking: 0.224, caps: true },
brand: { face: F, weight: 500, size: 12, leading: 16.5, minSize: 9 },
bandNumber: { face: F, weight: 400, size: 12, leading: 16.5, tracking: 0.022 },
label: { face: F, weight: 800, size: 12, leading: 16.5 },
dateValue: { face: F, weight: 400, size: 13, leading: 16.5, tracking: 0.047 },
body: { face: F, weight: 400, size: 12, leading: 16.5, tracking: 0.023 },
bodyStrong: { face: F, weight: 700, size: 12, leading: 16.5, tracking: 0.02 },
tableHead: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.165, caps: true },
tableBody: { face: F, weight: 400, size: 12, leading: 14.7, tracking: 0.025 },
tableSub: { face: F, weight: 400, size: 10, leading: 13 },
totalLabel: { face: F, weight: 700, size: 11, leading: 15, tracking: 0.136, caps: true },
totalValue: { face: F, weight: 400, size: 12, leading: 14.7, tracking: 0.025 },
grand: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.16, caps: true },
grandValue: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.04 },
small: { face: F, weight: 400, size: 11, leading: 15 },
wordsLabel: { face: F, weight: 800, size: 11, leading: 15 },
fine: { face: F, weight: 400, size: 8.5, leading: 12 },
contact: { face: F, weight: 400, size: 10, leading: 13, minSize: 7 },
footer: { face: F, weight: 400, size: 7 },
} satisfies BandRoles);
/**
* Reference A4 numbers: the logo starts at y 55.75, the dark band runs to y 103.5 and the blue one to 157.35
* (53.8 tall); the table's text is inset 9.8 from the 59.55 content edge. The footer rule sits 86.7 above
* the page bottom, so the bottom margin floor plus the footer reserve is at least 99.
*/
export const SLATE_VARIANT: BandVariant = {
id: "slate-band",
roles: SLATE_ROLES,
ink: "#2D3743",
text: SLATE,
accent: BLUE,
headBand: "#EEEFF1",
ruleColor: RULE,
margins: { top: 55.75, right: 59.55, bottom: 20, left: 59.55 },
marginFloor: { top: 36, right: 36, bottom: 20, left: 36 },
brandZone: { top: 0, h: 31.25 },
markGap: 7.8,
brandRightLimit: null,
bands: { top: SLATE, title: BLUE, titleText: "#FDFCFD", topPadBottom: { nominal: 16.5, min: 8 }, titlePadTop: 6.5, titlePadBottom: 11.3 },
titleGap: { nominal: 0, min: 0 },
gaps: {
meta: { nominal: 23.5, min: 11.5 },
party: { nominal: 7.55, min: 5 },
table: { nominal: 26.4, min: 14 },
tail: { nominal: 27, min: 12 },
notes: { nominal: 12, min: 8 },
},
inset: 9.8,
partyLeftShare: 0.568,
partyGap: 12,
headingGap: 4.76,
labelGap: 12.5,
padY: 13.5,
headPadY: 20.6,
rule: 2.25,
cols: { qty: 55, rate: 95, amount: 105 },
totals: { labelW: 120, amountW: 105, rowGap: 5, dash: { w: 41.85, h: 3 } },
closingRightW: 170,
signatureImageH: 40,
notesBesideMax: 100,
chromeInset: 0,
footerLeftW: 140,
};
const RULE_FROM_BOTTOM = 86.7;
const ICON = 19.2;
const ICON_FROM_BOTTOM = 71.05;
/** Footer groups share the table's inset width in these proportions (phone, e-mail, address). */
const GROUPS: Array<{ icon: IconName; share: number }> = [
{ icon: "phone", share: 0.24 },
{ icon: "email", share: 0.37 },
{ icon: "location", share: 0.39 },
];
/**
* The every-page footer: a soft rule and up to three circled contacts. It is decor (fixed, page space), so
* each text is fitted to one line (shrunk, then cut with an ellipsis); the full address is printed in the From block.
*/
function footerDecor(frame: PageFrame, model: RenderModel): DecorItem[] {
const r = SLATE_ROLES.contact;
const { width: pw, height: ph } = frame.page;
const present = GROUPS.map((g) => ({
...g,
text: g.icon === "phone" ? model.supplier.phone : g.icon === "email" ? model.supplier.email : model.supplier.addressLines.join(", "),
})).filter((g) => g.text.trim() !== "");
const items: DecorItem[] = [
{ x: frame.pad.left, y: ph - RULE_FROM_BOTTOM, width: frame.contentWidth, height: 2.25, children: <View style={{ height: 2.25, backgroundColor: RULE }} /> },
];
const total = present.reduce((sum, g) => sum + g.share, 0);
const innerW = frame.contentWidth - 2 * SLATE_VARIANT.inset;
let x = frame.pad.left + SLATE_VARIANT.inset;
for (const g of present) {
const w = (innerW * g.share) / total;
const textW = w - ICON - 6 - 8;
items.push({
x,
y: ph - ICON_FROM_BOTTOM,
width: w,
height: g.icon === "location" ? Math.max(ICON, 2 * (r.leading as number)) : ICON,
children: (
<View style={{ flexDirection: "row", alignItems: "center", width: w }}>
<CircledIcon name={g.icon} size={ICON} color={BLUE} ring={1.2} />
{g.icon === "location" ? (
// The address may take two lines (cut with an ellipsis after that); phone and e-mail are one fitted line.
<View style={{ marginLeft: 6 }}>
<Txt role={r} color={SLATE} width={textW} maxLines={2}>
{g.text}
</Txt>
</View>
) : (
<View style={{ marginLeft: 6 }}>
<FitText role={r} color={SLATE} width={textW}>
{g.text}
</FitText>
</View>
)}
</View>
),
});
x += w;
}
void pw;
return items;
}
export const slateBand: TemplateDefinition = {
...catalogEntry("slate-band"),
version: 1,
roles: SLATE_ROLES,
margins: SLATE_VARIANT.margins,
marginFloor: SLATE_VARIANT.marginFloor,
decor: { ink: SLATE, everyPage: (frame, model) => footerDecor(frame, model) },
chrome: {
color: "#6f6f6f",
rule: null,
role: SLATE_ROLES.footer,
inset: { left: SLATE_VARIANT.chromeInset, right: SLATE_VARIANT.chromeInset },
leftWidth: SLATE_VARIANT.footerLeftW,
// 20 + 79 = 99 = the rule's 86.7 plus 12 of air, on every margin preset.
reserve: 79,
bottom: 22,
},
logo: {
box: { w: 99.25, h: 31.25 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => 99.25,
slot: slotFor("slate-band"),
available: () => ({ w: 99.25, h: 31.25 }),
},
Layout: BandLayoutFor(SLATE_VARIANT),
};
+119
View File
@@ -0,0 +1,119 @@
import { defineRoles } from "../../fonts/roles";
import { cornerSwoosh, swooshKeepOuts } from "../../decor/CornerSwoosh";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { BandLayoutFor } from "./layoutFor";
import type { BandRoles, BandVariant } from "./variant";
const F = "Montserrat" as const;
const INK = "#333132";
const TEAL = "#32B3C7";
/**
* Teal Swoosh's type: Montserrat 400/700/800 (docs/templates/10-teal-swoosh.md), one colour. The business
* name is set at 13 pt (the design's 10.75 pt belongs to a short placeholder).
*/
export const TEAL_ROLES = defineRoles({
title: { face: F, weight: 800, size: 30, leading: 36, tracking: 0.26, caps: true },
brand: { face: F, weight: 700, size: 13, leading: 17, minSize: 9 },
bandNumber: { face: F, weight: 400, size: 12, leading: 16.5, tracking: 0.025 },
label: { face: F, weight: 800, size: 12, leading: 16.5, tracking: 0.025 },
dateValue: { face: F, weight: 400, size: 13, leading: 16.5, tracking: 0.05 },
body: { face: F, weight: 400, size: 12, leading: 16.5, tracking: 0.025 },
bodyStrong: { face: F, weight: 700, size: 12, leading: 16.5, tracking: 0.025 },
tableHead: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.2, caps: true },
tableBody: { face: F, weight: 400, size: 12, leading: 14.7, tracking: 0.03 },
tableSub: { face: F, weight: 400, size: 10, leading: 13 },
totalLabel: { face: F, weight: 700, size: 10.5, leading: 15, tracking: 0.152, caps: true },
totalValue: { face: F, weight: 400, size: 12, leading: 14.7, tracking: 0.025 },
grand: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.2, caps: true },
grandValue: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.05 },
small: { face: F, weight: 400, size: 10.5, leading: 15 },
wordsLabel: { face: F, weight: 800, size: 10.5, leading: 15 },
fine: { face: F, weight: 400, size: 8.5, leading: 12 },
contact: { face: F, weight: 400, size: 10, leading: 13 },
footer: { face: F, weight: 400, size: 7 },
} satisfies BandRoles);
/**
* Reference A4 numbers: the logo mark's top is at y 59.5 (4.5 below the 55 pt margin), the title baseline at
* 144, the table text inset 9.8. The bottom-left swoosh reaches up to y 654 on the page edge, so the bottom
* padding is 110: the flow stays above its inner edge on every page.
*/
export const TEAL_VARIANT: BandVariant = {
id: "teal-swoosh",
roles: TEAL_ROLES,
ink: INK,
text: INK,
accent: TEAL,
headBand: "#EFF1F2",
ruleColor: "#C2C7CF",
margins: { top: 55, right: 59.55, bottom: 24, left: 59.55 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
brandZone: { top: 4.5, h: 29.25 },
markGap: 6.5,
// The silver crescent's inner edge is near x 478 at the brand row; the design keeps content within about x 440.
brandRightLimit: 440,
bands: null,
titleGap: { nominal: 26.2, min: 14 },
gaps: {
meta: { nominal: 29.9, min: 16 },
party: { nominal: 7.55, min: 5 },
table: { nominal: 26.4, min: 14 },
tail: { nominal: 27, min: 12 },
notes: { nominal: 12, min: 8 },
},
inset: 9.8,
partyLeftShare: 0.568,
partyGap: 12,
headingGap: 4.76,
labelGap: 12.5,
padY: 13.5,
headPadY: 20.6,
rule: 2.25,
cols: { qty: 55, rate: 95, amount: 105 },
totals: { labelW: 120, amountW: 105, rowGap: 5, dash: { w: 41.85, h: 3 } },
closingRightW: 181,
signatureImageH: 40,
notesBesideMax: 100,
chromeInset: 140,
footerLeftW: 0,
};
export const tealSwoosh: TemplateDefinition = {
...catalogEntry("teal-swoosh"),
version: 1,
roles: TEAL_ROLES,
margins: TEAL_VARIANT.margins,
marginFloor: TEAL_VARIANT.marginFloor,
decor: {
ink: INK,
// The top-right cluster is page 1 only (it sits in the first-page header): on continuation pages the
// table band starts at the top margin, right under where the crescent would be.
everyPage: (frame) => [cornerSwoosh(frame, "bottom-left")],
keepOuts: (frame) => [...swooshKeepOuts(frame, "bottom-left"), ...swooshKeepOuts(frame, "top-right").map((r) => ({ ...r, page: 1 }))],
},
chrome: {
color: "#6f6f6f",
rule: null,
role: TEAL_ROLES.footer,
// The footer starts right of the bottom-left swoosh (its inner edge is at x 185 at the footer's height).
inset: { left: TEAL_VARIANT.chromeInset, right: 0 },
bandInset: { left: 0, right: 0 },
leftWidth: 0,
footerName: false,
// 24 + 86 = 110.
reserve: 86,
bottom: 22,
},
logo: {
box: { w: 125.75, h: 29.25 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => 125.75,
slot: slotFor("teal-swoosh"),
available: () => ({ w: 125.75, h: 29.25 }),
},
Layout: BandLayoutFor(TEAL_VARIANT),
};
+102
View File
@@ -0,0 +1,102 @@
import type { Sides } from "../../engine/geometry";
import type { TypeToken } from "../../fonts/roles";
import type { GapRange } from "../neutral/variant";
/** Type roles of a Band template (Montserrat throughout). */
export interface BandRoles {
/** "INVOICE" / "TAX INVOICE", widely tracked caps. */
title: TypeToken;
/** The business name beside the logo (white in Slate's band). */
brand: TypeToken;
/** "NO. 000001" in Slate's band. */
bandNumber: TypeToken;
/** "Date:", "Billed to:", "From:", "Payment details:", "Note:". */
label: TypeToken;
dateValue: TypeToken;
body: TypeToken;
bodyStrong: TypeToken;
tableHead: TypeToken;
tableBody: TypeToken;
/** Grey sub-lines under a cell: the HSN/SAC code, the rate unit. */
tableSub: TypeToken;
/** SUBTOTAL, DISCOUNT, CGST ... labels (tracked caps) and their amounts. */
totalLabel: TypeToken;
totalValue: TypeToken;
/** The TOTAL row. */
grand: TypeToken;
grandValue: TypeToken;
small: TypeToken;
wordsLabel: TypeToken;
fine: TypeToken;
/** Slate's footer contacts. */
contact: TypeToken;
/** Footer and continuation band: a render-prop role (no leading). */
footer: TypeToken;
}
export type BandId = "slate-band" | "teal-swoosh";
/**
* What differs between Slate Band and Teal Swoosh. They share one body (date row, two parties, a
* full-bleed table band, airy rows, the TOTAL row, closing); the header and the page art differ.
* y values are design distances measured on the reference A4 page, converted to flow gaps.
*/
export interface BandVariant {
id: BandId;
roles: BandRoles;
/** Labels, party text, the title (Teal). */
ink: string;
/** Table text, totals, payment text (the same as `ink` in Teal). */
text: string;
accent: string;
/** The table header's full-bleed band. */
headBand: string;
/** Row rules and the footer rule. */
ruleColor: string;
margins: Sides;
marginFloor: Sides;
/** The brand zone of the header row: where the slot starts below the row top, and its height. */
brandZone: { top: number; h: number };
/** Gap between a mark and the name beside it. */
markGap: number;
/** Rightmost x of the brand block, measured from the page's left edge (Teal keeps clear of the swoosh). */
brandRightLimit: number | null;
/** Slate: the dark band's colours and the paddings that turn flow positions into band edges. */
bands: { top: string; title: string; titleText: string; topPadBottom: GapRange; titlePadTop: number; titlePadBottom: number } | null;
/** Teal: space between the brand row and the title. */
titleGap: GapRange;
gaps: {
/** The header's last element to the date row. */
meta: GapRange;
/** Date row to the party headings. */
party: GapRange;
/** Party blocks to the table band. */
table: GapRange;
/** Last row rule to the totals stack. */
tail: GapRange;
notes: GapRange;
};
/** Content edge to the table's text (the table's inset). */
inset: number;
/** Width of the Billed to column as a share of the content width, and the gap to the From column. */
partyLeftShare: number;
partyGap: number;
headingGap: number;
/** Space between the date row's label and its value. */
labelGap: number;
/** Row paddings; the header band is taller than a row. */
padY: number;
headPadY: number;
rule: number;
cols: { qty: number; rate: number; amount: number };
/** The totals stack: label column minimum, and the pitch of its rows. */
totals: { labelW: number; amountW: number; rowGap: number; dash: { w: number; h: number } };
closingRightW: number;
signatureImageH: number;
notesBesideMax: number;
chromeInset: number;
footerLeftW: number;
}
+38
View File
@@ -0,0 +1,38 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { catalogEntry, FAMILY_LABELS, TEMPLATE_CATALOG } from "./catalog";
import { TEMPLATES } from "./registry";
describe("template catalog", () => {
it("has one entry per registered template, in the registry's order, ids unique", () => {
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
expect(new Set(TEMPLATE_CATALOG.map((e) => e.id)).size).toBe(TEMPLATE_CATALOG.length);
expect(TEMPLATE_CATALOG).toHaveLength(8);
});
it("groups the C4 templates into the Ledger and Band families", () => {
const byFamily = (f: string) => TEMPLATE_CATALOG.filter((e) => e.family === f).map((e) => e.id);
expect(byFamily("ledger")).toEqual(["crimson-grid", "tangerine-ledger"]);
expect(byFamily("band")).toEqual(["slate-band", "teal-swoosh"]);
expect(byFamily("neutral")).toEqual(["linea", "monogram", "serenity"]);
expect(FAMILY_LABELS.ledger).toBe("Ledger");
expect(FAMILY_LABELS.band).toBe("Band");
});
it("every entry has a name, a family label and a thumbnail on disk; every definition agrees", () => {
for (const e of TEMPLATE_CATALOG) {
expect(e.name.length, e.id).toBeGreaterThan(0);
expect(FAMILY_LABELS[e.family], e.id).toBeTruthy();
expect(e.thumbnail).toBe(`/templates/${e.id}.png`);
expect(existsSync(path.join(process.cwd(), "public", e.thumbnail)), e.thumbnail).toBe(true);
expect(TEMPLATES[e.id].family).toBe(e.family);
expect(TEMPLATES[e.id].name).toBe(e.name);
}
});
it("falls back to Classic for an unknown id", () => {
expect(catalogEntry("no-such").id).toBe("classic");
expect(catalogEntry("slate-band").name).toBe("Slate Band");
});
});
+7 -1
View File
@@ -5,7 +5,7 @@
export interface TemplateCatalogEntry {
id: string;
name: string;
family: "classic" | "neutral";
family: "classic" | "neutral" | "ledger" | "band";
/** Static PNG from /public, written by scripts/thumbnails.mjs (sample data, not the user's invoice). */
thumbnail: string;
badge?: "Matches your original format";
@@ -16,6 +16,8 @@ export interface TemplateCatalogEntry {
export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
classic: "Classic",
neutral: "Neutral",
ledger: "Ledger",
band: "Band",
};
/** Picker order: Classic first, Linea (the vendor's original format) second. */
@@ -24,6 +26,10 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
{ id: "linea", name: "Linea", family: "neutral", thumbnail: "/templates/linea.png", badge: "Matches your original format", logoOptIn: true },
{ id: "monogram", name: "Monogram", family: "neutral", thumbnail: "/templates/monogram.png" },
{ id: "serenity", name: "Serenity", family: "neutral", thumbnail: "/templates/serenity.png" },
{ id: "crimson-grid", name: "Crimson Grid", family: "ledger", thumbnail: "/templates/crimson-grid.png" },
{ id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" },
{ id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" },
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
];
/** The entry for an id; an unknown id falls back to Classic, like getTemplate. */
+1 -1
View File
@@ -90,7 +90,7 @@ describe("classic plan matches the laid-out blocks", () => {
describe("registry", () => {
it("has Classic and falls back to it for an unknown or missing id", () => {
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh"]);
expect(getTemplate("classic")).toBe(classic);
expect(getTemplate("no-such-template")).toBe(classic);
expect(getTemplate(undefined)).toBe(classic);
+17 -4
View File
@@ -26,10 +26,13 @@ export interface TemplateDecor {
background?: string;
/** Default text colour of the page. */
ink: string;
/** Fixed every-page decor in page space; must stay clear of the content column. */
everyPage?: (frame: PageFrame) => DecorItem[];
/** Page-space boxes the flow must stay out of (audited as `keepout`). */
keepOuts?: (frame: PageFrame) => Rect[];
/**
* Fixed every-page decor in page space; must stay clear of the content column. It gets the model for
* supplier contact lines (a footer band); decor is exempt from the audit, so regulated slots never go here.
*/
everyPage?: (frame: PageFrame, model: RenderModel) => DecorItem[];
/** Page-space boxes the flow must stay out of (audited as `keepout`); `page` limits one to a single 1-based page. */
keepOuts?: (frame: PageFrame) => Array<Rect & { page?: number }>;
}
/** Footer and page-label chrome of a template. */
@@ -45,6 +48,16 @@ export interface TemplateChrome {
leftWidth: number;
/** Height reserved at the foot of every page for the footer line; default FOOTER_RESERVE (it includes Classic's rule). */
reserve?: number;
/** Footer row offset from the page bottom, pt. Default: the bottom margin. A template with a footer band keeps the text inside it whatever the margin preset. */
bottom?: number;
/** The continuation band's own inset (page 2+, top). Default: `inset`. Corner art at the top right moves the band's right edge in. */
bandInset?: { left: number; right: number };
/** Colour of the continuation band's text; default `color` (a template whose footer sits on a dark band needs its own). */
bandColor?: string;
/** Show "continued" at the band's right end. Default true. */
bandRight?: boolean;
/** Show the supplier name at the footer's left. Default true. */
footerName?: boolean;
}
export interface TemplateProps {
+170
View File
@@ -0,0 +1,170 @@
/** The per-render assertions shared by the harness test files. */
import { expect } from "vitest";
import type { AuditIssue } from "../engine/audit";
import { PAGE_SIZES } from "../engine/geometry";
import { PAGE_LABEL_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, CLOSING_ID, TOTALS_ID } from "../engine/ids";
import { walk, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree";
import { forbiddenProbe, SLOT_RULES, slotContent, type SlotName } from "../model/slots";
import { frameForPrefs } from "../render/invoice";
import { SLOT_ID } from "../blocks/slots";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "./classic/ids";
import { HEADER_GAP, headerCols } from "./classic/plan";
import { FIXTURE_GSTIN, type Rendered } from "./harness";
/**
* Template harness: every registered template x {A4, Letter} x the fixture matrix, plus an item-count
* sweep. A failure here means the template or a primitive is wrong, not the assertion.
*/
const SIZE_PT = { A4: [PAGE_SIZES.A4.width, PAGE_SIZES.A4.height], LETTER: [612, 792] } as const;
const squash = (s: string) => s.replace(/\s+/g, "").toLowerCase();
const textOf = (r: Rendered) => r.pdf.pages.flatMap((p) => p.items.map((t) => t.str)).join(" ");
const nodesOf = (page: LayoutPage) => {
const out: LayoutNode[] = [];
walk(page.nodes, (n) => out.push(n));
return out;
};
const withId = (page: LayoutPage, id: string) => nodesOf(page).filter((n) => n.id === id);
const withIdPrefix = (page: LayoutPage, prefix: string) => nodesOf(page).filter((n) => n.id?.startsWith(prefix));
const hits = (a: Rect, b: Rect, tol = 0.5) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > tol && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > tol;
function textLinesUnder(root: LayoutNode): Rect[] {
const out: Rect[] = [];
const visit = (n: LayoutNode) => {
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ x: l.x, y: l.y, w: Math.max(0, l.w - (n.letterSpacing ?? 0)), h: l.h });
n.children.forEach(visit);
};
visit(root);
return out;
}
export function checkRendered(r: Rendered, label: string) {
const { model } = r.fixture;
const layout = r.result.core.layout;
const n = r.pdf.pages.length;
const ctx = (msg: string) => `${label}: ${msg}`;
// Page geometry as pdf.js reads it from the PDF.
const [w, h] = SIZE_PT[r.prefs.pageSize];
r.pdf.pages.forEach((p, i) => {
expect(Math.abs(p.width - w), ctx(`page ${i + 1} width ${p.width}`)).toBeLessThanOrEqual(0.5);
expect(Math.abs(p.height - h), ctx(`page ${i + 1} height ${p.height}`)).toBeLessThanOrEqual(0.5);
});
expect(r.result.pages).toBe(n);
// The audit: overlap, overflow-x, safe zone, truncation, empty render text, header-only page,
// split blocks, closing block position.
const errors = r.result.issues.filter((i: AuditIssue) => i.severity === "error");
expect(errors.map((i) => `${i.code} p${i.page}: ${i.message}`), ctx("audit errors")).toEqual([]);
// The reported bug: supplier and address boxes must stay clear of the title box.
const supplier = withId(layout[0], SUPPLIER_ID)[0];
const title = withId(layout[0], TITLE_ID)[0];
const meta = withId(layout[0], META_ID)[0];
expect(supplier && title && meta, ctx("supplier, title and meta nodes exist")).toBeTruthy();
expect(hits(supplier.box, title.box), ctx(`supplier box ${JSON.stringify(supplier.box)} meets title box ${JSON.stringify(title.box)}`)).toBe(false);
expect(hits(supplier.box, meta.box), ctx("supplier box meets meta box")).toBe(false);
const titleInk = textLinesUnder(title);
for (const line of textLinesUnder(supplier)) for (const t of titleInk) expect(hits(line, t), ctx("a supplier line meets the title text")).toBe(false);
// Classic's header columns (the old fixed 339.5 / 362.5 bounds, derived from the frame so Letter is checked too):
// supplier text stays inside its column and every meta line starts at or right of the meta column. The
// Neutral templates have no shared header row; the box checks above and the audit cover them.
const frame = frameForPrefs(r.prefs);
if (r.prefs.templateId === "classic") {
const [supplierW] = headerCols(frame.contentWidth);
const supplierRight = frame.pad.left + supplierW;
const metaLeft = supplierRight + HEADER_GAP;
for (const line of textLinesUnder(supplier)) expect(line.x + line.w, ctx(`supplier line [${line.x}..${line.x + line.w}] past its column edge ${supplierRight}`)).toBeLessThanOrEqual(supplierRight + 0.5);
for (const line of textLinesUnder(meta)) expect(line.x, ctx(`meta line at ${line.x} left of the meta column ${metaLeft}`)).toBeGreaterThanOrEqual(metaLeft - 0.5);
} else {
// Supplier lines stay inside the text column; the meta block never reaches left of the client column's end.
const margin = { left: frame.pad.left, right: frame.page.width - frame.pad.right };
for (const line of textLinesUnder(supplier)) {
expect(line.x, ctx("supplier line inside the page margins")).toBeGreaterThanOrEqual(margin.left - 0.5);
expect(line.x + line.w, ctx("supplier line inside the page margins")).toBeLessThanOrEqual(margin.right + 0.5);
}
}
// Header repeats on every continuation page that has rows; no page shows a header without rows.
layout.forEach((page, i) => {
const rows = withIdPrefix(page, TABLE_ROW_PREFIX).length;
const heads = withId(page, TABLE_HEAD_ID).length;
if (i > 0 && rows > 0) expect(heads, ctx(`page ${i + 1} repeats the table header`)).toBe(1);
if (heads > 0) expect(rows, ctx(`page ${i + 1} has a header but no rows`)).toBeGreaterThan(0);
});
// Totals and signatory are never split and never duplicated.
for (const id of [TOTALS_ID, CLOSING_ID, SLOT_ID.signatory]) {
const pages = layout.flatMap((p, i) => (withId(p, id).length > 0 ? [i + 1] : []));
expect(pages, ctx(`#${id} appears on exactly one page`)).toHaveLength(1);
}
expect(withId(layout[n - 1], CLOSING_ID), ctx("closing block is on the last page")).toHaveLength(1);
// "Page k of N" from the layout tree and from the extracted text.
layout.forEach((page, i) => {
const want = `Page ${i + 1} of ${n}`;
expect(withId(page, PAGE_LABEL_ID).flatMap((nd) => (nd.lines ?? []).map((l) => l.text)), ctx(`page ${i + 1} label (layout)`)).toEqual([want]);
const labels = r.pdf.pages[i].items.map((t) => t.str.trim()).filter((s) => /^Page \d+ of \d+$/.test(s));
expect(labels, ctx(`page ${i + 1} label (text)`)).toEqual([want]);
});
// Extracted text.
const text = textOf(r);
expect(r.pdf.raw, ctx("no Helvetica")).not.toContain("/BaseFont /Helvetica");
expect(text, ctx("rupee extracts as U+20B9, never a superscript one")).not.toContain("¹");
expect(text).toContain("₹");
// A face without the rupee sign (Open Sans) draws it from the fallback font, which extraction reports as
// its own item right before the digits, so the sign counts when it ends the previous item.
// pdf.js also splits an amount at a strongly kerned pair (Inter's "7," and "7."), so a fragment that starts
// with a digit-group separator is joined to the item before it first.
for (const page of r.pdf.pages) {
const items: string[] = [];
for (const t of page.items) {
const s = t.str.trim();
if (/^[.,]\d/.test(s) && items.length > 0) items[items.length - 1] += s;
else items.push(t.str);
}
items.forEach((str, i) => {
if (/\d\.\d\d$/.test(str.trim())) {
const carried = str.includes("₹") || (i > 0 && items[i - 1].trim().endsWith("₹"));
expect(carried, ctx(`amount "${str}" carries the rupee sign`)).toBe(true);
}
});
}
const flat = squash(text);
expect(flat, ctx("invoice number")).toContain(squash(model.meta.number));
expect(flat, ctx("total")).toContain(squash(model.totals.totalText));
expect(text.match(/Generated with Voiced/g)?.length, ctx("footer on every page")).toBe(n);
if (model.docType === "tax_invoice") expect(flat, ctx("supplier GSTIN")).toContain(squash(FIXTURE_GSTIN.length ? (model.supplier.gstin ?? "") : ""));
// Contract slots.
const rules = SLOT_RULES[model.docType];
for (const slot of rules.required) {
const content = slotContent(model, slot);
if (model.docType === "tax_invoice") expect(content.length, ctx(`fixture has content for required slot ${slot}`)).toBeGreaterThan(0);
for (const s of content) expect(flat, ctx(`slot ${slot} prints "${s}"`)).toContain(squash(s));
}
const ids = new Set(layout.flatMap((p) => nodesOf(p).flatMap((nd) => (nd.id ? [nd.id] : []))));
const idFor: Partial<Record<SlotName, string>> = {
gstin: `${SLOT_ID.gstin}-supplier`,
pos: SLOT_ID.pos,
taxRows: SLOT_ID.taxRows,
amountInWords: SLOT_ID.amountInWords,
signatory: SLOT_ID.signatory,
reverseCharge: SLOT_ID.reverseCharge,
};
for (const slot of rules.required) {
const id = idFor[slot];
if (id) expect(ids.has(id), ctx(`slot node #${id}`)).toBe(true);
}
for (const slot of rules.forbidden) {
const id = idFor[slot];
if (id) expect(ids.has(id), ctx(`forbidden slot node #${id}`)).toBe(false);
const probes = forbiddenProbe(model, slot, r.fixture.spec.registered === false ? "27ZZZZZ9999Z1Z9" : undefined);
for (const p of probes) expect(flat, ctx(`forbidden slot ${slot} leaked "${p}"`)).not.toContain(squash(p));
}
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { makeItems, prefsFor, PAGE_SIZE_IDS, renderFixture, type FixtureSpec } from "./harness";
import { checkRendered } from "./harness.check";
import { TEMPLATES } from "./registry";
// Its own file so vitest runs the long item-count sweeps in parallel with the fixture matrix.
describe("template harness: item-count sweep", () => {
for (const template of Object.values(TEMPLATES)) {
for (const size of PAGE_SIZE_IDS) {
it(`${template.id}: 1..45 items on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: template.id });
for (let count = 1; count <= 45; count++) {
const spec: FixtureSpec = { name: `sweep-${count}`, items: makeItems(count, true), invoice: { notes: count % 3 === 0 ? "Payment due within 30 days.\nThank you." : "" }, signature: count % 2 === 0 };
const r = await renderFixture(spec, prefs);
checkRendered(r, `${template.id}/items=${count}/${size} (${r.result.pages} pages)`);
}
});
}
}
});
+2 -159
View File
@@ -19,151 +19,9 @@ import { TEMPLATES } from "./registry";
* sweep. A failure here means the template or a primitive is wrong, not the assertion.
*/
import { checkRendered } from "./harness.check";
const OUT_DIR = process.env.PDF_OUT_DIR ?? "";
const SIZE_PT = { A4: [PAGE_SIZES.A4.width, PAGE_SIZES.A4.height], LETTER: [612, 792] } as const;
const squash = (s: string) => s.replace(/\s+/g, "").toLowerCase();
const textOf = (r: Rendered) => r.pdf.pages.flatMap((p) => p.items.map((t) => t.str)).join(" ");
const nodesOf = (page: LayoutPage) => {
const out: LayoutNode[] = [];
walk(page.nodes, (n) => out.push(n));
return out;
};
const withId = (page: LayoutPage, id: string) => nodesOf(page).filter((n) => n.id === id);
const withIdPrefix = (page: LayoutPage, prefix: string) => nodesOf(page).filter((n) => n.id?.startsWith(prefix));
const hits = (a: Rect, b: Rect, tol = 0.5) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > tol && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > tol;
function textLinesUnder(root: LayoutNode): Rect[] {
const out: Rect[] = [];
const visit = (n: LayoutNode) => {
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ x: l.x, y: l.y, w: Math.max(0, l.w - (n.letterSpacing ?? 0)), h: l.h });
n.children.forEach(visit);
};
visit(root);
return out;
}
function checkRendered(r: Rendered, label: string) {
const { model } = r.fixture;
const layout = r.result.core.layout;
const n = r.pdf.pages.length;
const ctx = (msg: string) => `${label}: ${msg}`;
// Page geometry as pdf.js reads it from the PDF.
const [w, h] = SIZE_PT[r.prefs.pageSize];
r.pdf.pages.forEach((p, i) => {
expect(Math.abs(p.width - w), ctx(`page ${i + 1} width ${p.width}`)).toBeLessThanOrEqual(0.5);
expect(Math.abs(p.height - h), ctx(`page ${i + 1} height ${p.height}`)).toBeLessThanOrEqual(0.5);
});
expect(r.result.pages).toBe(n);
// The audit: overlap, overflow-x, safe zone, truncation, empty render text, header-only page,
// split blocks, closing block position.
const errors = r.result.issues.filter((i: AuditIssue) => i.severity === "error");
expect(errors.map((i) => `${i.code} p${i.page}: ${i.message}`), ctx("audit errors")).toEqual([]);
// The reported bug: supplier and address boxes must stay clear of the title box.
const supplier = withId(layout[0], SUPPLIER_ID)[0];
const title = withId(layout[0], TITLE_ID)[0];
const meta = withId(layout[0], META_ID)[0];
expect(supplier && title && meta, ctx("supplier, title and meta nodes exist")).toBeTruthy();
expect(hits(supplier.box, title.box), ctx(`supplier box ${JSON.stringify(supplier.box)} meets title box ${JSON.stringify(title.box)}`)).toBe(false);
expect(hits(supplier.box, meta.box), ctx("supplier box meets meta box")).toBe(false);
const titleInk = textLinesUnder(title);
for (const line of textLinesUnder(supplier)) for (const t of titleInk) expect(hits(line, t), ctx("a supplier line meets the title text")).toBe(false);
// Classic's header columns (the old fixed 339.5 / 362.5 bounds, derived from the frame so Letter is checked too):
// supplier text stays inside its column and every meta line starts at or right of the meta column. The
// Neutral templates have no shared header row; the box checks above and the audit cover them.
const frame = frameForPrefs(r.prefs);
if (r.prefs.templateId === "classic") {
const [supplierW] = headerCols(frame.contentWidth);
const supplierRight = frame.pad.left + supplierW;
const metaLeft = supplierRight + HEADER_GAP;
for (const line of textLinesUnder(supplier)) expect(line.x + line.w, ctx(`supplier line [${line.x}..${line.x + line.w}] past its column edge ${supplierRight}`)).toBeLessThanOrEqual(supplierRight + 0.5);
for (const line of textLinesUnder(meta)) expect(line.x, ctx(`meta line at ${line.x} left of the meta column ${metaLeft}`)).toBeGreaterThanOrEqual(metaLeft - 0.5);
} else {
// Supplier lines stay inside the text column; the meta block never reaches left of the client column's end.
const margin = { left: frame.pad.left, right: frame.page.width - frame.pad.right };
for (const line of textLinesUnder(supplier)) {
expect(line.x, ctx("supplier line inside the page margins")).toBeGreaterThanOrEqual(margin.left - 0.5);
expect(line.x + line.w, ctx("supplier line inside the page margins")).toBeLessThanOrEqual(margin.right + 0.5);
}
}
// Header repeats on every continuation page that has rows; no page shows a header without rows.
layout.forEach((page, i) => {
const rows = withIdPrefix(page, TABLE_ROW_PREFIX).length;
const heads = withId(page, TABLE_HEAD_ID).length;
if (i > 0 && rows > 0) expect(heads, ctx(`page ${i + 1} repeats the table header`)).toBe(1);
if (heads > 0) expect(rows, ctx(`page ${i + 1} has a header but no rows`)).toBeGreaterThan(0);
});
// Totals and signatory are never split and never duplicated.
for (const id of [TOTALS_ID, CLOSING_ID, SLOT_ID.signatory]) {
const pages = layout.flatMap((p, i) => (withId(p, id).length > 0 ? [i + 1] : []));
expect(pages, ctx(`#${id} appears on exactly one page`)).toHaveLength(1);
}
expect(withId(layout[n - 1], CLOSING_ID), ctx("closing block is on the last page")).toHaveLength(1);
// "Page k of N" from the layout tree and from the extracted text.
layout.forEach((page, i) => {
const want = `Page ${i + 1} of ${n}`;
expect(withId(page, PAGE_LABEL_ID).flatMap((nd) => (nd.lines ?? []).map((l) => l.text)), ctx(`page ${i + 1} label (layout)`)).toEqual([want]);
const labels = r.pdf.pages[i].items.map((t) => t.str.trim()).filter((s) => /^Page \d+ of \d+$/.test(s));
expect(labels, ctx(`page ${i + 1} label (text)`)).toEqual([want]);
});
// Extracted text.
const text = textOf(r);
expect(r.pdf.raw, ctx("no Helvetica")).not.toContain("/BaseFont /Helvetica");
expect(text, ctx("rupee extracts as U+20B9, never a superscript one")).not.toContain("¹");
expect(text).toContain("₹");
// A face without the rupee sign (Open Sans) draws it from the fallback font, which extraction reports as
// its own item right before the digits, so the sign counts when it ends the previous item.
for (const page of r.pdf.pages) {
page.items.forEach((t, i) => {
if (/\d\.\d\d$/.test(t.str.trim())) {
const carried = t.str.includes("₹") || (i > 0 && page.items[i - 1].str.trim().endsWith("₹"));
expect(carried, ctx(`amount "${t.str}" carries the rupee sign`)).toBe(true);
}
});
}
const flat = squash(text);
expect(flat, ctx("invoice number")).toContain(squash(model.meta.number));
expect(flat, ctx("total")).toContain(squash(model.totals.totalText));
expect(text.match(/Generated with Voiced/g)?.length, ctx("footer on every page")).toBe(n);
if (model.docType === "tax_invoice") expect(flat, ctx("supplier GSTIN")).toContain(squash(FIXTURE_GSTIN.length ? (model.supplier.gstin ?? "") : ""));
// Contract slots.
const rules = SLOT_RULES[model.docType];
for (const slot of rules.required) {
const content = slotContent(model, slot);
if (model.docType === "tax_invoice") expect(content.length, ctx(`fixture has content for required slot ${slot}`)).toBeGreaterThan(0);
for (const s of content) expect(flat, ctx(`slot ${slot} prints "${s}"`)).toContain(squash(s));
}
const ids = new Set(layout.flatMap((p) => nodesOf(p).flatMap((nd) => (nd.id ? [nd.id] : []))));
const idFor: Partial<Record<SlotName, string>> = {
gstin: `${SLOT_ID.gstin}-supplier`,
pos: SLOT_ID.pos,
taxRows: SLOT_ID.taxRows,
amountInWords: SLOT_ID.amountInWords,
signatory: SLOT_ID.signatory,
reverseCharge: SLOT_ID.reverseCharge,
};
for (const slot of rules.required) {
const id = idFor[slot];
if (id) expect(ids.has(id), ctx(`slot node #${id}`)).toBe(true);
}
for (const slot of rules.forbidden) {
const id = idFor[slot];
if (id) expect(ids.has(id), ctx(`forbidden slot node #${id}`)).toBe(false);
const probes = forbiddenProbe(model, slot, r.fixture.spec.registered === false ? "27ZZZZZ9999Z1Z9" : undefined);
for (const p of probes) expect(flat, ctx(`forbidden slot ${slot} leaked "${p}"`)).not.toContain(squash(p));
}
}
describe("template harness: fixtures", () => {
for (const template of Object.values(TEMPLATES)) {
describe(template.id, () => {
@@ -199,21 +57,6 @@ describe("template harness: determinism", () => {
}
});
describe("template harness: item-count sweep", () => {
for (const template of Object.values(TEMPLATES)) {
for (const size of PAGE_SIZE_IDS) {
it(`${template.id}: 1..45 items on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: template.id });
for (let count = 1; count <= 45; count++) {
const spec: FixtureSpec = { name: `sweep-${count}`, items: makeItems(count, true), invoice: { notes: count % 3 === 0 ? "Payment due within 30 days.\nThank you." : "" }, signature: count % 2 === 0 };
const r = await renderFixture(spec, prefs);
checkRendered(r, `${template.id}/items=${count}/${size} (${r.result.pages} pages)`);
}
});
}
}
});
describe.skipIf(!OUT_DIR)("template harness: samples for visual review", () => {
const samples: Array<{ file: string; spec: FixtureSpec; size: "A4" | "LETTER"; pages: number }> = [
{ file: "classic-A4-tax", spec: { name: "s1", items: 3, signature: true, invoice: { poNumber: "PO-2026-0042", notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." } }, size: "A4", pages: 1 },
+41 -1
View File
@@ -12,7 +12,7 @@ import { setupPdfTest } from "../testing/setup";
/** Fixtures and helpers shared by the template harness and the sample renderer. */
export type LogoKind = "none" | "real" | "wide" | "square";
export type LogoKind = "none" | "real" | "wide" | "square" | "knockout";
export interface FixtureSpec {
name: string;
@@ -130,6 +130,33 @@ export function solidPng(w: number, h: number): Buffer {
]);
}
/** A transparent-margin w x h PNG in one colour: the shape of a logo that has alpha, so it can have a white knockout. */
function inkPng(w: number, h: number, rgb: [number, number, number]): Buffer {
const row = Buffer.alloc(1 + w * 4);
const raw = Buffer.alloc(row.length * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const inside = x >= w * 0.08 && x < w * 0.92 && y >= h * 0.12 && y < h * 0.88;
const o = y * row.length + 1 + x * 4;
raw[o] = rgb[0];
raw[o + 1] = rgb[1];
raw[o + 2] = rgb[2];
raw[o + 3] = inside ? 255 : 0;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8;
ihdr[9] = 6;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
const dataUri = (png: Buffer) => `data:image/png;base64,${png.toString("base64")}`;
const cache = new Map<string, LogoInput>();
@@ -142,6 +169,19 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null {
const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"));
const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)];
hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true };
} else if (kind === "knockout") {
// A wordmark with alpha: navy print image plus its white knockout, as the logo pipeline writes them.
hit = {
dataUri: dataUri(inkPng(460, 100, [24, 52, 120])),
knockoutDataUri: dataUri(inkPng(460, 100, [255, 255, 255])),
hasKnockout: true,
width: 460,
height: 100,
aspect: 4.6,
density: 0.6,
kind: "wordmark",
includesName: true,
};
} else if (kind === "wide") {
// The trimmed Test Vendor ink box is 4.6:1.
hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100, aspect: 4.6, density: 1, kind: "wordmark", includesName: true };
+376
View File
@@ -0,0 +1,376 @@
import { Image, View } from "@react-pdf/renderer";
import { type ReactNode } from "react";
import { FILL, pinned } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { getMeasurer, measureToken } from "../../engine/measure";
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 { TableRows } from "../../blocks/ItemsTable";
import { Cell, 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 { RenderModel } from "../../model/build";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import type { PlacedLogo, TemplateProps } from "../contract";
import { includesName } from "../logoName";
import { bankLines, CANCELLED_INK, CANCELLED_SENTENCE, CLOSING_GAP } from "../neutral/plan";
import { HEADING_AFTER, planLedger, signatureOptions, TAIL_COLUMN_GAP, WORDS_GAP, REVERSE_GAP, type LedgerPlan } from "./plan";
import type { LedgerVariant } from "./variant";
interface Ctx {
model: RenderModel;
plan: LedgerPlan;
v: LedgerVariant;
logo: PlacedLogo | null;
}
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
// --- header ---------------------------------------------------------------------------------------------
function Brand({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const b = plan.brand;
if (b.w <= 0) return null;
return (
<View style={{ ...pinned(b.w), flexDirection: "row", marginTop: v.brandZone.top } as never}>
{logo ? (
<Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: b.logoTop, objectFit: "contain" } as never} />
) : null}
{b.showName ? (
<View style={{ ...pinned(b.nameW), marginLeft: b.nameLeft - (logo?.w ?? 0), marginTop: b.nameTop } as never}>
<AvailableWidthProvider width={b.nameW}>
<SafeTxt role={v.roles.brand} color={v.ink}>
{model.supplier.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function HeaderRow({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const title = (
<View style={{ ...pinned(plan.title.w), marginTop: v.titleTop } as never}>
<FitText id={TITLE_ID} role={v.roles.title} color={v.ink} width={plan.title.w} minSize={Math.round(v.roles.title.size * 0.6)}>
{model.docTitle}
</FitText>
</View>
);
const brand = <Brand ctx={ctx} />;
return (
<View style={{ flexDirection: "row", ...pinned(plan.W), minHeight: plan.headerH } as never}>
{v.mirrored ? brand : title}
<View style={FILL} />
{v.mirrored ? title : brand}
</View>
);
}
function CancelledTag({ ctx }: { ctx: Ctx }) {
const textW = Math.ceil(measureToken(getMeasurer(), ctx.v.roles.label, "Cancelled")) + 2;
const boxW = textW + 12 + 1.6;
return (
<View style={{ borderWidth: 0.8, borderColor: CANCELLED_INK, paddingVertical: 2, paddingHorizontal: 6, marginBottom: 6, ...pinned(boxW) } as never}>
<Txt role={ctx.v.roles.label} color={CANCELLED_INK} width={textW}>
Cancelled
</Txt>
</View>
);
}
function MetaBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
return (
<View id={META_ID} style={pinned(plan.W) as never}>
{model.cancelled ? <CancelledTag ctx={ctx} /> : null}
{plan.meta.map((line, i) => (
<SafeTxt key={i} role={v.roles.body} color={v.ink} style={i > 0 ? { marginTop: v.metaGap } : undefined}>
{line}
</SafeTxt>
))}
</View>
);
}
function SupplierBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
<View id={SUPPLIER_ID} style={pinned(plan.leftW) as never}>
<SafeTxt role={r.label} color={v.ink}>
{s.name}
</SafeTxt>
<Spacer h={v.headingGap} />
{s.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.ink}>
{line}
</SafeTxt>
))}
{s.phone ? (
<IdText role={r.body} color={v.ink}>
{s.phone}
</IdText>
) : null}
{s.email ? (
<IdText role={r.body} color={v.ink}>
{s.email}
</IdText>
) : null}
{s.pan ? <IdText role={r.body} color={v.ink}>{`PAN: ${s.pan}`}</IdText> : null}
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
</View>
);
}
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
return (
<View style={pinned(plan.rightW) as never}>
<Txt role={r.label} color={v.ink} width={plan.rightW}>
Bill to
</Txt>
<Spacer h={v.headingGap} />
<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} />
</View>
);
}
// --- totals box ------------------------------------------------------------------------------------------
/** One ruled row of the totals box: label cell, value cell, the row's own top rule. */
function BoxRow({ ctx, label, amount, total }: { ctx: Ctx; label: string; amount: string; total?: boolean }) {
const { plan, v } = ctx;
const t = plan.totals;
const g = v.rule;
const filled = total && v.totalFilled;
const role = total ? v.roles.total : v.roles.totals;
const color = filled ? v.onAccent : v.ink;
const sep = filled ? v.onAccent : v.ink;
const padY = v.totals.padY;
return (
<Row
widths={[{ fixed: t.labelW }, { fixed: t.valueW }]}
width={t.boxW}
align="stretch"
style={{ borderTopWidth: g, borderTopColor: filled ? v.accent : v.ink, ...(filled ? { backgroundColor: v.accent } : {}) }}
>
<Cell
padding={{ top: padY, bottom: padY, left: v.padX, right: v.padX }}
borderLeft={g}
borderRight={g}
style={{ flexGrow: 1, flexBasis: "auto", borderLeftColor: filled ? v.accent : v.ink, borderRightColor: sep }}
>
<Txt role={role} color={color} width={t.labelW - 2 * v.padX - 2 * g}>
{label}
</Txt>
</Cell>
<Cell
padding={{ top: padY, bottom: padY, left: v.padX, right: v.padX }}
borderRight={g}
style={{ flexGrow: 1, flexBasis: "auto", borderRightColor: filled ? v.accent : v.ink }}
>
<Txt role={role} color={color} width={t.valueW - 2 * v.padX - g} align="right">
{amount}
</Txt>
</Cell>
</Row>
);
}
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.totals;
return (
<View style={{ ...pinned(t.boxW), borderBottomWidth: v.rule, borderBottomColor: v.ink } as never}>
<AvailableWidthProvider width={t.boxW}>
{t.rows.map((row, i) => (
<BoxRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
))}
<Slot.TaxRows
model={model}
role={v.roles.totals}
color={v.ink}
amountWidth={t.valueW}
format={(label, rate) => (rate ? `${label} @ ${rate}` : label)}
renderRow={(row) => <BoxRow ctx={ctx} label={row.label} amount={row.amount} />}
/>
<BoxRow ctx={ctx} label="Total" amount={model.totals.totalText} total />
</AvailableWidthProvider>
</View>
);
}
function NotesBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
if (model.notesLines.length === 0) return null;
return (
<View style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
{/* The label travels with the first line, and no paragraph splits (a cut Text leaves a zero-line node). */}
<View wrap={false}>
<Txt role={r.label} color={v.ink} width={width} style={{ marginBottom: v.headingGap }}>
Notes / Terms:
</Txt>
<SafeTxt role={r.body} color={v.ink}>
{model.notesLines[0]}
</SafeTxt>
</View>
{model.notesLines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.ink} wrap={false}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
return (
<View id={TOTALS_ID} wrap={false}>
<View style={{ marginTop: plan.gaps.tail }}>
<Row widths={[{ fill: true }, { fixed: plan.totals.boxW }]} gap={TAIL_COLUMN_GAP}>
{plan.notesBeside ? <NotesBlock ctx={ctx} width={plan.notesW} /> : <View />}
<TotalsBox ctx={ctx} />
</Row>
{plan.wordsH > 0 ? (
<View style={{ marginTop: WORDS_GAP }}>
<Slot.AmountInWords model={model} role={r.small} labelRole={r.wordsLabel} color={v.ink} />
</View>
) : null}
{plan.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: REVERSE_GAP }} /> : null}
</View>
</View>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const opts = signatureOptions(v);
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: CLOSING_GAP }}>
<Row widths={[{ fixed: plan.leftW }, { fill: true }]} gap={v.partyGap}>
{model.bank ? (
<View style={pinned(plan.leftW) as never}>
<Txt role={r.label} color={v.ink} width={plan.leftW} style={{ marginBottom: HEADING_AFTER }}>
Payment method
</Txt>
{bankLines(model).map((line, i) => (
<SafeTxt key={i} role={r.small} color={v.ink}>
{line}
</SafeTxt>
))}
</View>
) : (
<View />
)}
<View style={pinned(plan.signW) as never}>
<Txt role={r.label} color={v.ink} width={plan.signW} style={{ marginBottom: HEADING_AFTER }}>
Authorised signatory
</Txt>
<Slot.Signatory model={model} width={plan.signW} {...opts} />
</View>
</Row>
{model.cancelled ? (
<Txt role={r.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</View>
</>
);
}
// --- the layout -----------------------------------------------------------------------------------------
export function LedgerLayout({ model, frame, logo, prefs, variant }: TemplateProps & { variant: LedgerVariant }) {
const plan = planLedger(model, frame, variant, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v: variant, logo };
const v = variant;
const r = v.roles;
let notes: ReactNode = null;
if (model.notesLines.length > 0 && !plan.notesBeside) {
notes = (
<View style={{ marginTop: plan.gaps.notes }}>
<NotesBlock ctx={ctx} width={plan.W} />
</View>
);
}
return (
<>
<FirstPageHeader pad={frame.pad}>
<HeaderRow ctx={ctx} />
{v.headerRule ? (
<>
<Spacer h={plan.gaps.rule} />
<AccentRule width={plan.W} thickness={v.headerRule.thickness} color={v.accent} />
</>
) : null}
<Spacer h={plan.gaps.meta} />
<MetaBlock ctx={ctx} />
<Spacer h={plan.gaps.party} />
<Row widths={[{ fixed: plan.leftW }, { fill: true }]} gap={v.partyGap}>
<SupplierBlock ctx={ctx} />
<ClientBlock ctx={ctx} />
</Row>
</FirstPageHeader>
<Spacer h={plan.gaps.table} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={pinned(plan.W) as never}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.W}
header={plan.header}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={v.padX}
padY={v.padY}
border={v.rule}
borderColor={v.ink}
headColor={v.onAccent}
headerStyle={{ backgroundColor: v.accent, ...(v.totalFilled ? { borderBottomColor: v.accent } : {}) }}
grid={{ width: v.rule, color: v.ink, headerColor: v.onAccent, headerEdge: v.totalFilled ? v.accent : v.ink }}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{notes}
<Closing ctx={ctx} />
</>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { defineRoles } from "../../fonts/roles";
import { fullBleedBand } from "../../decor/FullBleedBand";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { FitText } from "../../blocks/FitText";
import { LedgerLayoutFor } from "./layoutFor";
import type { LedgerRoles, LedgerVariant } from "./variant";
const INK = "#000000";
const ACCENT = "#A00000";
const CREAM = "#EFEDDF";
const F = "Poppins" as const;
/** The full-bleed footer band: its height and the room kept clear above it. */
export const BAND_H = 63;
const BAND_CLEAR = 16;
/**
* Crimson Grid's type: Poppins only (docs/templates/07-crimson-grid.md). Poppins ascent 1.05, descent 0.35,
* cap 0.70. The design's tracking (0.0085 em on the title, 0.064 em on the footer line) is dropped or kept
* at 0.05 em so text extraction stays clean.
*/
export const CRIMSON_ROLES = defineRoles({
title: { face: F, weight: 700, size: 43.8, leading: 52, caps: true },
brand: { face: F, weight: 600, size: 15.44, leading: 14.47, caps: true, minSize: 10 },
label: { face: F, weight: 600, size: 12, leading: 16.5, caps: true },
body: { face: F, weight: 400, size: 12, leading: 16.5 },
bodyStrong: { face: F, weight: 600, size: 12, leading: 16.5 },
tableHead: { face: F, weight: 600, size: 11, leading: 15.4 },
tableBody: { face: F, weight: 400, size: 11, leading: 15.4 },
tableSub: { face: F, weight: 400, size: 8.5, leading: 12 },
totals: { face: F, weight: 400, size: 11, leading: 15.4 },
total: { face: F, weight: 700, size: 12, leading: 16.5 },
small: { face: F, weight: 400, size: 10.5, leading: 14.5 },
wordsLabel: { face: F, weight: 600, size: 10.5, leading: 14.5 },
fine: { face: F, weight: 400, size: 8.5, leading: 12 },
contact: { face: F, weight: 400, size: 11, leading: 15, tracking: 0.05, minSize: 7 },
footer: { face: F, weight: 400, size: 7 },
} satisfies LedgerRoles);
/**
* Reference A4 numbers: logo top 53, the title's cap top 69.7 (its box starts 1.6 below the row top), a 1.5 pt
* rule at y 128.85, a 59.55 side margin. The flow must never reach the 63 pt band, so the bottom margin
* floor plus the footer reserve is at least 79 on every preset.
*/
export const CRIMSON_VARIANT: LedgerVariant = {
id: "crimson-grid",
roles: CRIMSON_ROLES,
accent: ACCENT,
ink: INK,
onAccent: "#FFFFFF",
margins: { top: 53, right: 59.55, bottom: 26, left: 59.55 },
marginFloor: { top: 36, right: 36, bottom: 26, left: 36 },
mirrored: false,
titleTop: 1.6,
brandZone: { top: 0, h: 45.75 },
brandMaxShare: 0.4,
brandGap: 24,
markGap: 13.7,
headerRule: { thickness: 1.5 },
totalFilled: false,
art: "footer-band",
gaps: {
rule: { nominal: 21.5, min: 12 },
meta: { nominal: 31.4, min: 20 },
party: { nominal: 22.3, min: 14 },
table: { nominal: 27.4, min: 16 },
tail: { nominal: 11.3, min: 6 },
notes: { nominal: 12, min: 8 },
},
metaGap: 2.18,
headingGap: 7.48,
partyLeftShare: 0.4804,
partyGap: 16,
rule: 0.75,
padX: 9.4,
padY: 12.55,
cols: { hsn: 56, rate: 100, qty: 45, amount: 100 },
totals: { labelW: 96, valueW: 100, padY: 6, maxShare: 0.55 },
notesBesideMax: 110,
signatureW: 200,
signatureImageH: 40,
chromeInset: 0,
footerLeftW: 140,
};
/** Footer band height used by decor and by the footer's own placement. */
export const CRIMSON_FOOTER_BOTTOM = 14;
export const crimsonGrid: TemplateDefinition = {
...catalogEntry("crimson-grid"),
version: 1,
roles: CRIMSON_ROLES,
margins: CRIMSON_VARIANT.margins,
marginFloor: CRIMSON_VARIANT.marginFloor,
decor: {
ink: INK,
everyPage: (frame, model) => {
const items = [fullBleedBand(frame, { bottom: 0, height: BAND_H, fill: ACCENT })];
const contact = [model.supplier.email, model.supplier.phone].filter(Boolean).join(" | ");
if (contact) {
const r = CRIMSON_ROLES.contact;
items.push({
x: frame.pad.left,
y: frame.page.height - BAND_H + 13,
width: frame.contentWidth,
height: r.leading as number,
children: (
<FitText role={r} color={CREAM} width={frame.contentWidth} align="center">
{contact}
</FitText>
),
});
}
return items;
},
},
chrome: {
color: CREAM,
bandColor: "#6f6f6f",
rule: null,
role: CRIMSON_ROLES.footer,
leftWidth: CRIMSON_VARIANT.footerLeftW,
// 53 + the 26 pt margin floor = 79 = band + 16 clear, on every margin preset.
reserve: BAND_H + BAND_CLEAR - 26,
bottom: CRIMSON_FOOTER_BOTTOM,
},
logo: {
box: { w: 113.25, h: 45.75 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => 113.25,
slot: slotFor("crimson-grid"),
available: () => ({ w: 113.25, h: 45.75 }),
},
Layout: LedgerLayoutFor(CRIMSON_VARIANT),
};
+11
View File
@@ -0,0 +1,11 @@
import type { ComponentType } from "react";
import type { TemplateProps } from "../contract";
import { LedgerLayout } from "./Layout";
import type { LedgerVariant } from "./variant";
/** The family component bound to one variant's tokens, as the TemplateDefinition's Layout. */
export function LedgerLayoutFor(variant: LedgerVariant): ComponentType<TemplateProps> {
const Layout = (props: TemplateProps) => <LedgerLayout {...props} variant={variant} />;
Layout.displayName = `LedgerLayout(${variant.id})`;
return Layout;
}
+300
View File
@@ -0,0 +1,300 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { PAGE_SIZES } from "../../engine/geometry";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { frameForPrefs } from "../../render/invoice";
import { loadCanvas, inkPixels } from "../../testing/rasterize";
import { SLOT_ID } from "../../blocks/slots";
import { catalogEntry } from "../catalog";
import { marginsFor } from "../contract";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { SLOTS } from "../slots.generated";
import { planLedger } from "./plan";
import { crimsonGrid, CRIMSON_VARIANT, BAND_H } from "./crimson";
import { tangerineLedger, TANGERINE_VARIANT } from "./tangerine";
ensureSetup();
const LEDGER = [crimsonGrid, tangerineLedger];
const VARIANTS = { "crimson-grid": CRIMSON_VARIANT, "tangerine-ledger": TANGERINE_VARIANT } as const;
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
const out: LayoutNode[] = [];
for (const p of layout) walk(p.nodes, (n) => out.push(n));
return out;
};
const textOf = (r: Awaited<ReturnType<typeof renderFixture>>) => r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" "));
const withLogo = { name: "logo", items: 2, logo: "real" as const };
describe("registry and catalog", () => {
it("lists Crimson Grid and Tangerine Ledger in the Ledger family, with their thumbnails on disk", () => {
for (const t of LEDGER) {
expect(getTemplate(t.id)).toBe(t);
expect(t.family).toBe("ledger");
expect(catalogEntry(t.id).name).toBe(t.name);
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
expect(t.badge).toBeUndefined();
}
expect(crimsonGrid.name).toBe("Crimson Grid");
expect(tangerineLedger.name).toBe("Tangerine Ledger");
expect(Object.keys(TEMPLATES)).toContain("crimson-grid");
});
it("each template declares its own type: Crimson all Poppins, Tangerine Poppins Bold display over Inter", () => {
for (const token of Object.values(crimsonGrid.roles)) expect(token.face).toBe("Poppins");
expect(tangerineLedger.roles.title).toMatchObject({ face: "Poppins", weight: 700, size: 33 });
expect(tangerineLedger.roles.brand).toMatchObject({ face: "Poppins", weight: 700, size: 18 });
expect(tangerineLedger.roles.body.face).toBe("Inter");
expect(tangerineLedger.roles.tableBody.face).toBe("Inter");
expect(crimsonGrid.roles.title).toMatchObject({ weight: 700, size: 43.8 });
});
it("both logo slots are the measured reference ones", () => {
expect(crimsonGrid.logo.slot).toBe(SLOTS["crimson-grid"]);
expect(tangerineLedger.logo.slot).toBe(SLOTS["tangerine-ledger"]);
expect(crimsonGrid.logo.defaultOn && tangerineLedger.logo.defaultOn).toBe(true);
});
});
describe("page geometry and art clearance", () => {
it("template margins", () => {
expect(marginsFor(crimsonGrid, "template")).toEqual({ top: 53, right: 59.55, bottom: 26, left: 59.55 });
expect(marginsFor(tangerineLedger, "template")).toEqual({ top: 46, right: 59.55, bottom: 60, left: 59.55 });
});
it("the flow never reaches Crimson's 63 pt band on any margin preset or page size", () => {
for (const margins of ["template", "narrow", "normal"] as const) {
for (const size of ["A4", "LETTER"] as const) {
const frame = frameForPrefs(prefsFor(size, { templateId: "crimson-grid", margins }));
expect(frame.pad.bottom, `${margins}/${size}`).toBeGreaterThanOrEqual(BAND_H + 16 - 1e-6);
}
}
});
it("the footer text sits inside the band whatever the margin preset", () => {
expect(crimsonGrid.chrome.bottom).toBeDefined();
expect((crimsonGrid.chrome.bottom as number) + 12).toBeLessThan(BAND_H);
});
it("no side margin goes under Tangerine's 35 pt tabs plus 10 pt of air", () => {
for (const margins of ["template", "narrow", "normal"] as const) {
const m = marginsFor(tangerineLedger, margins);
expect(m.left, margins).toBeGreaterThanOrEqual(45);
expect(m.right, margins).toBeGreaterThanOrEqual(45);
}
});
it("Letter keeps the physical margins and gives the extra width to the content", () => {
for (const t of LEDGER) {
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.contentWidth - a4.contentWidth).toBeCloseTo(PAGE_SIZES.LETTER.width - PAGE_SIZES.A4.width, 2);
}
});
it("A4 numbers match the reference grid: columns 249 / 98 / 41 / 88.5 widen only where an amount needs it", () => {
const frame = frameForPrefs(prefsFor("A4", { templateId: "crimson-grid" }));
const { model } = buildFixture({ name: "p", items: 1, registered: false, logo: "none" });
const plan = planLedger(model, frame, CRIMSON_VARIANT, null, false);
expect(plan.table.widths.length).toBe(4);
expect(plan.table.widths.reduce((a, b) => a + b, 0)).toBeCloseTo(frame.contentWidth, 1);
// Money columns take at least 100 pt; the description keeps the rest.
expect(plan.table.widths[3]).toBeGreaterThanOrEqual(100);
expect(plan.table.widths[0]).toBeGreaterThan(200);
});
});
describe("what the header does with a logo", () => {
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
it("Crimson: a wordmark fills the lockup box and replaces the name; a mark sits left of the name", async () => {
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "crimson-grid" }));
const [img] = imageBoxes(wide);
const lockup = SLOTS["crimson-grid"]!.lockup!;
expect(img.box.y + img.box.h).toBeLessThanOrEqual(lockup.y + lockup.h + 0.6);
expect(img.box.h).toBeLessThanOrEqual(lockup.h + 0.01);
expect(img.box.w).toBeLessThanOrEqual(lockup.w + 0.01);
expect(img.box.x + img.box.w).toBeLessThanOrEqual(frameForPrefs(wide.prefs).page.width - frameForPrefs(wide.prefs).pad.right + 0.5);
// The business name is not repeated as text in the header (the party heading still prints it once).
expect(textOf(wide)[0].match(/Sample P/g)?.length).toBe(1);
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "crimson-grid" }));
const [mark] = imageBoxes(square);
const markBox = SLOTS["crimson-grid"]!.mark!;
expect(mark.box.h).toBeLessThanOrEqual(markBox.h + 0.01);
expect(mark.box.w).toBeLessThanOrEqual(markBox.w + 0.01);
expect(textOf(square)[0].match(/Sample P/g)?.length).toBe(2);
});
it("Tangerine: the logo stays in its 220 x 36 allowance at the left", async () => {
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "tangerine-ledger" }));
const [img] = imageBoxes(wide);
const box = SLOTS["tangerine-ledger"]!.allowance;
// The logo's left edge is the content edge (59.55), a point left of the reference text's ink start (60.5).
expect(img.box.x).toBeGreaterThanOrEqual(box.x - 1.1);
expect(img.box.x + img.box.w).toBeLessThanOrEqual(box.x + box.w + 0.6);
expect(img.box.y).toBeGreaterThanOrEqual(box.y - 0.6);
expect(img.box.y + img.box.h).toBeLessThanOrEqual(box.y + box.h + 0.6);
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "tangerine-ledger" }));
const [mark] = imageBoxes(square);
expect(mark.box.h).toBeLessThanOrEqual(36.01);
});
it("with no logo the vendor name stands in, in the template's own type", async () => {
for (const t of LEDGER) {
const r = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(imageBoxes(r), t.id).toHaveLength(0);
// The brand text in the header, the party heading and the footer: three prints of the name, one of them at the top.
const names = r.pdf.pages[0].items.filter((i) => /^Sample p$/i.test(i.str.trim()));
expect(names.length, t.id).toBe(3);
expect(names.filter((i) => i.bottom < 150).length, t.id).toBe(1);
expect(r.pdf.raw, t.id).toContain("Poppins");
expect(r.pdf.raw, t.id).not.toContain("/BaseFont /Helvetica");
}
const tang = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "tangerine-ledger" }));
expect(tang.pdf.raw).toContain("Inter");
});
it("a logo with includesName off keeps the name beside the mark", async () => {
const r = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "tangerine-ledger" }));
// The brand beside the mark and the footer (the party heading is upper case).
expect(textOf(r)[0].match(/Test Vendor/g)?.length).toBe(2);
expect(r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 150)).toHaveLength(1);
});
});
describe("the ruled grid", () => {
const multiline: FixtureSpec = {
name: "grid",
logo: "none",
items: [
{ description: "Voice-over for the onboarding course\nModule 1 - introduction\nModule 2 - safety basics\nIncludes two rounds of revisions", mode: "rate", rate: 1500, unit: "hour", quantity: 3, amount: 0, sortOrder: 0, hsnSac: "998397" },
{ description: "Studio hire", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 1, hsnSac: "997212" },
],
};
it("draws the vertical rules the full height of a multi-line row and the outer frame", async () => {
const canvas = await loadCanvas();
if (!canvas) return;
for (const t of LEDGER) {
const r = await renderFixture(multiline, prefsFor("A4", { templateId: t.id }));
const frame = frameForPrefs(r.prefs);
const row = nodes(r.result.core.layout).find((n) => n.id === "tbl-row-0")!;
expect(row.box.h).toBeGreaterThan(60);
const plan = planLedger(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false);
const x1 = frame.pad.left + plan.table.widths.slice(0, -1).reduce((a, b) => a + b, 0);
const scale = 4;
const vert = async (x: number) => inkPixels(canvas, r.result.bytes, { x0: x - 0.7, y0: row.box.y + 3, x1: x + 0.7, y1: row.box.y + row.box.h - 3 }, scale);
const full = (row.box.h - 6) * scale;
// The column rule before Amount, and the outer left edge.
expect(await vert(x1), `${t.id} inner rule`).toBeGreaterThan(full * 0.8);
expect(await vert(frame.pad.left + 0.375), `${t.id} left edge`).toBeGreaterThan(full * 0.8);
expect(await vert(frame.pad.left + frame.contentWidth - 0.375), `${t.id} right edge`).toBeGreaterThan(full * 0.8);
}
});
it("the header row repeats on the next page with its rules, and each row owns its bottom rule", async () => {
const canvas = await loadCanvas();
for (const t of LEDGER) {
const r = await renderFixture({ name: "p", items: 60, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(r.result.pages).toBeGreaterThan(1);
const frame = frameForPrefs(r.prefs);
for (const [i, page] of r.result.core.layout.entries()) {
const heads = nodes([page]).filter((n) => n.id === "tbl-head");
if (i > 0) expect(heads, `${t.id} p${i + 1}`).toHaveLength(1);
if (i > 0) expect(heads[0].box.y).toBeCloseTo(frame.pad.top, 0);
}
if (!canvas) continue;
// The first row of page 1 (inkPixels reads page 1): a rule runs under it across the whole table width.
const row = nodes([r.result.core.layout[0]]).find((n) => n.id === "tbl-row-0")!;
const bottom = row.box.y + row.box.h;
const strip = await inkPixels(canvas, r.result.bytes, { x0: frame.pad.left + 5, y0: bottom - 0.9, x1: frame.pad.left + frame.contentWidth - 5, y1: bottom }, 4);
expect(strip, `${t.id} row bottom rule`).toBeGreaterThan((frame.contentWidth - 10) * 4 * 0.9);
}
});
});
describe("the slots keep a home in the new layouts", () => {
it("a tax invoice carries every GST slot; an unregistered one carries none of the regulated ones", async () => {
for (const t of LEDGER) {
const tax = await renderFixture(FIXTURES.find((f) => f.name === "cgst-sgst")!, prefsFor("A4", { templateId: t.id }));
const ids = new Set(nodes(tax.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), `${t.id} #${id}`).toBe(true);
const plain = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: t.id }));
const plainIds = new Set(nodes(plain.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(plainIds.has(id), `${t.id} #${id}`).toBe(false);
}
});
it("the HSN/SAC column is a fifth grid column on a tax invoice", async () => {
const r = await renderFixture({ name: "h", items: 2, logo: "none" }, prefsFor("A4", { templateId: "crimson-grid" }));
expect(r.pdf.pages[0].items.some((i) => i.str.includes("HSN/SAC"))).toBe(true);
const plain = await renderFixture({ name: "h", items: 2, logo: "none", registered: false }, prefsFor("A4", { templateId: "crimson-grid" }));
expect(plain.pdf.pages[0].items.some((i) => i.str.includes("HSN/SAC"))).toBe(false);
});
});
describe("notes", () => {
it("short notes sit beside the totals box, long ones flow below the table", () => {
const frame = frameForPrefs(prefsFor("A4", { templateId: "crimson-grid" }));
const short = buildFixture({ name: "n", items: 2, invoice: { notes: "Payment due within 30 days." } }).model;
expect(planLedger(short, frame, CRIMSON_VARIANT, null, false).notesBeside).toBe(true);
const long = buildFixture(FIXTURES.find((f) => f.name === "long-notes")!).model;
expect(planLedger(long, frame, CRIMSON_VARIANT, null, false).notesBeside).toBe(false);
const none = buildFixture({ name: "n", items: 2 }).model;
expect(planLedger(none, frame, CRIMSON_VARIANT, null, false).notesBeside).toBe(false);
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error and stay inside the page", async () => {
for (const t of LEDGER) {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor(size, { templateId: t.id }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${t.id}/${size}`).toEqual([]);
for (const n of nodes(r.result.core.layout)) if (n.type === "text" && !n.fixed && !n.decor) for (const l of n.lines ?? []) expect(l.x + l.w, `${t.id}/${size} "${l.text}"`).toBeLessThanOrEqual(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right + 0.6);
}
}
});
});
describe("plan matches the laid-out blocks", () => {
// The totals tail and the closing block carry presence values predicted from the measurer; too low and a
// block can start a page without its spacer.
for (const t of LEDGER) {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${t.id}/${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: t.id });
const frame = frameForPrefs(prefs);
// Logo-less, so the plan made here is the one the layout made (a logo only changes the header).
const r = await renderFixture({ ...spec, logo: "none" }, prefs);
const find = (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 = planLedger(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false);
// Wrap is predicted greedily and can only err by whole lines, on the safe side.
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(15 + plan.gaps.tail);
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(15);
});
}
}
}
});
describe("design docs", () => {
it("each Ledger doc records its implementation notes", () => {
for (const f of ["07-crimson-grid.md", "11-tangerine-ledger.md"]) {
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
expect(text, f).toContain("## Implementation notes");
}
});
});
+294
View File
@@ -0,0 +1,294 @@
import { colsDetailed, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { gridPadRight, 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 type { TypeToken } from "../../fonts/roles";
import type { PlacedLogo } from "../contract";
import { bankLines, CANCELLED_SENTENCE, CLOSING_GAP, elastic, rateCell } from "../neutral/plan";
import type { LedgerVariant } from "./variant";
export const WORDS_GAP = 10;
export const REVERSE_GAP = 4;
/** Space between the closing block's heading and its content. */
export const HEADING_AFTER = 4;
/** Space between the tail's left column (notes) and the totals box. */
export const TAIL_COLUMN_GAP = 16;
const MIN_NAME_W = 40;
/** Narrowest the title may be squeezed to share the row with a brand block. */
const MIN_BRAND_W = 90;
const round2 = (n: number) => Math.round(n * 100) / 100;
const leading = (t: TypeToken) => t.leading as number;
export function metaLines(model: RenderModel): string[] {
const label = model.meta.numberLabel === "Invoice No." ? "Invoice Number" : model.meta.numberLabel;
const lines = [`${label}: ${model.meta.number}`, `Invoice Date: ${model.meta.date}`];
if (model.meta.dueDate) lines.push(`Due Date: ${model.meta.dueDate}`);
if (model.client.poNumber) lines.push(`PO No: ${model.client.poNumber}`);
return lines;
}
export function signatureOptions(v: LedgerVariant): SignatoryOptions {
const r = v.roles;
return {
roles: { name: r.bodyStrong, designation: r.small, fine: r.fine },
align: "left",
rule: { width: v.rule, color: v.ink },
color: v.ink,
imageHeight: v.signatureImageH,
};
}
export interface TotalsRowSpec {
label: string;
amount: string;
kind: "plain" | "total";
}
export interface LedgerPlan {
v: LedgerVariant;
W: number;
leftW: number;
rightW: number;
gaps: { rule: number; meta: number; party: number; table: number; tail: number; notes: number };
title: { token: TypeToken; w: number };
brand: {
w: number;
showName: boolean;
nameW: number;
nameH: number;
/** Offsets inside the brand block (which starts at the zone's top). */
logoTop: number;
nameTop: number;
nameLeft: number;
};
headerH: number;
meta: string[];
columns: ColSpec[];
header: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
table: ItemsTablePlan;
guardH: number;
totals: { labelW: number; valueW: number; boxW: number; rowH: number; totalRowH: number; rows: TotalsRowSpec[]; slotRows: number; boxH: number };
notesBeside: boolean;
notesW: number;
wordsH: number;
reverseH: number;
tailH: number;
closingH: number;
payH: number;
signH: number;
signW: number;
}
/**
* Everything the layout must know before rendering: the brand block, columns, the totals box and the
* predicted heights of the totals tail and the closing block (their minPresenceAhead values), each a hair
* high rather than low.
*/
export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVariant, logo: PlacedLogo | null, includesName: boolean): LedgerPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const hsn = Slot.HsnSac(model);
// --- header: title and brand block ------------------------------------------------------------------
const markW = logo?.w ?? 0;
const showName = !(logo && includesName) && model.supplier.name.trim() !== "";
const titleNatural = Math.ceil(measureToken(m, r.title, model.docTitle)) + 1;
const brandCap = Math.floor(W * v.brandMaxShare);
const minBrand = Math.max(markW, MIN_BRAND_W);
const titleW = Math.min(titleNatural, W - v.brandGap - minBrand);
const brandAvail = Math.min(brandCap, W - titleW - v.brandGap);
const markBlock = markW > 0 && showName ? markW + v.markGap : markW;
let nameW = 0;
let nameH = 0;
if (showName) {
const natural = Math.ceil(measureToken(m, r.brand, model.supplier.name)) + 1;
nameW = Math.max(MIN_NAME_W, Math.min(natural, brandAvail - markBlock));
nameH = textHeight(r.brand, model.supplier.name, nameW);
}
const brandW = markBlock + nameW;
const zone = v.brandZone;
const logoTop = logo ? (logo.slotOffset?.y ?? 0) : 0;
const nameTop = logo ? Math.max(0, logoTop + (logo.h - nameH) / 2) : Math.max(0, (zone.h - nameH) / 2);
const brandBottom = zone.top + Math.max(logo ? logoTop + logo.h : 0, showName ? nameTop + nameH : 0);
const headerH = Math.max(v.titleTop + leading(r.title), zone.top + zone.h, brandBottom);
// --- parties ---------------------------------------------------------------------------------------
const leftW = round2(W * v.partyLeftShare);
const rightW = round2(W - leftW - v.partyGap);
const s = model.supplier;
let supplierH = textHeight(r.label, s.name, leftW) + v.headingGap;
for (const l of s.addressLines) supplierH += textHeight(r.body, l, leftW);
supplierH += ([s.email, s.phone, s.pan].filter(Boolean).length + slotHeights.gstinLines(model, "supplier")) * leading(r.body);
const c = model.client;
let clientH = leading(r.label) + v.headingGap + textHeight(r.body, c.name, rightW);
for (const l of c.addressLines) clientH += textHeight(r.body, l, rightW);
clientH += (slotHeights.gstinLines(model, "client") + 0) * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, rightW) * leading(r.body);
const partyH = Math.max(supplierH, clientH);
const meta = metaLines(model);
const metaH = meta.length * leading(r.body) + (meta.length - 1) * v.metaGap;
// --- table -------------------------------------------------------------------------------------------
const hsnCol = hsn;
const ruleCols = (i: number) => (i === 0 ? 2 : 1) * v.rule;
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string, idx: number) =>
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const nCols = hsnCol ? 5 : 4;
const idx = (base: number) => (hsnCol ? base + 1 : base);
const c0 = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit Price", idx(1)),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)),
};
const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }];
const header = ["Item & Description", ...(hsnCol ? [hsnCol.header] : []), "Unit Price", "Qty", "Amount"];
const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"];
const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1));
const tableRows: TableCellValue[][] = model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateInner, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText];
});
void nCols;
// --- totals box --------------------------------------------------------------------------------------
const t = model.totals;
const labelTexts = ["Sub-Total", "Discount", "Taxable value", "UTGST @ 14%"];
const labelW = Math.max(v.totals.labelW, Math.ceil(Math.max(widest(r.totals, labelTexts), widest(r.total, ["Total"]))) + 2 * v.padX + 2 * v.rule + 1);
const amounts = [t.subtotalText, t.totalText, t.discountText ? `- ${t.discountText}` : "", t.taxableText ?? ""];
const valueW = Math.max(v.totals.valueW, Math.ceil(Math.max(widest(r.totals, amounts), widest(r.total, [t.totalText]))) + 2 * v.padX + v.rule + 1);
const boxW = Math.min(labelW + valueW, Math.floor(W * v.totals.maxShare) + 40);
const boxRows: TotalsRowSpec[] = [{ label: "Sub-Total", amount: t.subtotalText, kind: "plain" }];
if (t.discountText) boxRows.push({ label: "Discount", amount: `- ${t.discountText}`, kind: "plain" });
if (t.taxableText) boxRows.push({ label: "Taxable value", amount: t.taxableText, kind: "plain" });
const slotRows = slotHeights.taxRowCount(model);
const rowH = leading(r.totals) + 2 * v.totals.padY + v.rule;
const totalRowH = leading(r.total) + 2 * v.totals.padY + v.rule;
const boxH = (boxRows.length + slotRows) * rowH + totalRowH + v.rule;
// --- tail: notes beside the box, then the words ------------------------------------------------------
const notesW = Math.max(0, W - boxW - TAIL_COLUMN_GAP);
const notesH = model.notesLines.length
? leading(r.label) + v.headingGap + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, notesW), 0)
: 0;
const notesBeside = notesH > 0 && notesW >= 140 && notesH <= v.notesBesideMax;
const wordsH = slotHeights.amountInWords(model, W, { role: r.small, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, W, r.small);
const tailBody = (notesBeside ? Math.max(boxH, notesH) : boxH) + 1 + (wordsH ? WORDS_GAP + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
// --- closing -----------------------------------------------------------------------------------------
const signW = Math.min(v.signatureW, rightW);
const lines = bankLines(model);
let payH = 0;
if (model.bank) {
payH = leading(r.label) + HEADING_AFTER;
for (const l of lines) payH += textHeight(r.small, l, leftW);
}
const signH = leading(r.label) + HEADING_AFTER + signatoryHeight(model, signW, signatureOptions(v));
let closingH = CLOSING_GAP + Math.max(payH, signH) + 1.5;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
// --- elastic gaps ------------------------------------------------------------------------------------
const ranges = { rule: v.headerRule ? v.gaps.rule : { nominal: 0, min: 0 }, meta: v.gaps.meta, party: v.gaps.party, table: v.gaps.table, tail: v.gaps.tail, notes: v.gaps.notes };
const nominalTail = ranges.tail.nominal + tailBody;
const table0 = planTable(model, v, W, columns, header, tableRows, nCols, frame, nominalTail);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, b) => a + b, 0);
const ownNotes = model.notesLines.length > 0 && !notesBeside;
const ownNotesH = ownNotes ? leading(r.label) + v.headingGap + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, W), 0) : 0;
const nominalTotal =
headerH +
ranges.rule.nominal +
(v.headerRule?.thickness ?? 0) +
ranges.meta.nominal +
metaH +
ranges.party.nominal +
partyH +
ranges.table.nominal +
tableTotal +
nominalTail +
(ownNotes ? ranges.notes.nominal + ownNotesH : 0) +
closingH;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
const overflow = nominalTotal - frame.contentHeight;
const deficit = Math.max(frame.heightDeficit, overflow > 0 && overflow <= slack ? overflow : 0);
const g = elastic(ranges, deficit);
const tailH = g.tail + tailBody;
const table = planTable(model, v, W, columns, header, tableRows, nCols, frame, tailH);
// Rows are unbreakable, so the guard asks for the whole first row (the shared 3-line cap leaves a header alone
// above a taller row); only a row beyond 40% of the page is capped, so it cannot push the table off every page.
// A one-row table has no previous row to carry the last row's own presence value (the row is first in its
// container, where react-pdf never breaks before it), so the guard also holds the tail.
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (tableRows.length === 1 ? tailH : 0);
return {
v,
W,
leftW,
rightW,
gaps: { rule: g.rule, meta: g.meta, party: g.party, table: g.table, tail: g.tail, notes: g.notes },
title: { token: r.title, w: titleW },
brand: { w: brandW, showName, nameW, nameH, logoTop, nameTop, nameLeft: markBlock },
headerH,
meta,
columns,
header,
rows: tableRows,
align,
table,
guardH,
totals: { labelW, valueW, boxW, rowH, totalRowH, rows: boxRows, slotRows, boxH },
notesBeside,
notesW,
wordsH,
reverseH,
tailH,
closingH,
payH,
signH,
signW,
};
}
function planTable(
model: RenderModel,
v: LedgerVariant,
W: number,
columns: ColSpec[],
header: string[],
rows: TableCellValue[][],
nCols: number,
frame: PageFrame,
tailH: number,
): ItemsTablePlan {
const r = v.roles;
void model;
return planItemsTable({
columns,
gap: 0,
width: W,
header,
rows,
measurer: getMeasurer(),
roles: { head: r.tableHead, body: r.tableBody },
padX: v.padX,
padY: v.padY,
border: v.rule,
padRight: gridPadRight(nCols, v.rule),
// The header's rule above its cells.
headerTailH: v.rule,
tailH,
contentH: frame.contentHeight,
});
}
export { colsDetailed, textLines };
+110
View File
@@ -0,0 +1,110 @@
import { defineRoles } from "../../fonts/roles";
import { pennant, TANGERINE_TABS } from "../../decor/Pennant";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { LedgerLayoutFor } from "./layoutFor";
import type { LedgerRoles, LedgerVariant } from "./variant";
const INK = "#000000";
const ACCENT = "#FF5F2F";
const D = "Poppins" as const;
const B = "Inter" as const;
/** Height reserved for the footer line (10 pt of text plus air; there is no footer rule). */
const FOOTER_RESERVE = 16;
/**
* Tangerine Ledger's type: Poppins Bold for the brand and title, Inter for everything else
* (docs/templates/11-tangerine-ledger.md). Inter ascent 0.969, descent 0.241.
*/
export const TANGERINE_ROLES = defineRoles({
title: { face: D, weight: 700, size: 33, leading: 40, caps: true },
brand: { face: D, weight: 700, size: 18, leading: 26, minSize: 11 },
label: { face: B, weight: 600, size: 12, leading: 16.5, caps: true },
body: { face: B, weight: 400, size: 12, leading: 16.5 },
bodyStrong: { face: B, weight: 600, size: 12, leading: 16.5 },
tableHead: { face: B, weight: 600, size: 11, leading: 15.4 },
tableBody: { face: B, weight: 400, size: 11, leading: 15.4 },
tableSub: { face: B, weight: 400, size: 8.5, leading: 12 },
totals: { face: B, weight: 400, size: 11, leading: 15.4 },
total: { face: B, weight: 700, size: 12, leading: 16.5 },
small: { face: B, weight: 400, size: 10.5, leading: 14.5 },
wordsLabel: { face: B, weight: 600, size: 10.5, leading: 14.5 },
fine: { face: B, weight: 400, size: 8.5, leading: 12 },
contact: { face: B, weight: 400, size: 10.5, leading: 14.5 },
footer: { face: B, weight: 400, size: 7 },
} satisfies LedgerRoles);
/**
* Reference A4 numbers: the INVOICE box starts at y 46 (baseline 80.6), the brand text is centred in the
* 36 pt allowance at y 62-98, the content runs x 59.5-536.7. The tabs are 35 pt wide, so no side margin
* goes under 45 whatever the preset.
*/
export const TANGERINE_VARIANT: LedgerVariant = {
id: "tangerine-ledger",
roles: TANGERINE_ROLES,
accent: ACCENT,
ink: INK,
onAccent: "#FFFFFF",
margins: { top: 46, right: 59.55, bottom: 60, left: 59.55 },
marginFloor: { top: 36, right: 45, bottom: 36, left: 45 },
mirrored: true,
titleTop: 0,
brandZone: { top: 16, h: 36 },
brandMaxShare: 0.5,
brandGap: 24,
markGap: 10,
headerRule: null,
totalFilled: true,
art: "tabs",
gaps: {
rule: { nominal: 0, min: 0 },
meta: { nominal: 59, min: 32 },
party: { nominal: 20.8, min: 12 },
table: { nominal: 25.4, min: 14 },
tail: { nominal: 23.3, min: 10 },
notes: { nominal: 12, min: 8 },
},
metaGap: 2.2,
headingGap: 6.7,
partyLeftShare: 0.4453,
partyGap: 12.1,
rule: 0.75,
padX: 9.8,
padY: 10.8,
cols: { hsn: 56, rate: 100, qty: 45, amount: 100 },
totals: { labelW: 100, valueW: 105, padY: 6, maxShare: 0.55 },
notesBesideMax: 110,
signatureW: 200,
signatureImageH: 40,
chromeInset: 0,
footerLeftW: 140,
};
export const tangerineLedger: TemplateDefinition = {
...catalogEntry("tangerine-ledger"),
version: 1,
roles: TANGERINE_ROLES,
margins: TANGERINE_VARIANT.margins,
marginFloor: TANGERINE_VARIANT.marginFloor,
decor: {
ink: INK,
everyPage: (frame) => [pennant(frame, { ...TANGERINE_TABS.topRight, color: ACCENT }), pennant(frame, { ...TANGERINE_TABS.bottomLeft, color: ACCENT })],
},
chrome: {
color: "#6f6f6f",
rule: null,
role: TANGERINE_ROLES.footer,
leftWidth: TANGERINE_VARIANT.footerLeftW,
reserve: FOOTER_RESERVE,
},
logo: {
box: { w: 220, h: 36 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => 220,
slot: slotFor("tangerine-ledger"),
available: () => ({ w: 220, h: 36 }),
},
Layout: LedgerLayoutFor(TANGERINE_VARIANT),
};
+107
View File
@@ -0,0 +1,107 @@
import type { Sides } from "../../engine/geometry";
import type { TypeToken } from "../../fonts/roles";
import type { GapRange } from "../neutral/variant";
/** Type roles of a Ledger template (Crimson Grid: all Poppins; Tangerine Ledger: Poppins Bold display over Inter). */
export interface LedgerRoles {
/** "INVOICE" / "TAX INVOICE". */
title: TypeToken;
/** The brand text beside (or instead of) the logo. */
brand: TypeToken;
/** Section headings: vendor name, BILL TO, NOTES / TERMS, PAYMENT METHOD (caps). */
label: TypeToken;
/** Meta, party and notes lines. */
body: TypeToken;
bodyStrong: TypeToken;
tableHead: TypeToken;
tableBody: TypeToken;
/** The unit line under a rate ("per hour"). */
tableSub: TypeToken;
/** Totals labels and amounts. */
totals: TypeToken;
/** The Total row. */
total: TypeToken;
/** Bank lines, amount in words, notes under the table. */
small: TypeToken;
wordsLabel: TypeToken;
fine: TypeToken;
/** The contact line in Crimson's footer band. */
contact: TypeToken;
/** Footer and continuation band: a render-prop role (no leading). */
footer: TypeToken;
}
export type LedgerId = "crimson-grid" | "tangerine-ledger";
/**
* What differs between Crimson Grid and Tangerine Ledger. y values are design distances measured on the
* Reference A4 page and converted to flow gaps; x values are relative to the content edge.
*/
export interface LedgerVariant {
id: LedgerId;
roles: LedgerRoles;
accent: string;
/** Text, grid lines and the total's outline. */
ink: string;
/** Text on the accent (table header, filled Total). */
onAccent: string;
margins: Sides;
marginFloor: Sides;
/** Brand left and title right (Tangerine), or the title left and the brand right (Crimson). */
mirrored: boolean;
/** Offset of the title box below the header row's top (Poppins's tall ascent puts the cap top lower). */
titleTop: number;
/** The slot's area, row-relative: where the brand zone starts and how tall it is. */
brandZone: { top: number; h: number };
/** Widest the brand block may be, as a share of the content width. */
brandMaxShare: number;
/** Space between the title and the brand block. */
brandGap: number;
/** Space between a mark and the brand name. */
markGap: number;
/** A rule under the header (Crimson). */
headerRule: { thickness: number } | null;
/** The Total row is filled with the accent (Tangerine). */
totalFilled: boolean;
/** Footer art: a full-bleed band with the contact line (Crimson) or edge tabs (Tangerine). */
art: "footer-band" | "tabs";
gaps: {
/** Header row bottom to the rule (Crimson only). */
rule: GapRange;
/** Header (or rule) bottom to the meta lines. */
meta: GapRange;
/** Last meta line to the party headings. */
party: GapRange;
/** Party blocks to the table. */
table: GapRange;
/** Last row to the totals box. */
tail: GapRange;
notes: GapRange;
};
/** Extra space between meta lines beyond the leading. */
metaGap: number;
/** Heading to its first line. */
headingGap: number;
/** Width of the vendor column as a share of the content width; the BILL TO column takes the rest. */
partyLeftShare: number;
partyGap: number;
/** Rule width of the grid. */
rule: number;
padX: number;
/** Vertical padding of the header and body rows. */
padY: number;
/** Minimum widths; a column grows to its widest token. */
cols: { hsn: number; rate: number; qty: number; amount: number };
/** Totals box: minimum label and value column widths, and row padding. */
totals: { labelW: number; valueW: number; padY: number; maxShare: number };
/** Notes beside the totals box up to this height (pt); longer notes flow below the table. */
notesBesideMax: number;
signatureW: number;
signatureImageH: number;
/** Footer chrome. */
chromeInset: number;
footerLeftW: number;
}
+6
View File
@@ -0,0 +1,6 @@
import type { TemplateProps } from "./contract";
/** The logo already carries the business name (the user's setting, resolved from Automatic): the template then hides its own name text. */
export function includesName(props: Pick<TemplateProps, "model" | "prefs">): boolean {
return props.prefs.logo?.useWordmark ?? props.model.logo?.includesName ?? false;
}
+2 -2
View File
@@ -27,8 +27,8 @@ const nodes = (layout: LayoutNode[]) => {
};
describe("registry and catalog", () => {
it("lists Classic first and Linea second, then Monogram and Serenity", () => {
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity"]);
it("lists Classic first and Linea second, then the Neutral rest, the Ledger and the Band families", () => {
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
+8
View File
@@ -1,6 +1,10 @@
import type { TemplateDefinition } from "./contract";
import { classic } from "./classic";
import { linea } from "./neutral/linea";
import { crimsonGrid } from "./ledger/crimson";
import { tangerineLedger } from "./ledger/tangerine";
import { slateBand } from "./band/slate";
import { tealSwoosh } from "./band/teal";
import { monogram } from "./neutral/monogram";
import { serenity } from "./neutral/serenity";
@@ -10,6 +14,10 @@ export const TEMPLATES: Record<string, TemplateDefinition> = {
linea,
monogram,
serenity,
"crimson-grid": crimsonGrid,
"tangerine-ledger": tangerineLedger,
"slate-band": slateBand,
"teal-swoosh": tealSwoosh,
};
export const DEFAULT_TEMPLATE_ID = "classic";
+1 -1
View File
@@ -25,7 +25,7 @@ function sources(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return sources(full);
return /\.(ts|tsx)$/.test(name) && !/\.test\.|harness\.ts$/.test(name) ? [full] : [];
return /\.(ts|tsx)$/.test(name) && !/\.test\.|harness(\.check)?\.ts$/.test(name) ? [full] : [];
});
}