Add the Northline template family: Purple Pop and Citrus Split

Purple Pop (cream page, purple pills, asterisk and dome decor, opt-in
logo) and Citrus Split (grey split panel, halftone chevrons, icon
contacts, logo in the measured slot) are rebuilt from the Canva PDFs on
the engine with A4/Letter support, GST slots and zero audit errors. Adds
static DM Sans, Jost and Poppins 800 as look-alikes for the commercial
fonts. The picker now lists ten templates.
This commit is contained in:
2026-10-04 09:56:56 +05:30
parent 06976df55c
commit 5ea62a9be8
36 changed files with 3033 additions and 56 deletions
+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"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
});
it("both are Montserrat throughout, and their slots are the measured Canva ones", () => {
+1 -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(8);
expect(TEMPLATE_CATALOG).toHaveLength(10);
});
it("groups the C4 templates into the Ledger and Band families", () => {
+4 -1
View File
@@ -5,7 +5,7 @@
export interface TemplateCatalogEntry {
id: string;
name: string;
family: "classic" | "neutral" | "ledger" | "band";
family: "classic" | "neutral" | "ledger" | "band" | "northline";
/** Static PNG from /public, written by scripts/thumbnails.mjs (sample data, not the user's invoice). */
thumbnail: string;
badge?: "Matches your original format";
@@ -18,6 +18,7 @@ export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
neutral: "Neutral",
ledger: "Ledger",
band: "Band",
northline: "Northline",
};
/** Picker order: Classic first, Linea (the vendor's original format) second. */
@@ -30,6 +31,8 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
{ id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" },
{ id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" },
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
{ 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" },
];
/** 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"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
expect(getTemplate("classic")).toBe(classic);
expect(getTemplate("no-such-template")).toBe(classic);
expect(getTemplate(undefined)).toBe(classic);
+5
View File
@@ -31,6 +31,11 @@ export interface TemplateDecor {
* supplier contact lines (a footer band); decor is exempt from the audit, so regulated slots never go here.
*/
everyPage?: (frame: PageFrame, model: RenderModel) => DecorItem[];
/**
* Fixed every-page art that must sit UNDER the flow (a full-height panel the numbers are printed on, watermarks, halftone dots).
* everyPage is painted after the flow, so it would cover text; the underlay is painted before it. Still page-edge anchored.
*/
underlay?: (frame: PageFrame, model: RenderModel) => DecorItem[];
/** Page-space boxes the flow must stay out of (audited as `keepout`); `page` limits one to a single 1-based page. */
keepOuts?: (frame: PageFrame) => Array<Rect & { page?: number }>;
}
+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"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
@@ -0,0 +1,471 @@
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 } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Txt } from "../../blocks/Txt";
import { CircledIcon } 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 { atRate, BULLET, NOTES_FLOW_GAP, planCitrus, REVERSE_GAP, signatureOptions, type CitrusPlan } from "./plan";
import type { CitrusVariant } from "./variant";
interface Ctx {
model: TemplateProps["model"];
plan: CitrusPlan;
v: CitrusVariant;
logo: PlacedLogo | null;
}
const WHITE = "#FFFFFF";
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
// --- header -----------------------------------------------------------------------------------------------------
function Title({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const h = plan.head;
return (
<View id={TITLE_ID} style={pinned(h.titleW) as never}>
{h.tax ? (
<Txt role={v.roles.taxCaption} color={v.label} width={h.titleW} style={{ marginBottom: 2 }}>
TAX
</Txt>
) : null}
{/* Left-aligned, so the negative tracking only shortens the line; the ink stays inside the box. */}
<Txt role={{ ...v.roles.title, size: h.titleSize }} color={v.ink} width={h.titleW}>
Invoice
</Txt>
</View>
);
}
function PanelHeader({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const h = plan.head;
return (
<View id={SUPPLIER_ID} style={pinned(plan.panelW) as never}>
<View style={{ flexDirection: "row", marginLeft: v.head.panelInset, minHeight: h.boxH } as never}>
{logo ? <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: Math.max(0, (h.h - logo.h) / 2), objectFit: "contain" } as never} /> : null}
{h.showName ? (
<View style={{ ...pinned(h.nameW), marginLeft: logo ? v.head.markGap : 0, marginTop: Math.max(0, (h.h - h.nameH) / 2) } as never}>
<AvailableWidthProvider width={h.nameW}>
<SafeTxt role={v.roles.brand} color={v.ink}>
{model.supplier.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : null}
</View>
</View>
);
}
// --- client block, meta row -------------------------------------------------------------------------------------
function ClientBlock({ ctx, inset }: { ctx: Ctx; inset: number }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
const w = plan.client.w;
return (
<View style={{ marginLeft: inset, ...pinned(w) } as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.billedLabel} color={v.ink} width={w} style={{ marginBottom: v.client.labelGap }}>
Billed to:
</Txt>
<SafeTxt role={r.clientName} color={v.label} style={{ marginBottom: v.client.nameGap }}>
{c.name}
</SafeTxt>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.clientLine} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.clientLine} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.clientLine} color={v.ink} />
{c.poNumber ? (
<SafeTxt role={r.clientLine} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt>
) : null}
</AvailableWidthProvider>
</View>
);
}
function MetaCell({ ctx, label, children }: { ctx: Ctx; label: string; children: ReactNode }) {
const { v } = ctx;
return (
<View>
<Txt role={v.roles.metaLabel} color={v.label} style={{ marginBottom: v.meta.labelGap }}>
{label}
</Txt>
{children}
</View>
);
}
function MetaRow({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const m = plan.meta;
const no = `${model.meta.numberLabel.replace(/\.$/, "")} :`;
return (
<View id={META_ID} style={pinned(plan.W) as never}>
<Row widths={[{ fixed: v.meta.inset }, { fixed: m.noW }, { fixed: m.dateW }, { fixed: m.spacer }, { fixed: m.dueW }, { fill: true }]} width={plan.W}>
<View />
<MetaCell ctx={ctx} label={no}>
<IdText role={r.metaValue} color={v.ink}>
{model.meta.number}
</IdText>
</MetaCell>
<MetaCell ctx={ctx} label="Invoice Date :">
<SafeTxt role={r.metaValue} color={v.ink}>
{model.meta.date}
</SafeTxt>
</MetaCell>
<View />
{m.hasDue ? (
<MetaCell ctx={ctx} label="Due Date :">
<SafeTxt role={r.metaValue} color={v.ink}>
{model.meta.dueDate as string}
</SafeTxt>
</MetaCell>
) : (
<View />
)}
<MetaCell ctx={ctx} label="Amount Due:">
<IdText role={r.metaStrong} color={v.ink}>
{model.totals.totalText}
</IdText>
</MetaCell>
</Row>
</View>
);
}
// --- totals ------------------------------------------------------------------------------------------------------
function Panel({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const k = plan.tail;
const t = v.totals;
const inner = plan.panelW - t.labelInset - t.valueInset;
const labelW = inner - k.valueW;
const barInner = plan.panelW - 2 * t.barInset;
return (
<View style={pinned(plan.panelW) as never}>
<View style={{ height: v.separator.h, backgroundColor: v.separator.color, ...pinned(plan.panelW) } as never} />
<View style={{ marginTop: t.sepGap, marginLeft: t.labelInset, ...pinned(inner) } as never}>
<AvailableWidthProvider width={inner}>
{k.stack.map((row, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: k.valueW }]} style={{ marginBottom: t.rowGap }}>
<Txt role={r.totalLabel} color={v.text} width={labelW}>
{row.label}
</Txt>
<Txt role={r.totalValue} color={v.text} width={k.valueW} align="right">
{row.amount}
</Txt>
</Row>
))}
<Slot.TaxRows
model={model}
role={r.totalLabel}
amountRole={r.totalValue}
color={v.text}
amountWidth={k.valueW}
rowStyle={{ marginBottom: t.rowGap }}
format={atRate}
/>
</AvailableWidthProvider>
</View>
<View
style={{
marginTop: t.barGap,
marginLeft: t.barInset,
...pinned(barInner),
minHeight: t.barH,
justifyContent: "center",
paddingLeft: t.labelInset - t.barInset - 4.4,
paddingRight: t.valueInset - t.barInset,
backgroundColor: v.orange,
} as never}
>
<AvailableWidthProvider width={barInner - (t.labelInset - t.barInset - 4.4) - (t.valueInset - t.barInset)}>
<Row widths={[{ fill: true }, { fixed: k.barValueW }]}>
<Txt role={r.grand} color={v.text} width={barInner - (t.labelInset - t.barInset - 4.4) - (t.valueInset - t.barInset) - k.barValueW}>
Total
</Txt>
<Txt role={r.grandValue} color={v.text} width={k.barValueW} align="right">
{model.totals.totalText}
</Txt>
</Row>
</AvailableWidthProvider>
</View>
{k.wordsH > 0 || k.reverseH > 0 ? (
<View style={{ marginTop: t.wordsGap, marginLeft: 13.1, ...pinned(k.wordsW) } as never}>
<AvailableWidthProvider width={k.wordsW}>
<Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={v.ink} align="right" />
{k.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: REVERSE_GAP }} /> : null}
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const k = plan.tail;
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.tableW) as never}>
<View style={{ marginTop: plan.gaps.tail }}>
<Row widths={[{ fixed: k.leftW }, { fill: true }]} width={plan.tableW}>
<View>{plan.clientAbove ? null : <View style={{ marginTop: v.totals.sepGap }}><ClientBlock ctx={ctx} inset={v.client.inset - v.table.left} /></View>}</View>
<Panel ctx={ctx} />
</Row>
</View>
</View>
);
}
// --- closing -----------------------------------------------------------------------------------------------------
/** Terms too long for the closing's left column: a block of their own across the width, each bullet unbreakable, the label held with the first. */
function NotesFlow({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
const bullet = (line: string, i: number) => (
<View key={i} wrap={false} style={{ flexDirection: "row", marginBottom: i === c.terms.length - 1 ? 0 : cl.bulletGap, marginLeft: cl.bulletInset - cl.methodInset, ...pinned(c.flowW - (cl.bulletInset - cl.methodInset)) } as never}>
<View style={{ ...pinned(BULLET.dot), height: BULLET.dot, marginTop: ((r.bullet.leading as number) - BULLET.dot) / 2, borderRadius: BULLET.dot / 2, backgroundColor: v.ink } as never} />
<View style={{ ...pinned(c.flowBulletW), marginLeft: cl.bulletText - cl.bulletInset - BULLET.dot } as never}>
<SafeTxt role={r.bullet} color={v.ink} width={c.flowBulletW}>
{line}
</SafeTxt>
</View>
</View>
);
return (
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: cl.methodInset, ...pinned(c.flowW) } as never}>
<View wrap={false}>
<Txt role={r.sectionLabel} color={v.label} width={c.flowW} style={{ marginBottom: cl.labelGap }}>
Payment terms
</Txt>
{bullet(c.terms[0], 0)}
</View>
{c.terms.slice(1).map((line, i) => bullet(line, i + 1))}
</View>
);
}
function Method({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
return (
<View style={pinned(plan.Lw) as never}>
{model.bank ? (
<View style={{ marginLeft: cl.methodInset, ...pinned(plan.Lw - cl.methodInset) } as never}>
<Txt role={r.sectionLabel} color={v.label} width={plan.Lw - cl.methodInset} style={{ marginBottom: cl.labelGap }}>
Payment method
</Txt>
<View style={{ marginLeft: cl.bankInset - cl.methodInset, ...pinned(plan.Lw - cl.bankInset - 8) } as never}>
<AvailableWidthProvider width={plan.Lw - cl.bankInset - 8}>
{c.bank.map((line, i) => (
<SafeTxt key={i} role={r.bank} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
</View>
) : null}
{c.termsBeside && c.terms.length ? (
<View style={{ marginTop: model.bank ? cl.sectionGap : 0, marginLeft: cl.methodInset, ...pinned(plan.Lw - cl.methodInset) } as never}>
<Txt role={r.sectionLabel} color={v.label} width={plan.Lw - cl.methodInset} style={{ marginBottom: cl.labelGap }}>
Payment terms
</Txt>
{c.terms.map((line, i) => (
<View key={i} wrap={false} style={{ flexDirection: "row", marginBottom: i === c.terms.length - 1 ? 0 : cl.bulletGap, marginLeft: cl.bulletInset - cl.methodInset, ...pinned(plan.Lw - cl.bulletInset - 8) } as never}>
<View style={{ ...pinned(BULLET.dot), height: BULLET.dot, marginTop: ((r.bullet.leading as number) - BULLET.dot) / 2, borderRadius: BULLET.dot / 2, backgroundColor: v.ink } as never} />
<View style={{ ...pinned(c.bulletW), marginLeft: cl.bulletText - cl.bulletInset - BULLET.dot } as never}>
<SafeTxt role={r.bullet} color={v.ink} width={c.bulletW} wrap={false}>
{line}
</SafeTxt>
</View>
</View>
))}
</View>
) : null}
</View>
);
}
function Contacts({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
const iconCol = cl.iconGap + cl.iconSize + cl.iconInset;
return (
<View style={pinned(plan.panelW) as never}>
{c.contacts.map((x, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: iconCol }]} width={plan.panelW} style={{ marginBottom: i === c.contacts.length - 1 ? 0 : cl.contactGap }}>
<View style={pinned(plan.panelW - iconCol) as never}>
<AvailableWidthProvider width={c.contactTextW}>
{x.id ? (
<IdText role={r.contact} color={v.ink} align="right" fill width={c.contactTextW}>
{x.lines[0]}
</IdText>
) : (
x.lines.map((line, j) => (
<SafeTxt key={j} role={r.contact} color={v.ink} align="right" width={c.contactTextW}>
{line}
</SafeTxt>
))
)}
</AvailableWidthProvider>
</View>
<View style={{ marginLeft: cl.iconGap, marginTop: ((r.contact.leading as number) - cl.iconSize) / 2 }}>
<CircledIcon name={x.icon} size={cl.iconSize} color={x.icon === "phone" ? v.orange : v.icon} ring={1} solid={x.icon === "phone"} glyph={x.icon === "phone" ? 0.62 : 0.56} />
</View>
</Row>
))}
</View>
);
}
function RightColumn({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
const s = model.supplier;
const iconCol = cl.iconGap + cl.iconSize + cl.iconInset;
const opts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.text, "right");
const groups: ReactNode[] = [];
if (c.contacts.length || c.idLinesH > 0) {
groups.push(
<View key="contacts">
<Contacts ctx={ctx} />
{c.idLinesH > 0 ? (
<View style={{ marginTop: 4, marginRight: iconCol, ...pinned(plan.panelW - iconCol) } as never}>
<AvailableWidthProvider width={plan.panelW - iconCol}>
<Slot.Gstin model={model} party="supplier" role={r.small} color={v.ink} align="right" />
{s.pan ? (
<IdText role={r.small} color={v.ink} align="right" fill>{`PAN: ${s.pan}`}</IdText>
) : null}
</AvailableWidthProvider>
</View>
) : null}
</View>,
);
}
groups.push(
<View key="sign" style={{ marginLeft: plan.panelW - cl.signW - cl.iconInset, ...pinned(cl.signW) } as never}>
<Slot.Signatory model={model} width={cl.signW} {...opts} />
</View>,
);
return (
<View style={pinned(plan.panelW) as never}>
{groups.map((g, i) => (
<View key={i} style={{ marginTop: i === 0 ? 0 : 12 }}>
{g}
</View>
))}
</View>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: v.closing.clientTop, ...pinned(plan.W) } as never}>
<AvailableWidthProvider width={plan.W}>
<Row widths={[{ fixed: plan.Lw }, { fill: true }]} width={plan.W}>
<Method ctx={ctx} />
<RightColumn ctx={ctx} />
</Row>
{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 CitrusBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: CitrusVariant }) {
const plan = planCitrus(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
return (
<>
<FirstPageHeader pad={frame.pad}>
<Row widths={[{ fixed: plan.Lw }, { fill: true }]} width={plan.W}>
<Title ctx={ctx} />
<PanelHeader ctx={ctx} />
</Row>
{plan.clientAbove ? (
<>
<Spacer h={plan.gaps.client} />
<ClientBlock ctx={ctx} inset={v.client.inset} />
</>
) : null}
<Spacer h={plan.gaps.meta} />
<MetaRow ctx={ctx} />
</FirstPageHeader>
<Spacer h={plan.gaps.table} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={{ marginLeft: plan.tableLeft, ...pinned(plan.tableW) } as never}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.tableW}
header={plan.headerCells}
rows={plan.rows}
roles={{ head: r.headNum, body: r.tableBody }}
align={plan.align}
padX={0}
padLeft={plan.padLeft}
padRight={plan.padRight}
padY={plan.padY}
headPadY={plan.headPadY}
border={0}
headColor={(i) => (i === 0 ? WHITE : v.ink)}
headCellStyle={(i) => (i === 0 ? { backgroundColor: v.orange, paddingLeft: v.table.headInset } : { backgroundColor: WHITE })}
rowStyle={(i) => (i % 2 === 1 ? { backgroundColor: v.panel } : {})}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{plan.closing.termsBeside ? null : <NotesFlow ctx={ctx} />}
<Closing ctx={ctx} />
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { TemplateProps } from "../contract";
import { CitrusBody } from "./CitrusLayout";
import { PurpleBody } from "./PurpleLayout";
import type { NorthlineVariant } from "./variant";
/**
* The Northline family component. Purple Pop and Citrus Split share the Northline Group content model, the data
* blocks (slots, bank and terms lines, contact list, the table plan) and the type system; the page compositions
* differ (pills on cream, a split grey column on blush), so each variant has its own body.
*/
export function NorthlineLayout({ variant, ...props }: TemplateProps & { variant: NorthlineVariant }) {
return variant.id === "purple-pop" ? <PurpleBody {...props} variant={variant} /> : <CitrusBody {...props} variant={variant} />;
}
@@ -0,0 +1,457 @@
import { Image, View } from "@react-pdf/renderer";
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, type DecorItem } from "../../blocks/FirstPageHeader";
import { FitText } from "../../blocks/FitText";
import { IdText } from "../../blocks/IdText";
import { TableRows } from "../../blocks/ItemsTable";
import { Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { Slot } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Tracked } from "../../blocks/Tracked";
import { Txt } from "../../blocks/Txt";
import { Asterisk, Dome } from "../../decor/Asterisk";
import { CircledIcon } 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 { atRate, BULLET, BULLET_GAP, CLIENT_LABEL_GAP, CLIENT_PILL_GAP, CLOSING_COLUMN_GAP, DOME_TOP, MARK_GAP, NOTES_FLOW_GAP, PILL_GAP, planPurple, REVERSE_GAP, signatureOptions, type PurplePlan } from "./plan";
import type { PurpleVariant } from "./variant";
interface Ctx {
model: TemplateProps["model"];
plan: PurplePlan;
v: PurpleVariant;
logo: PlacedLogo | null;
}
const WHITE = "#FFFFFF";
const ON_PURPLE = "#FFF4F4";
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
// --- header -------------------------------------------------------------------------------------------------
function VendorBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const r = v.roles;
const h = plan.head;
const s = model.supplier;
return (
<View id={SUPPLIER_ID} style={pinned(h.leftW) as never}>
<View style={{ flexDirection: "row", ...pinned(h.leftW), minHeight: h.boxH } as never}>
{logo ? <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: Math.max(0, (h.boxH - logo.h) / 2), objectFit: "contain" } as never} /> : null}
{h.showName ? (
<View style={{ ...pinned(h.nameW), minHeight: h.boxH, justifyContent: "center", marginLeft: logo ? MARK_GAP : 0 } as never}>
<AvailableWidthProvider width={h.nameW}>
<SafeTxt role={r.brand} color={v.ink}>
{s.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : null}
</View>
<AvailableWidthProvider width={h.leftW}>
<Slot.Gstin model={model} party="supplier" role={r.small} color={v.ink} />
{s.pan ? <IdText role={r.small} color={v.ink}>{`PAN: ${s.pan}`}</IdText> : null}
</AvailableWidthProvider>
</View>
);
}
function NumberPill({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const h = plan.head;
return (
<View style={{ ...pinned(h.pillW), minHeight: v.head.pillH, justifyContent: "center", backgroundColor: v.grey, borderRadius: v.head.pillH / 2 } as never}>
<Tracked role={{ ...v.roles.pill, size: h.pillSize }} color={v.purple} width={h.pillW - 2 * v.head.pillPadX} align="center" style={{ marginLeft: v.head.pillPadX }}>
{h.pillText}
</Tracked>
</View>
);
}
// --- client and the wordmark -------------------------------------------------------------------------------------
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
const k = plan.client;
const lineStyle = { marginLeft: v.client.inset };
return (
<View style={pinned(k.cellW) as never}>
<Txt role={r.label} color={v.ink} width={k.cellW - v.client.inset} style={lineStyle}>
Billed to:
</Txt>
<View style={{ marginTop: CLIENT_LABEL_GAP, ...pinned(k.pillW), minHeight: k.pillBoxH, justifyContent: "center", paddingHorizontal: v.client.pillPadX, backgroundColor: v.yellow, borderRadius: v.client.pillH / 2 } as never}>
<AvailableWidthProvider width={k.pillW - 2 * v.client.pillPadX}>
<SafeTxt role={r.clientName} color={v.purple}>
{c.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
<View style={{ marginTop: CLIENT_PILL_GAP, ...lineStyle, ...pinned(k.lineW) } as never}>
<AvailableWidthProvider width={k.lineW}>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.clientLine} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.clientLine} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.clientLine} color={v.ink} />
</AvailableWidthProvider>
</View>
</View>
);
}
function Wordmark({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const t = plan.title;
return (
<View id={TITLE_ID} style={pinned(t.w) as never}>
{t.tax ? (
<Tracked role={r.taxCaption} color={v.purple} width={t.w} align="right" style={{ marginBottom: 2 }}>
TAX
</Tracked>
) : null}
{/* Negative tracking leaves the last glyph's ink |step| past the line's end; the padding makes that part of the box. */}
<Txt role={{ ...r.wordmark, size: t.size }} color={v.purple} width={t.w} align="right" style={{ paddingRight: t.pad }}>
Invoice
</Txt>
</View>
);
}
// --- amount due and dates -------------------------------------------------------------------------------------
function AmountBand({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const b = plan.band;
return (
<View id={META_ID} style={{ marginLeft: v.band.amountInset, ...pinned(b.amountW + v.band.gap + b.datesW) } as never}>
<Row widths={[{ fixed: b.amountW }, { fixed: b.datesW }]} gap={v.band.gap} width={b.amountW + v.band.gap + b.datesW}>
<View style={pinned(b.amountW) as never}>
<Txt role={r.label} color={v.ink} width={b.amountW}>
Amount due:
</Txt>
<FitText role={r.amount} color={v.purple} width={b.amountW} style={{ marginTop: 2 }}>
{b.amountText}
</FitText>
</View>
<View style={pinned(b.datesW) as never}>
{b.lines.map((line, i) => (
<Txt key={i} role={{ ...r.date, size: b.dateSize }} color={v.ink} width={b.datesW} align="right">
{line}
</Txt>
))}
</View>
</Row>
</View>
);
}
// --- totals -----------------------------------------------------------------------------------------------------
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const k = plan.tail;
const inner = k.boxW - v.totals.labelInset - v.totals.valueInset;
const labelW = inner - k.valueW;
return (
<View style={pinned(k.boxW) as never}>
<View style={{ marginLeft: v.totals.labelInset, ...pinned(inner) } as never}>
<AvailableWidthProvider width={inner}>
{k.stack.map((row, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: k.valueW }]} style={{ marginBottom: v.totals.rowGap }}>
<Txt role={r.totalLabel} color={v.ink} width={labelW}>
{row.label}
</Txt>
<Txt role={r.totalValue} color={v.ink} width={k.valueW} align="right">
{row.amount}
</Txt>
</Row>
))}
<Slot.TaxRows
model={model}
role={r.totalLabel}
amountRole={r.totalValue}
color={v.ink}
amountWidth={k.valueW}
rowStyle={{ marginBottom: v.totals.rowGap }}
format={atRate}
/>
</AvailableWidthProvider>
</View>
<View
style={{
marginTop: PILL_GAP - v.totals.rowGap,
...pinned(k.boxW),
minHeight: v.totals.pillH,
justifyContent: "center",
paddingLeft: v.totals.labelInset,
paddingRight: v.totals.pillValueInset,
backgroundColor: v.purple,
borderRadius: v.totals.pillH / 2,
} as never}
>
<AvailableWidthProvider width={k.boxW - v.totals.labelInset - v.totals.pillValueInset}>
<Row widths={[{ fill: true }, { fixed: k.valueW }]}>
<Txt role={r.grand} color={ON_PURPLE} width={inner - k.valueW}>
Total
</Txt>
<Txt role={r.grandValue} color={ON_PURPLE} width={k.valueW} align="right">
{model.totals.totalText}
</Txt>
</Row>
</AvailableWidthProvider>
</View>
{k.wordsH > 0 ? (
<View style={{ marginTop: v.totals.wordsGap, marginLeft: v.totals.labelInset, ...pinned(k.wordsW) } as never}>
<AvailableWidthProvider width={k.wordsW}>
<Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={v.ink} />
</AvailableWidthProvider>
</View>
) : null}
{k.reverseH > 0 ? (
<View style={{ marginTop: REVERSE_GAP, marginLeft: v.totals.labelInset, ...pinned(k.wordsW) } as never}>
<AvailableWidthProvider width={k.wordsW}>
<Slot.ReverseCharge model={model} role={r.small} color={v.ink} />
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const k = plan.tail;
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={[{ fixed: k.gutter }, { fill: true }]} width={plan.W}>
<View style={{ marginLeft: v.dome.x, marginTop: DOME_TOP }}>
<Dome width={v.dome.w} color={v.yellow} />
</View>
<TotalsBox ctx={ctx} />
</Row>
</View>
</View>
);
}
// --- closing ---------------------------------------------------------------------------------------------------
/** Terms with more bullets than fit beside the signature: a block of their own, each bullet unbreakable, the label held with the first. */
function NotesFlow({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const t = plan.terms;
const dotTop = (r.bullet.leading as number) / 2 - BULLET.dot / 2;
const bullet = (line: string, i: number) => (
<View key={i} wrap={false} style={{ flexDirection: "row", marginBottom: i === t.lines.length - 1 ? 0 : BULLET_GAP, ...pinned(t.flowW) } as never}>
<View style={{ ...pinned(BULLET.dot), height: BULLET.dot, marginTop: dotTop, borderRadius: BULLET.dot / 2, backgroundColor: v.ink } as never} />
<View style={{ ...pinned(t.flowBulletW), marginLeft: BULLET.textAt - BULLET.dot } as never}>
<SafeTxt role={r.bullet} color={v.ink} width={t.flowBulletW}>
{line}
</SafeTxt>
</View>
</View>
);
return (
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.terms.labelInset, ...pinned(t.flowW) } as never}>
<View wrap={false}>
<Txt role={r.termsLabel} color={v.ink} width={t.flowW} style={{ marginBottom: 4 }}>
Payment terms
</Txt>
{bullet(t.lines[0], 0)}
</View>
{t.lines.slice(1).map((line, i) => bullet(line, i + 1))}
</View>
);
}
function Terms({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const t = plan.terms;
const opts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.ink, "right");
const dotTop = (r.bullet.leading as number) / 2 - BULLET.dot / 2;
return (
<Row widths={[{ fixed: v.terms.labelInset + v.terms.labelW }, { fill: true }, { fixed: v.terms.signW }]} gap={CLOSING_COLUMN_GAP}>
<View style={pinned(v.terms.labelInset + v.terms.labelW) as never}>
{t.beside && t.lines.length ? (
<Txt role={r.termsLabel} color={v.ink} width={v.terms.labelW} style={{ marginLeft: v.terms.labelInset }}>
Payment terms
</Txt>
) : null}
</View>
<View>
{(t.beside ? t.lines : []).map((line, i) => (
<View key={i} wrap={false} style={{ flexDirection: "row", marginBottom: i === t.lines.length - 1 ? 0 : BULLET_GAP, ...pinned(t.fillW) } as never}>
<View style={{ ...pinned(BULLET.dot), height: BULLET.dot, marginTop: dotTop, borderRadius: BULLET.dot / 2, backgroundColor: v.ink } as never} />
<View style={{ ...pinned(t.bulletW), marginLeft: BULLET.textAt - BULLET.dot } as never}>
<SafeTxt role={r.bullet} color={v.ink} width={t.bulletW} wrap={false}>
{line}
</SafeTxt>
</View>
</View>
))}
</View>
<View style={pinned(v.terms.signW) as never}>
<Slot.Signatory model={model} width={v.terms.signW} {...opts} />
</View>
</Row>
);
}
function Footer({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const f = plan.footer;
const ft = v.footer;
const iconCol = ft.iconW + ft.iconGap;
const phoneMail = f.contacts.filter((x) => x.icon !== "location");
const address = f.contacts.find((x) => x.icon === "location");
const icon = (name: "phone" | "email" | "location") => <CircledIcon name={name} size={ft.iconW} color={v.ink} ring={0.9} solid={name !== "location"} glyph={0.62} />;
return (
<Row widths={[{ fixed: ft.contactLeft }, { fixed: ft.addressLeft - ft.contactLeft }, { fill: true }]} width={plan.W}>
<View style={{ marginLeft: ft.ruleLeft, ...pinned(ft.bankW) } as never}>
{model.bank ? (
<>
<Txt role={r.label} color={v.ink} width={ft.bankW} style={{ marginBottom: 4 }}>
Payment method
</Txt>
<AvailableWidthProvider width={ft.bankW}>
{f.bank.map((line, i) => (
<SafeTxt key={i} role={r.bank} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</>
) : null}
</View>
<View style={pinned(ft.contactW) as never}>
{phoneMail.map((x, i) => (
<Row key={i} widths={[{ fixed: iconCol }, { fill: true }]} width={ft.contactW} style={{ marginBottom: 1.5 }}>
<View style={{ marginTop: ((r.contact.leading as number) - ft.iconW) / 2 }}>{icon(x.icon)}</View>
<IdText role={r.contact} color={v.ink} width={f.textW.contact}>
{x.lines[0]}
</IdText>
</Row>
))}
</View>
<View>
{address ? (
<Row widths={[{ fixed: iconCol }, { fill: true }]} width={plan.W - ft.addressLeft}>
<View style={{ marginTop: ((r.contact.leading as number) - ft.iconW) / 2 }}>{icon("location")}</View>
<View>
<AvailableWidthProvider width={f.textW.address}>
{address.lines.map((line, i) => (
<SafeTxt key={i} role={r.contact} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
</Row>
) : null}
</View>
</Row>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: v.gaps.termsAbove, ...pinned(plan.W) } as never}>
<AvailableWidthProvider width={plan.W}>
<Terms ctx={ctx} />
<View style={{ marginTop: v.gaps.ruleGap, marginLeft: v.footer.ruleLeft, marginBottom: v.gaps.ruleGap, ...pinned(plan.W - v.footer.ruleLeft - v.footer.ruleRight), height: v.rule.w, backgroundColor: v.ink } as never} />
<Footer 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 PurpleBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: PurpleVariant }) {
const plan = planPurple(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
const decor: DecorItem[] = [];
// Beside the table's header and first rows, page 1 only: it is a child of the first-page header, never a page-level absolute.
if (plan.asteriskY !== null && plan.asteriskY + v.asterisk.size < frame.page.height - frame.pad.bottom) {
decor.push({ x: frame.pad.left + v.asterisk.offsetX, y: plan.asteriskY, width: v.asterisk.size, height: v.asterisk.size, children: <Asterisk size={v.asterisk.size} color={v.purple} /> });
}
const rule = (
<View style={{ marginLeft: v.rule.left, ...pinned(plan.W - v.rule.left - v.rule.right), height: v.rule.w, backgroundColor: v.ink } as never} />
);
return (
<>
<FirstPageHeader pad={frame.pad} decor={decor}>
<View style={{ marginLeft: v.head.nameInset, ...pinned(plan.head.innerW) } as never}>
<Row widths={[{ fill: true }, { fixed: plan.head.pillW }]} gap={12} width={plan.head.innerW}>
<VendorBlock ctx={ctx} />
<NumberPill ctx={ctx} />
</Row>
</View>
<Spacer h={plan.gaps.client} />
<Row widths={[{ fixed: v.client.colW }, { fill: true }]} width={plan.W}>
<ClientBlock ctx={ctx} />
<Wordmark ctx={ctx} />
</Row>
<Spacer h={plan.gaps.rule} />
{rule}
<Spacer h={plan.gaps.band} />
<AmountBand ctx={ctx} />
</FirstPageHeader>
<Spacer h={plan.gaps.table} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<TableRows
columns={plan.columns}
gap={0}
width={plan.tableW}
header={plan.headerCells}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={0}
padY={plan.padY}
headPadY={plan.headPadY}
border={0}
headColor={WHITE}
headerStyle={{ backgroundColor: v.purple, borderRadius: v.table.radiusHead }}
rowStyle={(i) => ({ backgroundColor: i % 2 === 0 ? v.rowGrey : v.yellow, borderRadius: v.table.radiusRow })}
pill={{ indent: plan.tableLeft, inset: v.table.inset }}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
{plan.terms.beside ? null : <NotesFlow ctx={ctx} />}
<Closing ctx={ctx} />
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { View } from "@react-pdf/renderer";
import { CITRUS_HALFTONE, halftoneField } from "../../decor/Halftone";
import { defineRoles } from "../../fonts/roles";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NorthlineLayoutFor } from "./layoutFor";
import { gotham, type CitrusRoles, type CitrusVariant } from "./variant";
const DM = "DM Sans" as const;
const G = "Gotham" as const;
const INK = "#060605";
const TEXT = "#231F20";
export const ORANGE = "#FF9220";
export const LABEL = "#F96300";
/**
* Citrus Split's type (docs/templates/13-citrus-split.md): the INVOICE wordmark in Now Bold (drawn in Jost 700, at
* the design's -0.081 em), DM Sans for labels, names and the words line, Gotham (drawn in Montserrat through
* gotham()) for the table, totals, client lines and the closing text.
*/
export const CITRUS_ROLES = defineRoles({
title: { face: "Now", weight: 700, size: 47.94, leading: 52, tracking: -0.081, caps: true, minSize: 30 },
taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true },
brand: { face: DM, weight: 700, size: 17.13, leading: 17.1, minSize: 12 },
metaLabel: { face: DM, weight: 700, size: 12, leading: 16 },
metaValue: { face: DM, weight: 400, size: 12, leading: 16, minSize: 8 },
metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 },
billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, caps: true },
clientName: { face: DM, weight: 700, size: 14.87, leading: 19, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.5), leading: 15.5 },
headService: { face: G, weight: 700, size: gotham(15.17), leading: 18, tracking: 0.15, caps: true },
headNum: { face: G, weight: 700, size: gotham(14.17), leading: 18, caps: true },
tableBody: { face: G, weight: 400, size: gotham(12), leading: 15 },
tableSub: { face: G, weight: 400, size: 9, leading: 11.5 },
totalLabel: { face: G, weight: 700, size: gotham(12.5), leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(12.5), leading: 17 },
grand: { face: G, weight: 700, size: gotham(14.7), leading: 18, caps: true },
grandValue: { face: G, weight: 700, size: gotham(14.7), leading: 18 },
words: { face: DM, weight: 700, size: 11, leading: 14 },
wordsLabel: { face: DM, weight: 700, size: 11, leading: 14 },
sectionLabel: { face: DM, weight: 700, size: 12, leading: 16, caps: true },
bank: { face: G, weight: 400, size: gotham(11), leading: 15 },
bullet: { face: G, weight: 400, size: gotham(12), leading: 16.5 },
contact: { face: G, weight: 400, size: gotham(12.77), leading: 18, minSize: 7 },
small: { face: DM, weight: 400, size: 9, leading: 12 },
fine: { face: DM, weight: 400, size: 7, leading: 10 },
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
footer: { face: DM, weight: 400, size: 7 },
} satisfies CitrusRoles);
/**
* Canva A4: content x 73.3, the stripe 298.8..535.6 (right margin 59.68). The title's cap top is y 75.6 and the
* footer text ends near y 750, so the bottom margin is 40 plus the footer reserve.
*/
export const CITRUS_VARIANT: CitrusVariant = {
id: "citrus-split",
roles: CITRUS_ROLES,
ink: INK,
text: TEXT,
blush: "#FFF7F7",
panel: "#F1F1F1",
orange: ORANGE,
label: LABEL,
icon: "#F39322",
stripeW: 236.8,
margins: { top: 58, right: 59.68, bottom: 40, left: 73.3 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
signatureImageH: 40,
chromeLeftW: 150,
head: { panelInset: 38.6, markGap: 20 },
client: { inset: 12.3, gap: 8, labelGap: 5, nameGap: 4 },
meta: { inset: 14.6, noMin: 86.2, dateMin: 90, dueW: 108.2, panelInset: 16.7, labelGap: 6 },
table: { left: 10.9, headH: 47.2, headInset: 31.7, bodyInset: 15.7, padY: 12.15, padYMin: 8, descRight: 8, cols: { qty: 66.8, rate: 80, amount: 90 } },
separator: { color: "#FFF7F7", h: 1.5 },
totals: { labelInset: 22.1, valueInset: 30.6, rowGap: 7, barH: 47.2, barInset: 1, barGap: 8, sepGap: 14, wordsGap: 12 },
closing: { methodInset: 8.4, bankInset: 9.9, bulletInset: 15.9, bulletText: 28.8, bulletGap: 4.3, sectionGap: 18, labelGap: 6, iconSize: 15, iconGap: 11.6, iconInset: 11.6, contactGap: 5, signW: 150, clientTop: 16, termsBesideMax: 110 },
gaps: {
client: { nominal: 14, min: 6 },
meta: { nominal: 75, min: 10 },
table: { nominal: 17, min: 6 },
tail: { nominal: 20, min: 6 },
},
};
export const citrusSplit: TemplateDefinition = {
...catalogEntry("citrus-split"),
version: 1,
roles: CITRUS_ROLES,
margins: CITRUS_VARIANT.margins,
marginFloor: CITRUS_VARIANT.marginFloor,
decor: {
ink: INK,
background: CITRUS_VARIANT.blush,
// The grey stripe runs the full page height on every page and the halftone fields sit on it and beside it, all
// under the flow. The stripe is anchored to the right margin, so it also follows Letter and the margin presets.
underlay: (frame) => [
{
x: frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW,
y: 0,
width: CITRUS_VARIANT.stripeW,
height: frame.page.height,
children: <View style={{ width: CITRUS_VARIANT.stripeW, height: frame.page.height, backgroundColor: CITRUS_VARIANT.panel }} />,
},
...CITRUS_HALFTONE.map((field) => halftoneField(frame, field)),
],
},
chrome: {
color: "#6f6f6f",
rule: null,
role: CITRUS_ROLES.footer,
leftWidth: CITRUS_VARIANT.chromeLeftW,
},
logo: {
box: { w: 144.5, h: 34 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => 144.5,
slot: slotFor("citrus-split"),
available: () => ({ w: 144.5, h: 34 }),
},
Layout: NorthlineLayoutFor(CITRUS_VARIANT),
};
+11
View File
@@ -0,0 +1,11 @@
import type { ComponentType } from "react";
import type { TemplateProps } from "../contract";
import { NorthlineLayout } from "./Layout";
import type { NorthlineVariant } from "./variant";
/** The family component bound to one variant's tokens, as the TemplateDefinition's Layout. */
export function NorthlineLayoutFor(variant: NorthlineVariant): ComponentType<TemplateProps> {
const Layout = (props: TemplateProps) => <NorthlineLayout {...props} variant={variant} />;
Layout.displayName = `NorthlineLayout(${variant.id})`;
return Layout;
}
@@ -0,0 +1,463 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ASTERISK_PATH, asteriskWatermark, DOME_PATH, WATERMARK } from "../../decor/Asterisk";
import { CITRUS_HALFTONE, halftoneDots, halftoneField, HALFTONE } from "../../decor/Halftone";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { measureToken, getMeasurer } from "../../engine/measure";
import { resolveFace } from "../../fonts/roles";
import { SLOT_ID } from "../../blocks/slots";
import { frameForPrefs } from "../../render/invoice";
import { loadCanvas, pagePng } from "../../testing/rasterize";
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
import { marginsFor } from "../contract";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { SLOTS } from "../slots.generated";
import { OPTIN_SLOTS } from "../slots.optin";
import { citrusSplit, CITRUS_ROLES, CITRUS_VARIANT } from "./citrus";
import { planCitrus, planPurple } from "./plan";
import { purplePop, PURPLE_ROLES, PURPLE_VARIANT } from "./purple";
import { gotham, GOTHAM_SIZE } from "./variant";
ensureSetup();
const NORTHLINE = [purplePop, citrusSplit];
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 textsOf = (r: Awaited<ReturnType<typeof renderFixture>>, page = 0) => r.pdf.pages[page].items.map((i) => i.str).join(" ");
/** The colour 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));
}
/** Items with short names, like the Canva sample: the narrow Purple Pop table and the one-page Citrus Split layout both need them. */
const SHORT = ["Consultation", "Logo design", "Web design"].map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i, hsnSac: "998397" }));
/** The same without an HSN/SAC code, so every row is one line tall. */
const PLAIN = SHORT.map(({ hsnSac, ...rest }) => ({ ...rest, ...(hsnSac ? {} : {}) }));
const near = (c: number[], hex: string, tol = 6) => {
const want = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return want.every((w, i) => Math.abs(c[i] - w) <= tol);
};
describe("registry, catalog and fonts", () => {
it("lists Purple Pop and Citrus Split last, 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(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"]);
});
it("Gotham tokens carry the Montserrat size factor, and the licensed faces resolve to the bundled look-alikes", () => {
expect(GOTHAM_SIZE).toBe(0.96);
expect(gotham(12)).toBe(11.52);
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
expect(CITRUS_ROLES.headNum.size).toBe(gotham(14.17));
expect(resolveFace("Gotham")).toBe("Montserrat");
expect(resolveFace("Now")).toBe("Jost");
expect(resolveFace("Open Sauce One")).toBe("Poppins");
// A user-imported font replaces the licensed name before the substitute applies.
expect(resolveFace("Gotham", { Gotham: "user:Gotham" })).toBe("user:Gotham");
// The wordmarks: Poppins 800 at -0.08 em (Purple) and Jost 700 at -0.081 em (Citrus).
expect(PURPLE_ROLES.wordmark).toMatchObject({ face: "Open Sauce One", weight: 800, size: 94.07, tracking: -0.08 });
expect(CITRUS_ROLES.title).toMatchObject({ face: "Now", weight: 700, size: 47.94, tracking: -0.081 });
});
it("amounts are never set in Jost (it has no rupee sign): Jost is only the Citrus wordmark", () => {
for (const t of NORTHLINE) {
for (const [name, token] of Object.entries(t.roles)) {
const face = resolveFace(token.face);
if (face === "Jost") expect(name, `${t.id}.${name}`).toBe("title");
}
}
});
});
describe("page geometry", () => {
it("template margins", () => {
expect(marginsFor(purplePop, "template")).toEqual({ top: 70, right: 52.48, bottom: 40, left: 54.8 });
expect(marginsFor(citrusSplit, "template")).toEqual({ top: 58, right: 59.68, bottom: 40, left: 73.3 });
});
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
for (const t of NORTHLINE) {
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
}
});
it("the cream (Purple Pop) and blush (Citrus Split) page fills are the Page background, free of any page-level block", async () => {
const purple = await renderFixture({ name: "p", items: 3, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const [a] = await colourAt(purple.result.bytes, [[20, 60]]);
if (a) expect(near(a, "#FEFFEE"), `cream ${a}`).toBe(true);
const citrus = await renderFixture({ name: "p", items: 3, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
const [b] = await colourAt(citrus.result.bytes, [[20, 300]]);
if (b) expect(near(b, "#FFF7F7"), `blush ${b}`).toBe(true);
});
});
describe("decor primitives", () => {
it("the asterisk has eight arms of 16.5% and the dome fits its box", () => {
expect(ASTERISK_PATH.startsWith("M100 41.7H69.9")).toBe(true);
expect(DOME_PATH).toContain("M89.7,565.3");
expect(WATERMARK.size).toBe(352.4);
});
it("each watermark box lies on the page (A4 and Letter), anchored to its corner", () => {
for (const size of ["A4", "LETTER"] as const) {
const frame = frameForPrefs(prefsFor(size, { templateId: "purple-pop" }));
const tr = asteriskWatermark(frame, "top-right", "#FFF7F7");
const bl = asteriskWatermark(frame, "bottom-left", "#FFF7F7");
expect(tr.x + tr.width).toBeCloseTo(frame.page.width, 6);
expect(tr.y).toBe(0);
expect(bl.x).toBe(0);
expect(bl.y + bl.height).toBeCloseTo(frame.page.height, 6);
}
});
it("a halftone field is 19 rows of 4 dots with the chevron's diameters, and every field lies on the page", () => {
const dots = halftoneDots();
expect(dots).toHaveLength(HALFTONE.rows * HALFTONE.perRow);
expect(Math.min(...dots.map((d) => d.d))).toBeCloseTo(HALFTONE.minD, 2);
expect(Math.max(...dots.map((d) => d.d))).toBeCloseTo(HALFTONE.maxD, 2);
for (const size of ["A4", "LETTER"] as const) {
const frame = frameForPrefs(prefsFor(size, { templateId: "citrus-split" }));
for (const f of CITRUS_HALFTONE) {
const item = halftoneField(frame, f);
expect(item.x, f.id).toBeGreaterThanOrEqual(0);
expect(item.y, f.id).toBeGreaterThanOrEqual(0);
expect(item.x + item.width, f.id).toBeLessThanOrEqual(frame.page.width + 1e-6);
expect(item.y + item.height, f.id).toBeLessThanOrEqual(frame.page.height + 1e-6);
}
}
});
});
describe("Purple Pop", () => {
it("the watermarks are fixed under the flow: two decor svgs at the page corners on every page", async () => {
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const frame = frameForPrefs(r.prefs);
expect(r.result.pages).toBeGreaterThan(1);
for (let i = 0; i < r.result.pages; i++) {
const wm = onPage(r, i).filter((n) => n.type === "svg" && n.decor && n.fixed && n.box.w > 250);
expect(wm, `page ${i + 1}`).toHaveLength(2);
const tr = wm.find((n) => n.box.y < 1)!;
const bl = wm.find((n) => n.box.x < 1)!;
expect(tr.box.x + tr.box.w).toBeCloseTo(frame.page.width, 1);
expect(bl.box.y + bl.box.h).toBeCloseTo(frame.page.height, 1);
}
});
it("a short invoice uses the narrow table beside the asterisk (page 1 only); a longer one the full width, without it", async () => {
const frame = frameForPrefs(prefsFor("A4", { templateId: "purple-pop" }));
const short = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
const head = onPage(short, 0).find((n) => n.id === TABLE_HEAD_ID)!;
expect(head.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.narrowLeft, 1);
const stars = (r: typeof short, i: number) => onPage(r, i).filter((n) => n.type === "svg" && n.decor && !n.fixed);
expect(stars(short, 0)).toHaveLength(1);
expect(stars(short, 0)[0].box.w).toBeCloseTo(PURPLE_VARIANT.asterisk.size, 1);
const long = await renderFixture({ name: "l", items: 12, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const head2 = onPage(long, 0).find((n) => n.id === TABLE_HEAD_ID)!;
expect(head2.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.fullLeft, 1);
expect(stars(long, 0)).toHaveLength(0);
// Every continuation page repeats the header at the full-width position.
for (let i = 1; i < long.result.pages; i++) {
const h = onPage(long, i).find((n) => n.id === TABLE_HEAD_ID);
if (h) expect(h.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.fullLeft, 1);
}
});
it("header and rows are pills: purple head, then grey and yellow rows, flat colours", async () => {
const r = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
const head = onPage(r, 0).find((n) => n.id === TABLE_HEAD_ID)!;
const rows = [0, 1, 2].map((i) => onPage(r, 0).find((n) => n.id === `${TABLE_ROW_PREFIX}${i}`)!);
const sample = [head, ...rows].map((n): [number, number] => [n.box.x + 8, n.box.y + n.box.h / 2]);
const colours = await colourAt(r.result.bytes, sample);
if (colours.length) {
expect(near(colours[0], "#530895"), `head ${colours[0]}`).toBe(true);
expect(near(colours[1], "#EDEDE8"), `row 1 ${colours[1]}`).toBe(true);
expect(near(colours[2], "#FFD80B"), `row 2 ${colours[2]}`).toBe(true);
expect(near(colours[3], "#EDEDE8"), `row 3 ${colours[3]}`).toBe(true);
}
// Pills are flush: each row starts where the one above ends.
for (let i = 1; i < rows.length; i++) expect(rows[i].box.y).toBeCloseTo(rows[i - 1].box.y + rows[i - 1].box.h, 1);
expect(rows[0].box.h).toBeCloseTo(PURPLE_VARIANT.table.rowH, 1);
expect(head.box.h).toBeCloseTo(PURPLE_VARIANT.table.headH, 1);
});
it("the INVOICE NO pill and both dates are explicit lines; TAX sits above the wordmark on a tax invoice only", async () => {
const tax = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const text = tax.pdf.pages[0].items.map((i) => i.str.replace(/\s+/g, "")).join("").toUpperCase();
expect(text).toContain("INVOICENO:INV/2026-001");
expect(text).toContain("INVOICEDATE:15SEPT2026");
expect(text).toContain("DUEDATE:15OCT2026");
expect(tax.pdf.pages[0].items.some((i) => i.str.replace(/\s+/g, "") === "TAX")).toBe(true);
const plain = await renderFixture({ name: "t", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
expect(plain.pdf.pages[0].items.some((i) => i.str.replace(/\s+/g, "") === "TAX")).toBe(false);
});
it("the wordmark's ink stays inside its box, whatever the page size", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "w", items: 3, logo: "none" }, prefsFor(size, { templateId: "purple-pop" }));
expect(r.result.issues.filter((i) => i.code === "overflow-x"), size).toEqual([]);
}
});
it("the logo is opt-in: off by default, on in the opt-in area, a mark keeps the name, a wordmark replaces it", async () => {
expect(purplePop.logo.optIn).toBe(true);
expect(purplePop.logo.defaultOn).toBe(false);
expect(purplePop.logo.slot).toBe(OPTIN_SLOTS["purple-pop"]);
const off = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "purple-pop" }));
expect(imageBoxes(off)).toHaveLength(0);
expect(off.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(1);
const on = (logo: FixtureSpec["logo"]) => renderFixture({ name: "o", items: 2, logo }, prefsFor("A4", { templateId: "purple-pop", logo: { show: true } }));
const wide = await on("real");
const [img] = imageBoxes(wide);
expect(imageBoxes(wide)).toHaveLength(1);
const slot = OPTIN_SLOTS["purple-pop"]!.lockup!;
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.01);
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.01);
expect(img.box.x).toBeCloseTo(frameForPrefs(wide.prefs).pad.left + PURPLE_VARIANT.head.nameInset, 0);
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(0);
const square = await on("square");
expect(imageBoxes(square)).toHaveLength(1);
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(1);
});
it("the dome and the TOTAL pill travel with the totals; amount in words sits under the pill", async () => {
const r = await renderFixture({ name: "t", items: SHORT, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const totals = onPage(r, 0).find((n) => n.id === TOTALS_ID)!;
const inside = (n: LayoutNode) => n.box.y >= totals.box.y - 0.5 && n.box.y + n.box.h <= totals.box.y + totals.box.h + 0.5;
expect(onPage(r, 0).filter((n) => n.type === "svg" && !n.decor && inside(n))).toHaveLength(1);
const words = onPage(r, 0).find((n) => n.id === SLOT_ID.amountInWords)!;
const pill = onPage(r, 0).find((n) => n.type === "view" && Math.abs(n.box.h - PURPLE_VARIANT.totals.pillH) < 0.6 && n.box.w > 300 && inside(n))!;
expect(words.box.y).toBeGreaterThanOrEqual(pill.box.y + pill.box.h);
});
it("long terms flow as a block of their own and never make the closing block taller than a page", async () => {
const spec = FIXTURES.find((f) => f.name === "long-notes")!;
const r = await renderFixture(spec, prefsFor("A4", { templateId: "purple-pop" }));
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
expect(closing.box.h).toBeLessThan(260);
const plan = planPurple(r.fixture.model, frameForPrefs(r.prefs), PURPLE_VARIANT, null, false);
expect(plan.terms.beside).toBe(false);
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
});
});
describe("Citrus Split", () => {
it("the grey stripe is a full-height underlay on every page, anchored to the right margin, on both sizes and every margin preset", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const margins of ["template", "narrow", "normal"] as const) {
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor(size, { templateId: "citrus-split", margins }));
const frame = frameForPrefs(r.prefs);
expect(r.result.pages).toBeGreaterThan(1);
for (let i = 0; i < r.result.pages; i++) {
const stripe = onPage(r, i).filter((n) => n.type === "view" && n.id === "decor" && n.fixed && Math.abs(n.box.w - CITRUS_VARIANT.stripeW) < 0.01 && n.box.h > frame.page.height - 1);
expect(stripe, `${size}/${margins} p${i + 1}`).toHaveLength(1);
expect(stripe[0].box.x).toBeCloseTo(frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW, 1);
expect(stripe[0].box.y).toBeCloseTo(0, 1);
// Three halftone fields on every page.
expect(onPage(r, i).filter((n) => n.type === "svg" && n.decor && n.fixed), `${size}/${margins} p${i + 1} halftone`).toHaveLength(3);
}
}
}
});
it("the numeric columns fill the stripe exactly, so amounts always print on the grey", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "p", items: 6, logo: "none" }, prefsFor(size, { templateId: "citrus-split" }));
const frame = frameForPrefs(r.prefs);
const stripeX = frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW;
const plan = planCitrus(r.fixture.model, frame, CITRUS_VARIANT, null, false);
expect(plan.cw.qty + plan.cw.rate + plan.cw.amount).toBeCloseTo(CITRUS_VARIANT.stripeW, 1);
expect(plan.table.widths[0] + frame.pad.left + CITRUS_VARIANT.table.left).toBeCloseTo(stripeX, 1);
// Every body amount starts inside the stripe.
for (const t of r.pdf.pages[0].items) if (/^₹[\d,]+\.\d\d$/.test(t.str.trim()) && t.top > 250) expect(t.x0, t.str).toBeGreaterThan(stripeX - 0.5);
}
});
it("the header: SERVICE on an orange cell, the numeric headings on white; zebra rows span the left part", async () => {
const r = await renderFixture({ name: "s", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
const frame = frameForPrefs(r.prefs);
const head = onPage(r, 0).find((n) => n.id === TABLE_HEAD_ID)!;
const row1 = onPage(r, 0).find((n) => n.id === `${TABLE_ROW_PREFIX}1`)!;
expect(head.box.h).toBeCloseTo(CITRUS_VARIANT.table.headH, 0);
const stripeX = frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW;
const colours = await colourAt(r.result.bytes, [
[head.box.x + 3, head.box.y + 3],
[stripeX + 3, head.box.y + 3],
[head.box.x + 3, row1.box.y + 3],
[head.box.x + 3, row1.box.y - 3],
]);
if (colours.length) {
expect(near(colours[0], "#FF9220"), `SERVICE cell ${colours[0]}`).toBe(true);
expect(near(colours[1], "#FFFFFF"), `numeric heading band ${colours[1]}`).toBe(true);
expect(near(colours[2], "#F1F1F1"), `zebra ${colours[2]}`).toBe(true);
expect(near(colours[3], "#FFF7F7"), `row above is blush ${colours[3]}`).toBe(true);
}
});
it("the client block sits beside the totals when the invoice fits a page, above the table when it does not", async () => {
const short = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
expect(planCitrus(short.fixture.model, frameForPrefs(short.prefs), CITRUS_VARIANT, null, false).clientAbove).toBe(false);
const client = short.pdf.pages[0].items.find((i) => i.str.toUpperCase().includes("ACME"))!;
const headY = onPage(short, 0).find((n) => n.id === TABLE_HEAD_ID)!.box.y;
expect(client.top).toBeGreaterThan(headY);
const long = await renderFixture({ name: "l", items: 60, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
expect(planCitrus(long.fixture.model, frameForPrefs(long.prefs), CITRUS_VARIANT, null, false).clientAbove).toBe(true);
const client2 = long.pdf.pages[0].items.find((i) => i.str.toUpperCase().includes("ACME"))!;
expect(client2.top).toBeLessThan(onPage(long, 0).find((n) => n.id === TABLE_HEAD_ID)!.box.y);
});
it("the contact list carries three icons in the closing block, right-aligned beside the stripe's right edge", async () => {
const r = await renderFixture({ name: "s", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
const closing = onPage(r, r.result.pages - 1).find((n) => n.id === CLOSING_ID)!;
const icons = onPage(r, r.result.pages - 1).filter((n) => n.type === "svg" && !n.decor && n.box.y >= closing.box.y && n.box.w === CITRUS_VARIANT.closing.iconSize);
expect(icons).toHaveLength(3);
const frame = frameForPrefs(r.prefs);
for (const i of icons) expect(i.box.x + i.box.w).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 0.5);
const text = textsOf(r, r.result.pages - 1);
expect(text).toContain("9000000000");
expect(text).toContain("vendor@example.com");
});
it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => {
const slot = SLOTS["citrus-split"]!.lockup!;
const withLogo = { name: "o", items: 2, logo: "real" as const };
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "citrus-split" }));
const [img] = imageBoxes(wide);
expect(imageBoxes(wide)).toHaveLength(1);
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.6);
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.6);
const frame = frameForPrefs(wide.prefs);
expect(img.box.x).toBeGreaterThanOrEqual(frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW);
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(0);
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "citrus-split" }));
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(1);
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(1);
});
it("the totals label column never wraps ('TAXABLE VALUE', 'UTGST @ 14%')", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none", vendor: { vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5" }, invoice: { placeOfSupplyStateCode: "04" } }, prefsFor("A4", { templateId: "citrus-split" }));
const totals = onPage(r, 0).find((n) => n.id === TOTALS_ID)!;
const labels = nodes([r.result.core.layout[0]]).filter((n) => n.type === "text" && n.box.y >= totals.box.y && /TAXABLE|UTGST|CGST/.test(n.text ?? ""));
expect(labels.length).toBeGreaterThan(2);
for (const l of labels) expect(l.lines).toHaveLength(1);
});
it("long terms flow as a block of their own", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "citrus-split" }));
expect(planCitrus(r.fixture.model, frameForPrefs(r.prefs), CITRUS_VARIANT, null, false).closing.termsBeside).toBe(false);
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
expect(closing.box.h).toBeLessThan(300);
});
});
describe("the GST slots and the closing", () => {
it("every GST slot has a home on a tax invoice", async () => {
for (const t of NORTHLINE) {
const r = await renderFixture({ name: "t", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: t.id }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, `${SLOT_ID.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `${t.id} #${id}`).toBe(true);
const 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", "PAN:ABCDE1234F"]) expect(flat, `${t.id} ${word}`).toContain(word);
}
});
it("an unregistered invoice carries none of the GST slots", async () => {
for (const t of NORTHLINE) {
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: t.id }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `${t.id} #${id}`).toBe(false);
}
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error", async () => {
for (const t of NORTHLINE) {
for (const size of ["A4", "LETTER"] as const) {
for (const name of ["id-tokens", "long-address", "long-client"]) {
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: t.id }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${t.id}/${size}/${name}`).toEqual([]);
}
}
}
});
it("a long invoice number shrinks inside the Purple Pop pill and the vendor name keeps its room", async () => {
const r = await renderFixture({ name: "n", items: 2, logo: "none", invoice: { number: "AB/2026-27/0012345" } }, prefsFor("A4", { templateId: "purple-pop" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
expect(r.pdf.pages[0].items.map((i) => i.str.replace(/\s+/g, "")).join("")).toContain("AB/2026-27/0012345");
});
});
describe("plan matches the laid-out blocks", () => {
for (const t of NORTHLINE) {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${t.id}/${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: t.id });
const frame = frameForPrefs(prefs);
// Logo-less (Purple Pop's logo is off anyway), 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 = t.id === "purple-pop" ? planPurple(r.fixture.model, frame, PURPLE_VARIANT, null, false) : planCitrus(r.fixture.model, frame, CITRUS_VARIANT, null, false);
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(20 + plan.gaps.tail);
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
// A wrapped footer or contact line is estimated at most a line or two high (never low).
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(24);
});
}
}
}
});
describe("measured fit", () => {
it("the Purple Pop wordmark and the Citrus title take about the design's width in the substitute faces", () => {
const m = getMeasurer();
// Canva: 'Invoice' 292.2 pt wide (Open Sauce One 700); 'INVOICE' 189.7 pt (Now Bold).
expect(Math.abs(measureToken(m, PURPLE_ROLES.wordmark, "Invoice") - 292.2)).toBeLessThan(16);
expect(Math.abs(measureToken(m, CITRUS_ROLES.title, "Invoice") - 189.7)).toBeLessThan(16);
});
});
describe("design docs", () => {
it("each Northline doc records its implementation notes", () => {
for (const f of ["12-purple-pop.md", "13-citrus-split.md"]) {
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
expect(text, f).toContain("## Implementation notes");
}
});
});
void buildFixture;
+553
View File
@@ -0,0 +1,553 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { DOME_BOX } from "../../decor/Asterisk";
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 { TypeToken } from "../../fonts/roles";
import type { RenderModel } from "../../model/build";
import type { PlacedLogo } from "../contract";
import { bankLines, CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
import type { CitrusVariant, PurpleVariant } from "./variant";
/** How both templates print a tax row's label: "CGST @ 9%". */
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
export const leading = (t: TypeToken) => t.leading as number;
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Space between the client label and the name pill, and between the pill and the address lines. */
export const CLIENT_LABEL_GAP = 9;
export const CLIENT_PILL_GAP = 6;
/** Gap between a mark and the vendor name beside it. */
export const MARK_GAP = 8;
/** Gap between the stack's last row and the TOTAL pill. */
export const PILL_GAP = 8;
export const REVERSE_GAP = 4;
/** Width of a bullet dot and the space after it. */
export const BULLET = { dot: 3.8, textAt: 12.7 };
/** Rows of the bullet column are this far apart on top of their line height. */
export const BULLET_GAP = 3;
export const CLOSING_COLUMN_GAP = 12;
/** Space above terms that flow as their own block (more bullets than fit beside the signature). */
export const NOTES_FLOW_GAP = 14;
/** Top of the dome below the totals box top (Canva: 565.3 against the subtotal row at 541.5). */
export const DOME_TOP = 22;
/** The notes as bullet lines: one bullet per note line. */
export const termsLines = (model: RenderModel): string[] => model.notesLines;
export interface ContactEntry {
icon: "phone" | "email" | "location";
lines: string[];
/** Identifiers (phone, e-mail) shrink instead of wrapping; free text wraps. */
id: boolean;
}
/** The supplier's phone, e-mail and address as an icon list. */
export function contactEntries(model: RenderModel): ContactEntry[] {
const s = model.supplier;
const out: ContactEntry[] = [];
if (s.phone) out.push({ icon: "phone", lines: [s.phone], id: true });
if (s.email) out.push({ icon: "email", lines: [s.email], id: true });
if (s.addressLines.length) out.push({ icon: "location", lines: s.addressLines, id: false });
return out;
}
export function signatureOptions(roles: { name: TypeToken; designation: TypeToken; fine: TypeToken }, imageHeight: number, color: string, align: "left" | "right"): SignatoryOptions {
return { roles, align, rule: { width: 0.75, color }, color, imageHeight };
}
/** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */
export 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);
}
/** Plain shrink-wrapped width of a one-line text. */
export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1;
export const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
/** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */
export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] {
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, body);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
});
}
/** Width of a numeric column: the design's, or wider when the widest value or the header needs it. */
export function numericColW(base: number, body: TypeToken, head: TypeToken, texts: string[], headText: string, pad = 6): number {
const m = getMeasurer();
const widest = Math.max(0, ...texts.map((t) => measureToken(m, body, t)), measureToken(m, head, headText));
return Math.max(base, Math.ceil(widest) + pad);
}
// --- Purple Pop ---------------------------------------------------------------------------------------------
export interface PurplePlan {
kind: "purple";
v: PurpleVariant;
W: number;
head: {
innerW: number;
pillText: string;
pillSize: number;
pillW: number;
leftW: number;
showName: boolean;
nameW: number;
nameLeft: number;
boxH: number;
linesH: number;
h: number;
};
client: { cellW: number; wordmarkW: number; pillW: number; pillBoxH: number; lineW: number; clientH: number };
title: { size: number; tax: boolean; h: number; w: number; pad: number };
rowH: number;
band: { amountW: number; datesW: number; dateSize: number; lines: string[]; amountText: string; amountSize: number; h: number };
mode: "narrow" | "full";
tableLeft: number;
tableW: number;
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
padY: number;
headPadY: number;
table: ItemsTablePlan;
guardH: number;
tail: { gutter: number; boxW: number; valueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackRowH: number; stackH: number; wordsW: number; wordsH: number; reverseH: number; body: number };
/** `beside`: the bullets sit next to the signature in the closing block; otherwise they flow as their own block before it. */
terms: { lines: string[]; beside: boolean; fillW: number; bulletW: number; bulletsH: number; signH: number; h: number; flowW: number; flowBulletW: number; flowH: number };
footer: { bank: string[]; contacts: ContactEntry[]; bankH: number; contactH: number; addressH: number; textW: { contact: number; address: number }; h: number };
gaps: { client: number; rule: number; band: number; table: number; tail: number };
tailH: number;
closingH: number;
/** Page-space y of the first-page asterisk's top, or null when the table is full width. */
asteriskY: number | null;
}
export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVariant, logo: PlacedLogo | null, includesName: boolean): PurplePlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
// --- header: name (and the opt-in logo) at the left, the INVOICE NO pill at the right -------------------
const innerW = W - v.head.nameInset - v.head.pillInset;
const showName = !(logo && includesName) && model.supplier.name.trim() !== "";
const markW = logo?.w ?? 0;
const nameLeft = markW > 0 ? markW + (showName ? MARK_GAP : 0) : 0;
const label = model.meta.numberLabel.replace(/\.$/, "");
const pillText = `${label} : ${model.meta.number}`;
const pillTextMax = Math.max(40, innerW - 12 - nameLeft - v.head.minNameW - 2 * v.head.pillPadX);
const fit = fitSize(r.pill, pillText, pillTextMax);
const pillW = Math.ceil(fit.width) + 2 * v.head.pillPadX + 2;
const leftW = innerW - pillW - 12;
const nameW = showName ? Math.min(v.head.nameMaxW, leftW - nameLeft) : 0;
const nameH = showName ? textHeight(r.brand, model.supplier.name, nameW) : 0;
const boxH = Math.max(v.head.pillH, nameH, logo?.h ?? 0);
const s = model.supplier;
const linesH = (slotHeights.gstinLines(model, "supplier") + (s.pan ? 1 : 0)) * leading(r.small);
const headH = Math.max(boxH + linesH, v.head.pillH);
// --- client column and the wordmark -------------------------------------------------------------------------
const cellW = v.client.colW - v.client.colGap;
const c = model.client;
const pillNatural = widthOf(r.clientName, c.name);
const pillW2 = Math.min(cellW, pillNatural + 2 * v.client.pillPadX);
const pillLines = textLines(r.clientName, c.name, pillW2 - 2 * v.client.pillPadX);
const pillBoxH = Math.max(v.client.pillH, pillLines * leading(r.clientName) + 2 * ((v.client.pillH - leading(r.clientName)) / 2));
const lineW = cellW - v.client.inset;
let clientH = leading(r.label) + CLIENT_LABEL_GAP + pillBoxH + CLIENT_PILL_GAP;
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, lineW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, lineW) * leading(r.clientLine);
const wordmarkW = W - v.client.colW - v.client.wordmarkInset;
const tax = model.docType === "tax_invoice";
const wmPad = Math.abs((r.wordmark.tracking ?? 0) * r.wordmark.size);
const wm = fitSize(r.wordmark, "Invoice", wordmarkW - wmPad);
const titleH = leading(r.wordmark) + (tax ? leading(r.taxCaption) + 2 : 0);
const rowH = Math.max(clientH, titleH);
// --- amount due and dates ---------------------------------------------------------------------------------
const bandInner = W - v.band.amountInset - v.band.datesInset;
const dateLines = [`Invoice Date : ${model.meta.date}`, ...(model.meta.dueDate ? [`Due Date : ${model.meta.dueDate}`] : []), ...(c.poNumber ? [`PO : ${c.poNumber}`] : [])];
const widest = dateLines.reduce((a, b) => (widthOf(r.date, b) > widthOf(r.date, a) ? b : a));
const amountMin = 150;
const dFit = fitSize(r.date, widest, bandInner - v.band.gap - amountMin);
const datesW = Math.ceil(dFit.width) + 2;
const amountW = bandInner - v.band.gap - datesW;
const aFit = fitSize(r.amount, model.totals.totalText, amountW);
const bandH = Math.max(leading(r.label) + leading(r.amount) + 2, dateLines.length * leading(r.date));
// --- table: the narrow variant (beside the asterisk) or the full width ---------------------------------------
const t = v.table;
const rowsFor = (rateW: number) => tableRows(model, rateW, r.tableBody, r.tableSub);
const headerCells = ["Service", "Qty", "Rate", "Amount"];
const align: PurplePlan["align"] = ["left", "center", "right", "right"];
const padY = (t.rowH - leading(r.tableBody)) / 2;
const headPadY = (t.headH - leading(r.tableHead)) / 2;
const priced = model.items.map((i) => priceOf(i.rateText));
const build = (left: number, base: { qty: number; rate: number; amount: number }) => {
const tableW = round2(W - left - t.right);
const inner = tableW - 2 * t.inset;
const cw = {
qty: numericColW(base.qty, r.tableBody, r.tableHead, model.items.map((i) => i.qtyText), "QTY"),
rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"),
amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const rows = rowsFor(cw.rate - 2);
return { tableW, inner, cw, columns, rows, descW: inner - cw.qty - cw.rate - cw.amount };
};
const plan = (b: ReturnType<typeof build>, tailH: number) =>
planItemsTable({
columns: b.columns,
gap: 0,
width: b.inner,
header: headerCells,
rows: b.rows,
measurer: m,
roles: { head: r.tableHead, body: r.tableBody },
padX: 0,
padY,
headPadY,
border: 0,
tailH,
contentH: frame.contentHeight,
});
const narrowB = build(t.narrowLeft, t.cols);
let mode: "narrow" | "full" = "full";
if (model.items.length <= 3 && narrowB.descW >= t.narrowDescMin) {
const probe = plan(narrowB, 0);
if (probe.rowLines.every((n) => n <= 2)) mode = "narrow";
}
const fullB = build(t.fullLeft, { qty: 46, rate: 100, amount: 108 });
const b = mode === "narrow" ? narrowB : fullB;
const tableLeft = mode === "narrow" ? t.narrowLeft : t.fullLeft;
// --- totals: the stack, the TOTAL pill, amount in words ---------------------------------------------------------
const tt = model.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 gutter = round2(W - v.totals.boxW);
const boxW = v.totals.boxW;
const taxTexts = slotHeights.taxRowTexts(model, atRate);
const valueTexts = [tt.subtotalText, tt.totalText, ...(tt.discountText ? [`- ${tt.discountText}`] : []), ...(tt.taxableText ? [tt.taxableText] : []), ...taxTexts.map((x) => x.amount)];
const valueW = Math.max(100, Math.ceil(Math.max(...valueTexts.map((x) => measureToken(m, r.totalValue, x)))) + 2);
const stackRowH = leading(r.totalLabel) + v.totals.rowGap;
const stackH = (stack.length + slotRows) * stackRowH;
const wordsW = boxW - v.totals.labelInset - v.totals.pillValueInset;
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
const boxBody = stackH + PILL_GAP + v.totals.pillH + (wordsH ? v.totals.wordsGap + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
// The dome in the left gutter must not make the row taller than its totals box.
const tailBody = Math.max(boxBody, DOME_TOP + DOME_BOX.h * (v.dome.w / DOME_BOX.w));
// --- closing: terms and signature, the rule, the three footer columns ----------------------------------------------
const lines = termsLines(model);
const signW = v.terms.signW;
const fillW = W - (v.terms.labelInset + v.terms.labelW) - signW - 2 * CLOSING_COLUMN_GAP;
const bulletW = fillW - BULLET.textAt;
const bulletsH = lines.reduce((h, l) => h + textHeight(r.bullet, l, bulletW) + BULLET_GAP, 0) - (lines.length ? BULLET_GAP : 0);
const sOpts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.ink, "right");
const signH = signatoryHeight(model, signW, sOpts);
const beside = lines.length === 0 || bulletsH <= v.terms.besideMax;
const flowW = W - v.terms.labelInset - 7;
const flowBulletW = flowW - BULLET.textAt;
const flowH = beside ? 0 : leading(r.termsLabel) + 4 + lines.reduce((h, l) => h + textHeight(r.bullet, l, flowBulletW) + BULLET_GAP, 0) - BULLET_GAP;
const termsH = beside ? Math.max(lines.length ? leading(r.termsLabel) : 0, bulletsH, signH) : signH;
const bank = bankLines(model);
const bankH = model.bank ? leading(r.label) + 4 + bank.reduce((h, l) => h + textHeight(r.bank, l, v.footer.bankW), 0) : 0;
const contacts = contactEntries(model);
const contactText = v.footer.contactW - v.footer.iconW - v.footer.iconGap;
const addressText = W - v.footer.addressLeft - v.footer.iconW - v.footer.iconGap;
const phoneMail = contacts.filter((x) => x.icon !== "location");
const address = contacts.find((x) => x.icon === "location");
const contactH = phoneMail.reduce((h, x) => h + idLines(r.contact, x.lines[0], contactText) * leading(r.contact) + 1.5, 0);
const addressH = address ? address.lines.reduce((h, l) => h + textHeight(r.contact, l, addressText), 0) : 0;
const footerH = Math.max(bankH, contactH, addressH);
let closingH = v.gaps.termsAbove + termsH + v.gaps.ruleGap + v.rule.w + v.gaps.ruleGap + footerH + 1.5;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
// --- gaps: the elastic ones give back Letter's missing height, or an overflow within their slack ------------------------
const ranges = { client: v.gaps.client, rule: v.gaps.rule, band: v.gaps.band, table: v.gaps.table, tail: v.gaps.tail };
const table0 = plan(b, 0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const tailNominal = ranges.tail.nominal + tailBody;
const notesBlock = beside ? 0 : NOTES_FLOW_GAP + flowH;
const nominalTotal = headH + ranges.client.nominal + rowH + ranges.rule.nominal + v.rule.w + ranges.band.nominal + bandH + ranges.table.nominal + tableTotal + tailNominal + notesBlock + closingH;
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 - closingH - frame.contentHeight;
const fits = overflow > 0 && overflow <= slack ? overflow : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? overflowBeforeClosing : 0;
const deficit = Math.max(frame.heightDeficit, fits);
const g = elastic(ranges, deficit);
const tailH = g.tail + tailBody;
const table = plan(b, tailH);
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (b.rows.length === 1 ? tailH : 0);
const asteriskY = mode === "narrow" ? round2(frame.pad.top + headH + g.client + rowH + g.rule + v.rule.w + g.band + bandH + g.table + v.asterisk.offsetY) : null;
return {
kind: "purple",
v,
W,
head: { innerW, pillText, pillSize: fit.size, pillW, leftW, showName, nameW, nameLeft, boxH, linesH, h: headH },
client: { cellW, wordmarkW, pillW: pillW2, pillBoxH, lineW, clientH },
title: { size: wm.size, tax, h: titleH, w: wordmarkW, pad: Math.abs((r.wordmark.tracking ?? 0) * wm.size) },
rowH,
band: { amountW, datesW, dateSize: dFit.size, lines: dateLines, amountText: model.totals.totalText, amountSize: aFit.size, h: bandH },
mode,
tableLeft,
tableW: b.tableW,
columns: b.columns,
headerCells,
rows: b.rows,
align,
padY,
headPadY,
table,
guardH,
tail: { gutter, boxW, valueW, stack, slotRows, stackRowH, stackH, wordsW, wordsH, reverseH, body: tailBody },
terms: { lines, beside, fillW, bulletW, bulletsH, signH, h: termsH, flowW, flowBulletW, flowH },
footer: { bank, contacts, bankH, contactH, addressH, textW: { contact: contactText, address: addressText }, h: footerH },
gaps: g,
tailH,
closingH,
asteriskY,
};
}
// --- Citrus Split --------------------------------------------------------------------------------------------
export interface CitrusPlan {
kind: "citrus";
v: CitrusVariant;
W: number;
/** The stripe's left edge from the content edge. */
Lw: number;
panelW: number;
/** The client block above the table (an invoice longer than a page), instead of beside the totals. */
clientAbove: boolean;
head: { titleW: number; titleSize: number; tax: boolean; titleH: number; showName: boolean; nameW: number; nameLeft: number; nameH: number; boxH: number; h: number };
client: { w: number; h: number; nameW: number };
meta: { noW: number; dateW: number; dueW: number; amountW: number; spacer: number; hasDue: boolean; h: number };
tableLeft: number;
tableW: number;
columns: ColSpec[];
headerCells: TableCellValue[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
padLeft: number[];
padRight: number[];
padY: number;
headPadY: number;
cw: { qty: number; rate: number; amount: number };
table: ItemsTablePlan;
guardH: number;
tail: { leftW: number; valueW: number; barValueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackRowH: number; stackH: number; panelH: number; leftH: number; body: number; wordsW: number; wordsH: number; reverseH: number };
closing: {
bank: string[];
terms: string[];
/** Terms in the closing's left column; otherwise a flowing block before the closing. */
termsBeside: boolean;
flowW: number;
flowBulletW: number;
flowH: number;
leftH: number;
bankH: number;
termsH: number;
bulletW: number;
contacts: ContactEntry[];
contactTextW: number;
contactsH: number;
idLinesH: number;
signH: number;
rightH: number;
h: number;
};
gaps: { client: number; meta: number; table: number; tail: number };
tailH: number;
closingH: number;
}
export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVariant, logo: PlacedLogo | null, includesName: boolean): CitrusPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const panelW = v.stripeW;
const Lw = round2(W - panelW);
const c = model.client;
const s = model.supplier;
// --- header: the wordmark at the left, the mark and name inside the panel ---------------------------------
const tax = model.docType === "tax_invoice";
const titleW = Lw - 8;
const tFit = fitSize(r.title, "Invoice", titleW);
const titleH = leading(r.title) + (tax ? leading(r.taxCaption) + 2 : 0);
const showName = !(logo && includesName) && s.name.trim() !== "";
const markW = logo?.w ?? 0;
const nameLeft = v.head.panelInset + (markW > 0 ? markW + (showName ? v.head.markGap : 0) : 0);
const nameW = showName ? Math.max(60, panelW - nameLeft - 4) : 0;
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
const boxH = Math.max(logo?.h ?? 0, nameH);
const headH = Math.max(titleH, boxH);
// --- client block -------------------------------------------------------------------------------------------
const cw = Lw - v.client.inset - 8;
let clientH = leading(r.billedLabel) + v.client.labelGap + textHeight(r.clientName, c.name, cw) + v.client.nameGap;
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, cw);
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, cw) * leading(r.clientLine);
if (c.poNumber) clientH += textHeight(r.clientLine, `PO: ${c.poNumber}`, cw);
// --- meta row ----------------------------------------------------------------------------------------------
const mt = v.meta;
const hasDue = Boolean(model.meta.dueDate);
const noLabel = `${model.meta.numberLabel.replace(/\.$/, "")} :`;
const noW = Math.min(Lw - mt.inset - mt.dateMin, Math.max(mt.noMin, widthOf(r.metaLabel, noLabel) + 8, Math.ceil(measureToken(m, r.metaValue, model.meta.number)) + 10));
const dateW = round2(Lw - mt.inset - noW);
const spacer = mt.panelInset;
const dueW = mt.dueW;
const amountW = round2(panelW - spacer - dueW);
const metaH = leading(r.metaLabel) + mt.labelGap + leading(r.metaValue);
// --- table ----------------------------------------------------------------------------------------------------
const t = v.table;
const tableW = round2(W - t.left);
const priced = model.items.map((i) => priceOf(i.rateText));
const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4;
const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4;
const qtyNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.qtyText)), measureToken(m, r.headNum, "QTY"))) + 6;
const amount = Math.max(t.cols.amount, amountNeed);
const rate = Math.max(t.cols.rate, rateNeed);
const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount));
const cwid = { qty: Math.max(qty, qtyNeed), rate, amount: round2(panelW - Math.max(qty, qtyNeed) - rate) };
const columns: ColSpec[] = [{ fill: true }, { fixed: cwid.qty }, { fixed: cwid.rate }, { fixed: cwid.amount }];
const headerCells: TableCellValue[] = [[{ text: "Service", role: r.headService }], [{ text: "Qty", role: r.headNum }], [{ text: "Rate", role: r.headNum }], [{ text: "Amount", role: r.headNum }]];
const align: CitrusPlan["align"] = ["left", "center", "right", "right"];
const padLeft = [t.bodyInset, 0, 0, 0];
const padRight = [t.descRight, 0, 14, 13.6];
const headPadY = (t.headH - leading(r.headNum)) / 2;
const rows = tableRows(model, cwid.rate - 14 - 2, r.tableBody, r.tableSub);
const planTable = (padY: number, tailH: number) =>
planItemsTable({ columns, gap: 0, width: tableW, header: headerCells, rows, measurer: m, roles: { head: r.headNum, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight });
// --- totals panel and the tail row ----------------------------------------------------------------------------
const tt = model.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 valueTexts = [tt.subtotalText, ...(tt.discountText ? [`- ${tt.discountText}`] : []), ...(tt.taxableText ? [tt.taxableText] : []), ...taxTexts.map((x) => x.amount)];
const innerTotals = panelW - v.totals.labelInset - v.totals.valueInset;
const stackLabels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label)];
const widestLabel = Math.ceil(Math.max(...stackLabels.map((l) => measureToken(m, r.totalLabel, l)))) + 4;
// The amounts take their own width; the labels get the rest and never wrap (the row height is one line).
const valueW = Math.min(innerTotals - widestLabel, Math.max(64, Math.ceil(Math.max(...valueTexts.map((x) => measureToken(m, r.totalValue, x)))) + 2));
const barValueW = Math.max(valueW, Math.ceil(Math.max(measureToken(m, r.grandValue, tt.totalText), 0)) + 2);
const stackRowH = leading(r.totalLabel) + v.totals.rowGap;
const stackH = (stack.length + slotRows) * stackRowH;
// Amount in words sits under the TOTAL bar (the design's tagline slot), right-aligned.
const wordsW = panelW - 2 * 13.1;
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
const panelTail = v.separator.h + v.totals.sepGap + stackH + v.totals.barGap + v.totals.barH + (wordsH ? v.totals.wordsGap + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
const tailLeftW = Lw - t.left;
const leftTail = v.totals.sepGap + clientH;
// --- closing: method and terms at the left; words, contacts, signature in the panel column --------------------------
const cl = v.closing;
const bank = bankLines(model);
const bankH = model.bank ? leading(r.sectionLabel) + cl.labelGap + bank.reduce((h, l) => h + textHeight(r.bank, l, Lw - cl.bankInset - 8), 0) : 0;
const terms = termsLines(model);
const bulletW = Lw - cl.bulletText - 8;
const termsH = terms.length ? leading(r.sectionLabel) + cl.labelGap + terms.reduce((h, l) => h + textHeight(r.bullet, l, bulletW), 0) + (terms.length - 1) * cl.bulletGap : 0;
const termsBeside = terms.length === 0 || termsH <= cl.termsBesideMax;
const flowW = W - cl.methodInset - 8;
const flowBulletW = flowW - (cl.bulletText - cl.methodInset);
const flowH = termsBeside ? 0 : leading(r.sectionLabel) + cl.labelGap + terms.reduce((h, l) => h + textHeight(r.bullet, l, flowBulletW), 0) + (terms.length - 1) * cl.bulletGap;
const leftH = bankH + (termsBeside && bankH && termsH ? cl.sectionGap : 0) + (termsBeside ? termsH : 0);
const contacts = contactEntries(model);
const contactTextW = panelW - (cl.iconGap + cl.iconSize + cl.iconInset) - 8;
const contactsH = contacts.reduce((h, x) => h + (x.id ? idLines(r.contact, x.lines[0], contactTextW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, contactTextW), 0)) + cl.contactGap, 0) - (contacts.length ? cl.contactGap : 0);
const idLinesH = (slotHeights.gstinLines(model, "supplier") + (s.pan ? 1 : 0)) * leading(r.small);
const sOpts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.text, "right");
const signH = signatoryHeight(model, cl.signW, sOpts);
const parts = [contactsH + (idLinesH ? 4 + idLinesH : 0), signH].filter((x) => x > 0);
const rightH = parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 12;
let closingH = cl.clientTop + Math.max(leftH, rightH) + 1.5;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
// --- gaps. The client block sits beside the totals when the invoice fits one page, above the table otherwise. -------------
const padYn = t.padY;
const n = rows.length;
const run = (clientAbove: boolean) => {
const tailBody = Math.max(panelTail, clientAbove ? 0 : leftTail);
const ranges = {
client: clientAbove ? v.gaps.client : { nominal: 0, min: 0 },
meta: clientAbove ? { nominal: 24, min: 12 } : v.gaps.meta,
table: v.gaps.table,
tail: v.gaps.tail,
rowPad: { nominal: 2 * padYn * n, min: 2 * t.padYMin * n },
};
const table0 = planTable(padYn, 0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const nominalTotal = headH + (clientAbove ? ranges.client.nominal + clientH : 0) + ranges.meta.nominal + metaH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + (termsBeside ? 0 : NOTES_FLOW_GAP + flowH) + closingH;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
return { tailBody, ranges, nominalTotal, slack, tableTotal };
};
const faithful = run(false);
const clientAbove = faithful.nominalTotal - faithful.slack > frame.contentHeight;
const chosen = clientAbove ? run(true) : faithful;
const { tailBody, ranges, nominalTotal, slack } = chosen;
const overflow = nominalTotal - frame.contentHeight;
const overflowBeforeClosing = nominalTotal - closingH - frame.contentHeight;
const fits = overflow > 0 && overflow <= slack ? overflow : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? overflowBeforeClosing : 0;
const deficit = Math.max(frame.heightDeficit, fits);
const g = elastic(ranges, deficit);
const padY = n ? g.rowPad / (2 * n) : padYn;
const tailH = g.tail + tailBody;
const table = planTable(padY, tailH);
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (rows.length === 1 ? tailH : 0);
return {
kind: "citrus",
v,
W,
Lw,
panelW,
clientAbove,
head: { titleW, titleSize: tFit.size, tax, titleH, showName, nameW, nameLeft, nameH, boxH, h: headH },
client: { w: cw, h: clientH, nameW: cw },
meta: { noW, dateW, dueW, amountW, spacer, hasDue, h: metaH },
tableLeft: t.left,
tableW,
columns,
headerCells,
rows,
align,
padLeft,
padRight,
padY,
headPadY,
cw: cwid,
table,
guardH,
tail: { leftW: tailLeftW, valueW, barValueW, stack, slotRows, stackRowH, stackH, panelH: panelTail, leftH: leftTail, body: tailBody, wordsW, wordsH, reverseH },
closing: { bank, terms, termsBeside, flowW, flowBulletW, flowH, leftH, bankH, termsH, bulletW, contacts, contactTextW, contactsH, idLinesH, signH, rightH, h: closingH },
gaps: { client: g.client, meta: g.meta, table: g.table, tail: g.tail },
tailH,
closingH,
};
}
+129
View File
@@ -0,0 +1,129 @@
import { defineRoles } from "../../fonts/roles";
import { asteriskWatermark } from "../../decor/Asterisk";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NorthlineLayoutFor } from "./layoutFor";
import { gotham, type PurpleRoles, type PurpleVariant } from "./variant";
const DM = "DM Sans" as const;
const G = "Gotham" as const;
const INK = "#060605";
export const PURPLE = "#530895";
export const YELLOW = "#FFD80B";
/**
* Purple Pop's type (docs/templates/12-purple-pop.md): DM Sans for names, amounts, dates and the table;
* Gotham (drawn in Montserrat, sizes through gotham()) for the labels, client lines, totals and footer;
* the "Invoice" wordmark in Open Sauce One 700, which is Poppins 800 here at the design's -0.08 em.
* Leadings are the design's pitch, tightened where a Voiced invoice carries more lines than the sample.
*/
export const PURPLE_ROLES = defineRoles({
wordmark: { face: "Open Sauce One", weight: 800, size: 94.07, leading: 84, tracking: -0.08, minSize: 48 },
taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true },
brand: { face: DM, weight: 700, size: 17.77, leading: 21, minSize: 12 },
pill: { face: G, weight: 700, size: gotham(13.39), leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
label: { face: G, weight: 700, size: gotham(12), leading: 16, caps: true },
clientName: { face: G, weight: 700, size: gotham(14.39), leading: 18, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.16), leading: 15.5 },
amount: { face: DM, weight: 700, size: 32.74, leading: 36, minSize: 20, tnum: true },
date: { face: DM, weight: 700, size: 14.05, leading: 19.5, caps: true, minSize: 10 },
tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true },
tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 },
tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 },
totalLabel: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(13), leading: 17 },
grand: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
grandValue: { face: G, weight: 700, size: gotham(13), leading: 17 },
words: { face: DM, weight: 400, size: 10, leading: 13 },
wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 },
termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true },
bullet: { face: DM, weight: 400, size: 11, leading: 14.2 },
bank: { face: G, weight: 400, size: gotham(11), leading: 12.5 },
contact: { face: G, weight: 400, size: gotham(10.14), leading: 15, minSize: 7 },
small: { face: DM, weight: 400, size: 9, leading: 12 },
fine: { face: DM, weight: 400, size: 7, leading: 10 },
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
footer: { face: DM, weight: 400, size: 7 },
} satisfies PurpleRoles);
/**
* Canva A4: content x 54.8..542.8, so the left margin is 54.8 and the right 52.48. The pill's top is y 77 (the
* row starts there) and the footer ends near y 772, so the bottom margin is 40 plus the footer reserve.
*/
export const PURPLE_VARIANT: PurpleVariant = {
id: "purple-pop",
roles: PURPLE_ROLES,
ink: INK,
purple: PURPLE,
yellow: YELLOW,
grey: "#E5E5E5",
rowGrey: "#EDEDE8",
cream: "#FEFFEE",
watermark: "#FFF7F7",
margins: { top: 70, right: 52.48, bottom: 40, left: 54.8 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
signatureImageH: 40,
chromeLeftW: 150,
head: { nameInset: 4.7, pillInset: 11.9, pillH: 33.2, pillPadX: 20, nameMaxW: 240, minNameW: 110 },
client: { inset: 12.3, pillH: 33.2, pillPadX: 12.3, colW: 183.4, colGap: 8.4, wordmarkInset: 12.4 },
rule: { w: 1.5, left: 10, right: 9.4 },
band: { amountInset: 17, datesInset: 23.1, gap: 12 },
table: {
narrowLeft: 122.1,
fullLeft: 4.7,
right: 3.4,
inset: 15,
headH: 46.6,
rowH: 32.7,
radiusHead: 23.3,
radiusRow: 16.35,
cols: { qty: 40, rate: 90, amount: 100 },
narrowDescMin: 100,
},
asterisk: { size: 71.5, offsetX: 2.2, offsetY: 19.5 },
dome: { x: 2.2, w: 65.3 },
totals: { boxW: 369.4, labelInset: 18.6, valueInset: 20.8, rowGap: 3, pillH: 41, pillValueInset: 13.7, wordsGap: 8 },
terms: { labelInset: 17, labelW: 118, signW: 190, besideMax: 90 },
footer: { ruleLeft: 12.6, ruleRight: 6.8, bankW: 143, contactLeft: 163.7, contactW: 158, addressLeft: 325.9, iconW: 11.2, iconGap: 9 },
gaps: {
client: { nominal: 35, min: 10 },
rule: { nominal: 18, min: 6 },
band: { nominal: 18, min: 6 },
table: { nominal: 20, min: 8 },
tail: { nominal: 14, min: 6 },
termsAbove: 8,
ruleGap: 7,
},
};
export const purplePop: TemplateDefinition = {
...catalogEntry("purple-pop"),
version: 1,
roles: PURPLE_ROLES,
margins: PURPLE_VARIANT.margins,
marginFloor: PURPLE_VARIANT.marginFloor,
decor: {
ink: INK,
background: PURPLE_VARIANT.cream,
// The pale watermarks sit under the flow, on every page, anchored to the page corners. Nothing is printed
// over them that needs a clear ground, so there are no keep-outs.
underlay: (frame) => [asteriskWatermark(frame, "top-right", PURPLE_VARIANT.watermark), asteriskWatermark(frame, "bottom-left", PURPLE_VARIANT.watermark)],
},
chrome: {
color: "#6f6f6f",
rule: null,
role: PURPLE_ROLES.footer,
leftWidth: PURPLE_VARIANT.chromeLeftW,
},
logo: {
box: { w: 200, h: 34 },
stackBelowTextWidth: 0,
defaultOn: false,
optIn: catalogEntry("purple-pop").logoOptIn === true,
slotWidth: () => 200,
slot: slotFor("purple-pop"),
available: () => ({ w: 200, h: 34 }),
},
Layout: NorthlineLayoutFor(PURPLE_VARIANT),
};
+169
View File
@@ -0,0 +1,169 @@
import type { Sides } from "../../engine/geometry";
import type { TypeToken } from "../../fonts/roles";
import type { GapRange } from "../neutral/variant";
/**
* Gotham (commercial) is drawn with Montserrat, which runs about 4% wider at the same size. Every Gotham-derived
* size is multiplied by this when a token is defined, so the substitute takes the width the design has. A real
* Gotham imported over the licensed name would need these tokens at factor 1.
*/
export const GOTHAM_SIZE = 0.96;
const round2 = (n: number) => Math.round(n * 100) / 100;
/** The size a Gotham design value takes in Montserrat. */
export const gotham = (designSize: number) => round2(designSize * GOTHAM_SIZE);
/** Type roles of Purple Pop (DM Sans, Gotham -> Montserrat, the wordmark in Poppins 800). */
export interface PurpleRoles {
/** The oversized "Invoice" wordmark. */
wordmark: TypeToken;
/** "TAX" above the wordmark on a tax invoice. */
taxCaption: TypeToken;
brand: TypeToken;
/** "INVOICE NO : 012345" in the grey pill. */
pill: TypeToken;
/** "BILLED TO:", "AMOUNT DUE:", "PAYMENT METHOD". */
label: TypeToken;
clientName: TypeToken;
clientLine: TypeToken;
amount: TypeToken;
date: TypeToken;
tableHead: TypeToken;
tableBody: TypeToken;
tableSub: TypeToken;
totalLabel: TypeToken;
totalValue: TypeToken;
grand: TypeToken;
grandValue: TypeToken;
words: TypeToken;
wordsLabel: TypeToken;
termsLabel: TypeToken;
bullet: TypeToken;
bank: TypeToken;
contact: TypeToken;
/** GSTIN and PAN under the vendor name. */
small: TypeToken;
fine: TypeToken;
signName: TypeToken;
footer: TypeToken;
}
/** Type roles of Citrus Split (Now -> Jost for the wordmark, DM Sans, Gotham -> Montserrat). */
export interface CitrusRoles {
title: TypeToken;
taxCaption: TypeToken;
brand: TypeToken;
metaLabel: TypeToken;
metaValue: TypeToken;
metaStrong: TypeToken;
billedLabel: TypeToken;
clientName: TypeToken;
clientLine: TypeToken;
headService: TypeToken;
headNum: TypeToken;
tableBody: TypeToken;
tableSub: TypeToken;
totalLabel: TypeToken;
totalValue: TypeToken;
grand: TypeToken;
grandValue: TypeToken;
words: TypeToken;
wordsLabel: TypeToken;
sectionLabel: TypeToken;
bank: TypeToken;
bullet: TypeToken;
contact: TypeToken;
small: TypeToken;
fine: TypeToken;
signName: TypeToken;
footer: TypeToken;
}
export type NorthlineId = "purple-pop" | "citrus-split";
interface Common {
margins: Sides;
marginFloor: Sides;
signatureImageH: number;
chromeLeftW: number;
}
/**
* Purple Pop: y values are design distances measured on the Canva A4 page (docs/templates/12-purple-pop.md)
* converted to flow gaps; x values are relative to the content edge (the left margin, x 54.8), so the same
* tokens serve every margin preset and both page sizes.
*/
export interface PurpleVariant extends Common {
id: "purple-pop";
roles: PurpleRoles;
ink: string;
purple: string;
yellow: string;
grey: string;
/** Alternate item row fill (the grey at 68% on cream, flattened). */
rowGrey: string;
cream: string;
watermark: string;
/** Header: inset of the vendor name and of the pill's right edge from the content edges. */
head: { nameInset: number; pillInset: number; pillH: number; pillPadX: number; nameMaxW: number; minNameW: number };
/** Client column: label inset, pill padding, the column's width and the gap to the wordmark. */
client: { inset: number; pillH: number; pillPadX: number; colW: number; colGap: number; wordmarkInset: number };
rule: { w: number; left: number; right: number };
/** Amount due / dates band. */
band: { amountInset: number; datesInset: number; gap: number };
/** Table: left offset of the narrow variant, the right inset, the pill paddings and the column widths. */
table: { narrowLeft: number; fullLeft: number; right: number; inset: number; headH: number; rowH: number; radiusHead: number; radiusRow: number; cols: { qty: number; rate: number; amount: number }; narrowDescMin: number };
asterisk: { size: number; offsetX: number; offsetY: number };
dome: { x: number; w: number };
totals: { boxW: number; labelInset: number; valueInset: number; rowGap: number; pillH: number; pillValueInset: number; wordsGap: number };
terms: { labelInset: number; labelW: number; signW: number; besideMax: number };
footer: { ruleLeft: number; ruleRight: number; bankW: number; contactLeft: number; contactW: number; addressLeft: number; iconW: number; iconGap: number };
gaps: {
/** Header row (or its GSTIN/PAN lines) to the client row. */
client: GapRange;
rule: GapRange;
band: GapRange;
table: GapRange;
tail: GapRange;
termsAbove: number;
ruleGap: number;
};
}
/**
* Citrus Split (docs/templates/13-citrus-split.md). x values are relative to the content edge (the left
* margin, x 73.3 on the Canva page); the grey stripe is anchored to the RIGHT margin, so on Letter the extra
* width goes to the left column.
*/
export interface CitrusVariant extends Common {
id: "citrus-split";
roles: CitrusRoles;
ink: string;
text: string;
blush: string;
panel: string;
orange: string;
label: string;
/** Orange of the icon glyphs. */
icon: string;
stripeW: number;
/** The header's panel cell: inset of the logo from the stripe's left edge, and the gap between a mark and the name. */
head: { panelInset: number; markGap: number };
/** Client block: inset of its text from the content edge and the gap to the stripe. */
client: { inset: number; gap: number; labelGap: number; nameGap: number };
/** Meta row: inset of the first cell, minimum cell widths, panel-side start, gap between a label and its value. */
meta: { inset: number; noMin: number; dateMin: number; dueW: number; panelInset: number; labelGap: number };
/** Table: left offset from the content edge, header height, the SERVICE text inset, body text inset and row padding, columns. */
table: { left: number; headH: number; headInset: number; bodyInset: number; padY: number; padYMin: number; descRight: number; cols: { qty: number; rate: number; amount: number } };
separator: { color: string; h: number };
totals: { labelInset: number; valueInset: number; rowGap: number; barH: number; barInset: number; barGap: number; sepGap: number; wordsGap: number };
closing: { methodInset: number; bankInset: number; bulletInset: number; bulletText: number; bulletGap: number; sectionGap: number; labelGap: number; iconSize: number; iconGap: number; iconInset: number; contactGap: number; signW: number; clientTop: number; termsBesideMax: number };
gaps: {
/** Client block above the table (only when the invoice is more than a page). */
client: GapRange;
meta: GapRange;
table: GapRange;
tail: GapRange;
};
}
export type NorthlineVariant = PurpleVariant | CitrusVariant;
+4
View File
@@ -5,6 +5,8 @@ import { crimsonGrid } from "./ledger/crimson";
import { tangerineLedger } from "./ledger/tangerine";
import { slateBand } from "./band/slate";
import { tealSwoosh } from "./band/teal";
import { citrusSplit } from "./northline/citrus";
import { purplePop } from "./northline/purple";
import { monogram } from "./neutral/monogram";
import { serenity } from "./neutral/serenity";
@@ -18,6 +20,8 @@ export const TEMPLATES: Record<string, TemplateDefinition> = {
"tangerine-ledger": tangerineLedger,
"slate-band": slateBand,
"teal-swoosh": tealSwoosh,
"purple-pop": purplePop,
"citrus-split": citrusSplit,
};
export const DEFAULT_TEMPLATE_ID = "classic";