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:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user