Add the Monolith and Cobalt Stripe templates

Standalone layouts for Canva templates 3 and 8, with the user's logo sized to the
placeholder box, a CapRule decor primitive, optional TableRows rule/heading options,
and a separate 1..45-row sweep test file.
This commit is contained in:
2026-10-04 11:25:19 +05:30
parent 5ea62a9be8
commit 385d7974cb
26 changed files with 2795 additions and 37 deletions
+21 -5
View File
@@ -141,6 +141,8 @@ export interface TableRowsProps extends TableLook {
roles: TableRoles;
/** Per column; default left. */
align?: Array<"left" | "right" | "center">;
/** Header alignment per column when it differs from the body's; default `align`. */
headAlign?: Array<"left" | "right" | "center">;
/** Passed as minPresenceAhead on the last row so the totals never start a page without an item row. */
tailH?: number;
/** Content after the last row, inside the table container (the totals). */
@@ -178,6 +180,12 @@ export interface TableRowsProps extends TableLook {
* of the container's left edge. Plan with planItemsTable at width - 2 * rowInset.
*/
pill?: { indent: number; inset: number };
/**
* Vertical rules between the columns only (no outer edges, no rule above the header): `width` after every column but
* the last, in `color` on body rows and `headerColor` in the header (default `color`). The cells stretch to the row's
* height. Plan with `innerRulePadRight(...)` added to the padding so wrapping uses the real inner width.
*/
innerRules?: { width: number; color: string; headerColor?: string };
}
/** Extra right padding per column that stands in for a grid's vertical rules, for planItemsTable's `padRight`. */
@@ -185,12 +193,17 @@ export function gridPadRight(columns: number, ruleWidth: number): number[] {
return Array.from({ length: columns }, (_, i) => (i === 0 ? 2 : 1) * ruleWidth);
}
/** Extra right padding per column that stands in for the inner rules of `innerRules`, for planItemsTable's `padRight`. */
export function innerRulePadRight(columns: number, ruleWidth: number): number[] {
return Array.from({ length: columns }, (_, i) => (i < columns - 1 ? ruleWidth : 0));
}
/**
* Header (a `fixed` first child, so it repeats on every page the table spans) followed by one
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because
* the repeated header is positioned against the container's top.
*/
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, headCellStyle, borderColor, headColor, headerTail, grid, bleed, pill, ...look }: TableRowsProps) {
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, headCellStyle, borderColor, headColor, headerTail, grid, bleed, pill, innerRules, headAlign, ...look }: TableRowsProps) {
const padX = look.padX ?? 0;
const padY = look.padY ?? 4;
const headPadY = look.headPadY ?? padY;
@@ -208,14 +221,17 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
const last = i === values.length - 1;
const inner = isHeader ? (grid?.headerColor ?? grid?.color) : grid?.color;
const outer = isHeader ? (grid?.headerEdge ?? grid?.color) : grid?.color;
const ruleW = edge * (i === 0 ? 2 : 1);
const inner2 = innerRules && !last ? innerRules.width : 0;
const ruleW = grid ? edge * (i === 0 ? 2 : 1) : inner2;
const alignOf = isHeader ? (headAlign?.[i] ?? align[i]) : align[i];
return (
<Cell
key={i}
padding={{ top: isHeader ? headPadY : padY, bottom: isHeader ? headPadY : padY, left: padX + (look.padLeft?.[i] ?? 0), right: padX + (look.padRight?.[i] ?? 0) }}
borderLeft={grid && i === 0 ? edge : 0}
borderRight={edge}
borderRight={grid ? edge : inner2}
style={{
...(innerRules ? { flexGrow: 1, flexBasis: "auto", ...(inner2 ? { borderRightColor: isHeader ? (innerRules.headerColor ?? innerRules.color) : innerRules.color } : {}) } : {}),
...(grid
? {
flexGrow: 1,
@@ -229,14 +245,14 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
}}
>
{cellParts(v).map((part, li) => (
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0) - (look.padLeft?.[i] ?? 0) - ruleW)} align={align[i]} faceOverrides={look.faceOverrides}>
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0) - (look.padLeft?.[i] ?? 0) - ruleW)} align={alignOf} faceOverrides={look.faceOverrides}>
{partText(part)}
</Txt>
))}
</Cell>
);
});
const rowAlign = grid ? ("stretch" as const) : undefined;
const rowAlign = grid || innerRules ? ("stretch" as const) : undefined;
const inset = pill?.inset ?? 0;
const cellsWidth = width - 2 * inset;
/** One table line: a plain Row, or for pills an outer box (the fill) around a Row of the inner width. */
+29
View File
@@ -0,0 +1,29 @@
import { Line, Svg } from "@react-pdf/renderer";
export interface CapRuleProps {
/** Total width of the Svg, pt. */
width: number;
/** The grey line: its ends are round caps, so x runs between its outer edges. */
line: { from: number; to: number; thickness: number; color: string };
/** Thicker accent segments drawn over the line (round caps, centred on the line), pt from the left. */
accents: Array<{ from: number; to: number; thickness: number; color: string }>;
}
/**
* A grey rule with round ends and short accent segments over it (Cobalt Stripe's header and footer rules). One static
* Svg, so it stays in the flow with a pinned size and needs no absolute positioning. Every `from`/`to` is the outer
* edge of the segment including its caps.
*/
export function CapRule({ width, line, accents }: CapRuleProps) {
const h = Math.max(line.thickness, ...accents.map((a) => a.thickness));
const seg = (s: { from: number; to: number; thickness: number; color: string }, key: number) => {
const r = s.thickness / 2;
return <Line key={key} x1={s.from + r} y1={h / 2} x2={Math.max(s.from + r, s.to - r)} y2={h / 2} stroke={s.color} strokeWidth={s.thickness} strokeLinecap="round" />;
};
return (
<Svg width={width} height={h} viewBox={`0 0 ${width} ${h}`}>
{seg(line, -1)}
{accents.map((a, i) => seg(a, i))}
</Svg>
);
}
+31
View File
@@ -8,6 +8,7 @@ import { setupPdfTest } from "../testing/setup";
import { loadCanvas } from "../testing/rasterize";
import { inkPixels } from "../testing/rasterize";
import { cornerSwoosh, mirrorPath, SWOOSH_BOTTOM_LEFT, SWOOSH_BOX, SWOOSH_PAGE, SWOOSH_TOP_RIGHT, swooshKeepOuts } from "./CornerSwoosh";
import { CapRule } from "./CapRule";
import { CircledIcon, ContactIcon } from "./CircledIcon";
import { fullBleedBand, GradientDefs } from "./FullBleedBand";
import { pennant, pennantBox, TANGERINE_TABS } from "./Pennant";
@@ -144,3 +145,33 @@ describe("icons", () => {
expect(await inkPixels(canvas, r.bytes, { x0: 40, y0: 40, x1: 43, y1: 43 })).toBe(0);
});
});
describe("CapRule", () => {
it("is one static Svg of the rule's own size, so it stays in the flow", async () => {
const r = await renderCore(
h(Document, null, h(Page, { size: "A4", style: { padding: 40 } }, h(CapRule, { width: 300, line: { from: 0.75, to: 300, thickness: 1.5, color: "#A6A6A6" }, accents: [{ from: 1.5, to: 37.2, thickness: 3, color: "#51ADE5" }] }))),
{ frame: frame("A4") },
);
const [svg] = find(r.layout[0], (n) => n.type === "svg");
expect(find(r.layout[0], (n) => n.type === "svg")).toHaveLength(1);
expect([svg.box.w, svg.box.h]).toEqual([300, 3]);
expect(svg.abs).toBeFalsy();
});
it("draws the accent thicker than the grey line, with rounded ends inside the segment's outer edges", async () => {
const canvas = await loadCanvas();
if (!canvas) return;
const r = await renderCore(
h(Document, null, h(Page, { size: "A4", style: { padding: 40 } }, h(CapRule, { width: 300, line: { from: 0.75, to: 300, thickness: 1.5, color: "#000000" }, accents: [{ from: 1.5, to: 37.2, thickness: 3, color: "#000000" }] }))),
{ frame: frame("A4") },
);
const band = (x0: number, x1: number) => inkPixels(canvas, r.bytes, { x0: 40 + x0, y0: 40, x1: 40 + x1, y1: 43 });
// Per unit of width the accent's strip carries clearly more ink than the grey line (3 pt against 1.5 pt, at pixel resolution).
const accent = (await band(10, 30)) / 20;
const grey = (await band(100, 120)) / 20;
expect(accent / grey).toBeGreaterThan(1.3);
expect(accent / grey).toBeLessThan(2.4);
// Nothing is drawn beyond the line's outer end.
expect(await band(301, 304)).toBe(0);
});
});
+1 -1
View File
@@ -51,7 +51,7 @@ describe("registry and catalog", () => {
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", "purple-pop", "citrus-split"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
});
it("both are Montserrat throughout, and their slots are the measured Canva ones", () => {
+6 -1
View File
@@ -8,7 +8,7 @@ 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(10);
expect(TEMPLATE_CATALOG).toHaveLength(12);
});
it("groups the C4 templates into the Ledger and Band families", () => {
@@ -20,6 +20,11 @@ describe("template catalog", () => {
expect(FAMILY_LABELS.band).toBe("Band");
});
it("lists the standalone templates, Monolith and Cobalt Stripe, last", () => {
expect(TEMPLATE_CATALOG.filter((e) => e.family === "standalone").map((e) => e.id)).toEqual(["monolith", "cobalt-stripe"]);
expect(FAMILY_LABELS.standalone).toBe("Standalone");
});
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);
+4 -1
View File
@@ -5,7 +5,7 @@
export interface TemplateCatalogEntry {
id: string;
name: string;
family: "classic" | "neutral" | "ledger" | "band" | "northline";
family: "classic" | "neutral" | "ledger" | "band" | "northline" | "standalone";
/** Static PNG from /public, written by scripts/thumbnails.mjs (sample data, not the user's invoice). */
thumbnail: string;
badge?: "Matches your original format";
@@ -19,6 +19,7 @@ export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
ledger: "Ledger",
band: "Band",
northline: "Northline",
standalone: "Standalone",
};
/** Picker order: Classic first, Linea (the vendor's original format) second. */
@@ -33,6 +34,8 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true },
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" },
{ id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" },
{ id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.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", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
expect(getTemplate("classic")).toBe(classic);
expect(getTemplate("no-such-template")).toBe(classic);
expect(getTemplate(undefined)).toBe(classic);
+438
View File
@@ -0,0 +1,438 @@
import { Image, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { pinned } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
import { IdText } from "../../blocks/IdText";
import { TableRows } from "../../blocks/ItemsTable";
import { Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { Slot, slotHeights } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Tracked } from "../../blocks/Tracked";
import { Txt } from "../../blocks/Txt";
import { CapRule } from "../../decor/CapRule";
import { ContactIcon } from "../../decor/CircledIcon";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import type { PlacedLogo, TemplateProps } from "../contract";
import { includesName } from "../logoName";
import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
import { labelWithColon, NOTES_FLOW_GAP, planCobalt, signatureOptions, type CobaltPlan } from "./plan";
import { COBALT, GREY, INK, RULE_GREY, SKY, WHITE_INK, ZEBRA, type CobaltVariant } from "./tokens";
interface Ctx {
model: TemplateProps["model"];
plan: CobaltPlan;
v: CobaltVariant;
logo: PlacedLogo | null;
}
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
// --- header -----------------------------------------------------------------------------------------------------
function Tagline({ ctx }: { ctx: Ctx }) {
const { model, v } = ctx;
return <Slot.Gstin model={model} party="supplier" role={v.roles.tagline} color={INK} />;
}
/** Logo, name and tagline: a mark sits beside the name, a wordmark replaces it with the tagline under the logo. */
function Supplier({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const h = plan.head;
const image = logo ? <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, objectFit: "contain" } as never} /> : null;
if (h.mode === "name") {
return (
<View id={SUPPLIER_ID} style={{ ...pinned(h.leftW), flexDirection: "row" } as never}>
{image ? <View style={{ marginTop: v.head.logoTop }}>{image}</View> : null}
<View style={{ ...pinned(h.nameW), marginLeft: logo ? v.head.markGap : 0, marginTop: v.head.nameTop } as never}>
<AvailableWidthProvider width={h.nameW}>
<SafeTxt role={v.roles.brand} color={INK}>
{model.supplier.name}
</SafeTxt>
{h.tagline ? (
<View style={{ marginTop: v.head.taglineGap }}>
<Tagline ctx={ctx} />
</View>
) : null}
</AvailableWidthProvider>
</View>
</View>
);
}
return (
<View id={SUPPLIER_ID} style={pinned(h.leftW) as never}>
{image ? <View style={{ marginTop: v.head.logoTop }}>{image}</View> : null}
{h.tagline ? (
<View style={{ ...pinned(h.leftW), marginTop: image ? 3 : 0 } as never}>
<AvailableWidthProvider width={h.leftW}>
<Tagline ctx={ctx} />
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function HeaderRow({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const h = plan.head;
return (
<Row widths={[{ fill: true }, { fixed: h.titleW }]} width={plan.W} style={{ minHeight: h.h }}>
<Supplier ctx={ctx} />
<View style={pinned(h.titleW) as never}>
<Tracked id={TITLE_ID} role={{ ...v.roles.title, size: h.titleSize }} color={COBALT} align="right" width={h.titleW}>
{model.docTitle}
</Tracked>
</View>
</Row>
);
}
function HeaderRule({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const h = plan.head;
const r = v.head.rule;
const rule = (
<View style={{ marginTop: (h.ruleH - r.accent) / 2 + r.drop }}>
<CapRule width={h.ruleW} line={{ from: r.inset, to: h.ruleW, thickness: r.thickness, color: RULE_GREY }} accents={[{ from: r.inset + 0.7, to: r.inset + 0.7 + r.accentW, thickness: r.accent, color: SKY }]} />
</View>
);
if (!h.ruleText) return <View style={{ ...pinned(plan.W), minHeight: h.ruleH } as never}>{rule}</View>;
return (
<Row widths={[{ fixed: h.ruleW }, { fixed: plan.W - h.ruleW }]} width={plan.W} style={{ minHeight: h.ruleH }}>
{rule}
<View style={{ ...pinned(h.ruleTextW), marginLeft: v.head.ruleTextGap } as never}>
<AvailableWidthProvider width={h.ruleTextW}>
<IdText role={v.roles.ruleText} color="#000000" align="right" fill width={h.ruleTextW}>
{h.ruleText}
</IdText>
</AvailableWidthProvider>
</View>
</Row>
);
}
// --- info row ---------------------------------------------------------------------------------------------------
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
const w = plan.info.clientW;
const more = c.addressLines.length > 0 || slotHeights.gstinLines(model, "client") > 0 || slotHeights.placeOfSupplyLines(model, r.clientLine, w) > 0;
return (
<View style={pinned(w) as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.label} color={INK} width={w}>
Billed to :
</Txt>
<View style={{ marginBottom: more ? v.info.nameGap : 0 }}>
<SafeTxt role={r.clientName} color={INK}>
{c.name}
</SafeTxt>
</View>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.clientLine} color={GREY}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.clientLine} color={GREY} />
<Slot.PlaceOfSupply model={model} role={r.clientLine} color={GREY} />
</AvailableWidthProvider>
</View>
);
}
function MetaBlock({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const w = plan.info.metaW;
return (
<View id={META_ID} style={pinned(w) as never}>
<AvailableWidthProvider width={w}>
{plan.info.lines.map((l, i) => (
<IdText key={i} role={l.strong ? v.roles.metaStrong : v.roles.metaLine} color={l.cancelled ? CANCELLED_INK : INK} align="right" fill width={w}>
{l.text}
</IdText>
))}
</AvailableWidthProvider>
</View>
);
}
// --- totals -----------------------------------------------------------------------------------------------------
function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) {
const { plan, v } = ctx;
const t = plan.tail;
const labelW = v.totals.labelW;
return (
<Row widths={[{ fixed: labelW }, { fixed: t.valueW }]} width={t.rightW}>
<Txt role={v.roles.totalLabel} color={INK} width={labelW} align="right">
{label}
</Txt>
<Txt role={v.roles.totalValue} color={INK} width={t.valueW - v.totals.valuePad} align="right">
{amount}
</Txt>
</Row>
);
}
function Bar({ v, width, children }: { v: CobaltVariant; width: number; children: ReactNode }) {
return <View style={{ ...pinned(width), minHeight: v.totals.barH, paddingTop: (v.totals.barH - (v.roles.barLabel.leading as number)) / 2, backgroundColor: COBALT } as never}>{children}</View>;
}
function RightColumn({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const t = plan.tail;
const o = v.totals;
return (
<View style={pinned(t.rightW) as never}>
<AvailableWidthProvider width={t.rightW}>
{t.stack.map((row, i) => (
<StackRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
))}
<Slot.TaxRows model={model} role={r.totalLabel} color={INK} amountWidth={t.valueW} format={(label, rate) => labelWithColon(label, rate)} renderRow={(row) => <StackRow ctx={ctx} label={row.label} amount={row.amount} />} />
<View style={{ marginTop: o.barGap }}>
<Bar v={v} width={t.rightW}>
<Row widths={[{ fixed: o.labelW }, { fixed: t.valueW }]} width={t.rightW}>
<Txt role={r.barLabel} color={WHITE_INK} width={o.labelW} align="right">
{"Grand total :"}
</Txt>
<Txt role={r.barValue} color={WHITE_INK} width={t.valueW - o.valuePad} align="right">
{model.totals.totalText}
</Txt>
</Row>
</Bar>
</View>
{t.wordsH > 0 ? (
<View style={{ marginTop: o.wordsGap }}>
<Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={GREY} align="right" />
</View>
) : null}
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.words} color={GREY} style={{ marginTop: o.reverseGap, textAlign: "right" }} /> : null}
</AvailableWidthProvider>
</View>
);
}
function LeftColumn({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const t = plan.tail;
const o = v.totals;
if (!model.bank) return <View />;
return (
<View style={{ ...pinned(t.leftW), marginTop: t.leftTop } as never}>
<AvailableWidthProvider width={t.leftW}>
<Bar v={v} width={o.leftBarW}>
<View style={{ marginLeft: o.barInset }}>
<Txt role={r.barLabel} color={WHITE_INK} width={o.leftBarW - o.barInset}>
Payment method :
</Txt>
</View>
</Bar>
<View style={{ marginTop: o.bankGap, marginLeft: 1.5, ...pinned(t.leftW - o.barInset) } as never}>
<AvailableWidthProvider width={t.leftW - o.barInset}>
{t.bank.map((line, i) => (
<SafeTxt key={i} role={r.bank} color={INK}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
</AvailableWidthProvider>
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
return (
// The gap is inside the block, so the totals' height is what the last row's minPresenceAhead (tailH) asks for.
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
<View style={{ marginTop: plan.gaps.tail }}>
<Row widths={[{ fill: true }, { fixed: plan.tail.rightW }]} gap={v.totals.leftGap} width={plan.W}>
<LeftColumn ctx={ctx} />
<RightColumn ctx={ctx} />
</Row>
</View>
</View>
);
}
// --- closing ----------------------------------------------------------------------------------------------------
function Terms({ ctx, width }: { ctx: Ctx; width: number }) {
const { plan, v } = ctx;
const r = v.roles;
const lines = plan.closing.terms;
if (lines.length === 0) return null;
return (
<View style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
{/* The heading travels with the first line, and no paragraph splits (a cut Text leaves a zero-line node). */}
<View wrap={false}>
<Txt role={r.termsHead} color={INK} width={width} style={{ marginBottom: 3.8 }}>
Term and Conditions :
</Txt>
<SafeTxt role={r.terms} color={GREY}>
{lines[0]}
</SafeTxt>
</View>
{lines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.terms} color={GREY} wrap={false}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function Contacts({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const { size, gap } = v.closing.icon;
return (
<Row widths={c.contacts.map((x) => ({ fixed: size + gap + x.textW }))} gap={c.contactGap} width={c.contactsW}>
{c.contacts.map((x, i) => (
<View key={i} style={{ ...pinned(size + gap + x.textW), flexDirection: "row" } as never}>
<ContactIcon name={x.icon} size={size} color={SKY} />
<View style={{ ...pinned(x.textW), marginLeft: gap, marginTop: Math.max(0, (size - (r.contact.leading as number)) / 2) } as never}>
<AvailableWidthProvider width={x.textW}>
{x.id ? (
<IdText role={r.contact} color={INK}>
{x.lines[0]}
</IdText>
) : (
x.lines.map((line, j) => (
<SafeTxt key={j} role={r.contact} color={INK}>
{line}
</SafeTxt>
))
)}
</AvailableWidthProvider>
</View>
</View>
))}
</Row>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
const rule = v.head.rule;
const opts = signatureOptions(v);
const W = plan.W;
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={pinned(W) as never}>
<AvailableWidthProvider width={W}>
<Row widths={[{ fill: true }, { fixed: c.signCol }]} width={W}>
<View style={pinned(c.leftW) as never}>
<CapRule width={cl.divider.w} line={{ from: 0, to: cl.divider.w, thickness: cl.divider.thickness, color: RULE_GREY }} accents={[]} />
<Spacer h={cl.dividerGap} />
<Txt role={r.thanks} color={INK} width={c.leftW}>
Thank you for business with us!
</Txt>
{c.termsBeside && c.terms.length ? (
<View style={{ marginTop: plan.gaps.terms }}>
<Terms ctx={ctx} width={c.leftW} />
</View>
) : null}
</View>
<Slot.Signatory model={model} width={cl.signW} {...opts} />
</Row>
<Spacer h={plan.gaps.footer} />
<CapRule
width={W}
line={{ from: cl.footerRule.inset, to: W - cl.footerRule.inset, thickness: rule.thickness, color: RULE_GREY }}
accents={[
{ from: cl.footerRule.accentInset, to: cl.footerRule.accentInset + cl.footerRule.accentW, thickness: rule.accent, color: SKY },
{ from: W - cl.footerRule.accentInset - cl.footerRule.accentW, to: W - cl.footerRule.accentInset, thickness: rule.accent, color: SKY },
]}
/>
{c.contacts.length ? (
<View style={{ marginTop: cl.ruleToContacts }}>
<Contacts ctx={ctx} />
</View>
) : null}
{model.cancelled ? (
<Txt role={r.fine} color={CANCELLED_INK} width={W} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- the layout -------------------------------------------------------------------------------------------------
export function CobaltBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: CobaltVariant }) {
const plan = planCobalt(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
const notesFlow: ReactNode = plan.closing.termsBeside ? null : (
<View style={{ marginTop: NOTES_FLOW_GAP }}>
<Terms ctx={ctx} width={plan.closing.flowW} />
</View>
);
return (
<>
<FirstPageHeader pad={frame.pad}>
<HeaderRow ctx={ctx} />
<Spacer h={plan.head.ruleGap} />
<HeaderRule ctx={ctx} />
<Spacer h={plan.gaps.client} />
<Row widths={[{ fixed: plan.info.clientW }, { fixed: plan.info.metaW }]} gap={v.info.clientGap} width={plan.W}>
<ClientBlock ctx={ctx} />
<MetaBlock 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.headerCells}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
headAlign={plan.headAlign}
padX={0}
padLeft={plan.padLeft}
padRight={plan.padRight}
padY={plan.padY}
headPadY={plan.headPadY}
border={0}
headColor={WHITE_INK}
headerStyle={{ backgroundColor: COBALT }}
headCellStyle={() => v.table.headPad}
innerRules={{ width: v.table.separator, color: ZEBRA, headerColor: COBALT }}
rowStyle={(i) => (i % 2 === 1 ? { backgroundColor: ZEBRA } : {})}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{notesFlow}
<Closing ctx={ctx} />
</>
);
}
+414
View File
@@ -0,0 +1,414 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { SLOT_ID } from "../../blocks/slots";
import { frameForPrefs, renderInvoicePdf } from "../../render/invoice";
import { loadCanvas, pagePng } from "../../testing/rasterize";
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
import { marginsFor } from "../contract";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { SLOTS } from "../slots.generated";
import { cobaltStripe } from "./cobalt";
import { planCobalt } from "./plan";
import { COBALT, COBALT_ROLES, COBALT_VARIANT, SKY, ZEBRA } from "./tokens";
ensureSetup();
const V = COBALT_VARIANT;
const slot = SLOTS["cobalt-stripe"]!;
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
const out: LayoutNode[] = [];
for (const p of layout) walk(p.nodes, (n) => out.push(n));
return out;
};
type R = Awaited<ReturnType<typeof renderFixture>>;
const onPage = (r: R, i: number) => nodes([r.result.core.layout[i]]);
const imageBoxes = (r: R) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
const byId = (r: R, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
const textLines = (root: LayoutNode) => {
const out: Array<{ text: string; x: number; right: number; y: number }> = [];
walk([root], (n) => {
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ text: l.text.trim(), x: l.x, right: l.x + l.w - (n.letterSpacing ?? 0), y: l.y });
});
return out;
};
const contentRight = (r: R) => {
const f = frameForPrefs(r.prefs);
return f.page.width - f.pad.right;
};
/** The Canva sample's shape: no due date, no logo, nothing the design has no slot for. */
const NAMES = ["Logo Design", "Website Design", "Brand Design", "Banner Design", "Flyer Design", "Social Media Template", "Name Card", "Web Developer"];
const PLAIN: FixtureSpec = {
name: "plain",
items: NAMES.map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i })),
registered: false,
logo: "none",
invoice: { dueDate: "", notes: "" },
};
/** RGB of pixels 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 near = (c: number[], hex: string, tol = 8) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).every((w, i) => Math.abs(c[i] - w) <= tol);
describe("registry, catalog and fonts", () => {
it("is registered last, as a standalone template with its thumbnail on disk", () => {
expect(getTemplate("cobalt-stripe")).toBe(cobaltStripe);
expect(cobaltStripe.family).toBe("standalone");
expect(catalogEntry("cobalt-stripe").name).toBe("Cobalt Stripe");
expect(existsSync(path.join(process.cwd(), "public", cobaltStripe.thumbnail)), cobaltStripe.thumbnail).toBe(true);
expect(Object.keys(TEMPLATES).at(-1)).toBe("cobalt-stripe");
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("uses Poppins 400/700 and Open Sans 400/700 only, and the design's palette only", () => {
for (const [name, token] of Object.entries(COBALT_ROLES)) {
expect(["Poppins", "Open Sans"], name).toContain(token.face);
expect([400, 700], name).toContain(token.weight);
}
const palette = new Set([COBALT, ZEBRA, SKY, "#1B1B1B", "#595A5A", "#A6A6A6", "#FFFFFF", "#000000"].map((c) => c.toLowerCase()));
const dir = path.join(process.cwd(), "src/pdf/templates/cobalt");
for (const f of readdirSync(dir).filter((x) => /\.(ts|tsx)$/.test(x) && !/\.test\./.test(x))) {
const colours = readFileSync(path.join(dir, f), "utf8").match(/#[0-9a-fA-F]{6}\b/g) ?? [];
expect(colours.filter((c) => !palette.has(c.toLowerCase())), f).toEqual([]);
}
});
});
describe("page geometry", () => {
it("template margins: the logo's top is the page margin, the footer's bottom sits 59.5 pt above the page foot", () => {
expect(marginsFor(cobaltStripe, "template")).toEqual({ top: 54.2, right: 59.55, bottom: 35.5, left: 59.55 });
const frame = frameForPrefs(prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(frame.pad.bottom).toBeCloseTo(59.5, 1);
expect(frame.contentWidth).toBeCloseTo(476.18, 1);
});
it("Letter keeps the physical margins and gives the extra width to the description", async () => {
const a4 = frameForPrefs(prefsFor("A4", { templateId: "cobalt-stripe" }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "cobalt-stripe" }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
const model = buildFixture(PLAIN).model;
const pa = planCobalt(model, a4, V, null, false);
const pl = planCobalt(model, letter, V, null, false);
expect(pl.cw).toEqual(pa.cw);
expect(pl.table.widths[1] - pa.table.widths[1]).toBeCloseTo(letter.contentWidth - a4.contentWidth, 1);
});
});
describe("header", () => {
it("matches the design: name, INVOICE and the rule text at the Canva baselines, INVOICE and the rule text ending on the right edge", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
const items = r.pdf.pages[0].items;
const right = contentRight(r);
const name = items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
const title = items.find((i) => i.str.trim() === "INVOICE")!;
const pan = items.find((i) => i.str.startsWith("PAN"))!;
expect(name.bottom).toBeCloseTo(74.6, 0);
expect(name.x0).toBeCloseTo(59.5, 0);
expect(title.bottom).toBeCloseTo(87.3, 0);
expect(title.x0).toBeCloseTo(395.6, 0);
expect(pan.bottom).toBeCloseTo(117.1, 0);
expect(pan.x1).toBeCloseTo(right, 0);
// INVOICE is tracked: its ink ends on the edge (the trailing letter-spacing is cancelled by Tracked).
expect(title.x1).toBeLessThanOrEqual(right + 1.5);
expect(title.x1).toBeGreaterThan(right - 3);
});
it("the GSTIN is the tagline under the name and the PAN is the rule text; the Canva script signature is not reproduced", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const supplier = textLines(byId(r, SUPPLIER_ID)).map((l) => l.text);
expect(supplier).toEqual(expect.arrayContaining(["Test Vendor", "GSTIN: 29AAAAA0000A1Z5"]));
expect(r.pdf.pages[0].items.some((i) => i.str.startsWith("PAN: ABCDE1234F"))).toBe(true);
expect(r.pdf.raw).not.toMatch(/BDScript|BD Script/i);
});
it("TAX INVOICE takes the room it needs and the left block shrinks; a long invoice number never meets the title", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345" } }, prefsFor(size, { templateId: "cobalt-stripe" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
const meta = textLines(byId(r, META_ID));
expect(Math.max(...meta.map((l) => l.right))).toBeLessThanOrEqual(contentRight(r) + 0.5);
const supplier = byId(r, SUPPLIER_ID);
const title = byId(r, TITLE_ID);
expect(supplier.box.x + supplier.box.w).toBeLessThanOrEqual(title.box.x + 0.5);
}
});
it("the grey rule has its sky accent at the left and sits under the header at the design's y (113.8)", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
const frame = frameForPrefs(r.prefs);
const svgs = onPage(r, 0).filter((n) => n.type === "svg" && !n.decor);
const rule = svgs.find((n) => n.box.h === V.head.rule.accent && n.box.w > 300)!;
expect(rule.box.y + rule.box.h / 2).toBeCloseTo(113.8, 0);
const [accent, grey] = await colourAt(r.result.bytes, [
[rule.box.x + 20, rule.box.y + rule.box.h / 2],
[rule.box.x + 120, rule.box.y + rule.box.h / 2],
]);
expect(near(accent, SKY), `accent ${accent}`).toBe(true);
expect(near(grey, "#A6A6A6"), `grey ${grey}`).toBe(true);
expect(rule.box.x).toBeCloseTo(frame.pad.left, 1);
});
});
describe("logo", () => {
it("a wordmark fills the measured lockup box from its left edge and replaces the name; the tagline stays under it", async () => {
const lockup = slot.lockup!;
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor(size, { templateId: "cobalt-stripe" }));
const [img] = imageBoxes(r);
expect(imageBoxes(r)).toHaveLength(1);
expect(img.box.h).toBeLessThanOrEqual(lockup.h + 0.6);
expect(img.box.w).toBeLessThanOrEqual(lockup.w + 0.6);
expect(img.box.x).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 1);
expect(r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(0);
const tag = r.pdf.pages[0].items.find((i) => i.str.startsWith("GSTIN: 29AAAAA"))!;
expect(tag.top).toBeGreaterThanOrEqual(img.box.y + img.box.h - 0.5);
}
});
it("a mark keeps to the measured mark box and keeps the name beside it; with no logo the name is Poppins Bold at the design's baseline", async () => {
const mark = slot.mark!;
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const [img] = imageBoxes(square);
expect(img.box.h).toBeLessThanOrEqual(mark.h + 0.6);
expect(img.box.w).toBeLessThanOrEqual(mark.w + 0.6);
const name = square.pdf.pages[0].items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
expect(name.x0).toBeGreaterThanOrEqual(img.box.x + img.box.w + V.head.markGap - 0.6);
const none = await renderFixture({ name: "o", items: 2, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(imageBoxes(none)).toHaveLength(0);
expect(none.pdf.pages[0].items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!.bottom).toBeCloseTo(74.6, 0);
});
it("a tall logo is held to the lockup height", async () => {
const { model } = buildFixture({ name: "tall", items: 2, logo: "square" });
model.logo = { ...model.logo!, aspect: 0.5, width: 60, height: 120, kind: "tall", includesName: false };
const result = await renderInvoicePdf({ model, prefs: prefsFor("A4", { templateId: "cobalt-stripe" }) });
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
const img = nodes(result.core.layout).find((n) => n.type === "image" && !n.decor)!;
expect(img.box.h).toBeLessThanOrEqual(slot.mark!.h + 0.6);
});
});
describe("table", () => {
it("is a cobalt bar over zebra rows: 20.76 pt header, 17.82 pt rows, white and light-blue alternating, 1.5 pt light-blue separators", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
const frame = frameForPrefs(r.prefs);
const head = byId(r, TABLE_HEAD_ID);
expect(head.box.h).toBeCloseTo(V.table.headH, 0);
const rows = [0, 1, 2, 3].map((i) => byId(r, `${TABLE_ROW_PREFIX}${i}`));
for (const row of rows) expect(row.box.h).toBeCloseTo(V.table.rowH, 0);
const plan = planCobalt(r.fixture.model, frame, V, null, false);
const left1 = frame.pad.left + plan.cw.no;
const colours = await colourAt(r.result.bytes, [
[head.box.x + 1.5, head.box.y + 10],
[rows[0].box.x + 1.5, rows[0].box.y + 8],
[rows[1].box.x + 1.5, rows[1].box.y + 8],
[left1 - 0.75, rows[0].box.y + 8],
[left1 - 0.75, head.box.y + 10],
[left1 - 0.75, rows[1].box.y + 8],
]);
if (colours.length) {
expect(near(colours[0], COBALT), `header ${colours[0]}`).toBe(true);
expect(near(colours[1], "#FFFFFF"), `row 1 ${colours[1]}`).toBe(true);
expect(near(colours[2], ZEBRA), `row 2 ${colours[2]}`).toBe(true);
expect(near(colours[3], ZEBRA), `separator on a white row ${colours[3]}`).toBe(true);
expect(near(colours[4], COBALT), `no separator in the header ${colours[4]}`).toBe(true);
}
});
it("columns follow the design: NO 24.9, QTY 84.9, PRICE 105.4, TOTAL 89.3 pt; amounts end 3.1 pt inside the edge, prices 31.7 pt inside their column", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
const frame = frameForPrefs(r.prefs);
const plan = planCobalt(r.fixture.model, frame, V, null, false);
expect(plan.cw).toMatchObject({ no: 24.9, qty: 84.9, price: 105.4, amount: 89.3 });
const right = contentRight(r);
const priceRight = right - plan.cw.amount - V.table.priceInset;
// The headings follow the design: DESCRIPTION is centred over its left-aligned column, PRICE ends where the prices do.
const heads = r.pdf.pages[0].items;
const desc = heads.find((i) => i.str.trim() === "DESCRIPTION")!;
const descLeft = frame.pad.left + plan.cw.no;
expect((desc.x0 + desc.x1) / 2).toBeCloseTo(descLeft + plan.table.widths[1] / 2, 0);
expect(heads.find((i) => i.str.trim() === "PRICE")!.x1).toBeCloseTo(priceRight, 0);
for (let i = 0; i < 3; i++) {
const lines = textLines(byId(r, `${TABLE_ROW_PREFIX}${i}`));
const amount = lines.find((l) => /^₹[\d,]+\.\d\d$/.test(l.text) && l.right > right - 20)!;
expect(amount.right, `row ${i}`).toBeCloseTo(right - V.table.amountInset, 0);
const price = lines.find((l) => l.text.endsWith("/hr"))!;
expect(price.right, `row ${i} price`).toBeCloseTo(priceRight, 0);
}
});
});
describe("totals", () => {
it("Sub Total rows end 3.1 pt inside the edge, labels end 95.9 pt into the 214.5 pt column; the GRAND TOTAL and PAYMENT METHOD bars share a top", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const frame = frameForPrefs(r.prefs);
const totals = byId(r, TOTALS_ID);
const lines = textLines(totals);
const right = contentRight(r);
const colLeft = right - V.totals.rightW;
const sub = lines.find((l) => l.text === "Sub Total :")!;
expect(sub.right).toBeCloseTo(colLeft + V.totals.labelW, 0);
expect(lines.find((l) => l.text === r.fixture.model.totals.subtotalText)!.right).toBeCloseTo(right - V.totals.valuePad, 0);
const bars = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.totals.barH) < 0.5 && n.box.y >= totals.box.y);
// The Sub Total rows are as tall and wide as the bar, so the bar is the lowest of them.
const grand = bars.filter((n) => Math.abs(n.box.w - V.totals.rightW) < 0.5).sort((a, b) => b.box.y - a.box.y)[0];
const pay = bars.find((n) => Math.abs(n.box.w - V.totals.leftBarW) < 0.5)!;
expect(grand.box.x).toBeCloseTo(colLeft, 1);
expect(pay.box.x).toBeCloseTo(frame.pad.left, 1);
expect(pay.box.y).toBeCloseTo(grand.box.y, 0);
const [g, p] = await colourAt(r.result.bytes, [
[grand.box.x + 3, grand.box.y + 2],
[pay.box.x + 3, pay.box.y + 2],
]);
if (g) {
expect(near(g, COBALT)).toBe(true);
expect(near(p, COBALT)).toBe(true);
}
});
it("amount in words sits right-aligned under the GRAND TOTAL bar", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const words = byId(r, SLOT_ID.amountInWords);
const totals = byId(r, TOTALS_ID);
const grand = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.totals.barH) < 0.5 && Math.abs(n.box.w - V.totals.rightW) < 0.5 && n.box.y >= totals.box.y).sort((a, b) => b.box.y - a.box.y)[0];
expect(words.box.y).toBeGreaterThanOrEqual(grand.box.y + grand.box.h);
for (const l of textLines(words)) expect(l.right).toBeCloseTo(contentRight(r), 0);
});
});
describe("closing and footer", () => {
it("ends with the icon footer: a rule with accents at both ends and three contacts, all inside the content box", async () => {
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const last = r.result.pages - 1;
const closing = byId(r, CLOSING_ID, last);
const frame = frameForPrefs(r.prefs);
const svgs = onPage(r, last).filter((n) => n.type === "svg" && !n.decor && n.box.y >= closing.box.y - 0.01);
const icons = svgs.filter((n) => n.box.w === V.closing.icon.size);
expect(icons).toHaveLength(3);
const rule = svgs.find((n) => n.box.w > 400 && n.box.h === V.head.rule.accent)!;
expect(rule.box.w).toBeCloseTo(frame.contentWidth, 1);
for (const i of icons) {
expect(i.box.y).toBeGreaterThanOrEqual(rule.box.y + rule.box.h);
expect(i.box.x + i.box.w).toBeLessThanOrEqual(contentRight(r) + 0.5);
}
expect(r.result.pages).toBe(1);
const sample = await colourAt(r.result.bytes, [[rule.box.x + 20, rule.box.y + 1.5], [rule.box.x + 200, rule.box.y + 1.5], [rule.box.x + rule.box.w - 20, rule.box.y + 1.5]]);
if (sample.length) {
expect(near(sample[0], SKY), `left accent ${sample[0]}`).toBe(true);
expect(near(sample[1], "#A6A6A6"), `grey ${sample[1]}`).toBe(true);
expect(near(sample[2], SKY), `right accent ${sample[2]}`).toBe(true);
}
// The closing block ends the page: its bottom is the wrap area's bottom.
expect(closing.box.y + closing.box.h).toBeCloseTo(frame.page.height - frame.pad.bottom, 0);
const text = r.pdf.pages[last].items.map((i) => i.str).join(" ");
expect(text).toContain("9000000000");
expect(text).toContain("vendor@example.com");
});
it("a pasted signature image replaces the script: right-aligned, at most 36 pt tall, with the signatory name and designation under it", async () => {
const r = await renderFixture({ name: "s", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const sig = byId(r, SLOT_ID.signatory, r.result.pages - 1);
const img = onPage(r, r.result.pages - 1).find((n) => n.type === "image" && n.box.y >= sig.box.y - 0.01 && n.box.y + n.box.h <= sig.box.y + sig.box.h + 0.01)!;
expect(img.box.h).toBeLessThanOrEqual(V.closing.signatureImageH + 0.01);
const text = textLines(sig).map((l) => l.text);
expect(text).toEqual(expect.arrayContaining(["Test Vendor", "Proprietor"]));
// Without an image: the computer-generated note stands in.
const plain = await renderFixture({ name: "s", items: 2, logo: "none", signature: false }, prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(plain.pdf.pages[plain.result.pages - 1].items.map((i) => i.str).join(" ")).toContain("Computer-generated");
});
it("long terms flow as a block of their own and keep the closing block bounded", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
const plan = planCobalt(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
expect(plan.closing.termsBeside).toBe(false);
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
});
it("an address, e-mail and phone that are too long for one row shrink and wrap inside the footer", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
for (const l of textLines(closing)) expect(l.right).toBeLessThanOrEqual(contentRight(r) + 0.5);
});
});
describe("the GST slots", () => {
it("every GST slot has a home on a tax invoice", async () => {
const r = await renderFixture({ name: "t", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, `${SLOT_ID.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(true);
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
for (const word of ["SUBTOTAL:", "TAXABLEVALUE:", "CGST9%:", "SGST9%:", "HSN/SAC:998397", "PAN:ABCDE1234F"]) expect(flat, word).toContain(word);
});
it("an unregistered invoice carries none of the GST slots", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(false);
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error on either size", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const name of ["id-tokens", "long-address", "long-client", "long-notes"]) {
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: "cobalt-stripe" }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
}
}
});
it("wrapped description rows grow and keep their zebra colour and separators (no row is taller than its text)", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "multiline")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
const rows = [0, 1, 2].map((i) => byId(r, `${TABLE_ROW_PREFIX}${i}`));
expect(rows[0].box.h).toBeGreaterThan(3 * 13.6);
expect(rows[2].box.h).toBeGreaterThan(rows[1].box.h);
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
});
});
describe("plan matches the laid-out blocks", () => {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: "cobalt-stripe" });
const frame = frameForPrefs(prefs);
// Logo-less, so the plan made here is the one the layout made.
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 = planCobalt(r.fixture.model, frame, V, null, false);
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(24);
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(32);
});
}
}
});
describe("design docs", () => {
it("records its implementation notes", () => {
const text = readFileSync(path.join(process.cwd(), "docs/templates/08-cobalt-stripe.md"), "utf8");
expect(text).toContain("## Implementation notes");
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { ComponentType } from "react";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition, TemplateProps } from "../contract";
import { slotFor } from "../slots.optin";
import { CobaltBody } from "./Layout";
import { COBALT_ROLES, COBALT_VARIANT, GREY, INK } from "./tokens";
export { COBALT_ROLES, COBALT_VARIANT } from "./tokens";
const CobaltLayout: ComponentType<TemplateProps> = (props) => <CobaltBody {...props} variant={COBALT_VARIANT} />;
CobaltLayout.displayName = "CobaltLayout";
const slot = slotFor("cobalt-stripe");
/** The measured lockup (mark + name placeholder): the room a wordmark may fill; a mark keeps to the mark box. */
const lockup = slot?.lockup ?? { w: 181.5, h: 34.25 };
export const cobaltStripe: TemplateDefinition = {
...catalogEntry("cobalt-stripe"),
version: 1,
roles: COBALT_ROLES,
margins: COBALT_VARIANT.margins,
marginFloor: COBALT_VARIANT.marginFloor,
decor: { ink: INK },
chrome: {
color: GREY,
rule: null,
role: COBALT_ROLES.footer,
leftWidth: COBALT_VARIANT.footerLeftW,
},
logo: {
box: { w: lockup.w, h: lockup.h },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => lockup.w,
slot,
available: () => ({ w: lockup.w, h: lockup.h }),
},
Layout: CobaltLayout,
};
+363
View File
@@ -0,0 +1,363 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { innerRulePadRight, 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 { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
import { INK, leading, type CobaltVariant } from "./tokens";
export const NOTES_FLOW_GAP = 14;
const FIT_AIR = 2;
/** A GSTIN is always 15 characters: the text the layout prints for the height estimate, which must not read the regulated field. */
const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
/** Narrowest the footer address column may get before the phone and e-mail shrink to make room. */
const MIN_ADDRESS_W = 120;
/** Space between a mark and the tagline under a wordmark logo. */
const WORDMARK_TAG_GAP = 3;
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
/** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */
export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`);
function idLines(role: TypeToken, text: string, width: number): number {
const f = fitSize(role, text, Math.max(1, width));
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)) + 1);
}
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(getMeasurer(), role, text)) + 1;
export interface MetaLine {
strong: boolean;
text: string;
cancelled?: boolean;
}
export function metaLines(model: RenderModel): MetaLine[] {
const out: MetaLine[] = [...(model.cancelled ? [{ strong: true, text: "CANCELLED", cancelled: true }] : []), { strong: true, text: `${model.meta.numberLabel.replace(/\.$/, "")} : ${model.meta.number}` }, { strong: false, text: model.meta.date }];
if (model.meta.dueDate) out.push({ strong: false, text: `Due date : ${model.meta.dueDate}` });
if (model.client.poNumber) out.push({ strong: false, text: `PO : ${model.client.poNumber}` });
return out;
}
/** The bank block's lines: "Label : value", the design's "Bank Name : ..." and "Account Number : ...". */
export function bankLines(model: RenderModel): string[] {
const bank = model.bank;
if (!bank) return [];
const rename: Record<string, string> = { Bank: "Bank Name", "Account No.": "Account Number" };
const out = bank.lines.map((l) => `${rename[l.label] ?? l.label} : ${l.value}`);
const at = bank.lines.findIndex((l) => l.label === "Bank") + 1;
out.splice(at, 0, `Account Name : ${bank.accountName}`);
return out;
}
export function signatureOptions(v: CobaltVariant): SignatoryOptions {
const r = v.roles;
return { roles: { name: r.signName, designation: r.signTitle, fine: r.fine }, align: "right", rule: null, color: INK, imageHeight: v.closing.signatureImageH };
}
/** The table's no., description (+ HSN/SAC sub-line), qty, price and amount cells. */
export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number): TableCellValue[][] {
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [String(i + 1), desc, item.qtyText, rateParts, item.amountText];
});
}
export interface ContactItem {
icon: "phone" | "email" | "location";
lines: string[];
/** Identifiers (phone, e-mail) shrink instead of wrapping; the address wraps. */
id: boolean;
/** Width of the text box beside the icon. */
textW: number;
h: number;
}
export interface CobaltPlan {
v: CobaltVariant;
W: number;
head: {
titleSize: number;
titleW: number;
leftW: number;
mode: "name" | "wordmark" | "none";
showName: boolean;
markW: number;
nameW: number;
nameH: number;
tagline: boolean;
taglineH: number;
leftH: number;
h: number;
ruleGap: number;
ruleText: string;
ruleTextW: number;
ruleW: number;
ruleH: number;
};
info: { metaW: number; clientW: number; lines: MetaLine[]; metaH: number; clientH: number; h: number };
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
headAlign: Array<"left" | "center" | "right">;
padLeft: number[];
padRight: number[];
padY: number;
headPadY: number;
cw: { no: number; qty: number; price: number; amount: number; priceInset: number };
table: ItemsTablePlan;
guardH: number;
tail: {
stack: Array<{ label: string; amount: string }>;
slotRows: number;
stackH: number;
rightW: number;
valueW: number;
leftW: number;
leftTop: number;
/** The PAYMENT METHOD bar is level with the GRAND TOTAL bar (the design) rather than beside the first totals row. */
aligned: boolean;
bank: string[];
bankH: number;
wordsH: number;
reverseH: number;
rightH: number;
leftH: number;
body: number;
};
closing: {
leftW: number;
signCol: number;
terms: string[];
termsBeside: boolean;
termsH: number;
flowW: number;
flowH: number;
leftH: number;
signH: number;
rowH: number;
contacts: ContactItem[];
contactGap: number;
contactsW: number;
contactsH: number;
footerH: number;
h: number;
};
gaps: { client: number; table: number; tail: number; terms: number; footer: number };
tailH: number;
closingH: number;
}
function planContacts(model: RenderModel, v: CobaltVariant, W: number): { items: ContactItem[]; gap: number; width: number; h: number } {
const s = model.supplier;
const r = v.roles;
const c = v.closing;
const raw: Array<Pick<ContactItem, "icon" | "lines" | "id">> = [];
if (s.phone) raw.push({ icon: "phone", lines: [s.phone], id: true });
if (s.email) raw.push({ icon: "email", lines: [s.email], id: true });
if (s.addressLines.length) raw.push({ icon: "location", lines: s.addressLines, id: false });
const n = raw.length;
if (n === 0) return { items: [], gap: 0, width: 0, h: 0 };
const natural = raw.map((x) => Math.max(...x.lines.map((l) => widthOf(r.contact, l))));
const iconBlock = c.icon.size + c.icon.gap;
const budget = W - n * iconBlock - (n - 1) * c.minContactGap;
let texts = natural;
if (natural.reduce((a, b) => a + b, 0) > budget) {
// Identifiers keep their natural width while the address (which wraps) keeps a usable column; otherwise they shrink
// (to their minimum size, then chunk) to a share of the row and the address wraps in what is left.
const ids = raw.reduce((a, x, i) => a + (x.id ? natural[i] : 0), 0);
const free = raw.filter((x) => !x.id).length;
const room = free ? (budget - ids) / free : 0;
const cap = free === 0 ? Math.floor(budget / n) : room >= MIN_ADDRESS_W ? Infinity : Math.floor(budget * 0.3);
const idW = raw.map((x, i) => (x.id ? Math.min(natural[i], cap) : 0));
const rest = budget - idW.reduce((a, b) => a + b, 0);
texts = raw.map((x, i) => (x.id ? idW[i] : Math.max(60, Math.floor(rest / Math.max(1, free)))));
}
const used = texts.reduce((a, b) => a + b, 0) + n * iconBlock;
const gap = n > 1 ? Math.max(c.minContactGap, Math.min(c.contactGap, (W - used) / (n - 1))) : 0;
const items = raw.map((x, i): ContactItem => {
const textW = texts[i];
const h = x.id ? idLines(r.contact, x.lines[0], textW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, textW), 0);
return { ...x, textW, h };
});
const width = used + (n - 1) * gap;
return { items, gap, width, h: Math.max(c.icon.size, ...items.map((x) => x.h + Math.max(0, (c.icon.size - leading(r.contact)) / 2))) };
}
/**
* Everything the layout must know before rendering: the header (the INVOICE title's size and the left block's mode),
* the info row, the five table columns, the totals' two columns, the closing block and the footer's contact row, and
* the heights of the tail and the closing block (their minPresenceAhead values), each a hair high rather than low.
*/
export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVariant, logo: PlacedLogo | null, includesName: boolean): CobaltPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const s = model.supplier;
const c = model.client;
const h = v.head;
// --- header ------------------------------------------------------------------------------------------------------------
const tFit = fitSize(r.title, model.docTitle, Math.floor(W * 0.62));
const titleW = Math.ceil(tFit.width) + 2;
const leftW = W - titleW - h.titleGap;
const showName = !(logo && includesName) && s.name.trim() !== "";
const mode: "name" | "wordmark" | "none" = showName ? "name" : logo ? "wordmark" : "none";
const markW = logo && showName ? logo.w : 0;
const nameW = showName ? Math.max(h.minNameW, leftW - (markW ? markW + h.markGap : 0)) : 0;
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
const tagline = slotHeights.gstinLines(model, "supplier") > 0;
const tagW = mode === "name" ? nameW : leftW;
const taglineH = tagline ? idLines(r.tagline, GSTIN_SAMPLE, tagW) * leading(r.tagline) : 0;
let headLeftH = 0;
if (mode === "name") headLeftH = Math.max(h.nameTop + nameH + (tagline ? h.taglineGap + taglineH : 0), logo ? h.logoTop + logo.h : 0);
else if (mode === "wordmark") headLeftH = h.logoTop + (logo?.h ?? 0) + (tagline ? WORDMARK_TAG_GAP + taglineH : 0);
const headH = Math.max(headLeftH, leading(r.title));
const ruleText = s.pan ? `PAN: ${s.pan}` : "";
const ruleH = leading(r.ruleText);
const ruleTextW = ruleText ? Math.min(widthOf(r.ruleText, ruleText), Math.floor(W * 0.38)) : 0;
const ruleW = W - (ruleTextW ? ruleTextW + h.ruleTextGap : 0);
const ruleGap = Math.max(h.ruleMinGap, h.ruleTop - headH);
// --- info row: Billed to at the left, the invoice numbers at the right --------------------------------------------------
const lines = metaLines(model);
const metaW = Math.min(Math.floor(W * v.info.metaMaxShare), Math.max(v.info.metaMinW, ...lines.map((l) => widthOf(l.strong ? r.metaStrong : r.metaLine, l.text))));
const clientW = W - metaW - v.info.clientGap;
let clientH = leading(r.label) + textHeight(r.clientName, c.name, clientW);
const clientLines = c.addressLines.length + slotHeights.gstinLines(model, "client") + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW);
if (clientLines > 0) clientH += v.info.nameGap;
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, clientW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW) * leading(r.clientLine);
const metaH = lines.reduce((a, l) => a + idLines(l.strong ? r.metaStrong : r.metaLine, l.text, metaW) * leading(r.metaLine), 0);
const infoH = Math.max(clientH, metaH);
// --- table: NO | DESCRIPTION | QTY | PRICE | TOTAL ---------------------------------------------------------------------------
const t = v.table;
const rule = t.separator;
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x))));
const priced = model.items.map((i) => priceOf(i.rateText));
const amountTexts = model.items.map((i) => i.amountText);
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12);
const qty = Math.max(t.cols.qty, widest(r.tableBody, model.items.map((i) => i.qtyText)) + 12 + rule);
const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
const price = Math.max(t.cols.price, priceNeed);
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
const amount = Math.max(t.cols.amount, Math.max(widest(r.tableBody, amountTexts), widest(r.tableHead, ["Total"])) + t.amountInset + 8);
const columns: ColSpec[] = [{ fixed: no }, { fill: true }, { fixed: qty }, { fixed: price }, { fixed: amount }];
const headerCells = ["No", "Description", "Qty", "Price", "Total"];
const align: CobaltPlan["align"] = ["center", "left", "center", "right", "right"];
const headAlign: CobaltPlan["headAlign"] = ["center", "center", "center", "right", "center"];
const padLeft = [0, t.descInset, 0, 0, 0];
// The inner rule counts toward the inset, so the price ends `priceInset` short of its column edge as in the design.
const padRight = [0, 6, 0, priceInset - rule, t.amountInset];
const planPadRight = padRight.map((p, i) => p + innerRulePadRight(5, rule)[i]);
const headPadY = (t.headH - leading(r.tableHead)) / 2;
const rows = tableRows(model, v, price - rule - padRight[3] - 2);
const n = rows.length;
const padY = t.padY;
const planTable = (tailH: number) =>
planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight: planPadRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight });
// --- totals: Sub Total rows and the GRAND TOTAL bar at the right, the PAYMENT METHOD bar and the bank lines at the left ------
const tt = model.totals;
const o = v.totals;
const stack: Array<{ label: string; amount: string }> = [{ label: labelWithColon("Sub Total"), amount: tt.subtotalText }];
if (tt.discountText) stack.push({ label: labelWithColon("Discount"), amount: `- ${tt.discountText}` });
if (tt.taxableText) stack.push({ label: labelWithColon("Taxable Value"), amount: tt.taxableText });
const slotRows = slotHeights.taxRowCount(model);
const stackH = (stack.length + slotRows) * leading(r.totalLabel);
const valueW = o.rightW - o.labelW;
const wordsH = slotHeights.amountInWords(model, o.rightW, { role: r.words, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, o.rightW, r.words);
const rightH = stackH + o.barGap + o.barH + (wordsH ? o.wordsGap + wordsH : 0) + (reverseH ? o.reverseGap + reverseH : 0);
const tailLeftW = W - o.rightW - o.leftGap;
const bank = bankLines(model);
const bankH = bank.reduce((a, l) => a + textHeight(r.bank, l, tailLeftW - o.barInset), 0);
const leftBlockH = o.barH + o.bankGap + bankH;
const leftHFor = (top: number) => (model.bank ? top + leftBlockH : 0);
// --- closing ------------------------------------------------------------------------------------------------------------------
const cl = v.closing;
const signCol = cl.signW + cl.signInset;
const cLeftW = W - signCol - 8;
const terms = model.notesLines;
const termsH = terms.length ? leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, cLeftW), 0) : 0;
const termsBeside = terms.length === 0 || termsH <= cl.notesBesideMax;
const flowW = W - 2;
const flowH = termsBeside ? 0 : leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, flowW), 0);
const signH = signatoryHeight(model, cl.signW, signatureOptions(v));
const contacts = planContacts(model, v, W);
const footerRuleH = h.rule.accent;
const footerH = footerRuleH + (contacts.items.length ? cl.ruleToContacts + contacts.h : 0);
const leftBase = cl.divider.thickness + cl.dividerGap + cl.thanksH;
const cancelledH = model.cancelled ? 10 + textHeight(r.fine, CANCELLED_SENTENCE, W) : 0;
const closingFor = (g: { terms: number; footer: number }) => {
const leftHh = leftBase + (termsBeside && terms.length ? g.terms + termsH : 0);
const rowH = Math.max(leftHh, signH);
return { leftH: leftHh, rowH, h: rowH + g.footer + footerH + cancelledH };
};
// --- gaps -----------------------------------------------------------------------------------------------------------------------
const ranges = { client: v.gaps.client, table: v.gaps.table, tail: v.gaps.tail, terms: termsBeside && terms.length ? v.gaps.terms : { nominal: 0, min: 0 }, footer: v.gaps.footer };
const nominalClosing = closingFor({ terms: ranges.terms.nominal, footer: ranges.footer.nominal });
const table0 = planTable(0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const notesBlock = termsBeside ? 0 : NOTES_FLOW_GAP + flowH;
const nominalFor = (tailBody: number) => headH + ruleGap + ruleH + ranges.client.nominal + infoH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + notesBlock + nominalClosing.h;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
// The PAYMENT METHOD bar sits level with the GRAND TOTAL bar, as in the design, whenever the invoice still fits its page
// that way; with more totals rows than the design's two it moves up beside the first row, which saves up to the stack's height.
const alignedTop = stackH + o.barGap;
const aligned = nominalFor(Math.max(rightH, leftHFor(alignedTop))) - slack <= frame.contentHeight || !model.bank;
const leftTop = aligned ? alignedTop : 0;
const leftH = leftHFor(leftTop);
const tailBody = Math.max(rightH, leftH);
const nominalTotal = nominalFor(tailBody);
const overflow = nominalTotal - frame.contentHeight;
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
// page 1 and let only the closing block move to the next page.
const overflowBeforeClosing = nominalTotal - nominalClosing.h - frame.contentHeight;
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
const air = (over: number) => Math.min(slack, over + FIT_AIR);
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
const deficit = Math.max(frame.heightDeficit, fits);
const g = elastic(ranges, deficit);
const closing = closingFor(g);
const tailH = g.tail + tailBody;
const table = planTable(tailH);
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailH : 0);
return {
v,
W,
head: { titleSize: tFit.size, titleW, leftW, mode, showName, markW, nameW, nameH, tagline, taglineH, leftH: headLeftH, h: headH, ruleGap, ruleText, ruleTextW, ruleW, ruleH },
info: { metaW, clientW, lines, metaH, clientH, h: infoH },
columns,
headerCells,
rows,
align,
headAlign,
padLeft,
padRight,
padY,
headPadY,
cw: { no, qty, price, amount, priceInset },
table,
guardH,
tail: { stack, slotRows, stackH, rightW: o.rightW, valueW, leftW: tailLeftW, leftTop, aligned, bank, bankH, wordsH, reverseH, rightH, leftH, body: tailBody },
closing: { leftW: cLeftW, signCol, terms, termsBeside, termsH, flowW, flowH, leftH: closing.leftH, signH, rowH: closing.rowH, contacts: contacts.items, contactGap: contacts.gap, contactsW: contacts.width, contactsH: contacts.h, footerH, h: closing.h },
gaps: { client: g.client, table: g.table, tail: g.tail, terms: g.terms, footer: g.footer },
tailH,
closingH: closing.h,
};
}
+177
View File
@@ -0,0 +1,177 @@
import type { Sides } from "../../engine/geometry";
import { defineRoles, type TypeToken } from "../../fonts/roles";
import type { GapRange } from "../neutral/variant";
const P = "Poppins" as const;
const O = "Open Sans" as const;
export const COBALT = "#3871C1";
export const ZEBRA = "#BEDEF1";
export const SKY = "#51ADE5";
export const INK = "#1B1B1B";
export const GREY = "#595A5A";
export const RULE_GREY = "#A6A6A6";
const WHITE = "#FFFFFF";
/**
* Cobalt Stripe's type (docs/templates/08-cobalt-stripe.md): Poppins Bold for the name and the tracked INVOICE title,
* Open Sans for everything else, Poppins Regular for the footer contacts. Open Sans ascent 1.069, descent 0.293;
* Poppins 1.05 / 0.35. Leadings are the design's pitches where the page keeps them, tighter where a longer
* invoice needs the room (client lines).
*/
export const COBALT_ROLES = defineRoles({
brand: { face: P, weight: 700, size: 17.38, leading: 20.5, tracking: -0.028, minSize: 11 },
tagline: { face: O, weight: 400, size: 10, leading: 13.6, caps: true, minSize: 6.5 },
title: { face: P, weight: 700, size: 31.45, leading: 36, tracking: 0.043, caps: true, minSize: 18 },
ruleText: { face: O, weight: 400, size: 10, leading: 13.6, caps: true, minSize: 6.5 },
label: { face: O, weight: 400, size: 11, leading: 17.8 },
clientName: { face: O, weight: 700, size: 15, leading: 20, minSize: 10 },
clientLine: { face: O, weight: 400, size: 10, leading: 17.5, minSize: 7 },
metaStrong: { face: O, weight: 700, size: 12, leading: 19.2, minSize: 8 },
metaLine: { face: O, weight: 400, size: 12, leading: 19.2, minSize: 8 },
tableHead: { face: O, weight: 700, size: 11, leading: 14.8, caps: true },
tableBody: { face: O, weight: 400, size: 10, leading: 13.6 },
tableSub: { face: O, weight: 400, size: 8.5, leading: 11.5 },
totalLabel: { face: O, weight: 400, size: 11, leading: 20.76 },
totalValue: { face: O, weight: 400, size: 11, leading: 20.76 },
barLabel: { face: O, weight: 700, size: 11, leading: 16.76, caps: true },
barValue: { face: O, weight: 700, size: 11, leading: 16.76 },
bank: { face: O, weight: 400, size: 11, leading: 15.76 },
thanks: { face: O, weight: 700, size: 11, leading: 16 },
termsHead: { face: O, weight: 700, size: 11, leading: 15 },
terms: { face: O, weight: 400, size: 10, leading: 14.25 },
words: { face: O, weight: 400, size: 9.5, leading: 12.5 },
wordsLabel: { face: O, weight: 700, size: 9.5, leading: 12.5 },
signName: { face: O, weight: 700, size: 13, leading: 17.8 },
signTitle: { face: O, weight: 700, size: 11, leading: 15 },
fine: { face: O, weight: 400, size: 9, leading: 12.5 },
contact: { face: P, weight: 400, size: 10, leading: 14, minSize: 6.5 },
footer: { face: O, weight: 400, size: 7 },
});
export type CobaltRoles = typeof COBALT_ROLES;
export interface CobaltVariant {
roles: CobaltRoles;
ink: string;
margins: Sides;
marginFloor: Sides;
head: {
/** Offsets below the header's top (the INVOICE line box): the logo, the name's line box. */
logoTop: number;
nameTop: number;
/** Name's last line to the tagline. */
taglineGap: number;
/** Mark to name, and the narrowest the name column may get. */
markGap: number;
minNameW: number;
/** Where the rule row starts below the header's top, and the narrowest gap under a taller header. */
ruleTop: number;
ruleMinGap: number;
/** Gap between the left block and the INVOICE title. */
titleGap: number;
/** Rule text: the rule's end to the text. */
ruleTextGap: number;
/** The rule row: grey line thickness, accent thickness and length, and the line's inset from the row. */
rule: { thickness: number; accent: number; accentW: number; inset: number; drop: number };
};
info: { metaMinW: number; metaMaxShare: number; clientGap: number; nameGap: number };
table: {
cols: { no: number; qty: number; price: number; amount: number };
headH: number;
/** The design sets the heading 1.8 pt above the bar's centre. */
headPad: { paddingTop: number; paddingBottom: number };
descInset: number;
priceInset: number;
amountInset: number;
padY: number;
separator: number;
/** Zebra row height of a single-line row, for the plan. */
rowH: number;
};
totals: { rightW: number; labelW: number; valuePad: number; barGap: number; barH: number; barInset: number; leftBarW: number; bankGap: number; wordsGap: number; reverseGap: number; leftGap: number };
closing: {
divider: { w: number; thickness: number };
dividerGap: number;
thanksH: number;
signW: number;
signInset: number;
signatureImageH: number;
notesBesideMax: number;
footerRule: { inset: number; accentInset: number; accentW: number };
ruleToContacts: number;
icon: { size: number; gap: number };
contactGap: number;
minContactGap: number;
};
gaps: {
/** Header rule row to "Billed to :". */
client: GapRange;
/** Client / meta block to the table. */
table: GapRange;
/** Last row to the Sub Total row. */
tail: GapRange;
/** Thank-you line to the terms. */
terms: GapRange;
/** Last closing line to the footer rule. */
footer: GapRange;
};
footerLeftW: number;
}
export const COBALT_VARIANT: CobaltVariant = {
roles: COBALT_ROLES,
ink: INK,
margins: { top: 54.2, right: 59.55, bottom: 35.5, left: 59.55 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
head: {
logoTop: 5.3,
nameTop: 2.1,
taglineGap: 2.7,
markGap: 8.5,
minNameW: 80,
ruleTop: 52.2,
ruleMinGap: 6,
titleGap: 16,
ruleTextGap: 11,
rule: { thickness: 1.5, accent: 3, accentW: 35.7, inset: 0.75, drop: 0.6 },
},
info: { metaMinW: 150, metaMaxShare: 0.45, clientGap: 16, nameGap: 9.3 },
table: {
cols: { no: 24.9, qty: 84.9, price: 105.4, amount: 89.3 },
headH: 20.76,
headPad: { paddingTop: 0.74, paddingBottom: 5.22 },
descInset: 6.8,
priceInset: 31.7,
amountInset: 3.1,
padY: 2.11,
separator: 1.5,
rowH: 17.82,
},
totals: { rightW: 214.5, labelW: 95.9, valuePad: 3.1, barGap: 7.2, barH: 20.76, barInset: 6, leftBarW: 133.8, bankGap: 15.3, wordsGap: 6, reverseGap: 4, leftGap: 16 },
closing: {
divider: { w: 176.6, thickness: 1.5 },
dividerGap: 12.3,
thanksH: 16,
signW: 200,
signInset: 5.5,
signatureImageH: 36,
notesBesideMax: 110,
footerRule: { inset: 15.35, accentInset: 1.5, accentW: 35.7 },
ruleToContacts: 9.3,
icon: { size: 26, gap: 4 },
contactGap: 32.5,
minContactGap: 12,
},
gaps: {
client: { nominal: 42, min: 26 },
table: { nominal: 30.5, min: 16 },
tail: { nominal: 9.8, min: 4 },
terms: { nominal: 26, min: 14 },
footer: { nominal: 16, min: 8 },
},
footerLeftW: 150,
};
export const WHITE_INK = WHITE;
export const leading = (t: TypeToken) => t.leading as number;
@@ -0,0 +1,10 @@
import { expect, it } from "vitest";
import { describeSweep, STANDALONE_SWEEP } from "./harness.sweep";
import { TEMPLATES } from "./registry";
// Monolith and Cobalt Stripe: the same sweep as the other templates, in a second file so the two run side by side.
describeSweep((id) => STANDALONE_SWEEP.includes(id));
it("the standalone sweep names registered templates only", () => {
expect(STANDALONE_SWEEP.every((id) => Object.hasOwn(TEMPLATES, id))).toBe(true);
});
+2 -19
View File
@@ -1,21 +1,4 @@
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";
import { describeSweep, STANDALONE_SWEEP } from "./harness.sweep";
// 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)`);
}
});
}
}
});
describeSweep((id) => !STANDALONE_SWEEP.includes(id));
+25
View File
@@ -0,0 +1,25 @@
import { describe, it } from "vitest";
import { makeItems, prefsFor, PAGE_SIZE_IDS, renderFixture, type FixtureSpec } from "./harness";
import { checkRendered } from "./harness.check";
import { TEMPLATES } from "./registry";
/** The standalone templates sweep in a file of their own, so vitest runs the two sweeps in parallel and the suite's wall time stays put. */
export const STANDALONE_SWEEP = ["monolith", "cobalt-stripe"];
/** 1..45 generated rows on A4 and Letter for the templates `pick` selects, each render through the harness checks. */
export function describeSweep(pick: (id: string) => boolean): void {
describe("template harness: item-count sweep", () => {
for (const template of Object.values(TEMPLATES).filter((t) => pick(t.id))) {
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)`);
}
});
}
}
});
}
+405
View File
@@ -0,0 +1,405 @@
import { Image, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { pinned } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
import { 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 { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import type { PlacedLogo, TemplateProps } from "../contract";
import { includesName } from "../logoName";
import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
import { atRate, LABEL_GAP, NOTES_FLOW_GAP, paymentRows, planMonolith, REVERSE_GAP, roleOf, signatureOptions, type MonolithPlan } from "./plan";
import type { MonolithVariant } from "./tokens";
/** Layout-tree ids of the blocks that are Monolith's own (the harness ids are in classic/ids.ts). */
export const BRAND_ID = "monolith-brand";
export const IDS_ID = "monolith-ids";
interface Ctx {
model: TemplateProps["model"];
plan: MonolithPlan;
v: MonolithVariant;
logo: PlacedLogo | null;
}
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
function Rule({ ctx, width }: { ctx: Ctx; width?: number }) {
return <AccentRule width={width ?? ctx.plan.W} thickness={ctx.v.rule} color={ctx.v.ink} />;
}
// --- header -----------------------------------------------------------------------------------------------------
function Title({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const h = plan.head;
return (
<View style={{ ...pinned(h.titleW), marginLeft: v.titleIndent } as never}>
<FitText id={TITLE_ID} role={{ ...v.roles.title, size: h.titleSize }} color={v.ink} width={h.titleW}>
{model.docTitle}
</FitText>
</View>
);
}
/** The logo (right-anchored in the stack's column) or the vendor name; under it the date and number lines flow, so a tall logo never covers them. */
function Brand({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const h = plan.head;
const name = h.showName ? (
<View style={{ ...pinned(h.nameW), marginTop: h.nameOffset } as never}>
<AvailableWidthProvider width={h.nameW}>
<SafeTxt role={v.roles.brand} color={v.ink} align="right">
{model.supplier.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : (
<View />
);
const body = logo ? (
<Row widths={[{ fill: true }, { fixed: logo.w + (h.showName ? v.stack.markGap : 0) }]} width={h.stackW}>
{name}
<Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginLeft: h.showName ? v.stack.markGap : 0, objectFit: "contain" } as never} />
</Row>
) : (
name
);
return (
<View id={SUPPLIER_ID} style={{ ...pinned(h.stackW), paddingTop: h.zoneTop, paddingBottom: logo ? v.stack.logoGap : 0 } as never}>
<View id={BRAND_ID}>{body}</View>
</View>
);
}
function MetaStack({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const h = plan.head;
return (
<View id={META_ID} style={pinned(h.stackW) as never}>
<AvailableWidthProvider width={h.stackW}>
{h.lines.map((l, i) => (
<IdText key={i} role={roleOf(v, l.kind)} color={l.kind === "cancelled" ? CANCELLED_INK : v.ink} align="right" fill width={h.stackW}>
{l.text}
</IdText>
))}
</AvailableWidthProvider>
</View>
);
}
function HeaderRow({ ctx }: { ctx: Ctx }) {
const { plan } = ctx;
return (
<Row widths={[{ fill: true }, { fixed: plan.head.stackW }]} width={plan.W} style={{ minHeight: plan.head.h }}>
<Title ctx={ctx} />
<View style={pinned(plan.head.stackW) as never}>
<Brand ctx={ctx} />
<MetaStack ctx={ctx} />
</View>
</Row>
);
}
// --- info row ---------------------------------------------------------------------------------------------------
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
const w = plan.info.clientW;
return (
<View style={pinned(w) as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.heading} color={v.ink} width={w} 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} />
{c.poNumber ? (
<SafeTxt role={r.body} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt>
) : null}
</AvailableWidthProvider>
</View>
);
}
function IdsBlock({ ctx }: { ctx: Ctx }) {
const { model, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
<View id={IDS_ID} style={pinned(v.sideW) as never}>
<AvailableWidthProvider width={v.sideW}>
<Txt role={r.heading} color={v.ink} width={v.sideW} style={{ marginBottom: v.headingGap }}>
Supplier:
</Txt>
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
{s.pan ? <IdText role={r.body} color={v.ink}>{`PAN: ${s.pan}`}</IdText> : null}
</AvailableWidthProvider>
</View>
);
}
// --- totals -----------------------------------------------------------------------------------------------------
function TotalRow({ ctx, label, amount, strong }: { ctx: Ctx; label: string; amount: string; strong?: boolean }) {
const { plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
return (
<Row widths={[{ fixed: t.labelW }, { fixed: t.valueW }]} width={t.boxW} style={strong ? undefined : { marginBottom: v.totals.rowGap }}>
<Txt role={strong ? r.total : r.totalLabel} color={v.ink} width={t.labelW} align="center">
{label}
</Txt>
<Txt role={strong ? r.total : r.totalValue} color={v.ink} width={t.valueW - v.totals.valuePad} align="right">
{amount}
</Txt>
</Row>
);
}
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
return (
<View style={{ ...pinned(t.boxW), paddingTop: v.totals.top } as never}>
<AvailableWidthProvider width={t.boxW}>
{t.stack.map((row, i) => (
<TotalRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
))}
<Slot.TaxRows model={model} role={v.roles.totalLabel} color={v.ink} amountWidth={t.valueW} format={atRate} renderRow={(row) => <TotalRow ctx={ctx} label={row.label} amount={row.amount} />} />
<View style={{ marginTop: v.totals.ruleGap, marginBottom: v.totals.totalGap }}>
<Rule ctx={ctx} width={t.boxW} />
</View>
<TotalRow ctx={ctx} label="Total" amount={model.totals.totalText} strong />
</AvailableWidthProvider>
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
return (
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
<Rule ctx={ctx} />
<Row widths={[{ fill: true }, { fixed: t.boxW }]} gap={v.totals.leftGap} width={plan.W}>
<View style={{ ...pinned(t.leftW), paddingTop: t.leftH ? v.totals.top : 0 } as never}>
<Slot.AmountInWords model={model} role={r.small} labelRole={r.smallStrong} color={v.ink} />
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null}
</View>
<TotalsBox ctx={ctx} />
</Row>
</View>
);
}
// --- closing ----------------------------------------------------------------------------------------------------
/** Notes: a label held with the first line, every line unbreakable (a cut Text would leave a zero-line node). */
function Notes({ ctx, width }: { ctx: Ctx; width: number }) {
const { plan, v } = ctx;
const r = v.roles;
const lines = plan.closing.notes;
if (lines.length === 0) return null;
return (
<View style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<View wrap={false}>
<Txt role={r.smallStrong} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}>
Notes / terms:
</Txt>
<SafeTxt role={r.small} color={v.ink}>
{lines[0]}
</SafeTxt>
</View>
{lines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.small} color={v.ink} wrap={false}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function Payment({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const cl = v.closing;
const w = plan.closing.leftW;
if (!model.bank) return <View />;
return (
<View style={pinned(w) as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.heading} color={v.ink} width={w} style={{ marginBottom: cl.headingGap }}>
Payment information :
</Txt>
<SafeTxt role={r.body} color={v.ink}>
{model.bank.accountName}
</SafeTxt>
{paymentRows(model).map((x, i) => (
<Row key={i} widths={[{ fixed: cl.labelW }, { fixed: cl.colonW }, { fill: true }]} width={w}>
<Txt role={r.body} color={v.ink} width={cl.labelW}>
{x.label}
</Txt>
<Txt role={r.colon} color={v.ink} width={cl.colonW}>
:
</Txt>
<SafeTxt role={r.body} color={v.ink}>
{x.value}
</SafeTxt>
</Row>
))}
</AvailableWidthProvider>
</View>
);
}
function Vendor({ ctx }: { ctx: Ctx }) {
const { model, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
<View style={pinned(v.sideW) as never}>
<AvailableWidthProvider width={v.sideW}>
<SafeTxt role={r.vendor} color={v.ink} style={{ marginBottom: v.closing.headingGap }}>
{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}
</AvailableWidthProvider>
</View>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const cl = v.closing;
const c = plan.closing;
const opts = signatureOptions(v);
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={pinned(plan.W) as never}>
<AvailableWidthProvider width={plan.W}>
<Row widths={[{ fixed: c.leftW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
{c.notesBeside ? <Notes ctx={ctx} width={c.leftW} /> : <View />}
<Slot.Signatory model={model} width={v.sideW} {...opts} />
</Row>
<Spacer h={plan.gaps.closeBand} />
<Rule ctx={ctx} />
<Spacer h={cl.top} />
<Row widths={[{ fixed: c.leftW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
<Payment ctx={ctx} />
<Vendor ctx={ctx} />
</Row>
<Spacer h={plan.gaps.closeBottom} />
<Rule ctx={ctx} />
{model.cancelled ? (
<Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- the layout -------------------------------------------------------------------------------------------------
export function MonolithBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: MonolithVariant }) {
const plan = planMonolith(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
const notesFlow: ReactNode = plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
<View style={{ marginTop: NOTES_FLOW_GAP }}>
<Notes ctx={ctx} width={plan.W} />
</View>
);
return (
<>
<FirstPageHeader pad={frame.pad}>
<HeaderRow ctx={ctx} />
<Spacer h={v.headRuleGap} />
<Rule ctx={ctx} />
<Spacer h={plan.gaps.infoTop} />
{plan.info.hasIds ? (
<Row widths={[{ fixed: plan.info.clientW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
<ClientBlock ctx={ctx} />
<IdsBlock ctx={ctx} />
</Row>
) : (
<ClientBlock ctx={ctx} />
)}
<Spacer h={plan.gaps.infoBottom} />
<Rule ctx={ctx} />
</FirstPageHeader>
<Spacer h={plan.gaps.band} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={pinned(plan.W) as never}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.W}
header={plan.headerCells}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={0}
padLeft={plan.padLeft}
padRight={plan.padRight}
padY={plan.padY}
headPadY={plan.headPadY}
border={0}
headBorder={v.heavyRule}
borderColor={v.ink}
headColor={v.ink}
headerStyle={{ borderTopWidth: v.heavyRule, borderTopColor: v.ink }}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{notesFlow}
<Closing ctx={ctx} />
</>
);
}
@@ -0,0 +1,327 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { SLOT_ID } from "../../blocks/slots";
import { frameForPrefs, renderInvoicePdf } from "../../render/invoice";
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
import { marginsFor } from "../contract";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { SLOTS } from "../slots.generated";
import { BRAND_ID, IDS_ID } from "./Layout";
import { monolith } from "./monolith";
import { planMonolith } from "./plan";
import { MONOLITH_ROLES, MONOLITH_VARIANT } from "./tokens";
ensureSetup();
const V = MONOLITH_VARIANT;
const allowance = SLOTS.monolith!.allowance;
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
const out: LayoutNode[] = [];
for (const p of layout) walk(p.nodes, (n) => out.push(n));
return out;
};
const onPage = (r: Awaited<ReturnType<typeof renderFixture>>, i: number) => nodes([r.result.core.layout[i]]);
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
const byId = (r: Awaited<ReturnType<typeof renderFixture>>, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
const textsOf = (r: Awaited<ReturnType<typeof renderFixture>>, page = 0) => r.pdf.pages[page].items.map((i) => i.str).join(" ");
const textLines = (root: LayoutNode) => {
const out: Array<{ text: string; x: number; right: number; y: number }> = [];
walk([root], (n) => {
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ text: l.text.trim(), x: l.x, right: l.x + l.w - (n.letterSpacing ?? 0), y: l.y });
});
return out;
};
/** The Canva sample's shape: no due date, so the right-hand stack is the design's three lines. */
const PLAIN: FixtureSpec = { name: "plain", items: 3, registered: false, logo: "none", invoice: { dueDate: "" } };
const contentRight = (r: Awaited<ReturnType<typeof renderFixture>>) => {
const f = frameForPrefs(r.prefs);
return f.page.width - f.pad.right;
};
describe("registry, catalog and fonts", () => {
it("is registered after the Northline family, as a standalone template with its thumbnail on disk", () => {
expect(getTemplate("monolith")).toBe(monolith);
expect(monolith.family).toBe("standalone");
expect(catalogEntry("monolith").name).toBe("Monolith");
expect(existsSync(path.join(process.cwd(), "public", monolith.thumbnail)), monolith.thumbnail).toBe(true);
expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["monolith", "cobalt-stripe"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("is black Poppins only: every role is Poppins 400/500/700 and the sources carry no colour but #000000", () => {
for (const [name, token] of Object.entries(MONOLITH_ROLES)) {
expect(token.face, name).toBe("Poppins");
expect([400, 500, 700], name).toContain(token.weight);
}
const dir = path.join(process.cwd(), "src/pdf/templates/monolith");
for (const f of readdirSync(dir).filter((x) => /\.(ts|tsx)$/.test(x) && !/\.test\./.test(x))) {
const colours = readFileSync(path.join(dir, f), "utf8").match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
expect(colours.filter((c) => c.toLowerCase() !== "#000000"), f).toEqual([]);
}
expect(monolith.decor.ink).toBe("#000000");
expect(monolith.chrome.color).toBe("#000000");
});
});
describe("page geometry", () => {
it("template margins", () => {
expect(marginsFor(monolith, "template")).toEqual({ top: 33, right: 35, bottom: 22, left: 35 });
expect(marginsFor(monolith, "narrow").left).toBe(36);
});
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
const a4 = frameForPrefs(prefsFor("A4", { templateId: "monolith" }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "monolith" }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
});
});
describe("header", () => {
it("matches the design: title origin 16 pt in, baseline 96 pt; the vendor, date and number lines end on the right edge at the design's pitch", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "monolith" }));
const items = r.pdf.pages[0].items;
const title = items.find((i) => i.str.trim() === "INVOICE")!;
expect(title.x0).toBeCloseTo(35 + V.titleIndent, 0);
expect(title.bottom).toBeCloseTo(96.2, 0);
const right = contentRight(r);
const brand = items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
const date = items.find((i) => i.str.includes("2026") && i.bottom < 130)!;
const number = items.find((i) => i.str.includes("INVOICE NO."))!;
for (const [what, it, baseline] of [["name", brand, 63.7], ["date", date, 84.7], ["number", number, 105.7]] as const) {
expect(it.bottom, what).toBeCloseTo(baseline, 0);
expect(it.x1, what).toBeCloseTo(right, 0);
}
});
it("TAX INVOICE shrinks to the room the right-hand stack leaves instead of meeting it", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345" } }, prefsFor(size, { templateId: "monolith" }));
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
expect(plan.head.titleSize).toBeLessThan(MONOLITH_ROLES.title.size);
expect(plan.head.titleSize).toBeGreaterThanOrEqual(MONOLITH_ROLES.title.minSize as number);
const title = textLines(byId(r, TITLE_ID));
const stack = textLines(byId(r, META_ID));
const stackLeft = Math.min(...stack.map((l) => l.x));
expect(Math.max(...title.map((l) => l.right)), size).toBeLessThan(stackLeft - 10);
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
}
});
it("a cancelled invoice is marked in the stack and closes with its sentence", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "cancelled")!, prefsFor("A4", { templateId: "monolith" }));
expect(textLines(byId(r, META_ID)).some((l) => l.text === "CANCELLED")).toBe(true);
expect(textsOf(r, r.result.pages - 1)).toContain("This invoice has been cancelled.");
});
});
describe("logo", () => {
it("is the text-only CAVEIN slot: the allowance is 160 x 28, right-anchored, and the template offers exactly it", () => {
expect(SLOTS.monolith!.textOnly).toBe(true);
expect(allowance).toMatchObject({ w: 160, h: 28 });
expect(monolith.logo.slot).toBe(SLOTS.monolith);
expect(monolith.logo.box).toEqual({ w: allowance.w, h: allowance.h });
expect(monolith.logo.available?.(frameForPrefs(prefsFor("A4", { templateId: "monolith" })))).toEqual({ w: allowance.w, h: allowance.h });
expect(monolith.logo.defaultOn).toBe(true);
});
it("every logo fits the allowance, ends on the right edge and the date and number lines flow BELOW its box", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const logo of ["real", "wide", "square"] as const) {
const r = await renderFixture({ name: "o", items: 2, logo }, prefsFor(size, { templateId: "monolith" }));
const imgs = imageBoxes(r);
expect(imgs, `${size}/${logo}`).toHaveLength(1);
const [img] = imgs;
expect(img.box.h).toBeLessThanOrEqual(allowance.h + 0.01);
expect(img.box.w).toBeLessThanOrEqual(allowance.w + 0.01);
expect(img.box.x + img.box.w, `${size}/${logo} right edge`).toBeCloseTo(contentRight(r), 0);
// The brand zone reserves the logo box, so the meta lines start under it.
const brand = byId(r, BRAND_ID);
const supplier = byId(r, SUPPLIER_ID);
const meta = byId(r, META_ID);
expect(img.box.y + img.box.h, `${size}/${logo} logo bottom against meta top`).toBeLessThanOrEqual(meta.box.y + 0.01);
expect(supplier.box.y + supplier.box.h, `${size}/${logo} supplier box against meta`).toBeLessThanOrEqual(meta.box.y + 0.01);
expect(brand.box.y + brand.box.h).toBeLessThanOrEqual(meta.box.y + 0.01);
const lines = textLines(meta);
for (const l of lines) expect(l.y, `${size}/${logo} meta line`).toBeGreaterThanOrEqual(img.box.y + img.box.h - 0.01);
}
}
});
it("a tall logo is held to the 28 pt allowance and still leaves the stack under it", async () => {
const { model } = buildFixture({ name: "tall", items: 2, logo: "square" });
model.logo = { ...model.logo!, aspect: 0.5, width: 60, height: 120, kind: "tall", includesName: false };
const prefs = prefsFor("A4", { templateId: "monolith" });
const result = await renderInvoicePdf({ model, prefs });
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
const all = nodes(result.core.layout);
const img = all.find((n) => n.type === "image" && !n.decor)!;
const meta = all.find((n) => n.id === META_ID)!;
expect(img.box.h).toBeLessThanOrEqual(allowance.h + 0.01);
expect(img.box.y + img.box.h).toBeLessThanOrEqual(meta.box.y + 0.01);
});
it("a wordmark replaces the vendor name text; a mark keeps it beside the mark; no logo prints the name in the template's type", async () => {
const header = (r: Awaited<ReturnType<typeof renderFixture>>) => r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130);
const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "monolith" }));
expect(header(wide)).toHaveLength(0);
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, prefsFor("A4", { templateId: "monolith" }));
const [name] = header(square);
expect(name).toBeTruthy();
expect(name.x1).toBeLessThanOrEqual(imageBoxes(square)[0].box.x + 0.5);
const none = await renderFixture({ name: "o", items: 2, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
expect(imageBoxes(none)).toHaveLength(0);
expect(header(none)).toHaveLength(1);
});
});
describe("table, totals and footer", () => {
it("rules only: a 1.5 pt rule above and below a 39.3 pt header, amounts right-aligned 10 pt inside the edge, qty centred", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "monolith" }));
const head = byId(r, TABLE_HEAD_ID);
expect(head.box.h).toBeCloseTo(V.table.headH, 0);
const right = contentRight(r);
for (let i = 0; i < 3; i++) {
const row = byId(r, `${TABLE_ROW_PREFIX}${i}`);
const lines = textLines(row);
const amount = lines[lines.length - 1];
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0);
}
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty;
expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
});
it("the totals box ends on the right edge, its labels are centred and a rule sits above Total", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
const totals = byId(r, TOTALS_ID);
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const frame = frameForPrefs(r.prefs);
const lines = textLines(totals);
const labelLeft = frame.pad.left + plan.W - plan.tail.boxW;
const subtotal = lines.find((l) => l.text === "Subtotal")!;
expect(subtotal.x + (subtotal.right - subtotal.x) / 2 - labelLeft).toBeCloseTo(plan.tail.labelW / 2, 0);
expect(lines.find((l) => l.text === r.fixture.model.totals.totalText)!.right).toBeCloseTo(frame.page.width - frame.pad.right - V.totals.valuePad, 0);
const rules = nodes([r.result.core.layout[0]]).filter((n) => n.type === "view" && n.box.h <= 0.76 && n.box.h >= 0.74 && Math.abs(n.box.w - plan.tail.boxW) < 0.5);
// The rule sits in a spacing wrapper of the same size, so count distinct positions.
expect(new Set(rules.map((n) => Math.round(n.box.y * 10)))).toHaveProperty("size", 1);
const total = textLines(totals).find((l) => l.text === "Total")!;
expect(rules[0].box.y).toBeLessThan(totals.box.y + total.y);
});
it("the closing block is the design's footer: payment information as a label : value grid at 114 pt, the vendor block at the right, between two rules", async () => {
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "monolith" }));
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
const lines = textLines(closing);
const frame = frameForPrefs(r.prefs);
const bank = lines.find((l) => l.text === "State Bank of India")!;
const label = lines.find((l) => l.text === "Bank")!;
expect(bank.x - label.x).toBeCloseTo(V.closing.labelW + V.closing.colonW, 0);
expect(lines.some((l) => l.text === "PAYMENT INFORMATION :")).toBe(true);
const plan = planMonolith(r.fixture.model, frame, V, null, false);
const vendor = lines.find((l) => l.text === "9000000000")!;
expect(vendor.x).toBeCloseTo(frame.pad.left + plan.W - V.sideW, 0);
const rules = onPage(r, r.result.pages - 1).filter((n) => n.type === "view" && Math.abs(n.box.h - V.rule) < 0.01 && Math.abs(n.box.w - plan.W) < 0.5 && n.box.y >= closing.box.y - 0.01);
expect(rules.length).toBeGreaterThanOrEqual(2);
// The signature (an image and the signatory) sits in the band above the footer's top rule.
expect(byId(r, SLOT_ID.signatory, r.result.pages - 1).box.y + byId(r, SLOT_ID.signatory, r.result.pages - 1).box.h).toBeLessThanOrEqual(rules[0].box.y + 0.5);
});
it("a short invoice with the sample's data fits one A4 page by giving back the empty band, row padding and spacing", async () => {
const spec: FixtureSpec = {
name: "thumb",
items: [
{ id: 1, description: "Voice-over narration", mode: "rate", rate: 1500, unit: "hour", quantity: 4, amount: 0, sortOrder: 0, hsnSac: "998397" },
{ id: 2, description: "Audio mastering", mode: "rate", rate: 800, unit: "hour", quantity: 2, amount: 0, sortOrder: 1, hsnSac: "998397" },
],
logo: "real",
vendor: { vendorEmail: "", vendorPhone: "", vendorPan: "" },
};
const r = await renderFixture(spec, prefsFor("A4", { templateId: "monolith" }));
expect(r.result.pages).toBe(1);
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
});
});
describe("the GST slots and the supplier ids", () => {
it("every GST slot has a home on a tax invoice, and the supplier's GSTIN and PAN are on page 1 whatever the length", async () => {
const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "monolith" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, `${SLOT_ID.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(true);
expect(byId(r, IDS_ID)).toBeTruthy();
expect(textLines(byId(r, IDS_ID)).map((l) => l.text)).toEqual(expect.arrayContaining(["SUPPLIER:", "GSTIN: 29AAAAA0000A1Z5", "PAN: ABCDE1234F"]));
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
for (const word of ["SUBTOTAL", "TAXABLEVALUE", "CGST@9%", "SGST@9%", "HSN/SAC:998397", "PLACEOFSUPPLY"]) expect(flat, word).toContain(word);
});
it("an unregistered invoice carries none of the GST slots", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "monolith" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(false);
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error on either size", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const name of ["id-tokens", "long-address", "long-client", "long-notes"]) {
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: "monolith" }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
}
}
});
it("long terms flow as a block of their own and keep the closing block bounded", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "monolith" }));
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
expect(plan.closing.notesBeside).toBe(false);
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
});
it("an over-long vendor name wraps right-aligned in its column and never reaches the title", async () => {
const r = await renderFixture({ ...FIXTURES.find((f) => f.name === "id-tokens")!, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
const brand = textLines(byId(r, BRAND_ID));
expect(brand.length).toBeGreaterThan(1);
const title = textLines(byId(r, TITLE_ID));
expect(Math.min(...brand.map((l) => l.x))).toBeGreaterThan(Math.max(...title.map((l) => l.right)));
});
});
describe("plan matches the laid-out blocks", () => {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: "monolith" });
const frame = frameForPrefs(prefs);
// Logo-less, so the plan made here is the one the layout made.
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 = planMonolith(r.fixture.model, frame, V, null, false);
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(24);
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(24);
});
}
}
});
describe("design docs", () => {
it("records its implementation notes", () => {
const text = readFileSync(path.join(process.cwd(), "docs/templates/03-monolith.md"), "utf8");
expect(text).toContain("## Implementation notes");
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { ComponentType } from "react";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition, TemplateProps } from "../contract";
import { slotFor } from "../slots.optin";
import { MonolithBody } from "./Layout";
import { INK, MONOLITH_ROLES, MONOLITH_VARIANT } from "./tokens";
export { MONOLITH_ROLES, MONOLITH_VARIANT } from "./tokens";
/** The text-only "CAVEIN" placeholder's allowance (slots.generated.ts): the largest box a logo may fill, right-anchored. */
const ALLOWANCE = { w: 160, h: 28 };
const MonolithLayout: ComponentType<TemplateProps> = (props) => <MonolithBody {...props} variant={MONOLITH_VARIANT} />;
MonolithLayout.displayName = "MonolithLayout";
export const monolith: TemplateDefinition = {
...catalogEntry("monolith"),
version: 1,
roles: MONOLITH_ROLES,
margins: MONOLITH_VARIANT.margins,
marginFloor: MONOLITH_VARIANT.marginFloor,
decor: { ink: INK },
chrome: {
color: INK,
rule: null,
role: MONOLITH_ROLES.footer,
leftWidth: 150,
// The footer line is one 7 pt line, so the closing rule sits close to the design's (30 pt above the page foot in Canva).
reserve: 16,
},
logo: {
box: ALLOWANCE,
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => ALLOWANCE.w,
slot: slotFor("monolith"),
available: () => ALLOWANCE,
},
Layout: MonolithLayout,
};
+274
View File
@@ -0,0 +1,274 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import type { RenderModel } from "../../model/build";
import type { TypeToken } from "../../fonts/roles";
import type { PlacedLogo } from "../contract";
import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
import { leading, type MonolithVariant } from "./tokens";
export const NOTES_FLOW_GAP = 14;
const FIT_AIR = 2;
/** A GSTIN is always 15 characters: the text the layout prints for the height estimate, which must not read the regulated field. */
const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
export const REVERSE_GAP = 4;
/** Space between the label of a words / notes block and its text. */
export const LABEL_GAP = 4;
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
/** "Tax" rows read "CGST @ 9%" like the design's "Tax". */
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
/** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */
function idLines(role: TypeToken, text: string, width: number): number {
const f = fitSize(role, text, Math.max(1, width));
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)));
}
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(getMeasurer(), role, text)) + 1;
export interface StackLine {
kind: "cancelled" | "date" | "due" | "number";
text: string;
}
/** The right-hand stack under the brand: the design's date and invoice number, plus a due date and the cancelled tag. */
export function stackLines(model: RenderModel): StackLine[] {
const out: StackLine[] = [];
if (model.cancelled) out.push({ kind: "cancelled", text: "CANCELLED" });
out.push({ kind: "date", text: model.meta.date });
if (model.meta.dueDate) out.push({ kind: "due", text: `Due: ${model.meta.dueDate}` });
out.push({ kind: "number", text: `${model.meta.numberLabel.toUpperCase()} ${model.meta.number}` });
return out;
}
export function signatureOptions(v: MonolithVariant): SignatoryOptions {
const r = v.roles;
return { roles: { name: r.signName, designation: r.signDesignation, fine: r.fine }, align: "right", rule: { width: v.rule, color: v.ink }, color: v.ink, imageHeight: v.closing.signatureImageH };
}
export const roleOf = (v: MonolithVariant, kind: StackLine["kind"]): TypeToken => (kind === "date" || kind === "due" ? v.roles.metaDate : v.roles.metaNo);
/** The table's description (+ HSN/SAC sub-line), price, qty and amount cells. */
export function tableRows(model: RenderModel, v: MonolithVariant, priceW: number): TableCellValue[][] {
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, priceW, 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, rateParts, item.qtyText, item.amountText];
});
}
/** The footer's payment grid: the account holder as a plain line, then label : value rows. */
export function paymentRows(model: RenderModel): Array<{ label: string; value: string }> {
return model.bank ? model.bank.lines : [];
}
export interface MonolithPlan {
v: MonolithVariant;
W: number;
head: {
titleSize: number;
titleW: number;
stackW: number;
lines: StackLine[];
showName: boolean;
nameW: number;
nameH: number;
/** Height of the brand zone (name or logo box) including its top offset and the gap below. */
zoneH: number;
zoneTop: number;
nameOffset: number;
stackH: number;
h: number;
};
info: { clientW: number; hasIds: boolean; idsH: number; clientH: number; h: number };
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
padLeft: number[];
padRight: number[];
padY: number;
headPadY: number;
cw: { price: number; qty: number; amount: number };
table: ItemsTablePlan;
guardH: number;
tail: { boxW: number; labelW: number; valueW: number; leftW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rowH: number; boxH: number; wordsH: number; reverseH: number; leftH: number; body: number };
closing: {
leftW: number;
notes: string[];
notesBeside: boolean;
notesH: number;
flowH: number;
signH: number;
bandH: number;
valueW: number;
payH: number;
vendorH: number;
footerH: number;
h: number;
};
gaps: { band: number; infoTop: number; infoBottom: number; closeBand: number; closeBottom: number };
tailH: number;
closingH: number;
}
/**
* Everything the layout must know before rendering: the right-hand stack (it reserves the logo's whole box, so the
* date and number lines flow under the logo), the title size, the columns, the totals box and the heights of the tail
* and the closing block (their minPresenceAhead values), each a hair high rather than low.
*/
export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVariant, logo: PlacedLogo | null, includesName: boolean): MonolithPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const s = model.supplier;
const c = model.client;
// --- header: the title at the left, the brand zone and the date / number stack at the right -----------------
const lines = stackLines(model);
const showName = !(logo && includesName) && s.name.trim() !== "";
const markBlock = logo ? logo.w + (showName ? v.stack.markGap : 0) : 0;
const natural = Math.max(...lines.map((l) => widthOf(roleOf(v, l.kind), l.text)), showName ? widthOf(r.brand, s.name) + markBlock : markBlock);
let stackW = Math.min(Math.max(v.stack.minW, natural), Math.floor(W * v.stack.maxShare));
stackW = Math.max(stackW, markBlock + (showName ? v.stack.minNameW : 0), logo?.w ?? 0);
const nameW = showName ? stackW - markBlock : 0;
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
const zoneTop = logo ? v.stack.logoTop : v.stack.nameTop;
const zoneBody = logo ? Math.max(logo.h, nameH) : nameH;
const zoneH = zoneTop + zoneBody + (logo ? v.stack.logoGap : 0);
const nameOffset = logo ? Math.max(0, (logo.h - nameH) / 2) : 0;
let linesH = 0;
for (const l of lines) linesH += idLines(roleOf(v, l.kind), l.text, stackW) * leading(roleOf(v, l.kind));
const stackH = zoneH + linesH;
const titleW = Math.max(40, W - stackW - v.titleGap - v.titleIndent);
const tFit = fitSize(r.title, model.docTitle, titleW);
const headH = Math.max(stackH, leading(r.title));
// --- info row: BILLED TO at the left, the supplier's identifiers at the right ---------------------------------
const gstinLines = slotHeights.gstinLines(model, "supplier");
const hasIds = gstinLines + (s.pan ? 1 : 0) > 0;
const clientW = hasIds ? W - v.sideW - v.sideGap : W;
let clientH = leading(r.heading) + v.headingGap + textHeight(r.body, c.name, clientW);
for (const l of c.addressLines) clientH += textHeight(r.body, l, clientW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, clientW) * leading(r.body);
if (c.poNumber) clientH += textHeight(r.body, `PO: ${c.poNumber}`, clientW);
const idsH = hasIds ? leading(r.heading) + v.headingGap + (gstinLines ? idLines(r.body, GSTIN_SAMPLE, v.sideW) * leading(r.body) : 0) + (s.pan ? idLines(r.body, `PAN: ${s.pan}`, v.sideW) * leading(r.body) : 0) : 0;
const infoH = Math.max(clientH, idsH);
// --- table ---------------------------------------------------------------------------------------------------------
const t = v.table;
const priced = model.items.map((i) => priceOf(i.rateText));
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + 6;
const cw = {
price: Math.max(t.cols.price, need(priced, "Price", t.numPad)),
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad)),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }];
const headerCells = ["Description", "Price", "Qty", "Total"];
const align: MonolithPlan["align"] = ["left", "right", "center", "right"];
const padLeft = [t.descInset, 0, 0, 0];
const padRight = [8, t.numPad, 0, t.numPad];
const headPadY = (t.headH - 2 * v.heavyRule - leading(r.tableHead)) / 2;
const rows = tableRows(model, v, cw.price - t.numPad - 2);
const n = rows.length;
const planTable = (padY: number, tailH: number) =>
planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY, border: 0, headBorder: v.heavyRule, headerTailH: v.heavyRule, tailH, contentH: frame.contentHeight });
// --- totals: the stack, a rule, the Total row; amount in words and reverse charge at the left ---------------------------
const tt = model.totals;
const o = v.totals;
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: tt.subtotalText }];
if (tt.discountText) stack.push({ label: "Discount", amount: `- ${tt.discountText}` });
if (tt.taxableText) stack.push({ label: "Taxable value", amount: tt.taxableText });
const slotRows = slotHeights.taxRowCount(model);
const taxTexts = slotHeights.taxRowTexts(model, atRate);
const labels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label)];
const amounts = [...stack.map((x) => x.amount), ...taxTexts.map((x) => x.amount)];
const labelW = Math.max(o.labelW, Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)), measureToken(m, r.total, "Total"))) + 12);
const valueW = Math.max(o.valueW, Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)), measureToken(m, r.total, tt.totalText))) + o.valuePad + 6);
const boxW = Math.max(o.boxW, labelW + valueW);
const rowH = leading(r.totalLabel) + o.rowGap;
const boxH = o.top + (stack.length + slotRows) * rowH + o.ruleGap + v.rule + o.totalGap + leading(r.total);
const leftW = Math.max(0, W - boxW - o.leftGap);
const wordsH = slotHeights.amountInWords(model, leftW, { role: r.small, labelRole: r.smallStrong });
const reverseH = slotHeights.reverseCharge(model, leftW, r.small);
const leftH = wordsH || reverseH ? o.top + wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0;
const body = Math.max(boxH, leftH);
const tailH = v.rule + body;
// --- closing: notes and signature, then the footer between two rules ------------------------------------------------
const cl = v.closing;
const cLeftW = W - v.sideW - v.sideGap;
const notes = model.notesLines;
const notesH = notes.length ? leading(r.smallStrong) + LABEL_GAP + notes.reduce((h, l) => h + textHeight(r.small, l, cLeftW), 0) : 0;
const notesBeside = notes.length === 0 || notesH <= cl.notesBesideMax;
const flowH = notesBeside ? 0 : notesH;
const signH = signatoryHeight(model, v.sideW, signatureOptions(v));
const bandH = Math.max(notesBeside ? notesH : 0, signH);
const valueColW = cLeftW - cl.labelW - cl.colonW;
const payH = model.bank
? leading(r.heading) + cl.headingGap + textHeight(r.body, model.bank.accountName, cLeftW) + paymentRows(model).reduce((h, x) => h + textHeight(r.body, x.value, valueColW), 0)
: 0;
let vendorH = textHeight(r.vendor, s.name, v.sideW) + cl.headingGap;
for (const l of s.addressLines) vendorH += textHeight(r.body, l, v.sideW);
if (s.phone) vendorH += idLines(r.body, s.phone, v.sideW) * leading(r.body);
if (s.email) vendorH += idLines(r.body, s.email, v.sideW) * leading(r.body);
const footerH = Math.max(payH, vendorH);
const cancelledH = model.cancelled ? 10 + textHeight(r.small, CANCELLED_SENTENCE, W) : 0;
const closingFor = (g: { closeBand: number; closeBottom: number }) => bandH + g.closeBand + v.rule + cl.top + footerH + g.closeBottom + v.rule + cancelledH;
// --- gaps: the empty band under the info row and the row padding give back Letter's missing height ------------------------
const padYn = t.padY(n);
const ranges = { ...v.gaps, rowPad: { nominal: 2 * padYn * n, min: 2 * t.padYMin * n } };
const closingNominal = closingFor({ closeBand: ranges.closeBand.nominal, closeBottom: ranges.closeBottom.nominal });
const table0 = planTable(padYn, 0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const notesBlock = notesBeside ? 0 : NOTES_FLOW_GAP + flowH;
const nominalTotal = headH + v.headRuleGap + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailH + notesBlock + closingNominal;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
const overflow = nominalTotal - frame.contentHeight;
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
// page 1 and let only the closing block move to the next page.
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
const air = (over: number) => Math.min(slack, over + FIT_AIR);
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
const deficit = Math.max(frame.heightDeficit, fits);
const g = elastic(ranges, deficit);
const closingH = closingFor(g);
const padY = n ? g.rowPad / (2 * n) : padYn;
const table = planTable(padY, tailH);
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailH : 0);
return {
v,
W,
head: { titleSize: tFit.size, titleW, stackW, lines, showName, nameW, nameH, zoneH, zoneTop, nameOffset, stackH, h: headH },
info: { clientW, hasIds, idsH, clientH, h: infoH },
columns,
headerCells,
rows,
align,
padLeft,
padRight,
padY,
headPadY,
cw,
table,
guardH,
tail: { boxW, labelW, valueW, leftW, stack, slotRows, rowH, boxH, wordsH, reverseH, leftH, body },
closing: { leftW: cLeftW, notes, notesBeside, notesH, flowH, signH, bandH, valueW: valueColW, payH, vendorH, footerH, h: closingH },
gaps: { band: g.band, infoTop: g.infoTop, infoBottom: g.infoBottom, closeBand: g.closeBand, closeBottom: g.closeBottom },
tailH,
closingH,
};
}
+142
View File
@@ -0,0 +1,142 @@
import type { Sides } from "../../engine/geometry";
import { defineRoles, type TypeToken } from "../../fonts/roles";
import type { GapRange } from "../neutral/variant";
const P = "Poppins" as const;
export const INK = "#000000";
/**
* Monolith's type (docs/templates/03-monolith.md): Poppins Bold for the title, headings, labels and the Total row,
* Medium for every data line, Regular for the date. Everything is black. Poppins ascent 1.05, descent 0.35.
*/
export const MONOLITH_ROLES = defineRoles({
/** "INVOICE" / "TAX INVOICE": 60 pt in the design, shrunk to the room the right-hand stack leaves. */
title: { face: P, weight: 700, size: 60.02, leading: 66, caps: true, minSize: 30 },
/** The vendor name (or its logo) at the top right. */
brand: { face: P, weight: 700, size: 12.5, leading: 21, minSize: 8 },
metaDate: { face: P, weight: 400, size: 12.5, leading: 21, minSize: 8 },
metaNo: { face: P, weight: 700, size: 12.5, leading: 21, minSize: 8 },
/** BILLED TO:, SUPPLIER:, PAYMENT INFORMATION :. */
heading: { face: P, weight: 700, size: 14.5, leading: 20, caps: true },
body: { face: P, weight: 500, size: 12.5, leading: 17.25, minSize: 8 },
bodyStrong: { face: P, weight: 700, size: 12.5, leading: 17.25, minSize: 8 },
colon: { face: P, weight: 400, size: 12.5, leading: 17.25 },
/** The footer's vendor name. */
vendor: { face: P, weight: 700, size: 12.5, leading: 20 },
tableHead: { face: P, weight: 700, size: 12.5, leading: 17.25, caps: true },
tableBody: { face: P, weight: 500, size: 12.5, leading: 17.25 },
tableSub: { face: P, weight: 500, size: 10, leading: 13.5 },
totalLabel: { face: P, weight: 700, size: 12.5, leading: 17.25 },
totalValue: { face: P, weight: 500, size: 12.5, leading: 17.25 },
total: { face: P, weight: 700, size: 12.5, leading: 17.25 },
small: { face: P, weight: 500, size: 10.5, leading: 14.5 },
smallStrong: { face: P, weight: 700, size: 10.5, leading: 14.5, caps: true },
signName: { face: P, weight: 700, size: 11, leading: 15 },
signDesignation: { face: P, weight: 500, size: 10, leading: 14 },
fine: { face: P, weight: 500, size: 9, leading: 12.5 },
footer: { face: P, weight: 500, size: 7 },
});
export type MonolithRoles = typeof MONOLITH_ROLES;
/**
* Canva A4 numbers (docs 03): content x 35..560.3, title origin 16 pt inside the left edge, the right-hand column
* ends on the right edge; y values are the design's distances turned into flow gaps.
*/
export interface MonolithVariant {
roles: MonolithRoles;
ink: string;
margins: Sides;
marginFloor: Sides;
/** Title origin inside the content edge. */
titleIndent: number;
/** Space between the title and the right-hand stack at its narrowest. */
titleGap: number;
stack: {
/** Narrowest the right-hand stack gets (the logo allowance's width). */
minW: number;
/** Widest share of the content width. */
maxShare: number;
/** Top of the vendor name's line box below the header's top (cap height lines up with the title's). */
nameTop: number;
/** Top of the logo box below the header's top: the allowance's top edge. */
logoTop: number;
/** Space between the logo box's bottom and the date line. */
logoGap: number;
/** Gap between a mark and the vendor name beside it, and the narrowest the name may get. */
markGap: number;
minNameW: number;
};
rule: number;
heavyRule: number;
/** Header bottom to its rule. */
headRuleGap: number;
/** The rule to the BILLED TO heading, and the last info line to the next rule. */
/** Heading to its first line. */
headingGap: number;
/** Width of the right-hand columns (supplier ids, footer vendor, signature) and the space beside them. */
sideW: number;
sideGap: number;
gaps: {
/** The empty band between the info row and the table (88 pt in the design). */
band: GapRange;
/** The rule to BILLED TO, and the last info line to the next rule. */
infoTop: GapRange;
infoBottom: GapRange;
/** The signature band to the footer, and the footer text to its bottom rule. */
closeBand: GapRange;
closeBottom: GapRange;
};
table: {
/** Header band height inside the heavy rules. */
headH: number;
descInset: number;
cols: { price: number; qty: number; amount: number };
/** Right padding of the price and amount columns. */
numPad: number;
/** Vertical row padding by item count: generous like the design for a few rows, tighter for many. */
padY: (rows: number) => number;
padYMin: number;
};
totals: { boxW: number; labelW: number; valueW: number; top: number; rowGap: number; ruleGap: number; totalGap: number; valuePad: number; leftGap: number; wordsMax: number };
closing: {
/** The footer's top rule to its heading, and the heading to its first line. */
top: number;
headingGap: number;
labelW: number;
colonW: number;
notesBesideMax: number;
signatureImageH: number;
};
footerLeftW: number;
}
export const MONOLITH_VARIANT: MonolithVariant = {
roles: MONOLITH_ROLES,
ink: INK,
margins: { top: 33, right: 35, bottom: 22, left: 35 },
marginFloor: { top: 24, right: 30, bottom: 18, left: 30 },
titleIndent: 15.7,
titleGap: 20,
stack: { minW: 160, maxShare: 0.46, nameTop: 17.6, logoTop: 21.75, logoGap: 4, markGap: 8, minNameW: 70 },
rule: 0.75,
heavyRule: 1.5,
headRuleGap: 3.15,
headingGap: 7.2,
sideW: 165,
sideGap: 24,
gaps: { band: { nominal: 88, min: 24 }, infoTop: { nominal: 21, min: 14 }, infoBottom: { nominal: 21.5, min: 14 }, closeBand: { nominal: 18, min: 8 }, closeBottom: { nominal: 14.8, min: 8 } },
table: {
headH: 39.3,
descInset: 9.4,
cols: { price: 98, qty: 74, amount: 98 },
numPad: 10,
padY: (rows) => (rows <= 3 ? 16 : rows <= 5 ? 12 : 8),
padYMin: 6,
},
totals: { boxW: 215.5, labelW: 108, valueW: 107.5, top: 6.15, rowGap: 5.75, ruleGap: 3, totalGap: 6.35, valuePad: 10, leftGap: 16, wordsMax: 120 },
closing: { top: 6.9, headingGap: 12.9, labelW: 114, colonW: 21, notesBesideMax: 110, signatureImageH: 45 },
footerLeftW: 336,
};
export const leading = (t: TypeToken) => t.leading as number;
+1 -1
View File
@@ -28,7 +28,7 @@ const nodes = (layout: LayoutNode[]) => {
describe("registry and catalog", () => {
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", "purple-pop", "citrus-split"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
@@ -55,14 +55,14 @@ const near = (c: number[], hex: string, tol = 6) => {
};
describe("registry, catalog and fonts", () => {
it("lists Purple Pop and Citrus Split last, in the Northline family, with their thumbnails on disk", () => {
it("lists Purple Pop and Citrus Split before the standalone templates, in the Northline family, with their thumbnails on disk", () => {
for (const t of NORTHLINE) {
expect(getTemplate(t.id)).toBe(t);
expect(t.family).toBe("northline");
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).slice(-2)).toEqual(["purple-pop", "citrus-split"]);
expect(Object.keys(TEMPLATES).slice(-4, -2)).toEqual(["purple-pop", "citrus-split"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
expect(TEMPLATE_CATALOG.filter((e) => e.logoOptIn).map((e) => e.id)).toEqual(["linea", "purple-pop"]);
});
+4
View File
@@ -7,6 +7,8 @@ import { slateBand } from "./band/slate";
import { tealSwoosh } from "./band/teal";
import { citrusSplit } from "./northline/citrus";
import { purplePop } from "./northline/purple";
import { cobaltStripe } from "./cobalt/cobalt";
import { monolith } from "./monolith/monolith";
import { monogram } from "./neutral/monogram";
import { serenity } from "./neutral/serenity";
@@ -22,6 +24,8 @@ export const TEMPLATES: Record<string, TemplateDefinition> = {
"teal-swoosh": tealSwoosh,
"purple-pop": purplePop,
"citrus-split": citrusSplit,
monolith,
"cobalt-stripe": cobaltStripe,
};
export const DEFAULT_TEMPLATE_ID = "classic";