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 reference 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 9de9a6ffee
commit 4cea3fdd8e
36 changed files with 3033 additions and 56 deletions
+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 reference 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 reference 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;