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:
@@ -51,7 +51,7 @@ describe("registry and catalog", () => {
|
||||
expect(catalogEntry(t.id).name).toBe(t.name);
|
||||
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
|
||||
}
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe", "marble", "highlighter"]);
|
||||
});
|
||||
|
||||
it("both are Montserrat throughout, and their slots are the measured Canva ones", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("template catalog", () => {
|
||||
it("has one entry per registered template, in the registry's order, ids unique", () => {
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
expect(new Set(TEMPLATE_CATALOG.map((e) => e.id)).size).toBe(TEMPLATE_CATALOG.length);
|
||||
expect(TEMPLATE_CATALOG).toHaveLength(12);
|
||||
expect(TEMPLATE_CATALOG).toHaveLength(14);
|
||||
});
|
||||
|
||||
it("groups the C4 templates into the Ledger and Band families", () => {
|
||||
@@ -20,8 +20,8 @@ describe("template catalog", () => {
|
||||
expect(FAMILY_LABELS.band).toBe("Band");
|
||||
});
|
||||
|
||||
it("lists the standalone templates, Monolith and Cobalt Stripe, last", () => {
|
||||
expect(TEMPLATE_CATALOG.filter((e) => e.family === "standalone").map((e) => e.id)).toEqual(["monolith", "cobalt-stripe"]);
|
||||
it("lists the standalone templates, Monolith, Cobalt Stripe, Marble and Highlighter, last", () => {
|
||||
expect(TEMPLATE_CATALOG.filter((e) => e.family === "standalone").map((e) => e.id)).toEqual(["monolith", "cobalt-stripe", "marble", "highlighter"]);
|
||||
expect(FAMILY_LABELS.standalone).toBe("Standalone");
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
|
||||
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" },
|
||||
{ id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" },
|
||||
{ id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.png" },
|
||||
{ id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true },
|
||||
{ id: "highlighter", name: "Highlighter", family: "standalone", thumbnail: "/templates/highlighter.png", logoOptIn: true },
|
||||
];
|
||||
|
||||
/** The entry for an id; an unknown id falls back to Classic, like getTemplate. */
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("classic plan matches the laid-out blocks", () => {
|
||||
|
||||
describe("registry", () => {
|
||||
it("has Classic and falls back to it for an unknown or missing id", () => {
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe", "marble", "highlighter"]);
|
||||
expect(getTemplate("classic")).toBe(classic);
|
||||
expect(getTemplate("no-such-template")).toBe(classic);
|
||||
expect(getTemplate(undefined)).toBe(classic);
|
||||
|
||||
@@ -64,12 +64,12 @@ async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Pro
|
||||
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", () => {
|
||||
it("is registered before Marble and Highlighter, as a standalone template with its thumbnail on disk", () => {
|
||||
expect(getTemplate("cobalt-stripe")).toBe(cobaltStripe);
|
||||
expect(cobaltStripe.family).toBe("standalone");
|
||||
expect(catalogEntry("cobalt-stripe").name).toBe("Cobalt Stripe");
|
||||
expect(existsSync(path.join(process.cwd(), "public", cobaltStripe.thumbnail)), cobaltStripe.thumbnail).toBe(true);
|
||||
expect(Object.keys(TEMPLATES).at(-1)).toBe("cobalt-stripe");
|
||||
expect(Object.keys(TEMPLATES).slice(-3, -2)).toEqual(["cobalt-stripe"]);
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { beforeAll, expect, it } from "vitest";
|
||||
import { prefsFor, renderFixture } from "./harness";
|
||||
import { describeSweep, STANDALONE_SWEEP } from "./harness.sweep";
|
||||
import { TEMPLATES } from "./registry";
|
||||
|
||||
// Monolith and Cobalt Stripe: the same sweep as the other templates, in a second file so the two run side by side.
|
||||
// Monolith, Cobalt Stripe, Marble and Highlighter: the same sweep as the other templates, in a second file so the two run side by side.
|
||||
//
|
||||
// Poppins Regular is warmed with a document that has amounts first. fontkit caches glyph objects per font for the process and
|
||||
// pdfkit creates a composite's components (the period inside Poppins' colon) at embed time with no code points: Monolith, which
|
||||
// sets ':' in Poppins Regular but amounts in Medium, would leave the period unextractable for Highlighter's amounts
|
||||
// (src/pdf/quirks.test.tsx, quirk 13).
|
||||
beforeAll(async () => {
|
||||
await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
});
|
||||
|
||||
describeSweep((id) => STANDALONE_SWEEP.includes(id));
|
||||
|
||||
it("the standalone sweep names registered templates only", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { checkRendered } from "./harness.check";
|
||||
import { TEMPLATES } from "./registry";
|
||||
|
||||
/** The standalone templates sweep in a file of their own, so vitest runs the two sweeps in parallel and the suite's wall time stays put. */
|
||||
export const STANDALONE_SWEEP = ["monolith", "cobalt-stripe"];
|
||||
export const STANDALONE_SWEEP = ["monolith", "cobalt-stripe", "marble", "highlighter"];
|
||||
|
||||
/** 1..45 generated rows on A4 and Letter for the templates `pick` selects, each render through the harness checks. */
|
||||
export function describeSweep(pick: (id: string) => boolean): void {
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import type { ReactNode } from "react";
|
||||
import { colsDetailed, pinned } from "../../engine/columns";
|
||||
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
|
||||
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
|
||||
import { BottomSpacer } from "../../blocks/BottomSpacer";
|
||||
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
|
||||
import { FitText } from "../../blocks/FitText";
|
||||
import { IdText } from "../../blocks/IdText";
|
||||
import { Cell, Row } from "../../blocks/Row";
|
||||
import { SafeTxt } from "../../blocks/SafeTxt";
|
||||
import { pdfFlags } from "../../blocks/pdfProps";
|
||||
import { Slot } from "../../blocks/slots";
|
||||
import { TableGuard } from "../../blocks/TableGuard";
|
||||
import { Txt } from "../../blocks/Txt";
|
||||
import { ContactIcon } from "../../decor/CircledIcon";
|
||||
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
|
||||
import type { PlacedLogo, TemplateProps } from "../contract";
|
||||
import { includesName } from "../logoName";
|
||||
import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
|
||||
import type { CellPart, TableCellValue } from "../../blocks/ItemsTable";
|
||||
import { LABEL_GAP, NOTES_FLOW_GAP, paymentLines, planHighlighter, REVERSE_GAP, signatureOptions, taxLabel, type HighlighterPlan } from "./plan";
|
||||
import { BLACK, INDIGO, YELLOW, type HighlighterVariant } from "./tokens";
|
||||
|
||||
/** Layout-tree ids of the blocks that are Highlighter's own (the harness ids are in classic/ids.ts). */
|
||||
export const PAY_ID = "highlighter-pay";
|
||||
export const PAD_PREFIX = "highlighter-pad-";
|
||||
|
||||
/** 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. */
|
||||
const NOTES_HOLD = 70;
|
||||
|
||||
interface Ctx {
|
||||
model: TemplateProps["model"];
|
||||
plan: HighlighterPlan;
|
||||
v: HighlighterVariant;
|
||||
logo: PlacedLogo | null;
|
||||
}
|
||||
|
||||
function Spacer({ h }: { h: number }) {
|
||||
return h > 0 ? <View style={{ height: h }} /> : null;
|
||||
}
|
||||
|
||||
// --- header -----------------------------------------------------------------------------------------------------
|
||||
|
||||
function Title({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const h = plan.head;
|
||||
return (
|
||||
<View style={{ ...pinned(h.titleW), marginLeft: v.inset } as never}>
|
||||
<FitText id={TITLE_ID} role={{ ...v.roles.title, size: h.titleSize }} color={v.ink} width={h.titleW}>
|
||||
{model.docTitle}
|
||||
</FitText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The vendor at the top right: the logo (right-aligned at the slot's top), the name, GSTIN and PAN. */
|
||||
function Vendor({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v, logo } = ctx;
|
||||
const h = plan.head;
|
||||
const r = v.roles;
|
||||
const s = model.supplier;
|
||||
return (
|
||||
<View id={SUPPLIER_ID} style={pinned(h.stackW) as never}>
|
||||
<AvailableWidthProvider width={h.stackW}>
|
||||
{logo ? <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginLeft: Math.max(0, h.stackW - logo.w), objectFit: "contain" } as never} /> : null}
|
||||
{h.showName ? (
|
||||
<SafeTxt role={r.vendorName} color={v.ink} align="right">
|
||||
{s.name}
|
||||
</SafeTxt>
|
||||
) : null}
|
||||
<Slot.Gstin model={model} party="supplier" role={r.vendorId} color={v.ink} align="right" />
|
||||
{s.pan ? (
|
||||
<IdText role={r.vendorId} color={v.ink} align="right" fill>
|
||||
{`PAN: ${s.pan}`}
|
||||
</IdText>
|
||||
) : null}
|
||||
{model.cancelled ? (
|
||||
<Txt role={r.vendorName} color={CANCELLED_INK} width={h.stackW} align="right">
|
||||
CANCELLED
|
||||
</Txt>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
return (
|
||||
<Row widths={[{ fill: true }, { fixed: plan.head.stackW + v.rightInset }]} width={plan.W} style={{ minHeight: plan.head.h }}>
|
||||
<Title ctx={ctx} />
|
||||
<Vendor ctx={ctx} />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
// --- fields and payment -----------------------------------------------------------------------------------------
|
||||
|
||||
function Field({ ctx, index }: { ctx: Ctx; index: number }) {
|
||||
const { plan, v } = ctx;
|
||||
const f = v.fields;
|
||||
const spec = plan.fields.specs[index];
|
||||
const labelW = plan.fields.labelW[index];
|
||||
const valueW = f.w - labelW - f.labelGap;
|
||||
return (
|
||||
<View style={{ marginTop: index > 0 ? f.rowGap : 0 }}>
|
||||
<Row widths={[{ fixed: labelW }, { fixed: valueW }]} gap={f.labelGap} width={f.w}>
|
||||
<Txt role={v.roles.label} color={v.ink} width={labelW}>
|
||||
{spec.label}
|
||||
</Txt>
|
||||
<View style={{ ...pinned(valueW), borderBottomWidth: v.hair, borderBottomColor: INDIGO } as never}>
|
||||
<AvailableWidthProvider width={valueW - f.valuePad}>
|
||||
<View style={{ marginLeft: f.valuePad }}>
|
||||
<SafeTxt role={v.roles.value} color={v.ink}>
|
||||
{spec.value}
|
||||
</SafeTxt>
|
||||
</View>
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</Row>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Fields({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const c = model.client;
|
||||
const f = v.fields;
|
||||
return (
|
||||
<View id={META_ID} style={pinned(f.w) as never}>
|
||||
{plan.fields.specs.map((_, i) => (
|
||||
<Field key={i} ctx={ctx} index={i} />
|
||||
))}
|
||||
{plan.fields.subH > 0 ? (
|
||||
<View style={{ ...pinned(f.w), marginTop: f.subGap } as never}>
|
||||
<AvailableWidthProvider width={f.w}>
|
||||
{c.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.sub} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
<Slot.Gstin model={model} party="client" role={r.sub} color={v.ink} />
|
||||
<Slot.PlaceOfSupply model={model} role={r.sub} color={v.ink} />
|
||||
{c.poNumber ? <SafeTxt role={r.sub} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt> : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Payment({ ctx }: { ctx: Ctx }) {
|
||||
const { model, v } = ctx;
|
||||
const r = v.roles;
|
||||
const lines = paymentLines(model);
|
||||
if (lines.length === 0) return <View />;
|
||||
const w = v.pay.w;
|
||||
return (
|
||||
<View id={PAY_ID} style={{ ...pinned(w), marginTop: v.pay.top } as never}>
|
||||
<AvailableWidthProvider width={w}>
|
||||
<Txt role={r.payHead} color={v.ink} width={w}>
|
||||
Payment Method:
|
||||
</Txt>
|
||||
{lines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.payLine} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- table ------------------------------------------------------------------------------------------------------
|
||||
|
||||
const partsOf = (value: TableCellValue): CellPart[] => (Array.isArray(value) ? (value.length ? value : [""]) : [value]);
|
||||
|
||||
/** One table line inside the 3 pt frame: the frame's sides are the line's own borders, the dividers are its cells'. */
|
||||
function Line({
|
||||
ctx,
|
||||
id,
|
||||
last,
|
||||
flags,
|
||||
minH,
|
||||
children,
|
||||
}: {
|
||||
ctx: Ctx;
|
||||
id: string;
|
||||
last: boolean;
|
||||
flags: ReturnType<typeof pdfFlags>;
|
||||
minH?: number;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { plan, v } = ctx;
|
||||
return (
|
||||
<View
|
||||
id={id}
|
||||
{...flags}
|
||||
style={
|
||||
{
|
||||
...pinned(plan.W),
|
||||
borderLeftWidth: v.frame,
|
||||
borderRightWidth: v.frame,
|
||||
borderLeftColor: v.ink,
|
||||
borderRightColor: v.ink,
|
||||
borderBottomWidth: last ? v.frame : v.hair,
|
||||
borderBottomColor: last ? v.ink : INDIGO,
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<Row widths={plan.columns} width={plan.innerW} align="stretch" style={minH ? { minHeight: minH } : undefined}>
|
||||
{children}
|
||||
</Row>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function colInner(ctx: Ctx, i: number): number {
|
||||
const { plan, v } = ctx;
|
||||
const w = plan.table.widths[i];
|
||||
const last = i === plan.columns.length - 1;
|
||||
return Math.max(0, w - plan.padLeft[i] - (plan.padRight[i] - (last ? 0 : v.hair)) - (last ? 0 : v.hair));
|
||||
}
|
||||
|
||||
function BodyCell({ ctx, i, children }: { ctx: Ctx; i: number; children?: ReactNode }) {
|
||||
const { plan, v } = ctx;
|
||||
const last = i === plan.columns.length - 1;
|
||||
return (
|
||||
<Cell
|
||||
padding={{ top: plan.padY, bottom: plan.padY, left: plan.padLeft[i], right: plan.padRight[i] - (last ? 0 : v.hair) }}
|
||||
borderRight={last ? 0 : v.hair}
|
||||
style={{ flexGrow: 1, flexBasis: "auto", ...(last ? {} : { borderRightColor: INDIGO }) }}
|
||||
>
|
||||
{children}
|
||||
</Cell>
|
||||
);
|
||||
}
|
||||
|
||||
function DataRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) {
|
||||
const { plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const cells = plan.rows[index];
|
||||
return (
|
||||
<Line ctx={ctx} id={`${TABLE_ROW_PREFIX}${index}`} last={lastRow} flags={pdfFlags({ wrap: false, minPresenceAhead: lastRow ? plan.tailH + (v.frame - v.hair) : undefined })}>
|
||||
{cells.map((value, i) => (
|
||||
<BodyCell key={i} ctx={ctx} i={i}>
|
||||
{partsOf(value).map((part, li) => {
|
||||
const text = typeof part === "string" ? part : part.text;
|
||||
const role = typeof part === "string" ? r.tableBody : (part.role as typeof r.tableBody);
|
||||
return i === 0 && typeof part === "string" ? (
|
||||
<SafeTxt key={li} role={role} color={v.ink}>
|
||||
{text}
|
||||
</SafeTxt>
|
||||
) : (
|
||||
<Txt key={li} role={role} color={v.ink} width={colInner(ctx, i)} align={plan.align[i]}>
|
||||
{text}
|
||||
</Txt>
|
||||
);
|
||||
})}
|
||||
</BodyCell>
|
||||
))}
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
|
||||
function PadRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) {
|
||||
const { plan, v } = ctx;
|
||||
return (
|
||||
<Line ctx={ctx} id={`${PAD_PREFIX}${index}`} last={lastRow} flags={pdfFlags({ wrap: false, minPresenceAhead: lastRow ? plan.tailH + (v.frame - v.hair) : undefined })} minH={plan.padRowH - v.hair}>
|
||||
{plan.columns.map((_, i) => (
|
||||
<BodyCell key={i} ctx={ctx} i={i} />
|
||||
))}
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
|
||||
/** The header group, repeated on every page: the yellow bar, the gap, and the body's 3 pt top rule. */
|
||||
function HeaderGroup({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
const t = v.table;
|
||||
const r = v.roles;
|
||||
const fr = v.frame;
|
||||
return (
|
||||
<View {...pdfFlags({ fixed: true })} style={pinned(plan.W) as never}>
|
||||
<View
|
||||
id={TABLE_HEAD_ID}
|
||||
style={
|
||||
{
|
||||
...pinned(plan.W),
|
||||
backgroundColor: YELLOW,
|
||||
borderWidth: fr,
|
||||
borderColor: v.ink,
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<Row widths={plan.columns} width={plan.innerW} align="stretch">
|
||||
{plan.headerCells.map((text, i) => {
|
||||
const last = i === plan.columns.length - 1;
|
||||
return (
|
||||
<Cell
|
||||
key={i}
|
||||
padding={{ top: t.barPadTop, bottom: t.barPadTop, left: 0, right: 0 }}
|
||||
borderRight={last ? 0 : v.hair}
|
||||
style={{ flexGrow: 1, flexBasis: "auto", ...(last ? {} : { borderRightColor: INDIGO }) }}
|
||||
>
|
||||
<Txt role={r.tableHead} color={v.ink} width={Math.max(0, plan.table.widths[i] - (last ? 0 : v.hair))} align="center">
|
||||
{text}
|
||||
</Txt>
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</View>
|
||||
<Spacer h={t.bodyGap} />
|
||||
<View style={{ ...pinned(plan.W), height: fr, backgroundColor: v.ink } as never} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Table({ ctx }: { ctx: Ctx }) {
|
||||
const { plan } = ctx;
|
||||
const n = plan.rows.length;
|
||||
return (
|
||||
<AvailableWidthProvider width={plan.W}>
|
||||
<View style={pinned(plan.W) as never}>
|
||||
<HeaderGroup ctx={ctx} />
|
||||
{plan.rows.map((_, i) => (
|
||||
<DataRow key={i} ctx={ctx} index={i} lastRow={i === n - 1 && plan.padRows === 0} />
|
||||
))}
|
||||
{Array.from({ length: plan.padRows }, (_, i) => (
|
||||
<PadRow key={i} ctx={ctx} index={i} lastRow={i === plan.padRows - 1} />
|
||||
))}
|
||||
<Tail ctx={ctx} />
|
||||
</View>
|
||||
</AvailableWidthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- totals -----------------------------------------------------------------------------------------------------
|
||||
|
||||
function TotalRow({ ctx, label, amount, total }: { ctx: Ctx; label: string; amount: string; total?: boolean }) {
|
||||
const { plan, v } = ctx;
|
||||
const t = plan.tail;
|
||||
const r = v.roles;
|
||||
const o = v.totals;
|
||||
const pad = { paddingTop: o.padY, paddingBottom: o.padY };
|
||||
return (
|
||||
<View style={{ flexDirection: "row", ...pinned(t.boxW - 2 * v.frame), ...(total ? {} : { borderBottomWidth: v.hair, borderBottomColor: INDIGO }) } as never}>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
...pinned(t.labelW),
|
||||
...pad,
|
||||
paddingLeft: o.labelPad,
|
||||
borderRightWidth: v.frame,
|
||||
borderRightColor: v.ink,
|
||||
...(total ? { backgroundColor: YELLOW } : {}),
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<Txt role={r.totalLabel} color={BLACK} width={t.labelW - v.frame - o.labelPad}>
|
||||
{label}
|
||||
</Txt>
|
||||
</View>
|
||||
<View style={{ ...pinned(t.valueW), ...pad, paddingRight: o.valuePad } as never}>
|
||||
<Txt role={total ? r.totalStrong : r.totalValue} color={v.ink} width={t.valueW - o.valuePad} align="right">
|
||||
{amount}
|
||||
</Txt>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalsBox({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const t = plan.tail;
|
||||
return (
|
||||
<View style={{ ...pinned(t.boxW), borderWidth: v.frame, borderColor: v.ink } as never}>
|
||||
<AvailableWidthProvider width={t.boxW - 2 * v.frame}>
|
||||
{t.stack.map((row, i) => (
|
||||
<TotalRow 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) => <TotalRow ctx={ctx} label={row.label} amount={row.amount} />} />
|
||||
<TotalRow ctx={ctx} label="total :" amount={model.totals.totalText} total />
|
||||
</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={o.gap} />
|
||||
<Row widths={[{ fill: true }, { fixed: t.boxW }]} width={plan.W}>
|
||||
<View style={{ ...pinned(t.wordsW), marginLeft: v.inset, paddingTop: t.leftH ? o.wordsTop : 0 } as never}>
|
||||
<AvailableWidthProvider width={t.wordsW}>
|
||||
<Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={v.ink} />
|
||||
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.words} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
<TotalsBox ctx={ctx} />
|
||||
</Row>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- closing ----------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Notes in flow mode (too long for the ruled lines): a label held with the first line, every line unbreakable. */
|
||||
function NotesFlow({ 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.wordsLabel} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}>
|
||||
Notes:
|
||||
</Txt>
|
||||
<SafeTxt role={r.note} color={v.ink}>
|
||||
{lines[0]}
|
||||
</SafeTxt>
|
||||
</View>
|
||||
{lines.slice(1).map((line, i) => (
|
||||
<SafeTxt key={i} role={r.note} color={v.ink} wrap={false}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOff({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const cl = v.closing;
|
||||
const c = plan.closing;
|
||||
const w = c.leftW - v.inset;
|
||||
return (
|
||||
<View style={{ ...pinned(c.leftW), paddingLeft: v.inset, paddingTop: cl.leftTop } as never}>
|
||||
<AvailableWidthProvider width={w}>
|
||||
<Txt role={r.thanks} color={v.ink} width={w}>
|
||||
THANK YOU
|
||||
</Txt>
|
||||
<View style={{ marginTop: c.contacts.length ? cl.thanksGap : 0 }}>
|
||||
{c.contacts.map((x, i) => (
|
||||
<View key={i} style={{ marginTop: i > 0 ? cl.contactGap : 0 }}>
|
||||
<Row widths={[{ fixed: cl.iconSize }, { fill: true }]} gap={cl.iconGap} width={w}>
|
||||
<View style={{ marginTop: 0.6 }}>
|
||||
<ContactIcon name={x.icon} size={cl.iconSize} color={v.ink} />
|
||||
</View>
|
||||
{x.icon === "location" ? (
|
||||
<SafeTxt role={r.contact} color={BLACK}>
|
||||
{x.text}
|
||||
</SafeTxt>
|
||||
) : (
|
||||
// A phone number or e-mail is an identifier: one fitted line (shrunk, then split at "@ . / -"), never a wrapped one.
|
||||
<IdText role={r.contact} color={BLACK}>
|
||||
{x.text}
|
||||
</IdText>
|
||||
)}
|
||||
</Row>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The design's ruled lines: the notes written on them (a line per wrapped line), then the signature on the last one. */
|
||||
function Ruled({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const cl = v.closing;
|
||||
const c = plan.closing;
|
||||
const opts = signatureOptions(v);
|
||||
return (
|
||||
<View style={pinned(cl.ruledW) as never}>
|
||||
<AvailableWidthProvider width={cl.ruledW}>
|
||||
{Array.from({ length: c.ruledRows }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={
|
||||
{
|
||||
...pinned(cl.ruledW),
|
||||
minHeight: cl.ruledH,
|
||||
paddingTop: cl.ruledH - v.hair - (r.note.leading as number),
|
||||
borderBottomWidth: v.hair,
|
||||
borderBottomColor: INDIGO,
|
||||
} as never
|
||||
}
|
||||
>
|
||||
{!c.notesFlow && c.ruled[i] !== undefined ? (
|
||||
<FitText role={r.note} color={v.ink} width={cl.ruledW - 2}>
|
||||
{c.ruled[i]}
|
||||
</FitText>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
<Slot.Signatory model={model} width={cl.ruledW} {...opts} />
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Closing({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const cl = v.closing;
|
||||
const c = plan.closing;
|
||||
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={[{ fixed: c.leftW }, { fixed: cl.ruledW }]} gap={cl.colGap} width={plan.W - v.rightInset}>
|
||||
<SignOff ctx={ctx} />
|
||||
<Ruled ctx={ctx} />
|
||||
</Row>
|
||||
{model.cancelled ? (
|
||||
<Txt role={v.roles.note} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10, marginLeft: v.inset }}>
|
||||
{CANCELLED_SENTENCE}
|
||||
</Txt>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- the layout -------------------------------------------------------------------------------------------------
|
||||
|
||||
export function HighlighterBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: HighlighterVariant }) {
|
||||
const plan = planHighlighter(model, frame, v, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v, logo };
|
||||
const notesFlow: ReactNode = !plan.closing.notesFlow ? null : (
|
||||
<>
|
||||
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.inset }}>
|
||||
<NotesFlow ctx={ctx} width={plan.W - v.inset - v.rightInset} />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FirstPageHeader pad={frame.pad}>
|
||||
<HeaderRow ctx={ctx} />
|
||||
<Spacer h={v.fields.top} />
|
||||
<Row widths={[{ fixed: plan.fields.leftW }, { fixed: v.pay.w + v.rightInset }]} width={plan.W}>
|
||||
<View style={{ ...pinned(plan.fields.leftW), paddingLeft: v.inset } as never}>
|
||||
<Fields ctx={ctx} />
|
||||
</View>
|
||||
<Payment ctx={ctx} />
|
||||
</Row>
|
||||
</FirstPageHeader>
|
||||
|
||||
<Spacer h={plan.gaps.bar} />
|
||||
|
||||
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
|
||||
<Table ctx={ctx} />
|
||||
{notesFlow}
|
||||
<Closing ctx={ctx} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
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 { parsePdfText } from "../../testing/pdfText";
|
||||
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 { PAD_PREFIX, PAY_ID } from "./Layout";
|
||||
import { highlighter } from "./highlighter";
|
||||
import { planHighlighter } from "./plan";
|
||||
import { BLACK, HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INDIGO, INK, YELLOW } from "./tokens";
|
||||
|
||||
ensureSetup();
|
||||
|
||||
// fontkit caches glyph objects per font for the process, and pdfkit creates a composite's components (the period and comma inside
|
||||
// Poppins' colon and semicolon) at embed time with no code points (src/pdf/quirks.test.tsx, quirk 13). A first render without any
|
||||
// period or comma, such as the 3-row sample at 100.00 an hour, then makes a later wrapped client name overflow its box by 3.6 pt
|
||||
// (textkit's glyph-to-string mapping slips). The tests run in a fixed order, so warm Poppins with the harness's default invoice,
|
||||
// whose amounts have both.
|
||||
beforeAll(async () => {
|
||||
await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
});
|
||||
|
||||
const V = HIGHLIGHTER_VARIANT;
|
||||
const slot = OPTIN_SLOTS.highlighter!;
|
||||
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 contentRight = (r: R) => {
|
||||
const f = frameForPrefs(r.prefs);
|
||||
return f.page.width - f.pad.right;
|
||||
};
|
||||
const optIn = (size: "A4" | "LETTER" = "A4") => prefsFor(size, { templateId: "highlighter", logo: { show: true } });
|
||||
const hits = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) => Math.min(a.x + a.w, b.x + b.w) - 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;
|
||||
|
||||
/** The Canva sample's shape: no due date, no address, the payment block's two lines, nothing the design has no slot for. */
|
||||
const NAMES = ["Brand consultation", "Logo design", "Website design"];
|
||||
const PLAIN_ITEMS = NAMES.map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i }));
|
||||
async function canva(size: "A4" | "LETTER" = "A4"): Promise<{ r: R; items: Array<{ str: string; x0: number; x1: number; bottom: number }> }> {
|
||||
const fixture = buildFixture({ name: "canva", registered: false, logo: "none", items: PLAIN_ITEMS, invoice: { dueDate: "", notes: "", clientName: "Olivia Wilson", clientAddress: "", clientGstin: "" } });
|
||||
fixture.model.bank = { accountName: "Wealth", lines: [{ label: "Bank", value: "Wealth Banking" }, { label: "Account No.", value: "123 4567 8901" }] };
|
||||
fixture.model.client.posLabel = null;
|
||||
const prefs = prefsFor(size, { templateId: "highlighter" });
|
||||
const result = await renderInvoicePdf({ model: fixture.model, prefs });
|
||||
const pdf = await parsePdfText(result.bytes);
|
||||
return { r: { fixture, prefs, result, pdf } as R, items: pdf.pages[0].items };
|
||||
}
|
||||
const find = (items: Array<{ str: string; x0: number; x1: number; bottom: number }>, text: string) => items.find((i) => i.str.trim().startsWith(text))!;
|
||||
|
||||
/** 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("highlighter")).toBe(highlighter);
|
||||
expect(highlighter.family).toBe("standalone");
|
||||
expect(catalogEntry("highlighter").name).toBe("Highlighter");
|
||||
expect(catalogEntry("highlighter").logoOptIn).toBe(true);
|
||||
expect(existsSync(path.join(process.cwd(), "public", highlighter.thumbnail)), highlighter.thumbnail).toBe(true);
|
||||
expect(Object.keys(TEMPLATES).at(-1)).toBe("highlighter");
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
});
|
||||
|
||||
it("is Poppins 400/700 and Montserrat 400 only, in the design's four colours", () => {
|
||||
for (const [name, token] of Object.entries(HIGHLIGHTER_ROLES)) {
|
||||
expect(["Poppins", "Montserrat"], name).toContain(token.face);
|
||||
expect([400, 700], name).toContain(token.weight);
|
||||
if (token.face === "Montserrat") expect(token.weight, name).toBe(400);
|
||||
}
|
||||
const dir = path.join(process.cwd(), "src/pdf/templates/highlighter");
|
||||
const palette = [INK, INDIGO, YELLOW, BLACK].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(highlighter.decor.ink).toBe(INK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("page geometry", () => {
|
||||
it("template margins put the 3 pt frame's outer edge on the content box (479.4 pt wide on A4)", () => {
|
||||
expect(marginsFor(highlighter, "template")).toEqual({ top: 50, right: 57.8, bottom: 36, left: 58.1 });
|
||||
expect(marginsFor(highlighter, "narrow").left).toBe(36);
|
||||
expect(frameForPrefs(prefsFor("A4", { templateId: "highlighter" })).contentWidth).toBeCloseTo(479.4, 0);
|
||||
});
|
||||
|
||||
it("Letter keeps the physical margins and takes the missing height from the gaps and the empty rows", () => {
|
||||
const a4 = frameForPrefs(prefsFor("A4", { templateId: "highlighter" }));
|
||||
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "highlighter" }));
|
||||
expect(letter.pad).toEqual(a4.pad);
|
||||
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("header and fields", () => {
|
||||
it("matches the design: title baseline 98 at x 60.3, the fields at the 34 pt pitch from 132.2, the payment block at 162.9 / 185.1", async () => {
|
||||
const { items } = await canva();
|
||||
const title = find(items, "Invoice");
|
||||
expect(title.bottom).toBeCloseTo(98.0, 0);
|
||||
expect(title.x0).toBeCloseTo(60.3, 0);
|
||||
expect(find(items, "Date:").bottom).toBeCloseTo(132.2, 0);
|
||||
expect(find(items, "Invoice number:").bottom).toBeCloseTo(166.0, 0);
|
||||
expect(find(items, "Billed to:").bottom).toBeCloseTo(200.4, 0);
|
||||
expect(find(items, "Billed to:").x0).toBeCloseTo(60.3, 0);
|
||||
const pay = find(items, "Payment Method:");
|
||||
expect(pay.bottom).toBeCloseTo(162.9, 0);
|
||||
expect(pay.x0).toBeCloseTo(352.5, 0);
|
||||
expect(find(items, "Account Name").bottom).toBeCloseTo(185.1, 0);
|
||||
expect(find(items, "Account Name").bottom + 16.5).toBeCloseTo(find(items, "Bank Name").bottom, 0);
|
||||
});
|
||||
|
||||
it("each field's value sits on a 0.75 pt indigo underline 5 pt after its label, ending at x 266 (the first three underlines at y 137.4 / 170.4 / 204.8)", async () => {
|
||||
const { r } = await canva();
|
||||
const meta = byId(r, META_ID);
|
||||
const rules = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - 19.25) < 0.3 && Math.abs(n.box.x + n.box.w - 266) < 0.6 && n.box.y >= meta.box.y - 0.1);
|
||||
expect(rules.length).toBeGreaterThanOrEqual(3);
|
||||
const ys = [...new Set(rules.map((n) => Math.round((n.box.y + n.box.h) * 10) / 10))].sort((a, b) => a - b);
|
||||
// The design's pitch is 33.8 / 34.4 pt; ours is a steady 34.1.
|
||||
for (const [got, want] of [[ys[0], 137.8], [ys[1], 170.8], [ys[2], 205.2]] as const) expect(Math.abs(got - want), `underline ${got} vs ${want}`).toBeLessThan(1.3);
|
||||
});
|
||||
|
||||
it("a long invoice number, client name or reference wraps inside its underline and the payment block stays beside it", async () => {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345/REV-3/LONG-REFERENCE-NUMBER", clientName: "The Board of Trustees of the Mahatma Gandhi Memorial Institute of Education" } }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
const meta = byId(r, META_ID);
|
||||
const pay = byId(r, PAY_ID);
|
||||
expect(hits(meta.box, pay.box)).toBe(false);
|
||||
expect(meta.box.x + meta.box.w).toBeLessThanOrEqual(pay.box.x + 0.5);
|
||||
});
|
||||
|
||||
it("TAX INVOICE never reaches the vendor stack, on either size", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none", vendor: { vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt" } }, prefsFor(size, { templateId: "highlighter" }));
|
||||
const title = textLines(byId(r, TITLE_ID));
|
||||
const supplier = textLines(byId(r, SUPPLIER_ID));
|
||||
expect(Math.max(...title.map((l) => l.right)), size).toBeLessThan(Math.min(...supplier.map((l) => l.x)) - 4);
|
||||
expect(r.result.issues.filter((i) => i.severity === "error"), size).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("a cancelled invoice is marked in the vendor stack and closes with its sentence", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "cancelled")!, prefsFor("A4", { templateId: "highlighter" }));
|
||||
expect(textLines(byId(r, SUPPLIER_ID)).some((l) => l.text === "CANCELLED")).toBe(true);
|
||||
expect(r.pdf.pages[r.result.pages - 1].items.map((i) => i.str).join(" ")).toContain("This invoice has been cancelled.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logo", () => {
|
||||
it("is an opt-in slot (the design has no placeholder): off by default, 150 x 50 at the top right", () => {
|
||||
expect(SLOTS.highlighter).toBeNull();
|
||||
expect(highlighter.logo.slot).toBe(slot);
|
||||
expect(highlighter.logo.optIn).toBe(true);
|
||||
expect(highlighter.logo.defaultOn).toBe(false);
|
||||
expect(slot.lockup).toMatchObject({ x: 386, y: 50, w: 150, h: 50 });
|
||||
expect(highlighter.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: "highlighter" }));
|
||||
expect(imageBoxes(r)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("every opted-in logo fits the 150 x 50 slot, ends on the right edge at the slot's top, and the fields flow below it", 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 + img.box.w, `${size}/${logo} right edge`).toBeCloseTo(contentRight(r) - V.rightInset, 0);
|
||||
expect(img.box.y, `${size}/${logo} top`).toBeCloseTo(frameForPrefs(r.prefs).pad.top, 0);
|
||||
expect(byId(r, SUPPLIER_ID).box.y + byId(r, SUPPLIER_ID).box.h, `${size}/${logo}`).toBeLessThanOrEqual(byId(r, META_ID).box.y + 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 stack = (r: R) => textLines(byId(r, SUPPLIER_ID)).map((l) => l.text);
|
||||
expect(stack(await renderFixture({ name: "o", items: 2, logo: "real" }, optIn()))).not.toContain("Test Vendor");
|
||||
expect(stack(await renderFixture({ name: "o", items: 2, logo: "square" }, optIn()))).toContain("Test Vendor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("table", () => {
|
||||
it("the bar is #FFE535 inside a 3 pt ink frame, 38.4 pt tall, with the headings centred over the design's columns (Qty x 277.4, Price x 360.8)", async () => {
|
||||
const { r, items } = await canva();
|
||||
const bar = byId(r, TABLE_HEAD_ID);
|
||||
expect(bar.box.h).toBeCloseTo(V.table.barH, 0);
|
||||
expect(bar.box.x).toBeCloseTo(58.1, 0);
|
||||
expect(bar.box.x + bar.box.w).toBeCloseTo(537.5, 0);
|
||||
const centre = (t: string) => {
|
||||
const i = find(items, t);
|
||||
return (i.x0 + i.x1) / 2;
|
||||
};
|
||||
expect(centre("Qty")).toBeCloseTo(277.4, 0);
|
||||
expect(centre("Price")).toBeCloseTo(360.8, 0);
|
||||
const [fill, frame, divider] = await colourAt(r.result.bytes, [[200, bar.box.y + 3 + 3], [200, bar.box.y + 1.5], [245.5, bar.box.y + 5]]);
|
||||
expect(near(fill, YELLOW), `bar ${fill}`).toBe(true);
|
||||
expect(near(frame, INK, 10), `frame ${frame}`).toBe(true);
|
||||
expect(near(divider, INDIGO, 40), `divider ${divider}`).toBe(true);
|
||||
});
|
||||
|
||||
it("the body follows 4.5 pt under the bar with its own 3 pt frame; dividers are hairlines, the sides and the last row's bottom are the frame", async () => {
|
||||
const { r } = await canva();
|
||||
const bar = byId(r, TABLE_HEAD_ID).box;
|
||||
const row0 = byId(r, `${TABLE_ROW_PREFIX}0`).box;
|
||||
expect(row0.y - (bar.y + bar.h)).toBeCloseTo(V.table.bodyGap + V.frame, 0);
|
||||
expect(row0.x).toBeCloseTo(58.1, 0);
|
||||
expect(row0.w).toBeCloseTo(479.4, 0);
|
||||
const pads = onPage(r, 0).filter((n) => n.id?.startsWith(PAD_PREFIX));
|
||||
expect(pads.length).toBeGreaterThan(0);
|
||||
const last = pads[pads.length - 1].box;
|
||||
const [side, hair, bottom] = await colourAt(r.result.bytes, [[59.6, row0.y + 10], [150, row0.y + row0.h - 0.4], [150, last.y + last.h - 1.5]]);
|
||||
expect(near(side, INK, 10), `side ${side}`).toBe(true);
|
||||
expect(near(hair, INDIGO, 60), `hairline ${hair}`).toBe(true);
|
||||
expect(near(bottom, INK, 10), `bottom frame ${bottom}`).toBe(true);
|
||||
});
|
||||
|
||||
it("the form look pads to 8 empty rows on A4 and 7 on Letter while the page holds them, and to none for a long invoice", async () => {
|
||||
const a4 = await canva("A4");
|
||||
const rowsA4 = onPage(a4.r, 0).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX) || n.id?.startsWith(PAD_PREFIX)).length;
|
||||
expect(rowsA4).toBe(8);
|
||||
const letter = await canva("LETTER");
|
||||
expect(onPage(letter.r, 0).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX) || n.id?.startsWith(PAD_PREFIX)).length).toBe(7);
|
||||
const many = await renderFixture({ name: "m", items: 12 }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
expect(nodes(many.result.core.layout).filter((n) => n.id?.startsWith(PAD_PREFIX))).toHaveLength(0);
|
||||
// A GST invoice with its longer stack drops pad rows rather than spill the sign-off onto a second page when one page can hold it.
|
||||
const gst = await renderFixture({ name: "g", items: 2, logo: "none", vendor: { vendorEmail: "", vendorPhone: "" }, invoice: { clientAddress: "" } }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
expect(gst.result.pages).toBe(1);
|
||||
});
|
||||
|
||||
it("description left (9 pt in), qty centred, price and total right-aligned 9 pt inside their cells", async () => {
|
||||
const { r } = await canva();
|
||||
const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
const row = textLines(byId(r, `${TABLE_ROW_PREFIX}0`));
|
||||
const left = frameForPrefs(r.prefs).pad.left + V.frame;
|
||||
expect(row[0].x).toBeCloseTo(left + V.table.descPad, 0);
|
||||
const amount = row[row.length - 1];
|
||||
expect(amount.right).toBeCloseTo(frameForPrefs(r.prefs).pad.left + plan.W - V.frame - V.table.numPad, 0);
|
||||
});
|
||||
|
||||
it("repeats the bar on every continuation page, never leaves it alone, and closes the frame after the last row", async () => {
|
||||
const r = await renderFixture({ name: "rows", items: 40 }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
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 and closing", () => {
|
||||
it("the totals box lines up with the table's price and total columns; only the total row's label cell is yellow", async () => {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
const last = r.result.core.layout.findIndex((p) => nodes([p]).some((n) => n.id === TOTALS_ID));
|
||||
const totals = byId(r, TOTALS_ID, last);
|
||||
const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const box = onPage(r, last).filter((n) => n.type === "view" && n.box.y > totals.box.y && Math.abs(n.box.w - plan.tail.boxW) < 0.5).sort((a, b) => a.box.y - b.box.y)[0];
|
||||
expect(box.box.x + box.box.w).toBeCloseTo(frame.pad.left + plan.W, 0);
|
||||
const lines = textLines(totals);
|
||||
expect(lines.map((l) => l.text)).toEqual(expect.arrayContaining(["Subtotal :", "Taxable value :", "CGST @ 9% :", "SGST @ 9% :", "total :"]));
|
||||
const totalLabel = lines.find((l) => l.text === "total :")!;
|
||||
const subLabel = lines.find((l) => l.text === "Subtotal :")!;
|
||||
const [yellow, white] = await colourAt(r.result.bytes, [[totalLabel.x - 3, totalLabel.y + 8], [subLabel.x - 3, subLabel.y + 8]], last + 1);
|
||||
expect(near(yellow, YELLOW), `total label ${yellow}`).toBe(true);
|
||||
expect(near(white, "#FFFFFF"), `subtotal label ${white}`).toBe(true);
|
||||
expect(lines.find((l) => l.text === r.fixture.model.totals.totalText)!.right).toBeCloseTo(box.box.x + box.box.w - V.frame - V.totals.valuePad, 0);
|
||||
});
|
||||
|
||||
it("the amount in words stands at the left of the box, top-aligned with its first row, and never meets it, 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: "highlighter" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
const at = r.result.core.layout.findIndex((p) => nodes([p]).some((n) => n.id === TOTALS_ID));
|
||||
const totals = byId(r, TOTALS_ID, at);
|
||||
const words = onPage(r, at).find((n) => n.id === SLOT_ID.amountInWords)!;
|
||||
const box = onPage(r, at).filter((n) => n.type === "view" && n.box.y >= totals.box.y && n.box.h > 100).sort((a, b) => b.box.x - a.box.x)[0];
|
||||
expect(words.box.x + words.box.w).toBeLessThanOrEqual(box.box.x + 0.5);
|
||||
});
|
||||
|
||||
it("the sign-off is THANK YOU and the contact rows with icons at the left, the ruled lines at the right (212 pt, notes on them, the signature on the last)", async () => {
|
||||
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true, invoice: { notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." } }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
const last = r.result.pages - 1;
|
||||
const closing = byId(r, CLOSING_ID, last);
|
||||
const lines = textLines(closing).map((l) => l.text);
|
||||
expect(lines).toEqual(expect.arrayContaining(["THANK YOU", "9000000000", "vendor@example.com", "Payment due within 30 days."]));
|
||||
expect(onPage(r, last).filter((n) => n.type === "svg" && n.box.y >= closing.box.y)).toHaveLength(3);
|
||||
const sign = byId(r, SLOT_ID.signatory, last);
|
||||
expect(sign.box.w).toBeCloseTo(V.closing.ruledW, 0);
|
||||
expect(sign.box.x + sign.box.w).toBeCloseTo(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right - V.rightInset, 0);
|
||||
const note = textLines(closing).find((l) => l.text === "Payment due within 30 days.")!;
|
||||
expect(closing.box.x + note.x - closing.box.x).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("long terms flow as a block of their own and keep the ruled lines bounded", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "highlighter" }));
|
||||
const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
expect(plan.closing.notesFlow).toBe(true);
|
||||
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
|
||||
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("a long address wraps in the contact row and pushes nothing into the ruled lines", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-address")!, prefsFor("A4", { templateId: "highlighter" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
|
||||
const left = textLines(closing).filter((l) => l.x < 300);
|
||||
const right = textLines(closing).filter((l) => l.x >= 300);
|
||||
for (const a of left) for (const b of right) expect(hits({ x: a.x, y: a.y, w: a.right - a.x, h: a.h }, { x: b.x, y: b.y, w: b.right - b.x, h: b.h }), `${a.text} / ${b.text}`).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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: "highlighter" }));
|
||||
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 === `${SLOT_ID.gstin}-supplier`)).toBe(true);
|
||||
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
|
||||
for (const word of ["SUBTOTAL:", "TAXABLEVALUE:", "CGST@9%:", "SGST@9%:", "HSN/SAC:998397", "PLACEOFSUPPLY", "PAN:ABCDE1234F"]) 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: "highlighter" }));
|
||||
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: "highlighter" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error").map((i) => `${i.code}: ${i.message}`), `${size}/${name}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("the header's three columns never overlap, whatever the vendor and client", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor(size, { templateId: "highlighter" }));
|
||||
const boxes = [byId(r, TITLE_ID), byId(r, SUPPLIER_ID), byId(r, META_ID), byId(r, PAY_ID)];
|
||||
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) expect(hits(boxes[i].box, boxes[j].box), `${size}: ${boxes[i].id} / ${boxes[j].id}`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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: "highlighter" });
|
||||
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" } as FixtureSpec, prefs);
|
||||
const heights = (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 = planHighlighter(r.fixture.model, frame, V, null, false);
|
||||
expect(plan.tailH - heights(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.tailH - heights(TOTALS_ID)).toBeLessThanOrEqual(24);
|
||||
expect(plan.closingH - heights(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.closingH - heights(CLOSING_ID)).toBeLessThanOrEqual(24);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("design docs", () => {
|
||||
it("records its implementation notes", () => {
|
||||
const text = readFileSync(path.join(process.cwd(), "docs/templates/02-highlighter.md"), "utf8");
|
||||
expect(text).toContain("## Implementation notes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { catalogEntry } from "../catalog";
|
||||
import type { TemplateDefinition, TemplateProps } from "../contract";
|
||||
import { slotFor } from "../slots.optin";
|
||||
import { HighlighterBody } from "./Layout";
|
||||
import { HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INK } from "./tokens";
|
||||
|
||||
export { HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT } from "./tokens";
|
||||
|
||||
const slot = slotFor("highlighter");
|
||||
/** The opt-in logo area (docs/templates/02-highlighter.md: the empty top right, 150 x 50, right-aligned at the vendor name). */
|
||||
const area = slot?.lockup ?? { x: 386, y: 50, w: 150, h: 50 };
|
||||
|
||||
const HighlighterLayout: ComponentType<TemplateProps> = (props) => <HighlighterBody {...props} variant={HIGHLIGHTER_VARIANT} />;
|
||||
HighlighterLayout.displayName = "HighlighterLayout";
|
||||
|
||||
export const highlighter: TemplateDefinition = {
|
||||
...catalogEntry("highlighter"),
|
||||
version: 1,
|
||||
roles: HIGHLIGHTER_ROLES,
|
||||
margins: HIGHLIGHTER_VARIANT.margins,
|
||||
marginFloor: HIGHLIGHTER_VARIANT.marginFloor,
|
||||
decor: { ink: INK },
|
||||
chrome: {
|
||||
color: INK,
|
||||
rule: null,
|
||||
role: HIGHLIGHTER_ROLES.footer,
|
||||
leftWidth: 150,
|
||||
},
|
||||
logo: {
|
||||
box: { w: area.w, h: area.h },
|
||||
stackBelowTextWidth: 0,
|
||||
defaultOn: false,
|
||||
optIn: catalogEntry("highlighter").logoOptIn === true,
|
||||
slotWidth: () => area.w,
|
||||
slot,
|
||||
available: () => ({ w: area.w, h: area.h }),
|
||||
},
|
||||
Layout: HighlighterLayout,
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { drawnText, getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { textHeight, textLines } 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 { resolveFace, type TypeToken } from "../../fonts/roles";
|
||||
import type { PlacedLogo } from "../contract";
|
||||
import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
|
||||
import { leading, INDIGO, type HighlighterVariant } 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;
|
||||
export const LABEL_GAP = 3;
|
||||
|
||||
/** 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)));
|
||||
}
|
||||
|
||||
/** Tax rows read "CGST @ 9% :" like the design's "total :". */
|
||||
export const taxLabel = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate} :` : `${label} :`);
|
||||
|
||||
export function signatureOptions(v: HighlighterVariant): SignatoryOptions {
|
||||
const r = v.roles;
|
||||
return { roles: { name: r.signName, designation: r.signDesignation, fine: r.fine }, align: "right", rule: { width: v.hair, color: INDIGO }, color: v.ink, imageHeight: v.closing.signatureImageH };
|
||||
}
|
||||
|
||||
/** The table's description (+ HSN/SAC sub-line), qty, rate and amount cells. */
|
||||
export function tableRows(model: RenderModel, v: HighlighterVariant, 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 payment block: "Bank Name : ...", one bold line per bank field (the design's "label : value" pairs). */
|
||||
export function paymentLines(model: RenderModel): string[] {
|
||||
if (!model.bank) return [];
|
||||
const names: Record<string, string> = { Bank: "Bank Name", "Account No.": "Account No" };
|
||||
return [`Account Name : ${model.bank.accountName}`, ...model.bank.lines.map((l) => `${names[l.label] ?? l.label} : ${l.value}`)];
|
||||
}
|
||||
|
||||
export interface FieldSpec {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** The underlined fields in the design's order (Date, Invoice number), plus the Due date, then Billed to. */
|
||||
export function fieldSpecs(model: RenderModel): FieldSpec[] {
|
||||
const out: FieldSpec[] = [
|
||||
{ label: "Date:", value: model.meta.date },
|
||||
{ label: "Invoice number:", value: model.meta.number },
|
||||
];
|
||||
if (model.meta.dueDate) out.push({ label: "Due date:", value: model.meta.dueDate });
|
||||
out.push({ label: "Billed to:", value: model.client.name });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The note lines wrapped to the ruled lines' width: one string per drawn line. */
|
||||
export function ruledNoteLines(model: RenderModel, role: TypeToken, width: number): string[] {
|
||||
const m = getMeasurer();
|
||||
return model.notesLines.flatMap((n) => m.wrapLines(drawnText(role, n), width, { face: resolveFace(role.face), weight: role.weight, italic: false, size: role.size, letterSpacing: (role.tracking ?? 0) * role.size }));
|
||||
}
|
||||
|
||||
/** The contact rows of the sign-off, with the address as one wrapping line. */
|
||||
export function contactRows(model: RenderModel): Array<{ icon: "phone" | "email" | "location"; text: string }> {
|
||||
const s = model.supplier;
|
||||
const out: Array<{ icon: "phone" | "email" | "location"; text: string }> = [];
|
||||
if (s.phone) out.push({ icon: "phone", text: s.phone });
|
||||
if (s.email) out.push({ icon: "email", text: s.email });
|
||||
const address = s.addressLines.join(", ");
|
||||
if (address.trim()) out.push({ icon: "location", text: address });
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface HighlighterPlan {
|
||||
v: HighlighterVariant;
|
||||
W: number;
|
||||
head: { titleSize: number; titleW: number; stackW: number; showName: boolean; nameH: number; idsH: number; stackH: number; h: number };
|
||||
fields: { specs: FieldSpec[]; labelW: number[]; valueW: number; heights: number[]; fieldsH: number; subLines: number; subH: number; leftW: number; payLines: string[]; payH: 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 };
|
||||
innerW: number;
|
||||
table: ItemsTablePlan;
|
||||
padRows: number;
|
||||
padRowH: number;
|
||||
groupH: number;
|
||||
guardH: number;
|
||||
tail: { boxW: number; labelW: number; valueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rows: number; boxH: number; wordsW: number; wordsH: number; reverseH: number; leftH: number; body: number };
|
||||
closing: { notes: string[]; ruledRows: number; ruled: string[]; notesFlow: boolean; notesFlowH: number; leftW: number; contacts: Array<{ icon: "phone" | "email" | "location"; text: string; h: number }>; leftH: number; rightH: number; signH: number; h: number };
|
||||
gaps: { bar: number; close: number };
|
||||
tailH: number;
|
||||
closingH: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the layout must know before rendering: the title fitted to its zone, the underlined fields and the payment
|
||||
* block, the table (its columns widen only where an amount needs it; the form look pads to 8 empty rows on A4 and 7 on Letter
|
||||
* while they fit), the totals stack and the heights of the tail and the closing block, each a hair high rather than low.
|
||||
*/
|
||||
export function planHighlighter(model: RenderModel, frame: PageFrame, v: HighlighterVariant, logo: PlacedLogo | null, includesName: boolean): HighlighterPlan {
|
||||
const m = getMeasurer();
|
||||
const r = v.roles;
|
||||
const W = frame.contentWidth;
|
||||
const s = model.supplier;
|
||||
const c = model.client;
|
||||
const hd = v.head;
|
||||
const fr = v.frame;
|
||||
|
||||
// --- header: the title at the left, the vendor's logo, name and identifiers at the right --------------------------------------
|
||||
const stackW = Math.min(hd.stackW, Math.max(80, W - hd.minTitleW - hd.gap - v.inset - v.rightInset));
|
||||
const titleW = Math.max(hd.minTitleW, W - stackW - v.rightInset - hd.gap - v.inset);
|
||||
const tFit = fitSize(r.title, model.docTitle, titleW);
|
||||
const showName = !(logo && includesName) && s.name.trim() !== "";
|
||||
const nameH = showName ? textHeight(r.vendorName, s.name, stackW) : 0;
|
||||
const gstinLines = slotHeights.gstinLines(model, "supplier");
|
||||
const idsH = (gstinLines ? idLines(r.vendorId, GSTIN_SAMPLE, stackW) * leading(r.vendorId) : 0) + (s.pan ? idLines(r.vendorId, `PAN: ${s.pan}`, stackW) * leading(r.vendorId) : 0);
|
||||
const cancelH = model.cancelled ? leading(r.vendorName) : 0;
|
||||
const stackH = (logo ? logo.h : 0) + nameH + idsH + cancelH;
|
||||
const headH = Math.max(hd.zoneH, stackH);
|
||||
|
||||
// --- fields (underlined) and the payment block ---------------------------------------------------------------------------------
|
||||
const specs = fieldSpecs(model);
|
||||
const f = v.fields;
|
||||
const fieldLabelW = specs.map((sp) => Math.ceil(measureToken(m, r.label, sp.label)) + 1);
|
||||
const valueWs = fieldLabelW.map((lw) => f.w - lw - f.labelGap);
|
||||
const heights = specs.map((sp, i) => textLines(r.value, sp.value, valueWs[i] - f.valuePad) * leading(r.value) + v.hair);
|
||||
const fieldsRows = heights.reduce((a, h) => a + h, 0) + f.rowGap * (specs.length - 1);
|
||||
const leftW = W - v.pay.w - v.rightInset;
|
||||
const subW = f.w;
|
||||
let subH = 0;
|
||||
for (const l of c.addressLines) subH += textHeight(r.sub, l, subW);
|
||||
subH += slotHeights.gstinLines(model, "client") * leading(r.sub) + slotHeights.placeOfSupplyLines(model, r.sub, subW) * leading(r.sub);
|
||||
if (c.poNumber) subH += textHeight(r.sub, `PO: ${c.poNumber}`, subW);
|
||||
const fieldsH = fieldsRows + (subH > 0 ? f.subGap + subH : 0);
|
||||
const payLines = paymentLines(model);
|
||||
const payW = Math.min(v.pay.w, W);
|
||||
const payH = payLines.length ? v.pay.top + leading(r.payHead) + payLines.reduce((h, x) => h + textHeight(r.payLine, x, payW), 0) : 0;
|
||||
const blocksH = Math.max(fieldsH, payH);
|
||||
|
||||
// --- table -----------------------------------------------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const innerW = W - 2 * fr;
|
||||
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
|
||||
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + v.hair + 6;
|
||||
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)),
|
||||
rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))),
|
||||
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair),
|
||||
};
|
||||
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
|
||||
const headerCells = ["Description", "Qty", "Price", "Total"];
|
||||
const align: HighlighterPlan["align"] = ["left", "center", "right", "right"];
|
||||
// Each cell but the last ends in a 0.75 pt rule that is part of its width.
|
||||
const padLeft = [t.descPad, 0, 0, 0];
|
||||
const padRight = [8 + v.hair, v.hair, t.numPad + v.hair, t.numPad];
|
||||
const rows = tableRows(model, v, cw.rate - t.numPad - v.hair - 2);
|
||||
const n = rows.length;
|
||||
const baseLines = leading(r.tableBody);
|
||||
const padYn = (t.rowH - v.hair - baseLines) / 2;
|
||||
const planTable = (padY: number, tailH: number) =>
|
||||
planItemsTable({ columns, gap: 0, width: innerW, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY: 0, border: v.hair, headBorder: 0, tailH, contentH: frame.contentHeight });
|
||||
const groupH = t.barH + t.bodyGap + fr;
|
||||
// The last row's bottom edge is the frame (3 pt) instead of a hairline.
|
||||
const lastExtra = fr - v.hair;
|
||||
|
||||
// --- totals: a stack of two-cell rows, the Total row's label cell yellow; 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), "total :"];
|
||||
const amounts = [...stack.map((x) => x.amount), ...taxTexts.map((x) => x.amount)];
|
||||
const labelNeed = Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)))) + 2 * o.labelPad + fr;
|
||||
const valueNeed = Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)), measureToken(m, r.totalStrong, tt.totalText))) + o.valuePad + o.labelPad + 2;
|
||||
// The box lines up with the table's columns: its label cell is the price column wide, its value cell the rest (the design's
|
||||
// box starts 1.5 pt outside the price column's left rule).
|
||||
const boxW0 = fr + cw.amount + cw.rate + o.boxOutside;
|
||||
const labelW = Math.max(cw.rate, labelNeed);
|
||||
const valueW = Math.max(boxW0 - 2 * fr - cw.rate, valueNeed);
|
||||
const boxW = labelW + valueW + 2 * fr;
|
||||
const totalRows = stack.length + slotRows + 1;
|
||||
const boxH = 2 * fr + totalRows * o.rowH + (totalRows - 1) * v.hair;
|
||||
const tailLeft = Math.max(0, W - boxW);
|
||||
const wordsW = Math.max(60, tailLeft - o.wordsGap - v.inset);
|
||||
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel });
|
||||
const reverseH = slotHeights.reverseCharge(model, wordsW, r.words);
|
||||
const leftH = wordsH || reverseH ? o.wordsTop + wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0;
|
||||
const body = Math.max(boxH, leftH);
|
||||
const tailH = o.gap + body;
|
||||
|
||||
// --- closing: THANK YOU and the contact rows at the left, the ruled lines (notes, then the signature) at the right -----------
|
||||
const cl = v.closing;
|
||||
const closeLeftW = W - v.rightInset - cl.ruledW - cl.colGap;
|
||||
const textW = Math.max(60, closeLeftW - v.inset - cl.iconSize - cl.iconGap);
|
||||
const contacts = contactRows(model).map((x) => ({ ...x, h: Math.max(cl.iconSize, (x.icon === "location" ? textLines(r.contact, x.text, textW) : idLines(r.contact, x.text, textW)) * leading(r.contact)) }));
|
||||
const leftContentH = leading(r.thanks) + (contacts.length ? cl.thanksGap + contacts.reduce((h, x) => h + x.h, 0) + cl.contactGap * (contacts.length - 1) : 0);
|
||||
const leftH2 = cl.leftTop + leftContentH;
|
||||
const ruled = ruledNoteLines(model, r.note, cl.ruledW - 2);
|
||||
const notesFlow = ruled.length > cl.ruledMax;
|
||||
const ruledRows = notesFlow ? cl.ruledMin : Math.max(cl.ruledMin, ruled.length);
|
||||
const signH = signatoryHeight(model, cl.ruledW, signatureOptions(v));
|
||||
const rightH = ruledRows * cl.ruledH + signH;
|
||||
const notesFlowH = notesFlow ? leading(r.wordsLabel) + LABEL_GAP + model.notesLines.reduce((h, l) => h + textHeight(r.note, l, W - v.inset - v.rightInset), 0) : 0;
|
||||
const cancelledH = model.cancelled ? 10 + textHeight(r.note, CANCELLED_SENTENCE, W) : 0;
|
||||
const bandH = Math.max(leftH2, rightH);
|
||||
const closingFor = (g: { close: number }) => g.close + bandH + cancelledH;
|
||||
|
||||
// --- pad rows, gaps: the form look pads to N rows while the page holds them; the gaps and row padding give back Letter's height -----
|
||||
const gapBarNominal = Math.max(v.gaps.bar.nominal, f.designH - blocksH);
|
||||
const ranges0 = { bar: { nominal: gapBarNominal, min: v.gaps.bar.min }, close: v.gaps.close };
|
||||
const table0 = planTable(padYn, 0);
|
||||
const dataH = table0.rowHeights.reduce((a, x) => a + x, 0);
|
||||
const padRowH = t.rowH;
|
||||
const notesBlock = notesFlow ? NOTES_FLOW_GAP + notesFlowH : 0;
|
||||
const headTop = headH + f.top;
|
||||
const nominalNoClosing = (pad: number) => headTop + ranges0.bar.nominal + blocksH + groupH + dataH + pad * padRowH + lastExtra + tailH + notesBlock;
|
||||
let padRows = Math.max(0, t.padTo[frame.page.id] - n);
|
||||
const rowPadRange = (pad: number) => ({ nominal: 2 * padYn * (n + pad), min: 2 * t.padYMin * (n + pad) });
|
||||
// What the gaps can give back while the table and totals stay on page 1 (the sign-off's own gap does not help them).
|
||||
const slackBefore = (pad: number) => ranges0.bar.nominal - ranges0.bar.min + rowPadRange(pad).nominal - rowPadRange(pad).min;
|
||||
const closeNominal = ranges0.close.nominal;
|
||||
const closeSlack = ranges0.close.nominal - ranges0.close.min;
|
||||
const closingAt = closingFor({ close: closeNominal });
|
||||
// The pad rows are decoration, so they give way first: the most of them that still lets the whole invoice (sign-off included)
|
||||
// sit on one page; failing that, the most that lets the table and totals sit on page 1 with only the sign-off moving on.
|
||||
const wanted = padRows;
|
||||
const fitsAll = (pad: number) => nominalNoClosing(pad) + closingAt - frame.contentHeight <= slackBefore(pad) + closeSlack;
|
||||
const fitsBefore = (pad: number) => nominalNoClosing(pad) - frame.contentHeight <= slackBefore(pad);
|
||||
while (padRows > 0 && !fitsAll(padRows)) padRows--;
|
||||
if (!fitsAll(padRows)) {
|
||||
padRows = wanted;
|
||||
while (padRows > 0 && !fitsBefore(padRows)) padRows--;
|
||||
}
|
||||
const ranges = { ...ranges0, rowPad: rowPadRange(padRows) };
|
||||
const closingNominal = closingFor({ close: ranges.close.nominal });
|
||||
const nominalTotal = nominalNoClosing(padRows) + closingNominal;
|
||||
const slackAll = slackBefore(padRows) + ranges.close.nominal - ranges.close.min;
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
|
||||
const air = (over: number, slack: number) => Math.min(slack, over + FIT_AIR);
|
||||
// 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 (then its own gap stays nominal).
|
||||
const { close: closeRange, ...beforeRanges } = ranges;
|
||||
let g: Record<"bar" | "rowPad" | "close", number>;
|
||||
if (overflow > 0 && overflow <= slackAll) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slackAll)));
|
||||
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore(padRows)) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore(padRows)))), close: closeRange.nominal };
|
||||
else g = elastic(ranges, frame.heightDeficit);
|
||||
const rowCount = n + padRows;
|
||||
const padY = rowCount ? g.rowPad / (2 * rowCount) : padYn;
|
||||
const closingH = closingFor({ close: g.close });
|
||||
const tailHigh = o.gap + body;
|
||||
const table = planTable(padY, tailHigh + lastExtra);
|
||||
const guardH = groupH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailHigh : 0);
|
||||
|
||||
return {
|
||||
v,
|
||||
W,
|
||||
head: { titleSize: tFit.size, titleW, stackW, showName, nameH, idsH, stackH, h: headH },
|
||||
fields: { specs, labelW: fieldLabelW, valueW: f.w, heights, fieldsH, subLines: subH > 0 ? 1 : 0, subH, leftW, payLines, payH, h: blocksH },
|
||||
columns,
|
||||
headerCells,
|
||||
rows,
|
||||
align,
|
||||
padLeft,
|
||||
padRight,
|
||||
padY,
|
||||
cw,
|
||||
innerW,
|
||||
table,
|
||||
padRows,
|
||||
padRowH: baseLines + 2 * padY + v.hair,
|
||||
groupH,
|
||||
guardH,
|
||||
tail: { boxW, labelW, valueW, stack, slotRows, rows: totalRows, boxH, wordsW, wordsH, reverseH, leftH, body },
|
||||
closing: { notes: model.notesLines, ruledRows, ruled, notesFlow, notesFlowH, leftW: closeLeftW, contacts, leftH: leftH2, rightH, signH, h: closingH },
|
||||
gaps: { bar: g.bar, close: g.close },
|
||||
tailH: tailHigh,
|
||||
closingH,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Sides } from "../../engine/geometry";
|
||||
import { defineRoles, type TypeToken } from "../../fonts/roles";
|
||||
import type { GapRange } from "../neutral/variant";
|
||||
|
||||
const P = "Poppins" as const;
|
||||
const M = "Montserrat" as const;
|
||||
|
||||
/** Near-black ink: the title, labels, table heads, the 3 pt frames and the icons. */
|
||||
export const INK = "#1C2120";
|
||||
/** Deep indigo hairlines: the field underlines, the table grid and the ruled lines. */
|
||||
export const INDIGO = "#423869";
|
||||
/** Signal yellow: the table header bar and the total cell (the template's accent). */
|
||||
export const YELLOW = "#FFE535";
|
||||
/** The contact lines and the total label are pure black in the design. */
|
||||
export const BLACK = "#000000";
|
||||
|
||||
/**
|
||||
* Highlighter's type (docs/templates/02-highlighter.md): Poppins Bold for the title, table heads, bank lines, total
|
||||
* and THANK YOU; Poppins Regular for the field labels and the table; Montserrat Regular for the contact lines.
|
||||
* Poppins ascent 1.05, descent 0.35; Montserrat 0.968 / 0.251. A text line's baseline sits `ascent x size` under its top.
|
||||
*/
|
||||
export const HIGHLIGHTER_ROLES = defineRoles({
|
||||
/** "Invoice" / "Tax Invoice": 45.68 pt, tracked -0.062 em; the line box is the design's 64 pt tall zone. */
|
||||
title: { face: P, weight: 700, size: 45.68, leading: 64, tracking: -0.062, minSize: 22 },
|
||||
vendorName: { face: P, weight: 700, size: 13, leading: 18.5, minSize: 9 },
|
||||
vendorId: { face: P, weight: 400, size: 10, leading: 14, minSize: 7 },
|
||||
/** "Date:", "Invoice number:", "Billed to:". The line box ends where the field's underline begins. */
|
||||
label: { face: P, weight: 400, size: 13, leading: 18.5, tracking: -0.028 },
|
||||
value: { face: P, weight: 400, size: 13, leading: 18.5, minSize: 9 },
|
||||
/** The client's address lines under the Billed to field. */
|
||||
sub: { face: P, weight: 400, size: 11, leading: 15, minSize: 8 },
|
||||
payHead: { face: P, weight: 400, size: 13, leading: 23.25 },
|
||||
payLine: { face: P, weight: 700, size: 12, leading: 16.5, minSize: 8 },
|
||||
tableHead: { face: P, weight: 700, size: 13, leading: 20.3 },
|
||||
tableBody: { face: P, weight: 400, size: 11, leading: 15 },
|
||||
tableSub: { face: P, weight: 400, size: 9, leading: 12 },
|
||||
totalLabel: { face: P, weight: 700, size: 12, leading: 17.7 },
|
||||
totalValue: { face: P, weight: 400, size: 12, leading: 17.7 },
|
||||
totalStrong: { face: P, weight: 700, size: 12, leading: 17.7 },
|
||||
words: { face: P, weight: 400, size: 10.5, leading: 14 },
|
||||
wordsLabel: { face: P, weight: 700, size: 10.5, leading: 14 },
|
||||
note: { face: P, weight: 400, size: 10.5, leading: 14 },
|
||||
thanks: { face: P, weight: 700, size: 25.01, leading: 30 },
|
||||
contact: { face: M, weight: 400, size: 11.67, leading: 16, minSize: 8 },
|
||||
signName: { face: P, weight: 700, size: 10.5, leading: 14.5 },
|
||||
signDesignation: { face: P, weight: 400, size: 9.5, leading: 13 },
|
||||
fine: { face: P, weight: 400, size: 8, leading: 11 },
|
||||
footer: { face: P, weight: 400, size: 7 },
|
||||
});
|
||||
|
||||
export type HighlighterRoles = typeof HIGHLIGHTER_ROLES;
|
||||
|
||||
/**
|
||||
* Canva A4 numbers. The content box is the frame's outer edge (x 58.1 to 537.5, 479.4 wide: the design's strokes are
|
||||
* centred on x 59.6 / 536.0, so the 3 pt frames reach 1.5 pt outside them); y values are the design's distances turned into
|
||||
* flow gaps. Everything is relative to the content box, so the margin presets and Letter reuse the same tokens.
|
||||
*/
|
||||
export interface HighlighterVariant {
|
||||
roles: HighlighterRoles;
|
||||
ink: string;
|
||||
margins: Sides;
|
||||
marginFloor: Sides;
|
||||
/** Text left of the frame's outer edge, and the right-hand column's inset from the right edge. */
|
||||
inset: number;
|
||||
rightInset: number;
|
||||
/** The 3 pt frame and the 0.75 pt hairlines. */
|
||||
frame: number;
|
||||
hair: number;
|
||||
head: {
|
||||
/** The title's and the vendor stack's zone (the design's y 50 to 114). */
|
||||
zoneH: number;
|
||||
/** The vendor stack's width (x 352 to 536). */
|
||||
stackW: number;
|
||||
/** Space between the title and the stack at its narrowest. */
|
||||
gap: number;
|
||||
minTitleW: number;
|
||||
};
|
||||
fields: {
|
||||
/** Title zone bottom to the first field. */
|
||||
top: number;
|
||||
/** Width of the label + underline row (x 60.3 to 266). */
|
||||
w: number;
|
||||
/** Label to the underline's start, and one field's bottom to the next field's top. */
|
||||
labelGap: number;
|
||||
rowGap: number;
|
||||
/** The underline's text starts this far in. */
|
||||
valuePad: number;
|
||||
/** Billed-to underline to the first address line. */
|
||||
subGap: number;
|
||||
/** The design's distance from the first field's top to the table bar's top (the empty space the bar leaves). */
|
||||
designH: number;
|
||||
};
|
||||
pay: { w: number; top: number };
|
||||
table: {
|
||||
/** The bar's outer height (frame included) and the gap under it to the body's top rule. */
|
||||
barH: number;
|
||||
barPadTop: number;
|
||||
bodyGap: number;
|
||||
descPad: number;
|
||||
numPad: number;
|
||||
cols: { qty: number; rate: number; amount: number };
|
||||
/** Row height for one line, hairline included (the design's 30.65 pt pitch), and the empty rows the form look pads to. */
|
||||
rowH: number;
|
||||
padTo: { A4: number; LETTER: number };
|
||||
padYMin: number;
|
||||
};
|
||||
totals: {
|
||||
/** The gap under the table, the cell height inside the frame and how far the box's outer edge reaches left of the price column. */
|
||||
gap: number;
|
||||
rowH: number;
|
||||
padY: number;
|
||||
boxOutside: number;
|
||||
labelPad: number;
|
||||
valuePad: number;
|
||||
wordsGap: number;
|
||||
wordsTop: number;
|
||||
};
|
||||
closing: {
|
||||
ruledW: number;
|
||||
colGap: number;
|
||||
thanksGap: number;
|
||||
contactGap: number;
|
||||
iconSize: number;
|
||||
iconGap: number;
|
||||
/** The ruled lines: row height, the text's padding above its baseline, and the lines the form always draws. */
|
||||
ruledH: number;
|
||||
ruledMin: number;
|
||||
ruledMax: number;
|
||||
leftTop: number;
|
||||
signatureImageH: number;
|
||||
};
|
||||
gaps: {
|
||||
/** Fields and payment block bottom to the table bar (the design leaves 64 pt). */
|
||||
bar: GapRange;
|
||||
/** The totals to the sign-off. */
|
||||
close: GapRange;
|
||||
};
|
||||
}
|
||||
|
||||
export const HIGHLIGHTER_VARIANT: HighlighterVariant = {
|
||||
roles: HIGHLIGHTER_ROLES,
|
||||
ink: INK,
|
||||
margins: { top: 50, right: 57.8, bottom: 36, left: 58.1 },
|
||||
marginFloor: { top: 36, right: 36, bottom: 30, left: 36 },
|
||||
inset: 2.2,
|
||||
rightInset: 1.5,
|
||||
frame: 3,
|
||||
hair: 0.75,
|
||||
head: { zoneH: 64, stackW: 183.5, gap: 12, minTitleW: 120 },
|
||||
fields: { top: 4.55, w: 205.7, labelGap: 5, rowGap: 14.85, valuePad: 3, subGap: 8, designH: 149.65 },
|
||||
pay: { w: 183.5, top: 30.7 },
|
||||
table: {
|
||||
barH: 38.4,
|
||||
barPadTop: 6.05,
|
||||
bodyGap: 4.5,
|
||||
descPad: 9,
|
||||
numPad: 9,
|
||||
cols: { qty: 64, rate: 102.8, amount: 121.9 },
|
||||
rowH: 30.65,
|
||||
padTo: { A4: 8, LETTER: 7 },
|
||||
padYMin: 4.5,
|
||||
},
|
||||
totals: { gap: 4.5, rowH: 25.1, padY: 3.7, boxOutside: 1.5, labelPad: 7, valuePad: 9, wordsGap: 8, wordsTop: 3 },
|
||||
closing: { ruledW: 212.3, colGap: 24, thanksGap: 22.6, contactGap: 6.5, iconSize: 14.75, iconGap: 7.55, ruledH: 29.25, ruledMin: 3, ruledMax: 6, leftTop: 3.6, signatureImageH: 36 },
|
||||
gaps: { bar: { nominal: 18, min: 14 }, close: { nominal: 24, min: 12 } },
|
||||
};
|
||||
|
||||
export const leading = (t: TypeToken) => t.leading as number;
|
||||
@@ -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, "Sample 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: ABCDE1234F"]));
|
||||
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, "Sample 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) === "SampleP" && 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, "9000000000")[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: "Sample.longsurname.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;
|
||||
@@ -49,7 +49,7 @@ describe("registry, catalog and fonts", () => {
|
||||
expect(monolith.family).toBe("standalone");
|
||||
expect(catalogEntry("monolith").name).toBe("Monolith");
|
||||
expect(existsSync(path.join(process.cwd(), "public", monolith.thumbnail)), monolith.thumbnail).toBe(true);
|
||||
expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["monolith", "cobalt-stripe"]);
|
||||
expect(Object.keys(TEMPLATES).slice(-4, -2)).toEqual(["monolith", "cobalt-stripe"]);
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const nodes = (layout: LayoutNode[]) => {
|
||||
|
||||
describe("registry and catalog", () => {
|
||||
it("lists Classic first and Linea second, then the Neutral rest, the Ledger and the Band families", () => {
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe", "marble", "highlighter"]);
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
});
|
||||
|
||||
|
||||
@@ -62,9 +62,9 @@ describe("registry, catalog and fonts", () => {
|
||||
expect(catalogEntry(t.id).name).toBe(t.name);
|
||||
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
|
||||
}
|
||||
expect(Object.keys(TEMPLATES).slice(-4, -2)).toEqual(["purple-pop", "citrus-split"]);
|
||||
expect(Object.keys(TEMPLATES).slice(-6, -4)).toEqual(["purple-pop", "citrus-split"]);
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
expect(TEMPLATE_CATALOG.filter((e) => e.logoOptIn).map((e) => e.id)).toEqual(["linea", "purple-pop"]);
|
||||
expect(TEMPLATE_CATALOG.filter((e) => e.logoOptIn).map((e) => e.id)).toEqual(["linea", "purple-pop", "marble", "highlighter"]);
|
||||
});
|
||||
|
||||
it("Gotham tokens carry the Montserrat size factor, and the licensed faces resolve to the bundled look-alikes", () => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { tealSwoosh } from "./band/teal";
|
||||
import { citrusSplit } from "./northline/citrus";
|
||||
import { purplePop } from "./northline/purple";
|
||||
import { cobaltStripe } from "./cobalt/cobalt";
|
||||
import { highlighter } from "./highlighter/highlighter";
|
||||
import { marble } from "./marble/marble";
|
||||
import { monolith } from "./monolith/monolith";
|
||||
import { monogram } from "./neutral/monogram";
|
||||
import { serenity } from "./neutral/serenity";
|
||||
@@ -26,6 +28,8 @@ export const TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
"citrus-split": citrusSplit,
|
||||
monolith,
|
||||
"cobalt-stripe": cobaltStripe,
|
||||
marble,
|
||||
highlighter,
|
||||
};
|
||||
|
||||
export const DEFAULT_TEMPLATE_ID = "classic";
|
||||
|
||||
Reference in New Issue
Block a user