Add the Marble and Highlighter templates, completing the 13 reference templates

Marble uses a flat-to-gradient band in place of the marble photo and bundles Lustria (OFL).
Both logo slots are opt-in. Quirk 13 documents a fontkit glyph-cache issue that the
tests work around with warm-up renders; the engine fix follows separately.
This commit is contained in:
2026-10-04 12:47:05 +05:30
parent dd85c3b3c5
commit 76533ad2c7
30 changed files with 3190 additions and 27 deletions
+156
View File
@@ -0,0 +1,156 @@
import type { Sides } from "../../engine/geometry";
import { defineRoles, type TypeToken } from "../../fonts/roles";
import type { GapRange } from "../neutral/variant";
const L = "Lustria" as const;
const N = "Now" as const;
export const INK = "#000000";
/** Hairlines around the parties band. */
export const RULE = "#E7E1E6";
/** Zebra rows. */
export const ZEBRA = "#F7F7F8";
/** The flat/gradient stand-in for the marble photograph: a light warm grey with a soft sheen. */
export const BAND_FROM = "#F4F3F5";
export const BAND_TO = "#ECEAEE";
/**
* Marble's type (docs/templates/01-marble.md): Lustria in tracked caps for the title, headings, table heads and the
* amount due; "Now" (drawn in Jost, the bundled look-alike) for everything else. Jost sets about 12% narrower than Now,
* so every Now role carries the tracking that restores the reference widths (0.10 to 0.13 em on top of the design's
* 0.04 em). Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375.
*/
export const MARBLE_ROLES = defineRoles({
/** "INVOICE" / "TAX INVOICE": 25.18 pt, tracked 0.192 em, shrunk to the room the stack has. */
title: { face: L, weight: 400, size: 25.18, leading: 25.2, tracking: 0.192, caps: true, minSize: 13 },
/** The vendor's name at the header's left (the design leaves it empty). */
brand: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true, minSize: 9 },
meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, minSize: 8 },
/** BILL TO:, PAYABLE TO:, SUPPLIER:. */
heading: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true },
body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, minSize: 8 },
tableHead: { face: L, weight: 400, size: 11.68, leading: 16, tracking: 0.192, caps: true },
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102 },
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1 },
totalLabel: { face: L, weight: 400, size: 9, leading: 16, tracking: 0.192, caps: true },
totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102 },
/** AMOUNT DUE: 14.6 pt, tracked 0.192 em. */
due: { face: L, weight: 400, size: 14.6, leading: 20, tracking: 0.192, caps: true, minSize: 10 },
small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13 },
smallLabel: { face: L, weight: 400, size: 9, leading: 15.3, tracking: 0.192, caps: true },
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102 },
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13 },
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1 },
/** The footer band's contact lines. */
contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, minSize: 7 },
footer: { face: N, weight: 400, size: 7 },
});
export type MarbleRoles = typeof MARBLE_ROLES;
/**
* Reference A4 numbers: the rules and zebra bands start at x 31.5 and end at 549.8 (the text is inset 7 and 9.5 pt),
* the right-hand column ends at 549.0; y values are the design's distances turned into flow gaps.
*/
export interface MarbleVariant {
roles: MarbleRoles;
ink: string;
margins: Sides;
marginFloor: Sides;
/** Text inset inside the rules, left (parties) and right. */
inset: { left: number; right: number };
/** The top band's height and the bottom band's (A4 and Letter share the bottom band; the footer text is capped by lines). */
bands: { top: number; bottom: number };
head: {
/** Title line box below the header's top (the logo slot's top). */
titleTop: number;
/** Title box bottom to the first line of the stack. */
titleGap: number;
/** The widest share of the content width the right-hand stack may take. */
maxShare: number;
minStackW: number;
/** Vendor name top below the header's top when there is no logo; below the logo box otherwise. */
nameTop: number;
nameGap: number;
minNameW: number;
/** Space between the left column and the stack at its narrowest. */
colGap: number;
/** The logo slot's box height. */
logoH: number;
};
rule: number;
parties: { colA: number; colB: number; headingGap: number };
gaps: {
/** Header bottom to the first hairline. */
headRule: GapRange;
/** Hairline to the first heading, and the last info line to the second hairline. */
infoTop: GapRange;
infoBottom: GapRange;
/** Second hairline to the table heading. */
band: GapRange;
/** Last table row to the totals. */
tail: GapRange;
/** The totals (or the notes) to the signature band. */
close: GapRange;
};
table: {
/** Heading box bottom to the first zebra band. */
headGap: number;
descInset: number;
headDescInset: number;
cols: { qty: number; rate: number; amount: number };
numPad: number;
/** Right padding of the rate column (the unit is part of the rate, so it gets less than the amounts). */
ratePad: number;
padY: (rows: number) => number;
padYMin: number;
};
totals: { boxW: number; rowGap: number; dueGap: number; rightInset: number; leftGap: number; wordsMax: number };
closing: { signW: number; gap: number; notesBesideMax: number; signatureImageH: number };
footer: {
/** Footer text left edge, from the page's left edge (A4 reference: x 40.7) and the second column's left (x 197.8). */
col1: number;
col2: number;
colGap: number;
col1MaxW: number;
/** Band top to the first line box. */
top: number;
/** Lines the first column may take (name plus address) on A4 and Letter. */
maxLines: number;
};
}
export const MARBLE_VARIANT: MarbleVariant = {
roles: MARBLE_ROLES,
ink: INK,
margins: { top: 86, right: 45.8, bottom: 24, left: 31.5 },
marginFloor: { top: 86, right: 31.5, bottom: 24, left: 31.5 },
inset: { left: 7, right: 0.8 },
bands: { top: 64, bottom: 130 },
head: { titleTop: 14.85, titleGap: 3.7, maxShare: 0.7, minStackW: 170, nameTop: 26.8, nameGap: 6, minNameW: 90, colGap: 24, logoH: 50 },
rule: 0.75,
parties: { colA: 172.9, colB: 168.6, headingGap: 2.6 },
gaps: {
headRule: { nominal: 36.8, min: 14 },
infoTop: { nominal: 16.05, min: 10 },
infoBottom: { nominal: 11.56, min: 8 },
band: { nominal: 50.2, min: 22 },
tail: { nominal: 14, min: 6 },
close: { nominal: 14, min: 8 },
},
table: {
headGap: 8.85,
descInset: 9.5,
headDescInset: 7.3,
cols: { qty: 84, rate: 72.9, amount: 74.9 },
numPad: 9.5,
ratePad: 5,
padY: (rows) => (rows <= 8 ? 7.3 : rows <= 14 ? 6 : 4.5),
padYMin: 3.5,
},
totals: { boxW: 230, rowGap: 0, dueGap: 10, rightInset: 14.1, leftGap: 16, wordsMax: 280 },
closing: { signW: 170, gap: 24, notesBesideMax: 90, signatureImageH: 40 },
footer: { col1: 40.7, col2: 197.8, colGap: 24, col1MaxW: 190, top: 24.7, maxLines: 5 },
};
export const leading = (t: TypeToken) => t.leading as number;