Add the Marble and Highlighter templates, completing the 13 Canva 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 3d42f383ca
commit 579508742f
32 changed files with 3190 additions and 27 deletions
+392
View File
@@ -0,0 +1,392 @@
import { Image, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { pinned, type ColSpec } 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 { fitSize, FitText } from "../../blocks/FitText";
import { IdText } from "../../blocks/IdText";
import { TableRows } from "../../blocks/ItemsTable";
import { Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { Slot } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Txt } from "../../blocks/Txt";
import { AccentRule } from "../../decor/AccentRule";
import type { TypeToken } from "../../fonts/roles";
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 { LABEL_GAP, NOTES_FLOW_GAP, PARTY_GUTTER, payableLines, planMarble, REVERSE_GAP, signatureOptions, stepOf, taxLabel, type MarblePlan } from "./plan";
import { RULE, ZEBRA, type MarbleVariant } from "./tokens";
/** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */
const NOTES_HOLD = 70;
/** Layout-tree ids of the blocks that are Marble's own (the harness ids are in classic/ids.ts). */
export const IDS_ID = "marble-ids";
export const PAYABLE_ID = "marble-payable";
export const BILL_ID = "marble-bill";
interface Ctx {
model: TemplateProps["model"];
plan: MarblePlan;
v: MarbleVariant;
logo: PlacedLogo | null;
}
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
function Rule({ ctx }: { ctx: Ctx }) {
return <AccentRule width={ctx.plan.W} thickness={ctx.v.rule} color={RULE} />;
}
/**
* One right-aligned line of tracked text. textkit adds the tracking after the last glyph too, so the box is
* widened by one step and pulled back with a negative margin: the ink then ends on the edge.
*/
function RightText({ role, color, width, children }: { role: TypeToken; color: string; width: number; children: string }) {
const step = stepOf(role);
const fit = fitSize(role, children, width + step);
if (!fit.fits) {
return (
<IdText role={role} color={color} width={width} align="right" fill>
{children}
</IdText>
);
}
const sized = { ...role, size: fit.size };
const s = stepOf(sized);
return (
<Txt role={sized} color={color} width={width + s} align="right" style={{ marginRight: -s }}>
{children}
</Txt>
);
}
// --- header -----------------------------------------------------------------------------------------------------
/** The vendor name (Lustria caps) or its logo; the design leaves this corner empty. */
function Brand({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const h = plan.head;
const hd = v.head;
return (
<View id={SUPPLIER_ID} style={{ ...pinned(h.leftW), marginLeft: v.inset.left } as never}>
{logo ? (
<View style={{ ...pinned(h.leftW), height: Math.max(hd.logoH, h.logoTop + logo.h) } as never}>
<Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: h.logoTop, objectFit: "contain" } as never} />
</View>
) : null}
{h.showName ? (
<View style={{ ...pinned(h.leftW), marginTop: logo ? hd.nameGap : hd.nameTop } as never}>
<AvailableWidthProvider width={h.leftW}>
<SafeTxt role={v.roles.brand} color={v.ink}>
{model.supplier.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function Stack({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const h = plan.head;
const r = v.roles;
return (
<View style={{ ...pinned(h.stackW), paddingTop: v.head.titleTop } as never}>
<FitText id={TITLE_ID} role={{ ...r.title, size: h.titleSize }} color={v.ink} width={h.stackW + stepOf({ ...r.title, size: h.titleSize })} align="right" style={{ marginRight: -stepOf({ ...r.title, size: h.titleSize }) }}>
{model.docTitle}
</FitText>
<View id={META_ID} style={{ ...pinned(h.stackW), marginTop: v.head.titleGap } as never}>
<AvailableWidthProvider width={h.stackW}>
{h.lines.map((l, i) => (
<RightText key={i} role={r.meta} color={l.kind === "cancelled" ? CANCELLED_INK : v.ink} width={h.stackW}>
{l.text}
</RightText>
))}
</AvailableWidthProvider>
</View>
</View>
);
}
function HeaderRow({ ctx }: { ctx: Ctx }) {
const { plan } = ctx;
return (
<Row widths={[{ fill: true }, { fixed: plan.head.stackW }]} width={plan.W} style={{ minHeight: plan.head.h }}>
<Brand ctx={ctx} />
<Stack ctx={ctx} />
</Row>
);
}
// --- parties ----------------------------------------------------------------------------------------------------
function ClientBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
const c = model.client;
return (
<View id={BILL_ID} style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
Bill to:
</Txt>
<SafeTxt role={r.body} color={v.ink}>
{c.name}
</SafeTxt>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.body} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.body} color={v.ink} />
{c.poNumber ? <SafeTxt role={r.body} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt> : null}
</AvailableWidthProvider>
</View>
);
}
function PayableBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
return (
<View id={PAYABLE_ID} style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
Payable to:
</Txt>
{payableLines(model).map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function IdsBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
<View id={IDS_ID} style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
Supplier:
</Txt>
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
{s.pan ? (
<IdText role={r.body} color={v.ink}>
{`PAN: ${s.pan}`}
</IdText>
) : null}
</AvailableWidthProvider>
</View>
);
}
function Parties({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const cols = plan.info.cols;
const text = cols.map((w) => Math.max(40, w - PARTY_GUTTER));
const blocks: ReactNode[] = [<ClientBlock key="a" ctx={ctx} width={text[0]} />];
let k = 1;
if (plan.info.hasBank) blocks.push(<PayableBlock key="b" ctx={ctx} width={text[k++]} />);
if (plan.info.hasIds) blocks.push(<IdsBlock key="c" ctx={ctx} width={text[k++]} />);
const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w }));
return (
<View style={{ ...pinned(plan.W), paddingLeft: v.inset.left } as never}>
<Row widths={specs} width={plan.W - v.inset.left}>
{blocks}
</Row>
</View>
);
}
// --- totals -----------------------------------------------------------------------------------------------------
function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) {
const { plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
const labelW = t.boxW - t.valueW;
return (
<Row widths={[{ fixed: labelW }, { fixed: t.valueW }]} width={t.boxW} style={v.totals.rowGap ? { marginBottom: v.totals.rowGap } : undefined}>
<Txt role={r.totalLabel} color={v.ink} width={labelW}>
{label}
</Txt>
<RightText role={r.totalValue} color={v.ink} width={t.valueW}>
{amount}
</RightText>
</Row>
);
}
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
return (
<View style={pinned(t.boxW) as never}>
<AvailableWidthProvider width={t.boxW}>
{t.stack.map((row, i) => (
<StackRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
))}
<Slot.TaxRows model={model} role={v.roles.totalLabel} color={v.ink} amountWidth={t.valueW} format={taxLabel} renderRow={(row) => <StackRow ctx={ctx} label={row.label} amount={row.amount} />} />
<View style={{ marginTop: v.totals.dueGap, ...pinned(t.boxW) } as never}>
<RightText role={{ ...v.roles.due, size: t.dueSize }} color={v.ink} width={t.boxW}>
{t.dueText}
</RightText>
</View>
</AvailableWidthProvider>
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
const o = v.totals;
return (
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
<Spacer h={plan.gaps.tail} />
<Row widths={[{ fill: true }, { fixed: t.boxW }]} gap={o.leftGap} width={plan.W - o.rightInset}>
<View style={{ ...pinned(t.leftW), marginLeft: v.inset.left } as never}>
<AvailableWidthProvider width={t.leftW}>
<Slot.AmountInWords model={model} role={r.small} labelRole={r.smallLabel} color={v.ink} />
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null}
</AvailableWidthProvider>
</View>
<TotalsBox ctx={ctx} />
</Row>
</View>
);
}
// --- closing ----------------------------------------------------------------------------------------------------
/** Notes: a label held with the first line, every line unbreakable (a cut Text would leave a zero-line node). */
function Notes({ ctx, width }: { ctx: Ctx; width: number }) {
const { plan, v } = ctx;
const r = v.roles;
const lines = plan.closing.notes;
if (lines.length === 0) return null;
return (
<View style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<View wrap={false}>
<Txt role={r.smallLabel} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}>
Notes:
</Txt>
<SafeTxt role={r.small} color={v.ink}>
{lines[0]}
</SafeTxt>
</View>
{lines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.small} color={v.ink} wrap={false}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const cl = v.closing;
const c = plan.closing;
const opts = signatureOptions(v);
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={pinned(plan.W) as never}>
<AvailableWidthProvider width={plan.W}>
<Spacer h={plan.gaps.close} />
<Row widths={[{ fill: true }, { fixed: cl.signW }]} gap={cl.gap} width={plan.W - v.totals.rightInset}>
<View style={{ ...pinned(c.notesW), marginLeft: v.inset.left } as never}>{c.notesBeside ? <Notes ctx={ctx} width={c.notesW} /> : null}</View>
<Slot.Signatory model={model} width={cl.signW} {...opts} />
</Row>
{model.cancelled ? (
<Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10, marginLeft: v.inset.left }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- the layout -------------------------------------------------------------------------------------------------
export function MarbleBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: MarbleVariant }) {
const plan = planMarble(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
const t = v.table;
const notesFlow: ReactNode =
plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
<>
{/* A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */}
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.inset.left }}>
<Notes ctx={ctx} width={plan.W - v.inset.left - v.totals.rightInset} />
</View>
</>
);
return (
<>
<FirstPageHeader pad={frame.pad}>
<HeaderRow ctx={ctx} />
<Spacer h={plan.gaps.headRule} />
<Rule ctx={ctx} />
<Spacer h={plan.gaps.infoTop} />
<Parties ctx={ctx} />
<Spacer h={plan.gaps.infoBottom} />
<Rule ctx={ctx} />
</FirstPageHeader>
<Spacer h={plan.gaps.band} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={pinned(plan.W) as never}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.W}
header={plan.headerCells}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={0}
padLeft={plan.padLeft}
padRight={plan.padRight}
padY={plan.padY}
headPadY={0}
border={0}
headBorder={0}
headColor={v.ink}
rowStyle={(i) => (i % 2 === 0 ? { backgroundColor: ZEBRA } : {})}
headCellStyle={(i) => (i === 0 ? { marginLeft: -(t.descInset - t.headDescInset) } : undefined)}
headerTail={<Spacer h={t.headGap} />}
headerTailH={t.headGap}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{notesFlow}
<Closing ctx={ctx} />
</>
);
}
+483
View File
@@ -0,0 +1,483 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { FONT_MANIFEST } from "../../fonts/manifest";
import { familySources } from "../../fonts/stack";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { SLOT_ID } from "../../blocks/slots";
import { frameForPrefs, renderInvoicePdf } from "../../render/invoice";
import { loadCanvas, pagePng } from "../../testing/rasterize";
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
import { marginsFor } from "../contract";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { OPTIN_SLOTS } from "../slots.optin";
import { SLOTS } from "../slots.generated";
import { IDS_ID, PAYABLE_ID } from "./Layout";
import { marble } from "./marble";
import { planMarble } from "./plan";
import { MARBLE_ROLES, MARBLE_VARIANT, RULE, ZEBRA } from "./tokens";
ensureSetup();
const V = MARBLE_VARIANT;
const slot = OPTIN_SLOTS.marble!;
type R = Awaited<ReturnType<typeof renderFixture>>;
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: R, i: number) => nodes([r.result.core.layout[i]]);
const imageBoxes = (r: R) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
const byId = (r: R, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
const textLines = (root: LayoutNode) => {
const out: Array<{ text: string; x: number; right: number; y: number; h: number }> = [];
walk([root], (n) => {
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ text: l.text.trim(), x: l.x, right: l.x + l.w - (n.letterSpacing ?? 0), y: l.y, h: l.h });
});
return out;
};
const squash = (s: string) => s.replace(/\s+/g, "");
/** Text items of page `p` whose text, spaces removed, is `text`. */
const items = (r: R, text: string, p = 0) => r.pdf.pages[p].items.filter((i) => squash(i.str) === squash(text));
const contentRight = (r: R) => {
const f = frameForPrefs(r.prefs);
return f.page.width - f.pad.right;
};
const optIn = (size: "A4" | "LETTER" = "A4") => prefsFor(size, { templateId: "marble", logo: { show: true } });
/** The Canva sample's shape: three rows, no GST data, no due date. */
const PLAIN: FixtureSpec = {
name: "plain",
items: ["Brand consultation", "Logo design", "Website design"].map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i })),
registered: false,
logo: "none",
invoice: { dueDate: "", notes: "" },
};
/** RGB of pixels of page `page` at 4x. */
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>, page = 1): Promise<number[][]> {
const lib = await loadCanvas();
if (!lib) return [];
const png = await pagePng(lib, bytes, page, 4);
const img = await lib.loadImage(png);
const c = lib.createCanvas(img.width, img.height);
const ctx = c.getContext("2d");
ctx.drawImage(img, 0, 0);
return points.map(([x, y]) => Array.from(ctx.getImageData(Math.round(x * 4), Math.round(y * 4), 1, 1).data).slice(0, 3));
}
const near = (c: number[], hex: string, tol = 8) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).every((w, i) => Math.abs(c[i] - w) <= tol);
describe("registry, catalog and fonts", () => {
it("is registered last, as a standalone template with its thumbnail on disk and the logo opt-in", () => {
expect(getTemplate("marble")).toBe(marble);
expect(marble.family).toBe("standalone");
expect(catalogEntry("marble").name).toBe("Marble");
expect(catalogEntry("marble").logoOptIn).toBe(true);
expect(existsSync(path.join(process.cwd(), "public", marble.thumbnail)), marble.thumbnail).toBe(true);
expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["marble", "highlighter"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("sets Lustria for the tracked caps and Now (drawn in Jost) for the text, in the design's palette only", () => {
for (const [name, token] of Object.entries(MARBLE_ROLES)) {
expect(["Lustria", "Now"], name).toContain(token.face);
expect(token.weight, name).toBe(400);
}
const lustria = FONT_MANIFEST.families.find((f) => f.family === "Lustria")!;
expect(lustria.license).toBe("OFL-1.1");
expect(lustria.fonts.map((f) => f.file)).toEqual(["Lustria-Regular.ttf"]);
// Lustria has no italic: the registration reuses the upright file, so a fallback stack never fails to resolve one.
expect(familySources(lustria).map((s) => `${s.fontWeight}/${s.fontStyle}`)).toEqual(["400/normal", "400/italic"]);
const dir = path.join(process.cwd(), "src/pdf/templates/marble");
const palette = ["#000000", RULE, ZEBRA, "#F4F3F5", "#ECEAEE"].map((c) => c.toLowerCase());
for (const f of readdirSync(dir).filter((x) => /\.(ts|tsx)$/.test(x) && !/\.test\./.test(x))) {
const colours = readFileSync(path.join(dir, f), "utf8").match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
expect(colours.filter((c) => !palette.includes(c.toLowerCase())), f).toEqual([]);
}
expect(marble.decor.ink).toBe("#000000");
});
it("the tracked rupee amounts come from the fallback font: every amount extracts with its sign", async () => {
const r = await renderFixture({ name: "t", items: 3 }, prefsFor("A4", { templateId: "marble" }));
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("");
expect(flat).toContain("₹");
expect(r.pdf.raw).not.toContain("/BaseFont /Helvetica");
});
});
describe("page geometry", () => {
it("template margins, floors and the bands", () => {
expect(marginsFor(marble, "template")).toEqual({ top: 86, right: 45.8, bottom: 24, left: 31.5 });
expect(marginsFor(marble, "narrow").left).toBe(36);
expect(marginsFor(marble, "narrow").top).toBe(86);
// The flow starts under the 64 pt top band (and the continuation text) and stops above the 130 pt foot band.
const frame = frameForPrefs(prefsFor("A4", { templateId: "marble" }));
expect(frame.pad.top).toBeGreaterThanOrEqual(V.bands.top + 18);
expect(frame.page.height - frame.pad.bottom).toBeLessThanOrEqual(frame.page.height - V.bands.bottom - 10);
});
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
const a4 = frameForPrefs(prefsFor("A4", { templateId: "marble" }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "marble" }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
});
it("both bands are on every page, full bleed", async () => {
const r = await renderFixture({ name: "rows", items: 40, signature: true }, prefsFor("A4", { templateId: "marble" }));
expect(r.result.pages).toBeGreaterThan(1);
for (let p = 0; p < r.result.pages; p++) {
const decor = onPage(r, p).filter((n) => n.decor && n.type === "svg");
const boxes = decor.map((n) => n.box);
expect(boxes.some((b) => b.y === 0 && Math.abs(b.h - V.bands.top) < 0.01 && b.w >= 595), `page ${p + 1} top band`).toBe(true);
expect(boxes.some((b) => Math.abs(b.y + b.h - 841.89) < 0.5 && Math.abs(b.h - V.bands.bottom) < 0.01), `page ${p + 1} foot band`).toBe(true);
}
});
});
describe("header", () => {
it("matches the design: title baseline 124.9, then the number, Issued and Due lines at the 17.5 pt pitch, all ending on the right edge", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
const right = contentRight(r);
const title = items(r, "INVOICE")[0];
expect(title.bottom).toBeCloseTo(124.9, 0);
expect(title.x1).toBeCloseTo(right, 0);
const number = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("InvoiceNo."))!;
const issued = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("Issued:"))!;
const due = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("Due:"))!;
for (const [what, it, baseline] of [["number", number, 142.1], ["issued", issued, 159.6], ["due", due, 177.1]] as const) {
expect(it.bottom, what).toBeCloseTo(baseline, 0);
expect(it.x1, what).toBeCloseTo(right, 0);
}
expect(squash(due.str)).toBe("Due:OnReceipt");
});
it("the vendor name stands in the empty left corner, level with the title, in Lustria caps", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
const name = items(r, "ARUN P")[0];
expect(name.bottom).toBeCloseTo(124.9, 0);
expect(name.x0).toBeCloseTo(38.5, 0);
});
it("TAX INVOICE and a long invoice number never reach the vendor name", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345/REV-3/LONG-REFERENCE-NUMBER" } }, prefsFor(size, { templateId: "marble" }));
const supplier = textLines(byId(r, SUPPLIER_ID));
const stack = [...textLines(byId(r, TITLE_ID)), ...textLines(byId(r, META_ID))];
expect(Math.max(...supplier.map((l) => l.right)), size).toBeLessThan(Math.min(...stack.map((l) => l.x)) - 10);
expect(r.result.issues.filter((i) => i.severity === "error"), size).toEqual([]);
}
});
it("a cancelled invoice is marked in the stack and closes with its sentence", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "cancelled")!, prefsFor("A4", { templateId: "marble" }));
expect(textLines(byId(r, META_ID)).some((l) => l.text === "CANCELLED")).toBe(true);
expect(squash(r.pdf.pages[r.result.pages - 1].items.map((i) => i.str).join(""))).toContain(squash("This invoice has been cancelled."));
});
});
describe("parties band", () => {
it("BILL TO and the first lines sit where the design has them (heading 247.9, lines at the 18.2 pt pitch from 269.0)", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
const heading = items(r, "BILL TO:")[0];
expect(heading.bottom).toBeCloseTo(247.9, 0);
expect(heading.x0).toBeCloseTo(38.5, 0);
const first = items(r, "Acme Learning Pvt Ltd")[0];
expect(first.bottom).toBeCloseTo(269.0, 0);
const pay = items(r, "PAYABLE TO:")[0];
expect(pay.bottom).toBeCloseTo(247.9, 0);
expect(pay.x0).toBeCloseTo(211.4, 0);
});
it("the supplier's GSTIN and PAN are in the third column, and the hairlines are the mauve-grey 0.75 pt rules", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" }));
const ids = textLines(byId(r, IDS_ID)).map((l) => l.text);
expect(ids).toEqual(expect.arrayContaining(["SUPPLIER:", "GSTIN: 29AAAAA0000A1Z5", "PAN: CPOPP7388K"]));
expect(textLines(byId(r, PAYABLE_ID)).map((l) => l.text)).toContain("PAYABLE TO:");
const plan = planMarble(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const rules = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.rule) < 0.01 && Math.abs(n.box.w - plan.W) < 0.5);
expect(rules.length).toBeGreaterThanOrEqual(2);
const [c] = await colourAt(r.result.bytes, [[300, rules[0].box.y + 0.375]]);
expect(near(c, RULE, 14), `rule ${c}`).toBe(true);
});
});
describe("logo", () => {
it("is an opt-in slot (the design has no placeholder): off by default, 150 x 50 at the left, bottom edge on the title's baseline", () => {
expect(SLOTS.marble).toBeNull();
expect(marble.logo.slot).toBe(slot);
expect(marble.logo.optIn).toBe(true);
expect(marble.logo.defaultOn).toBe(false);
expect(slot.lockup).toMatchObject({ x: 38.5, y: 86, w: 150, h: 50 });
expect(marble.logo.box).toEqual({ w: 150, h: 50 });
});
it("without the opt-in no logo is drawn, even when the invoice has one", async () => {
const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "marble" }));
expect(imageBoxes(r)).toHaveLength(0);
expect(items(r, "ARUN P")).toHaveLength(1);
});
it("every opted-in logo fits the 150 x 50 slot, starts at the text inset and rests on its bottom edge", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const logo of ["real", "wide", "square"] as const) {
const r = await renderFixture({ name: "o", items: 2, logo }, optIn(size));
const imgs = imageBoxes(r);
expect(imgs, `${size}/${logo}`).toHaveLength(1);
const [img] = imgs;
expect(img.box.w).toBeLessThanOrEqual(150.01);
expect(img.box.h).toBeLessThanOrEqual(50.01);
expect(img.box.x, `${size}/${logo} left`).toBeCloseTo(frameForPrefs(r.prefs).pad.left + V.inset.left, 0);
expect(img.box.y + img.box.h, `${size}/${logo} bottom`).toBeCloseTo(frameForPrefs(r.prefs).pad.top + 50, 0);
// The logo box and the supplier block never reach the title stack.
const stack = byId(r, META_ID);
expect(byId(r, SUPPLIER_ID).box.x + byId(r, SUPPLIER_ID).box.w).toBeLessThanOrEqual(stack.box.x + 0.5);
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${logo}`).toEqual([]);
}
}
});
it("a tall logo is held to the slot's 50 pt height", async () => {
const { model } = buildFixture({ name: "tall", items: 2, logo: "square" });
model.logo = { ...model.logo!, aspect: 0.5, width: 60, height: 120, kind: "tall", includesName: false };
const result = await renderInvoicePdf({ model, prefs: optIn() });
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
const img = nodes(result.core.layout).find((n) => n.type === "image" && !n.decor)!;
expect(img.box.h).toBeLessThanOrEqual(50.01);
});
it("a wordmark replaces the vendor name text; a mark keeps it under the mark", async () => {
const header = (r: R) => r.pdf.pages[0].items.filter((i) => squash(i.str) === "ARUNP" && i.bottom < 200);
const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, optIn());
expect(header(wide)).toHaveLength(0);
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, optIn());
const [name] = header(square);
expect(name).toBeTruthy();
expect(name.bottom - 12).toBeGreaterThan(imageBoxes(square)[0].box.y + imageBoxes(square)[0].box.h - 1);
});
});
describe("table", () => {
it("zebra rows on the 1st, 3rd ... (0-based even) in #F7F7F8, white between, no rule or fill on the heading", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
const row = (i: number) => byId(r, `${TABLE_ROW_PREFIX}${i}`).box;
const head = byId(r, TABLE_HEAD_ID).box;
const pts: Array<[number, number]> = [[34, row(0).y + 2], [34, row(1).y + 2], [34, row(2).y + 2], [34, head.y + 4]];
const colours = await colourAt(r.result.bytes, pts);
if (colours.length === 0) return;
expect(near(colours[0], ZEBRA, 4), `row 1 ${colours[0]}`).toBe(true);
expect(near(colours[1], "#FFFFFF", 4), `row 2 ${colours[1]}`).toBe(true);
expect(near(colours[2], ZEBRA, 4), `row 3 ${colours[2]}`).toBe(true);
expect(near(colours[3], "#FFFFFF", 4), `heading ${colours[3]}`).toBe(true);
});
it("rows are 32 pt, the first band starts 8.85 pt under the heading and qty is centred where the design centres it (x 355.5)", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
const head = byId(r, TABLE_HEAD_ID).box;
const r0 = byId(r, `${TABLE_ROW_PREFIX}0`).box;
const r1 = byId(r, `${TABLE_ROW_PREFIX}1`).box;
expect(r0.h).toBeCloseTo(32, 0);
expect(r1.y - r0.y).toBeCloseTo(32, 0);
expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0);
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0);
expect(r0.x).toBeCloseTo(31.5, 0);
});
it("amounts are right-aligned 9.5 pt inside the edge so the rupee figures line up", async () => {
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" }));
const right = contentRight(r);
for (let i = 0; i < 3; i++) {
const lines = textLines(byId(r, `${TABLE_ROW_PREFIX}${i}`));
const amount = lines[lines.length - 1];
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0);
}
});
it("the rate keeps its unit on one line while the column can grow to take it, and an HSN/SAC code is a small sub-line", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" }));
const row0 = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).map((l) => l.text);
expect(row0).toContain("₹1,200.00/hr");
expect(row0).toContain("HSN/SAC: 998397");
});
it("repeats the heading on every continuation page and never leaves it alone", async () => {
const r = await renderFixture({ name: "rows", items: 40 }, prefsFor("A4", { templateId: "marble" }));
expect(r.result.pages).toBeGreaterThan(1);
for (let p = 0; p < r.result.pages; p++) {
const rows = onPage(r, p).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX)).length;
const heads = onPage(r, p).filter((n) => n.id === TABLE_HEAD_ID).length;
if (rows > 0) expect(heads, `page ${p + 1}`).toBe(1);
if (heads > 0) expect(rows, `page ${p + 1}`).toBeGreaterThan(0);
}
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
});
});
describe("totals, closing and the foot band", () => {
it("AMOUNT DUE ends on the totals edge (x 535.4), under a stack of SUBTOTAL and the tax rows", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" }));
const totals = byId(r, TOTALS_ID, r.result.pages - 1);
const lines = textLines(totals);
const due = lines.find((l) => /^AMOUNT DUE:/.test(l.text))!;
expect(due.right).toBeCloseTo(contentRight(r) - V.totals.rightInset, 0);
const labels = lines.map((l) => l.text);
expect(labels).toEqual(expect.arrayContaining(["SUBTOTAL", "TAXABLE VALUE", "CGST 9%", "SGST 9%"]));
expect(due.y).toBeGreaterThan(lines.find((l) => l.text === "SGST 9%")!.y);
});
it("the totals stack, the words and the reverse-charge line never overlap, even with a long amount", async () => {
const r = await renderFixture({ name: "t", items: [{ id: 1, description: "Retainer", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 98765432.1, sortOrder: 0, hsnSac: "998397" }], logo: "none", invoice: { discount: 5000 } }, prefsFor("A4", { templateId: "marble" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
const totals = byId(r, TOTALS_ID, r.result.pages - 1);
const lines = textLines(totals);
for (let i = 0; i < lines.length; i++)
for (let j = i + 1; j < lines.length; j++) {
const a = lines[i];
const b = lines[j];
const overlap = Math.min(a.right, b.right) - Math.max(a.x, b.x) > 0.5 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0.5;
expect(overlap, `${a.text} / ${b.text}`).toBe(false);
}
});
it("the signature sits in the flow above the foot band, never inside it", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor(size, { templateId: "marble" }));
const last = r.result.pages - 1;
const sign = byId(r, SLOT_ID.signatory, last);
expect(sign, size).toBeTruthy();
const frame = frameForPrefs(r.prefs);
expect(sign.box.y + sign.box.h, size).toBeLessThanOrEqual(frame.page.height - V.bands.bottom + 0.5);
expect(byId(r, CLOSING_ID, last).box.x + byId(r, CLOSING_ID, last).box.w).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 0.5);
}
});
it("the foot band carries the vendor's name and address in column 1 (x 40.7) and the phone and e-mail in column 2 (x 197.8 while the address is short)", async () => {
const short = await renderFixture({ ...PLAIN, vendor: { vendorAddress: "123 Anywhere St.\nAny City, ST 12345", vendorName: "Olivia Wilson", vendorPhone: "123-456-7890", vendorEmail: "hello@reallygreatsite.com" } }, prefsFor("A4", { templateId: "marble" }));
const name = items(short, "Olivia Wilson").filter((i) => i.bottom > 700)[0];
expect(name.x0).toBeCloseTo(40.7, 0);
expect(name.bottom).toBeCloseTo(746.9, 0);
expect(items(short, "123 Anywhere St.")[0].bottom).toBeCloseTo(762.2, 0);
expect(items(short, "Any City, ST 12345")[0].bottom).toBeCloseTo(777.5, 0);
const phone = items(short, "123-456-7890")[0];
expect(phone.x0).toBeCloseTo(197.8, 0);
expect(phone.bottom).toBeCloseTo(746.9, 0);
// A long address takes the first column's full width and the second column moves right of it.
const long = await renderFixture({ ...PLAIN }, prefsFor("A4", { templateId: "marble" }));
expect(items(long, "9738284459")[0].x0).toBeGreaterThan(197.8 + 20);
});
it("a short invoice with the sample's data fits one A4 page by giving back the gaps and row padding", async () => {
const spec: FixtureSpec = {
name: "thumb",
items: [
{ id: 1, description: "Voice-over narration", mode: "rate", rate: 1500, unit: "hour", quantity: 4, amount: 0, sortOrder: 0, hsnSac: "998397" },
{ id: 2, description: "Audio mastering", mode: "rate", rate: 800, unit: "hour", quantity: 2, amount: 0, sortOrder: 1, hsnSac: "998397" },
],
logo: "real",
vendor: { vendorEmail: "", vendorPhone: "", vendorPan: "" },
};
const r = await renderFixture(spec, prefsFor("A4", { templateId: "marble" }));
expect(r.result.pages).toBe(1);
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
});
});
describe("the GST slots", () => {
it("every GST slot has a home on a tax invoice, and the supplier's identifiers are on page 1 whatever the length", async () => {
const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "marble" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, `${SLOT_ID.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(true);
expect(onPage(r, 0).some((n) => n.id === IDS_ID)).toBe(true);
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
for (const word of ["SUBTOTAL", "TAXABLEVALUE", "CGST9%", "SGST9%", "HSN/SAC:998397", "PLACEOFSUPPLY"]) expect(flat, word).toContain(word);
});
it("an unregistered invoice carries none of the GST slots", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "marble" }));
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(false);
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error on either size", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const name of ["id-tokens", "long-address", "long-client", "long-notes"]) {
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: "marble" }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
}
}
});
it("the foot band's text stays inside the band whatever the address, and its two columns never overlap", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "long", items: 3, vendor: { vendorAddress: Array.from({ length: 8 }, (_, i) => `Address line ${i + 1} of a very long Indian address with a locality name`).join("\n"), vendorEmail: "arun.padmanabhan.voiceover.studio.bangalore@examplestudios-india.co.in", vendorName: "Supercalifragilisticexpialidocious-Pneumonoultramicroscopic-1234567890" } }, prefsFor(size, { templateId: "marble" }));
const frame = frameForPrefs(r.prefs);
const decorText = onPage(r, 0).filter((n) => n.decor && n.type === "text" && n.box.y > frame.page.height - V.bands.bottom);
const lines = decorText.flatMap((n) => textLines(n));
expect(lines.length).toBeGreaterThan(2);
for (const l of lines) {
expect(l.y + l.h, `${size} "${l.text}" bottom`).toBeLessThanOrEqual(frame.page.height - 12);
expect(l.right, `${size} "${l.text}" right`).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 1);
}
for (let i = 0; i < lines.length; i++)
for (let j = i + 1; j < lines.length; j++) {
const a = lines[i];
const b = lines[j];
const hit = Math.min(a.right, b.right) - Math.max(a.x, b.x) > 0.5 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0.5;
expect(hit, `${size}: "${a.text}" / "${b.text}"`).toBe(false);
}
}
});
it("long terms flow as a block of their own and keep the closing block bounded", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "marble" }));
const plan = planMarble(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
expect(plan.closing.notesBeside).toBe(false);
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
expect(squash(r.pdf.pages.map((p) => p.items.map((i) => i.str).join("")).join(""))).toContain("Note18");
});
it("a long client name and the supplier columns stay in their own columns", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "marble" }));
const a = textLines(byId(r, "marble-bill"));
const b = textLines(byId(r, PAYABLE_ID));
expect(Math.max(...a.map((l) => l.right))).toBeLessThan(Math.min(...b.map((l) => l.x)));
});
});
describe("plan matches the laid-out blocks", () => {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: "marble" });
const frame = frameForPrefs(prefs);
// Logo-less (the opt-in is off), 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 = planMarble(r.fixture.model, frame, V, null, false);
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(24);
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(24);
});
}
}
});
describe("design docs", () => {
it("records its implementation notes", () => {
const text = readFileSync(path.join(process.cwd(), "docs/templates/01-marble.md"), "utf8");
expect(text).toContain("## Implementation notes");
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { ComponentType } from "react";
import { FitText } from "../../blocks/FitText";
import type { DecorItem } from "../../blocks/FirstPageHeader";
import { Txt } from "../../blocks/Txt";
import { fullBleedBand, type LinearGradientSpec } from "../../decor/FullBleedBand";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textLines } from "../../engine/textplan";
import type { PageFrame } from "../../engine/geometry";
import type { RenderModel } from "../../model/build";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition, TemplateProps } from "../contract";
import { slotFor } from "../slots.optin";
import { MarbleBody } from "./Layout";
import { BAND_FROM, BAND_TO, INK, MARBLE_ROLES, MARBLE_VARIANT, leading } from "./tokens";
export { MARBLE_ROLES, MARBLE_VARIANT } from "./tokens";
const V = MARBLE_VARIANT;
const slot = slotFor("marble");
/** The opt-in logo area (docs/templates/01-marble.md: header-left, 150 x 50, bottom edge on the title's baseline). */
const area = slot?.lockup ?? { x: 38.5, y: 86, w: 150, h: 50 };
/** A soft sheen standing in for the marble photograph (which is stock media and not bundled). */
const SHEEN: LinearGradientSpec = {
x1: 0,
y1: 0,
x2: 1,
y2: 1,
stops: [
{ offset: 0, color: BAND_FROM },
{ offset: 0.55, color: BAND_TO },
{ offset: 1, color: BAND_FROM },
],
};
/** Lines of the first footer column, each with the share of the line budget it may take (an ellipsis after that). */
function footerLines(texts: string[], width: number, budget: number): Array<{ text: string; maxLines: number; fit: boolean }> {
const out: Array<{ text: string; maxLines: number; fit: boolean }> = [];
const m = getMeasurer();
let left = budget;
for (const text of texts) {
if (left <= 0) break;
// A word wider than the column cannot wrap (textkit never breaks inside a word): that line is fitted to the width instead.
const fit = Math.max(...text.split(" ").map((w) => measureToken(m, MARBLE_ROLES.contact, w))) > width;
const take = fit ? 1 : Math.min(textLines(MARBLE_ROLES.contact, text, width), left);
out.push({ text, maxLines: take, fit });
left -= take;
}
return out;
}
/**
* The every-page art: the top band, and the foot band with the vendor's contact columns. It is decor (fixed, page space), so
* the address is capped at a few lines and the phone and e-mail are one fitted line each; the supplier's identifiers are
* printed in the flow (the "Supplier" column of the parties band).
*/
function everyPage(frame: PageFrame, model: RenderModel): DecorItem[] {
const r = MARBLE_ROLES.contact;
const f = V.footer;
const { width: pw, height: ph } = frame.page;
const bottom = V.bands.bottom;
const items: DecorItem[] = [fullBleedBand(frame, { top: 0, height: V.bands.top, fill: SHEEN }), fullBleedBand(frame, { bottom: 0, height: bottom, fill: SHEEN })];
const x1 = frame.pad.left + (f.col1 - 31.5);
const texts = [model.supplier.name, ...model.supplier.addressLines].filter((t) => t.trim() !== "");
const natural = Math.max(0, ...texts.map((t) => Math.ceil(measureToken(getMeasurer(), r, t)) + 1));
const col1W = Math.min(f.col1MaxW, Math.max(60, natural));
const y = ph - bottom + f.top;
const budget = frame.page.id === "A4" ? f.maxLines : f.maxLines - 1;
const lines = footerLines(texts, col1W, budget);
items.push({
x: x1,
y,
width: col1W,
height: lines.reduce((h, l) => h + l.maxLines * leading(r), 0),
children: (
<>
{lines.map((l, i) =>
l.fit ? (
<FitText key={i} role={r} color={INK} width={col1W}>
{l.text}
</FitText>
) : (
<Txt key={i} role={r} color={INK} width={col1W} maxLines={l.maxLines}>
{l.text}
</Txt>
),
)}
</>
),
});
const x2 = Math.max(frame.pad.left + (f.col2 - 31.5), x1 + col1W + f.colGap);
const col2W = Math.max(80, pw - frame.pad.right - x2);
const contacts = [model.supplier.phone, model.supplier.email].filter((t) => t.trim() !== "");
if (contacts.length > 0) {
items.push({
x: x2,
y,
width: col2W,
height: contacts.length * leading(r),
children: (
<>
{contacts.map((t, i) => (
<FitText key={i} role={r} color={INK} width={col2W}>
{t}
</FitText>
))}
</>
),
});
}
return items;
}
const MarbleLayout: ComponentType<TemplateProps> = (props) => <MarbleBody {...props} variant={MARBLE_VARIANT} />;
MarbleLayout.displayName = "MarbleLayout";
export const marble: TemplateDefinition = {
...catalogEntry("marble"),
version: 1,
roles: MARBLE_ROLES,
margins: V.margins,
marginFloor: V.marginFloor,
decor: { ink: INK, everyPage },
chrome: {
color: INK,
rule: null,
role: MARBLE_ROLES.footer,
leftWidth: 150,
footerName: false,
// The foot band is 130 pt; the content stops 12 pt above it, and the footer line sits inside the band, 14 pt off the page foot.
reserve: V.bands.bottom + 12 - V.margins.bottom,
bottom: 14,
// The continuation text on pages 2+ sits under the top band.
bandInset: { left: 7, right: 0 },
},
logo: {
box: { w: area.w, h: area.h },
stackBelowTextWidth: 0,
defaultOn: false,
optIn: catalogEntry("marble").logoOptIn === true,
slotWidth: () => area.w,
slot,
available: () => ({ w: area.w, h: area.h }),
},
Layout: MarbleLayout,
};
+277
View File
@@ -0,0 +1,277 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import type { RenderModel } from "../../model/build";
import type { TypeToken } from "../../fonts/roles";
import type { PlacedLogo } from "../contract";
import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
import { leading, type MarbleVariant } from "./tokens";
export const NOTES_FLOW_GAP = 14;
const FIT_AIR = 2;
/** A GSTIN is always 15 characters: the text the layout prints for the height estimate, which must not read the regulated field. */
const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
export const REVERSE_GAP = 4;
/** Space between the label of a words / notes block and its text. */
export const LABEL_GAP = 3;
/** Right gutter of a parties column: the text never reaches the next column. */
export const PARTY_GUTTER = 12;
/** The GST-free sample's due line when the invoice has no due date. */
/** Widest the rate column grows to keep the unit on the rate's own line. */
const RATE_MAX = 112;
export const ON_RECEIPT = "On Receipt";
/** Tracking after the last glyph: right-aligned tracked text is drawn this far into the margin to end on the edge. */
export const stepOf = (t: TypeToken) => (t.tracking ?? 0) * t.size;
/** "Tax" rows read "CGST 9%" like the rest of the invoice's labels. */
export const taxLabel = (label: string, rate: string | undefined) => (rate ? `${label} ${rate}` : label);
/** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */
function idLines(role: TypeToken, text: string, width: number): number {
const f = fitSize(role, text, Math.max(1, width));
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)));
}
export interface StackLine {
kind: "cancelled" | "number" | "issued" | "due";
text: string;
}
/** The right-hand stack under the title: the number on its own line (the title stays short), then the design's Issued and Due. */
export function stackLines(model: RenderModel): StackLine[] {
const out: StackLine[] = [];
if (model.cancelled) out.push({ kind: "cancelled", text: "CANCELLED" });
out.push({ kind: "number", text: `${model.meta.numberLabel} ${model.meta.number}` });
out.push({ kind: "issued", text: `Issued: ${model.meta.date}` });
out.push({ kind: "due", text: `Due: ${model.meta.dueDate ?? ON_RECEIPT}` });
return out;
}
export function signatureOptions(v: MarbleVariant): SignatoryOptions {
const r = v.roles;
return { roles: { name: r.signName, designation: r.signDesignation, fine: r.fine }, align: "right", rule: { width: v.rule, color: v.ink }, color: v.ink, imageHeight: v.closing.signatureImageH };
}
/** The table's description (+ HSN/SAC sub-line), rate, qty and amount cells. */
export function tableRows(model: RenderModel, v: MarbleVariant, rateW: number): TableCellValue[][] {
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
});
}
/** The payable-to column: the account holder, then "label: value" per bank field. */
export function payableLines(model: RenderModel): string[] {
// "A/c No." keeps an 11-digit account number on its label's line in the 160 pt column.
return model.bank ? [model.bank.accountName, ...model.bank.lines.map((l) => `${l.label === "Account No." ? "A/c No." : l.label}: ${l.value}`)] : [];
}
export interface MarblePlan {
v: MarbleVariant;
W: number;
head: {
titleSize: number;
stackW: number;
lines: StackLine[];
leftW: number;
showName: boolean;
nameH: number;
/** Where the logo sits inside the 50 pt slot box, and the name below it. */
logoTop: number;
leftH: number;
stackH: number;
h: number;
};
info: { cols: number[]; hasBank: boolean; hasIds: boolean; clientH: number; bankH: number; idsH: number; h: number };
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
padLeft: number[];
padRight: number[];
padY: number;
cw: { qty: number; rate: number; amount: number };
table: ItemsTablePlan;
guardH: number;
tail: { boxW: number; labelW: number; valueW: number; leftW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackH: number; dueText: string; dueSize: number; wordsH: number; reverseH: number; leftH: number; body: number };
closing: { notes: string[]; notesBeside: boolean; notesH: number; flowH: number; signH: number; notesW: number; bandH: number; h: number };
gaps: { headRule: number; infoTop: number; infoBottom: number; band: number; tail: number; close: number };
tailH: number;
closingH: number;
}
/**
* Everything the layout must know before rendering: the right-hand stack (the title fitted to it), the parties
* columns, the table columns, the totals stack and the heights of the tail and the closing block (their
* minPresenceAhead values), each a hair high rather than low.
*/
export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVariant, logo: PlacedLogo | null, includesName: boolean): MarblePlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const s = model.supplier;
const c = model.client;
const hd = v.head;
// --- header: the vendor (or its logo) at the left, the title and the number / issued / due lines at the right -------------
const lines = stackLines(model);
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(m, role, text)) + 1;
const natural = Math.max(widthOf(r.title, model.docTitle), ...lines.map((l) => widthOf(r.meta, l.text)));
const stackW = Math.min(Math.max(hd.minStackW, natural), Math.floor(W * hd.maxShare));
const titleFit = fitSize(r.title, model.docTitle, stackW + stepOf(r.title));
let linesH = 0;
for (const l of lines) linesH += idLines(r.meta, l.text, stackW + stepOf(r.meta)) * leading(r.meta);
const stackH = hd.titleTop + leading(r.title) + hd.titleGap + linesH;
const leftW = Math.max(hd.minNameW, W - stackW - hd.colGap - v.inset.left);
const showName = !(logo && includesName) && s.name.trim() !== "";
const nameH = showName ? textHeight(r.brand, s.name, leftW) : 0;
const logoBox = logo ? hd.logoH : 0;
const logoTop = logo ? Math.max(0, logo.slotOffset?.y ?? hd.logoH - logo.h) : 0;
const leftH = logo ? Math.max(logoBox, logoTop + logo.h) + (showName ? hd.nameGap + nameH : 0) : showName ? hd.nameTop + nameH : 0;
const headH = Math.max(stackH, leftH);
// --- parties band: BILL TO, PAYABLE TO (bank) and the supplier's identifiers, in the design's three columns -----------------
const gstinLines = slotHeights.gstinLines(model, "supplier");
const hasIds = gstinLines + (s.pan ? 1 : 0) > 0;
const payable = payableLines(model);
const hasBank = payable.length > 0;
const colW = [v.parties.colA, v.parties.colB, W - v.inset.left - v.inset.right - v.parties.colA - v.parties.colB];
const present = [true, hasBank, hasIds];
const widths = colW.filter((_, i) => present[i]);
const textW = (i: number) => Math.max(40, widths[i] - PARTY_GUTTER);
let k = 0;
const clientW = textW(k++);
const bankW = hasBank ? textW(k++) : 0;
const idsW = hasIds ? textW(k++) : 0;
const head = leading(r.heading) + v.parties.headingGap;
let clientH = head + textHeight(r.body, c.name, clientW);
for (const l of c.addressLines) clientH += textHeight(r.body, l, clientW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, clientW) * leading(r.body);
if (c.poNumber) clientH += textHeight(r.body, `PO: ${c.poNumber}`, clientW);
const bankH = hasBank ? head + payable.reduce((h, x) => h + textHeight(r.body, x, bankW), 0) : 0;
const idsH = hasIds ? head + (gstinLines ? idLines(r.body, GSTIN_SAMPLE, idsW) * leading(r.body) : 0) + (s.pan ? idLines(r.body, `PAN: ${s.pan}`, idsW) * leading(r.body) : 0) : 0;
const infoH = Math.max(clientH, bankH, idsH);
// --- table -----------------------------------------------------------------------------------------------------------
const t = v.table;
const need = (texts: string[], head: string, pad: number, headStep: number) =>
Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
// The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour".
const shortRate = model.items.map((i) => {
const cell = rateCell(i.rateText, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—";
});
const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0, 0)),
rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const headerCells = ["Description", "Qty", "Rate", "Total"];
const align: MarblePlan["align"] = ["left", "center", "right", "right"];
const padLeft = [t.descInset, 0, 0, 0];
// The tracking after a right-aligned cell's last glyph is part of its width: take it off the padding so the ink ends where the design puts it.
const bodyStep = stepOf(r.tableBody);
const padRight = [8, 0, t.ratePad - bodyStep, t.numPad - bodyStep];
const rows = tableRows(model, v, cw.rate - t.ratePad - 2);
const n = rows.length;
const planTable = (padY: number, tailH: number) =>
planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY: 0, border: 0, headBorder: 0, headerTailH: t.headGap, tailH, contentH: frame.contentHeight });
// --- totals: the stack (small caps labels, values at the right), then AMOUNT DUE; words and reverse charge at the left ----------
const tt = model.totals;
const o = v.totals;
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: tt.subtotalText }];
if (tt.discountText) stack.push({ label: "Discount", amount: `- ${tt.discountText}` });
if (tt.taxableText) stack.push({ label: "Taxable value", amount: tt.taxableText });
const slotRows = slotHeights.taxRowCount(model);
const taxTexts = slotHeights.taxRowTexts(model, taxLabel);
const labels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label)];
const amounts = [...stack.map((x) => x.amount), ...taxTexts.map((x) => x.amount)];
const labelW = Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)))) + 16;
const valueW = Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)))) + 4;
const dueText = `Amount due: ${tt.totalText}`;
const dueFit = fitSize(r.due, dueText, 10000);
const dueW = Math.ceil(dueFit.width) + 4;
const boxW = Math.max(o.boxW, labelW + valueW, dueW);
const leftW0 = Math.max(0, W - o.rightInset - boxW - o.leftGap - v.inset.left);
const stackRowsH = (stack.length + slotRows) * leading(r.totalLabel) + o.rowGap * (stack.length + slotRows);
const stackH2 = stackRowsH + o.dueGap + leading(r.due);
const wordsW = Math.min(o.wordsMax, leftW0);
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.small, labelRole: r.smallLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
const wordsBlockH = wordsH || reverseH ? wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0;
const body = Math.max(stackH2, wordsBlockH);
// --- closing: the notes (when short) beside the signature, above the foot band ---------------------------------------------
const cl = v.closing;
const notes = model.notesLines;
const notesW = Math.max(100, W - cl.signW - cl.gap - v.inset.left);
const notesH = notes.length ? leading(r.smallLabel) + LABEL_GAP + notes.reduce((h, l) => h + textHeight(r.small, l, notesW), 0) : 0;
const notesBeside = notes.length === 0 || notesH <= cl.notesBesideMax;
const flowH = notesBeside ? 0 : notesH;
const signH = signatoryHeight(model, cl.signW, signatureOptions(v));
const bandH = Math.max(notesBeside ? notesH : 0, signH);
const cancelledH = model.cancelled ? 10 + textHeight(r.small, CANCELLED_SENTENCE, W) : 0;
const closingFor = (g: { close: number }) => g.close + bandH + cancelledH;
// --- gaps: the empty band under the hairlines and the row padding give back Letter's missing height --------------------
const padYn = t.padY(n);
const ranges = { ...v.gaps, rowPad: { nominal: 2 * padYn * n, min: 2 * t.padYMin * n } };
const tailNominal = ranges.tail.nominal + body;
const closingNominal = closingFor({ close: ranges.close.nominal });
const table0 = planTable(padYn, 0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const notesBlock = notesBeside ? 0 : NOTES_FLOW_GAP + flowH;
const nominalTotal = headH + ranges.headRule.nominal + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailNominal + notesBlock + closingNominal;
const slackBefore = ranges.headRule.nominal - ranges.headRule.min + ranges.infoTop.nominal - ranges.infoTop.min + ranges.infoBottom.nominal - ranges.infoBottom.min + ranges.band.nominal - ranges.band.min + ranges.tail.nominal - ranges.tail.min + ranges.rowPad.nominal - ranges.rowPad.min;
const slack = slackBefore + ranges.close.nominal - ranges.close.min;
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 (its own gap, which cannot help page 1, stays nominal).
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
const air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
const { close: closeRange, ...beforeRanges } = ranges;
let g: Record<"headRule" | "infoTop" | "infoBottom" | "band" | "tail" | "rowPad" | "close", number>;
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), close: closeRange.nominal };
else g = elastic(ranges, frame.heightDeficit);
const padY = n ? g.rowPad / (2 * n) : padYn;
const tailH = g.tail + body;
const closingH = closingFor({ close: g.close });
const table = planTable(padY, tailH);
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailH : 0);
return {
v,
W,
head: { titleSize: titleFit.size, stackW, lines, leftW, showName, nameH, logoTop, leftH, stackH, h: headH },
info: { cols: widths, hasBank, hasIds, clientH, bankH, idsH, h: infoH },
columns,
headerCells,
rows,
align,
padLeft,
padRight,
padY,
cw,
table,
guardH,
tail: { boxW, labelW: boxW - valueW, valueW, leftW: wordsW, stack, slotRows, stackH: stackH2, dueText, dueSize: dueFit.size, wordsH, reverseH, leftH: wordsBlockH, body },
closing: { notes, notesBeside, notesH, flowH, signH, notesW, bandH, h: closingH },
gaps: { headRule: g.headRule, infoTop: g.infoTop, infoBottom: g.infoBottom, band: g.band, tail: g.tail, close: g.close },
tailH,
closingH,
};
}
+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 Canva 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;
/**
* Canva 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 Canva: 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;