Add the Neutral template family, template picker and text-only logo allowances

Linea (the vendor's original format.pdf design), Monogram and Serenity are
rebuilt from the Canva PDFs as one parametric family on the engine, with
the user's logo in the measured slot, a home for every GST field and
A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and
a generated Ms Madi outline for Serenity's vertical word. Text-only logo
placeholders (Monolith, Tangerine) use a doc-derived allowance box. A
Carbon TemplatePicker with committed thumbnails opens from the page
setup controls.
This commit is contained in:
2026-10-04 07:27:12 +05:30
parent 86aaa1a790
commit 04d92aa149
61 changed files with 3278 additions and 110 deletions
+32
View File
@@ -0,0 +1,32 @@
/**
* What the template picker shows about each template. Kept free of layout code so the UI can import it
* without pulling react-pdf into the main bundle; every TemplateDefinition takes its identity from here.
*/
export interface TemplateCatalogEntry {
id: string;
name: string;
family: "classic" | "neutral";
/** Static PNG from /public, written by scripts/thumbnails.mjs (sample data, not the user's invoice). */
thumbnail: string;
badge?: "Matches your original format";
/** No Canva placeholder: the logo shows only when the user turns it on. */
logoOptIn?: boolean;
}
export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
classic: "Classic",
neutral: "Neutral",
};
/** Picker order: Classic first, Linea (the vendor's original format) second. */
export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
{ id: "classic", name: "Voiced Standard", family: "classic", thumbnail: "/templates/classic.png" },
{ id: "linea", name: "Linea", family: "neutral", thumbnail: "/templates/linea.png", badge: "Matches your original format", logoOptIn: true },
{ id: "monogram", name: "Monogram", family: "neutral", thumbnail: "/templates/monogram.png" },
{ id: "serenity", name: "Serenity", family: "neutral", thumbnail: "/templates/serenity.png" },
];
/** The entry for an id; an unknown id falls back to Classic, like getTemplate. */
export function catalogEntry(id: string | undefined): TemplateCatalogEntry {
return TEMPLATE_CATALOG.find((e) => e.id === id) ?? TEMPLATE_CATALOG[0];
}
+1 -1
View File
@@ -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"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity"]);
expect(getTemplate("classic")).toBe(classic);
expect(getTemplate("no-such-template")).toBe(classic);
expect(getTemplate(undefined)).toBe(classic);
+7 -3
View File
@@ -1,3 +1,5 @@
import { TYPE_ROLES } from "../../fonts/roles";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { ClassicLayout } from "./Layout";
import { headerCols } from "./plan";
@@ -10,9 +12,10 @@ import { headerCols } from "./plan";
* the footer text sits 24 pt above it. The narrow (36) and normal (54) presets only ever widen a side.
*/
export const classic: TemplateDefinition = {
id: "classic",
name: "Voiced Standard",
family: "classic",
...catalogEntry("classic"),
roles: TYPE_ROLES,
decor: { ink: "#161616" },
chrome: { color: "#6f6f6f", rule: "#e0e0e0", leftWidth: 170 },
version: 1,
margins: { top: 40, right: 40, bottom: 24, left: 40 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
@@ -21,6 +24,7 @@ export const classic: TemplateDefinition = {
stackBelowTextWidth: 160,
defaultOn: true,
slotWidth: (frame) => headerCols(frame.contentWidth)[0],
slot: null,
},
Layout: ClassicLayout,
};
+53
View File
@@ -1,7 +1,12 @@
import type { ComponentType } from "react";
import { effectiveMargins, type MarginPreset, type PageFrame, type Sides } from "../engine/geometry";
import type { Rect } from "../engine/layoutTree";
import type { SlotLogoPlacement } from "../engine/logo";
import type { RoleName, RoleTable, TypeToken } from "../fonts/roles";
import type { DecorItem } from "../blocks/FirstPageHeader";
import type { RenderModel } from "../model/build";
import type { RenderPrefsV1 } from "../model/prefs";
import type { TemplateSlot } from "./slots.types";
/** A logo after fitting and the stack-or-side-by-side decision (engine/logo.ts). */
export interface PlacedLogo {
@@ -9,6 +14,37 @@ export interface PlacedLogo {
w: number;
h: number;
stacked: boolean;
/** Set for slot-based templates: where the Canva placeholder was and how the logo was fitted to it. */
placement?: SlotLogoPlacement;
/** Where the fitted logo sits inside the slot's target box (pt from its top-left), so a flow layout can reproduce the alignment. */
slotOffset?: { x: number; y: number };
}
/** What the page itself carries besides the flow: the paper, the ink and every-page art. */
export interface TemplateDecor {
/** Page fill (the Page backgroundColor), e.g. Serenity's paper. Omitted: white. */
background?: string;
/** Default text colour of the page. */
ink: string;
/** Fixed every-page decor in page space; must stay clear of the content column. */
everyPage?: (frame: PageFrame) => DecorItem[];
/** Page-space boxes the flow must stay out of (audited as `keepout`). */
keepOuts?: (frame: PageFrame) => Rect[];
}
/** Footer and page-label chrome of a template. */
export interface TemplateChrome {
color: string;
/** Hairline above the footer (Classic); null for none. */
rule: string | null;
/** Footer type (a render-prop role: no leading). Default: the "footer" role. */
role?: RoleName | TypeToken;
/** The footer text starts and ends this far inside the content column. */
inset?: { left: number; right: number };
/** Width the left footer text (supplier name) is fitted to. */
leftWidth: number;
/** Height reserved at the foot of every page for the footer line; default FOOTER_RESERVE (it includes Classic's rule). */
reserve?: number;
}
export interface TemplateProps {
@@ -22,6 +58,14 @@ export interface TemplateDefinition {
id: string;
name: string;
family: string;
/** Static PNG for the template picker, served from /public (written by scripts/thumbnails.mjs). */
thumbnail: string;
/** Shown on the picker tile. */
badge?: "Matches your original format";
/** The template's own type roles (Classic's are TYPE_ROLES). */
roles: RoleTable;
decor: TemplateDecor;
chrome: TemplateChrome;
/** Bump when the layout changes in a way that alters pages; it goes into the PDF Creator string. */
version: number;
/** The template's own physical margins (the "template" margin preset). */
@@ -36,6 +80,15 @@ export interface TemplateDefinition {
defaultOn: boolean;
/** Width of the column the logo shares with the supplier text, for this frame. */
slotWidth: (frame: PageFrame) => number;
/**
* The measured Canva placeholder (or opt-in area) the user's logo replaces, from slotFor(id).
* Null for Classic, which fits its logo with computeClassicLogoPlacement.
*/
slot: TemplateSlot | null;
/** The template has no placeholder: the logo is shown only when the user turns it on (prefs.logo.show). */
optIn?: boolean;
/** Room the layout can really give the logo; the slot's target box shrinks to it. */
available?: (frame: PageFrame) => { w: number; h: number };
};
Layout: ComponentType<TemplateProps>;
}
+26 -9
View File
@@ -73,14 +73,24 @@ function checkRendered(r: Rendered, label: string) {
const titleInk = textLinesUnder(title);
for (const line of textLinesUnder(supplier)) for (const t of titleInk) expect(hits(line, t), ctx("a supplier line meets the title text")).toBe(false);
// Header columns (the old fixed 339.5 / 362.5 bounds, derived from the frame so Letter is checked too):
// supplier text stays inside its column and every meta line starts at or right of the meta column.
// Classic's header columns (the old fixed 339.5 / 362.5 bounds, derived from the frame so Letter is checked too):
// supplier text stays inside its column and every meta line starts at or right of the meta column. The
// Neutral templates have no shared header row; the box checks above and the audit cover them.
const frame = frameForPrefs(r.prefs);
const [supplierW] = headerCols(frame.contentWidth);
const supplierRight = frame.pad.left + supplierW;
const metaLeft = supplierRight + HEADER_GAP;
for (const line of textLinesUnder(supplier)) expect(line.x + line.w, ctx(`supplier line [${line.x}..${line.x + line.w}] past its column edge ${supplierRight}`)).toBeLessThanOrEqual(supplierRight + 0.5);
for (const line of textLinesUnder(meta)) expect(line.x, ctx(`meta line at ${line.x} left of the meta column ${metaLeft}`)).toBeGreaterThanOrEqual(metaLeft - 0.5);
if (r.prefs.templateId === "classic") {
const [supplierW] = headerCols(frame.contentWidth);
const supplierRight = frame.pad.left + supplierW;
const metaLeft = supplierRight + HEADER_GAP;
for (const line of textLinesUnder(supplier)) expect(line.x + line.w, ctx(`supplier line [${line.x}..${line.x + line.w}] past its column edge ${supplierRight}`)).toBeLessThanOrEqual(supplierRight + 0.5);
for (const line of textLinesUnder(meta)) expect(line.x, ctx(`meta line at ${line.x} left of the meta column ${metaLeft}`)).toBeGreaterThanOrEqual(metaLeft - 0.5);
} else {
// Supplier lines stay inside the text column; the meta block never reaches left of the client column's end.
const margin = { left: frame.pad.left, right: frame.page.width - frame.pad.right };
for (const line of textLinesUnder(supplier)) {
expect(line.x, ctx("supplier line inside the page margins")).toBeGreaterThanOrEqual(margin.left - 0.5);
expect(line.x + line.w, ctx("supplier line inside the page margins")).toBeLessThanOrEqual(margin.right + 0.5);
}
}
// Header repeats on every continuation page that has rows; no page shows a header without rows.
layout.forEach((page, i) => {
@@ -110,8 +120,15 @@ function checkRendered(r: Rendered, label: string) {
expect(r.pdf.raw, ctx("no Helvetica")).not.toContain("/BaseFont /Helvetica");
expect(text, ctx("rupee extracts as U+20B9, never a superscript one")).not.toContain("¹");
expect(text).toContain("₹");
for (const t of r.pdf.pages.flatMap((p) => p.items)) {
if (/\d\.\d\d$/.test(t.str.trim())) expect(t.str, ctx(`amount "${t.str}" carries the rupee sign`)).toContain("₹");
// A face without the rupee sign (Open Sans) draws it from the fallback font, which extraction reports as
// its own item right before the digits, so the sign counts when it ends the previous item.
for (const page of r.pdf.pages) {
page.items.forEach((t, i) => {
if (/\d\.\d\d$/.test(t.str.trim())) {
const carried = t.str.includes("₹") || (i > 0 && page.items[i - 1].str.trim().endsWith("₹"));
expect(carried, ctx(`amount "${t.str}" carries the rupee sign`)).toBe(true);
}
});
}
const flat = squash(text);
expect(flat, ctx("invoice number")).toContain(squash(model.meta.number));
+504
View File
@@ -0,0 +1,504 @@
import { Image, View } from "@react-pdf/renderer";
import type { ComponentProps, ReactNode } from "react";
import { FILL, pinned } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { getMeasurer, measureToken } from "../../engine/measure";
import { AvailableWidthProvider, useAvailableWidth } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
import { 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, type TxtChild } from "../../blocks/Txt";
import type { RenderModel } from "../../model/build";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import type { PlacedLogo, TemplateProps } from "../contract";
import {
bankLines,
CANCELLED_INK,
CANCELLED_SENTENCE,
CLIENT_LABEL_GAP,
CLOSING_GAP,
INFO_GAP,
initialsOf,
LOGO_TITLE_GAP,
META_ROW_GAP,
META_VALUE_GAP,
metaRows,
planNeutral,
SERENITY_LABEL_GAP,
signatureOptions,
TITLE_RULE_GAP,
WORDS_GAP,
WORDS_TOP,
type NeutralPlan,
} from "./plan";
import type { NeutralVariant } from "./variant";
interface Ctx {
model: RenderModel;
plan: NeutralPlan;
v: NeutralVariant;
logo: PlacedLogo | null;
}
/** Text as wide as its cell, so alignment and wrapping use the real box. */
function FullTxt({ children, ...rest }: Omit<ComponentProps<typeof Txt>, "width" | "children" | "grow" | "maxLines"> & { children?: TxtChild }) {
const w = useAvailableWidth();
return (
<Txt {...rest} width={w}>
{children}
</Txt>
);
}
/** The text column: inset from the content edge on both sides, with its own available width. */
function TextColumn({ ctx, children, style }: { ctx: Ctx; children: ReactNode; style?: Record<string, unknown> }) {
const { v, plan } = ctx;
return (
<View style={{ marginLeft: v.textInset.left, ...pinned(plan.geo.T), ...style }}>
<AvailableWidthProvider width={plan.geo.T}>{children}</AvailableWidthProvider>
</View>
);
}
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
function LogoImage({ logo, style }: { logo: PlacedLogo; style?: Record<string, unknown> }) {
return <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, objectFit: "contain", ...style } as never} />;
}
// --- header --------------------------------------------------------------------------------------------
function LineaTitleRow({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const step = (plan.titleToken.tracking ?? 0) * plan.titleToken.size;
return (
<View style={{ flexDirection: "row", alignItems: "center", ...pinned(plan.geo.W) }}>
<Txt id={TITLE_ID} role={plan.titleToken} color={v.ink} width={plan.titleW}>
{model.docTitle}
</Txt>
{/* The tracking after the last glyph is part of the box, so the visible gap is TITLE_RULE_GAP. */}
<View style={{ ...FILL, borderTopWidth: v.rule, borderTopColor: v.ink, marginLeft: Math.max(0, TITLE_RULE_GAP - step - 1) }} />
</View>
);
}
function LineaHeader({ ctx }: { ctx: Ctx }) {
const { plan, logo } = ctx;
const logoBand = logo ? logo.h + LOGO_TITLE_GAP : 0;
return (
<>
<Spacer h={plan.gaps.top - logoBand} />
{logo ? <LogoImage logo={logo} style={{ marginBottom: LOGO_TITLE_GAP }} /> : null}
<LineaTitleRow ctx={ctx} />
</>
);
}
function MonogramHeader({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const r = v.roles;
const T = plan.geo.T;
// The mark cell: the slot width on the right of the text column, or room for the initials.
const slotW = logo ? Math.max(logo.w, Math.min(142.75, Math.floor(T * 0.4))) : Math.min(120, Math.floor(T * 0.4));
const initials = initialsOf(model.supplier.name);
return (
<>
<Spacer h={plan.gaps.top} />
<View style={{ flexDirection: "row", ...pinned(plan.geo.W) }}>
<View style={{ ...FILL, paddingTop: plan.titleTop }}>
<AvailableWidthProvider width={plan.geo.W - slotW - v.textInset.right}>
<FitText id={TITLE_ID} role={r.title} color={v.ink} width={plan.geo.W - slotW - v.textInset.right}>
{model.docTitle}
</FitText>
</AvailableWidthProvider>
</View>
<View style={{ ...pinned(slotW), marginRight: v.textInset.right, alignItems: "flex-start" }}>
<View style={{ height: plan.markTop }} />
{logo ? (
<LogoImage logo={logo} style={{ marginLeft: slotW - logo.w }} />
) : initials ? (
<Txt role={r.initials} color={v.ink} width={slotW} align="right">
{initials}
</Txt>
) : null}
</View>
</View>
</>
);
}
function SerenityHeader({ ctx }: { ctx: Ctx }) {
const { plan, v, logo } = ctx;
const T = plan.geo.T;
return (
<>
<Spacer h={plan.gaps.top} />
{logo ? (
<>
<Spacer h={plan.markTop} />
<LogoImage logo={logo} style={{ marginLeft: v.textInset.left + T - logo.w }} />
</>
) : null}
</>
);
}
function FromBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const s = model.supplier;
const w = Math.min(v.fromW, plan.geo.T);
return (
<View id={SUPPLIER_ID} style={{ marginLeft: plan.geo.T - w, ...pinned(w) }}>
<AvailableWidthProvider width={w}>
<SafeTxt role={r.bodyStrong} color={v.ink} align="right">
{s.name}
</SafeTxt>
{s.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.address} color={v.ink} align="right">
{line}
</SafeTxt>
))}
{s.email ? (
<IdText role={r.address} color={v.ink} align="right" fill>
{s.email}
</IdText>
) : null}
{s.phone ? (
<IdText role={r.address} color={v.ink} align="right" fill>
{s.phone}
</IdText>
) : null}
{s.pan ? (
<IdText role={r.address} color={v.ink} align="right" fill>{`PAN: ${s.pan}`}</IdText>
) : null}
<Slot.Gstin model={model} party="supplier" role={r.address} color={v.ink} align="right" />
</AvailableWidthProvider>
</View>
);
}
// --- info row ------------------------------------------------------------------------------------------
function PayBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
if (!model.bank) return <View />;
return (
<View style={pinned(width)}>
<AvailableWidthProvider width={width}>
<Txt role={r.label} color={v.ink} width={width} style={{ marginBottom: CLIENT_LABEL_GAP }}>
{v.payTo.label}
</Txt>
{bankLines(model).map((line, i) => (
<SafeTxt key={i} role={r.address} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, v } = ctx;
const r = v.roles;
const c = model.client;
const w = useAvailableWidth();
return (
<View style={pinned(w)}>
<Txt role={r.label} color={v.ink} width={w} style={{ marginBottom: CLIENT_LABEL_GAP }}>
Issued to:
</Txt>
<SafeTxt role={r.address} color={v.ink}>
{c.name}
</SafeTxt>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.address} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.address} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.address} color={v.ink} />
{v.payTo.where === "under-client" && model.bank ? (
<View style={{ marginTop: v.payGap }}>
<PayBlock ctx={ctx} width={w} />
</View>
) : null}
</View>
);
}
function CancelledTag({ ctx }: { ctx: Ctx }) {
const { v, plan } = ctx;
const textW = Math.ceil(measureToken(getMeasurer(), v.roles.label, "Cancelled")) + 2;
const boxW = textW + 12 + 1.6;
return (
<View
style={{ borderWidth: 0.8, borderColor: CANCELLED_INK, paddingVertical: 2, paddingHorizontal: 6, marginBottom: 6, marginLeft: plan.metaW - boxW, ...pinned(boxW) }}
>
<Txt role={v.roles.label} color={CANCELLED_INK} width={textW}>
Cancelled
</Txt>
</View>
);
}
function MetaBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const w = useAvailableWidth();
return (
<View id={META_ID} style={pinned(w)}>
{model.cancelled ? <CancelledTag ctx={ctx} /> : null}
{metaRows(model).map((row, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: plan.metaValueW }]} gap={META_VALUE_GAP} style={{ marginBottom: META_ROW_GAP }}>
<FullTxt role={row.strong ? r.label : { ...r.label, weight: r.body.weight }} color={v.ink} align="right">
{row.label}
</FullTxt>
<FitText role={row.strong ? r.bodyStrong : r.body} color={v.ink} width={plan.metaValueW} align="right">
{row.value}
</FitText>
</Row>
))}
</View>
);
}
function InfoRow({ ctx }: { ctx: Ctx }) {
const { plan } = ctx;
return (
<Row widths={[{ fill: true, min: 150 }, { fixed: plan.metaW }]} gap={INFO_GAP}>
<ClientBlock ctx={ctx} />
<MetaBlock ctx={ctx} />
</Row>
);
}
// --- table and totals ------------------------------------------------------------------------------------
function TotalsLine({ ctx, label, amount, role, color }: { ctx: Ctx; label: string; amount: string; role?: keyof NeutralVariant["roles"]; color?: string }) {
const { v } = ctx;
const token = v.roles[role ?? "body"];
return (
<Row widths={[{ fill: true }, { fixed: v.amountW }]} gap={v.labelGap} style={{ marginTop: v.totalsRowGap }}>
<FullTxt role={token} color={color ?? v.ink} align="right">
{label}
</FullTxt>
<FullTxt role={token} color={color ?? v.ink} align="right">
{amount}
</FullTxt>
</Row>
);
}
function TotalsBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const t = model.totals;
const innerW = plan.geo.innerW;
const taxFormat = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
const rightCol = (
<View style={pinned(v.totalsW)}>
<AvailableWidthProvider width={v.totalsW}>
{t.discountText ? <TotalsLine ctx={ctx} label="Discount" amount={`- ${t.discountText}`} /> : null}
{t.taxableText ? <TotalsLine ctx={ctx} label="Taxable value" amount={t.taxableText} /> : null}
<Slot.TaxRows
model={model}
role={r.body}
color={v.ink}
amountWidth={v.amountW}
labelGap={v.labelGap}
labelAlign="right"
format={taxFormat}
rowStyle={{ marginTop: v.totalsRowGap }}
/>
<TotalsLine ctx={ctx} label="Total" amount={t.totalText} role="total" />
</AvailableWidthProvider>
</View>
);
const words = (
<>
<Slot.AmountInWords model={model} role={r.small} labelRole={r.wordsLabel} color={v.ink} />
<Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: 4 }} />
</>
);
return (
<View id={TOTALS_ID} wrap={false}>
<View
style={{
marginTop: v.closeGap,
borderTopWidth: v.rule,
borderTopColor: v.ink,
paddingTop: v.subtotalPad,
paddingHorizontal: v.padX,
}}
>
<AvailableWidthProvider width={innerW}>
<Row widths={[{ fill: true }, { fixed: v.amountW }]} gap={v.labelGap}>
<FullTxt role={r.subtotal} color={v.ink}>
Subtotal
</FullTxt>
<FullTxt role={r.subtotal} color={v.ink} align="right">
{t.subtotalText}
</FullTxt>
</Row>
{plan.wordsBeside ? (
<Row widths={[{ fill: true }, { fixed: v.totalsW }]} gap={16}>
<View style={{ marginTop: WORDS_TOP }}>{words}</View>
{rightCol}
</Row>
) : (
<>
<Row widths={[{ fill: true }, { fixed: v.totalsW }]}>
<View />
{rightCol}
</Row>
<View style={{ marginTop: WORDS_GAP }}>{words}</View>
</>
)}
</AvailableWidthProvider>
</View>
</View>
);
}
function Notes({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
if (model.notesLines.length === 0) return null;
return (
<TextColumn ctx={ctx} style={{ marginTop: plan.gaps.notes }}>
{/* The label travels with the first line, and no paragraph splits (a cut Text leaves a zero-line node). */}
<View wrap={false}>
<Txt role={r.label} color={v.ink} style={{ marginBottom: CLIENT_LABEL_GAP }}>
Notes:
</Txt>
<SafeTxt role={r.small} color={v.ink}>
{model.notesLines[0]}
</SafeTxt>
</View>
{model.notesLines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.small} color={v.ink} wrap={false}>
{line}
</SafeTxt>
))}
</TextColumn>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const signW = v.signatureW;
const T = plan.geo.T;
const opts = signatureOptions(v);
const sign = (
<View style={pinned(signW)}>
{v.id === "serenity" ? (
<Txt role={r.label} color={v.ink} width={signW} align="right" style={{ marginBottom: 4 }}>
Thank you
</Txt>
) : null}
<Slot.Signatory model={model} width={signW} {...opts} />
</View>
);
const payInClosing = v.payTo.where === "closing";
// Top-aligned columns; the shorter one gets a margin so the two end on the same line.
const pad = (own: number, other: number) => Math.max(0, other - own);
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: CLOSING_GAP }}>
<TextColumn ctx={ctx}>
<Row widths={[{ fill: true }, { fixed: signW }]} gap={16}>
{payInClosing ? (
<View style={{ marginTop: pad(plan.payH, plan.signH), paddingLeft: v.payTo.inset }}>
<AvailableWidthProvider width={T - signW - 16 - v.payTo.inset}>
<PayBlock ctx={ctx} width={T - signW - 16 - v.payTo.inset} />
</AvailableWidthProvider>
</View>
) : (
<View />
)}
<View style={{ marginTop: payInClosing ? pad(plan.signH, plan.payH) : 0 }}>{sign}</View>
</Row>
{model.cancelled ? (
<Txt role={r.small} color={CANCELLED_INK} width={T} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</TextColumn>
</View>
</>
);
}
// --- the layout --------------------------------------------------------------------------------------------
export function NeutralLayout({ model, frame, logo, variant }: TemplateProps & { variant: NeutralVariant }) {
const plan = planNeutral(model, frame, variant, logo);
const ctx: Ctx = { model, plan, v: variant, logo };
const { v } = ctx;
const r = v.roles;
return (
<>
<FirstPageHeader pad={frame.pad}>
{v.id === "linea" ? <LineaHeader ctx={ctx} /> : v.id === "monogram" ? <MonogramHeader ctx={ctx} /> : <SerenityHeader ctx={ctx} />}
<Spacer h={v.fromGap} />
<TextColumn ctx={ctx}>
<FromBlock ctx={ctx} />
</TextColumn>
<Spacer h={plan.gaps.info} />
{v.id === "serenity" ? (
<TextColumn ctx={ctx} style={{ marginBottom: SERENITY_LABEL_GAP }}>
<Txt id={TITLE_ID} role={r.label} color={v.ink} width={plan.geo.T} align="right">
{model.docTitle}
</Txt>
</TextColumn>
) : null}
<TextColumn ctx={ctx}>
<InfoRow ctx={ctx} />
</TextColumn>
</FirstPageHeader>
<Spacer h={plan.gaps.table} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={{ marginLeft: v.ruleInset, ...pinned(plan.geo.ruleW) }}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.geo.ruleW}
header={plan.header}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={v.padX}
padY={v.padY}
border={0}
headColor={v.ink}
headerTail={
<View style={{ marginTop: v.headTail.before, borderTopWidth: v.rule, borderTopColor: v.ink, marginBottom: v.headTail.after }} />
}
tailH={plan.tailH}
tail={<TotalsBlock ctx={ctx} />}
/>
</View>
<Notes ctx={ctx} />
<Closing ctx={ctx} />
</>
);
}
+2
View File
@@ -0,0 +1,2 @@
/** Height reserved for the footer line of a Neutral page (10 pt line plus 6 pt of air; there is no footer rule). */
export const FOOTER_RESERVE = 16;
+11
View File
@@ -0,0 +1,11 @@
import type { ComponentType } from "react";
import type { TemplateProps } from "../contract";
import { NeutralLayout } from "./Layout";
import type { NeutralVariant } from "./variant";
/** The family component bound to one variant's tokens, as the TemplateDefinition's Layout. */
export function NeutralLayoutFor(variant: NeutralVariant): ComponentType<TemplateProps> {
const Layout = (props: TemplateProps) => <NeutralLayout {...props} variant={variant} />;
Layout.displayName = `NeutralLayout(${variant.id})`;
return Layout;
}
+98
View File
@@ -0,0 +1,98 @@
import { defineRoles } from "../../fonts/roles";
import { FOOTER_RESERVE } from "./constants";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NeutralLayoutFor } from "./layoutFor";
import type { NeutralRoles, NeutralVariant } from "./variant";
const INK = "#3D3B3A";
const M = "Montserrat" as const;
/**
* Linea's type: Montserrat 400/700 at 10 pt with 0.05 em tracking, a 38 pt title with 0.20 em
* (docs/templates/04-linea.md). Montserrat ascent 0.968, descent 0.251, cap height 0.700.
*/
export const LINEA_ROLES = defineRoles({
title: { face: M, weight: 400, size: 38, leading: 47, tracking: 0.2, caps: true },
label: { face: M, weight: 700, size: 10, leading: 13.5, tracking: 0.05, caps: true },
body: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
bodyStrong: { face: M, weight: 700, size: 10, leading: 13.5, tracking: 0.05 },
address: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
tableHead: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05, caps: true },
tableBody: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
subtotal: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05, caps: true },
total: { face: M, weight: 700, size: 10, leading: 13.5, tracking: 0.05, caps: true },
small: { face: M, weight: 400, size: 9, leading: 12.5, tracking: 0.05 },
wordsLabel: { face: M, weight: 700, size: 8, leading: 11, tracking: 0.1, caps: true },
fine: { face: M, weight: 400, size: 8, leading: 11, tracking: 0.05 },
initials: { face: M, weight: 400, size: 40, leading: 46 },
footer: { face: M, weight: 400, size: 7 },
} satisfies NeutralRoles);
/**
* Canva A4 numbers, converted: the rules start 10.09 pt inside a 59.55 pt margin (x 69.64, the title
* starts on the margin), text 7.8 pt further in (x 77.44). Vertical numbers are the empty bands of the
* design (title top 124.85, info row 259.5, table header 434.3, ...) turned into elastic gaps.
*/
export const LINEA_VARIANT: NeutralVariant = {
id: "linea",
roles: LINEA_ROLES,
ink: INK,
margins: { top: 56, right: 59.55, bottom: 60, left: 59.55 },
marginFloor: { top: 36, right: 36, bottom: 36, left: 36 },
ruleInset: 10.09,
padX: 7.8,
textInset: { left: 17.89, right: 17.89 },
rule: 0.75,
topGap: { nominal: 68.85, min: 33.85 },
fromGap: 6,
bandToInfo: 134.65,
infoMinGap: 14,
tableGap: { nominal: 49.5, min: 24 },
headTail: { before: 5.05, after: 9.06 },
padY: 3.98,
closeGap: 9.94,
subtotalPad: 8.32,
totalsRowGap: 1.5,
labelGap: 8,
hsnMode: "column",
cols: { hsn: 64, rate: 96, qty: 44, amount: 88, descMin: 80 },
totalsW: 236,
amountW: 92,
wordsPlacement: "beside",
notesGap: { nominal: 14, min: 8 },
fromW: 245,
payTo: { where: "under-client", label: "Pay to:", inset: 0 },
signatureW: 200,
payGap: 12,
chromeInset: 17.89,
footerLeftW: 150,
signatureImageH: 56,
};
export const linea: TemplateDefinition = {
...catalogEntry("linea"),
version: 1,
roles: LINEA_ROLES,
margins: LINEA_VARIANT.margins,
marginFloor: LINEA_VARIANT.marginFloor,
decor: { ink: INK },
chrome: {
color: INK,
rule: null,
role: LINEA_ROLES.footer,
inset: { left: LINEA_VARIANT.chromeInset, right: LINEA_VARIANT.chromeInset },
reserve: FOOTER_RESERVE,
leftWidth: LINEA_VARIANT.footerLeftW,
},
logo: {
box: { w: 150, h: 48 },
stackBelowTextWidth: 0,
defaultOn: false,
optIn: catalogEntry("linea").logoOptIn === true,
slotWidth: (frame) => frame.contentWidth,
slot: slotFor("linea"),
},
Layout: NeutralLayoutFor(LINEA_VARIANT),
};
+99
View File
@@ -0,0 +1,99 @@
import { defineRoles } from "../../fonts/roles";
import { FOOTER_RESERVE } from "./constants";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NeutralLayoutFor } from "./layoutFor";
import type { NeutralRoles, NeutralVariant } from "./variant";
const INK = "#3D3B3A";
const F = "Open Sans" as const;
/**
* Monogram's type: Open Sans, bold tracked caps for titles and labels (0.10 em), Medium for address
* lines. Data text is tracked 0.05 em, not the design's 0.10 em: wider tracking makes PDF text extraction
* split words (docs/templates/05-monogram.md; layout rule 7).
* Open Sans ascent 1.069, descent 0.293, cap height 0.714.
*/
export const MONOGRAM_ROLES = defineRoles({
title: { face: F, weight: 700, size: 38, leading: 52, tracking: 0.1, caps: true },
label: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.1, caps: true },
body: { face: F, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
bodyStrong: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.05 },
address: { face: F, weight: 500, size: 10, leading: 13.5, tracking: 0.05 },
tableHead: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.1, caps: true },
tableBody: { face: F, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
subtotal: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.05, caps: true },
total: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.05, caps: true },
small: { face: F, weight: 400, size: 9, leading: 12.5, tracking: 0.05 },
wordsLabel: { face: F, weight: 700, size: 8, leading: 11, tracking: 0.1, caps: true },
fine: { face: F, weight: 400, size: 8, leading: 11, tracking: 0.05 },
initials: { face: F, weight: 700, size: 46, leading: 52, tracking: 0.02, caps: true },
footer: { face: F, weight: 400, size: 7 },
} satisfies NeutralRoles);
/**
* Canva A4 numbers: the rules run from the margin (x 69.64) and the title sits flush with them; text
* is 7.8 pt inside. The logo box starts 23.25 pt below the 58 pt top margin (y 81.25).
*/
export const MONOGRAM_VARIANT: NeutralVariant = {
id: "monogram",
roles: MONOGRAM_ROLES,
ink: INK,
margins: { top: 58, right: 69.64, bottom: 60, left: 69.64 },
marginFloor: { top: 36, right: 36, bottom: 36, left: 36 },
ruleInset: 0,
padX: 7.8,
textInset: { left: 7.8, right: 7.8 },
rule: 0.75,
topGap: { nominal: 23.25, min: 8 },
fromGap: 7,
bandToInfo: 233.48,
infoMinGap: 14,
tableGap: { nominal: 47.5, min: 24 },
headTail: { before: 3.57, after: 7.06 },
padY: 5.72,
closeGap: 7.98,
subtotalPad: 8.06,
totalsRowGap: 5.1,
labelGap: 8,
hsnMode: "column",
cols: { hsn: 72, rate: 96, qty: 44, amount: 88, descMin: 80 },
totalsW: 236,
amountW: 92,
wordsPlacement: "beside",
notesGap: { nominal: 14, min: 8 },
fromW: 240,
payTo: { where: "closing", label: "Payment info:", inset: 0 },
signatureW: 170,
payGap: 0,
chromeInset: 7.8,
footerLeftW: 150,
signatureImageH: 60,
};
export const monogram: TemplateDefinition = {
...catalogEntry("monogram"),
version: 1,
roles: MONOGRAM_ROLES,
margins: MONOGRAM_VARIANT.margins,
marginFloor: MONOGRAM_VARIANT.marginFloor,
decor: { ink: INK },
chrome: {
color: INK,
rule: null,
role: MONOGRAM_ROLES.footer,
inset: { left: MONOGRAM_VARIANT.chromeInset, right: MONOGRAM_VARIANT.chromeInset },
reserve: FOOTER_RESERVE,
leftWidth: MONOGRAM_VARIANT.footerLeftW,
},
logo: {
box: { w: 142.75, h: 116.5 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: (frame) => Math.min(142.75, Math.floor(frame.contentWidth * 0.4)),
slot: slotFor("monogram"),
available: (frame) => ({ w: Math.min(142.75, Math.floor(frame.contentWidth * 0.4)), h: 116.5 }),
},
Layout: NeutralLayoutFor(MONOGRAM_VARIANT),
};
+236
View File
@@ -0,0 +1,236 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { PAGE_SIZES } from "../../engine/geometry";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { frameForPrefs } from "../../render/invoice";
import { marginsFor } from "../contract";
import { TEMPLATE_CATALOG, catalogEntry } from "../catalog";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture } from "../harness";
import { DEFAULT_TEMPLATE_ID, TEMPLATES, getTemplate } from "../registry";
import { classic } from "../classic";
import { elastic, geometry, initialsOf, planNeutral, rateCell } from "./plan";
import { linea, LINEA_VARIANT } from "./linea";
import { monogram, MONOGRAM_VARIANT } from "./monogram";
import { serenity, SERENITY_VARIANT } from "./serenity";
import { getMeasurer } from "../../engine/measure";
ensureSetup();
const NEUTRAL = [linea, monogram, serenity];
const VARIANTS = { linea: LINEA_VARIANT, monogram: MONOGRAM_VARIANT, serenity: SERENITY_VARIANT } as const;
const nodes = (layout: LayoutNode[]) => {
const out: LayoutNode[] = [];
walk(layout, (n) => out.push(n));
return out;
};
describe("registry and catalog", () => {
it("lists Classic first and Linea second, then Monogram and Serenity", () => {
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("falls back to Classic for an unknown, missing or prototype-key id", () => {
for (const id of ["no-such-template", undefined, "", "constructor", "__proto__", "toString"]) expect(getTemplate(id), String(id)).toBe(classic);
expect(getTemplate("serenity")).toBe(serenity);
expect(DEFAULT_TEMPLATE_ID).toBe("classic");
expect(catalogEntry("nope").id).toBe("classic");
});
it("the catalog and the definitions agree, and every thumbnail is on disk", () => {
for (const entry of TEMPLATE_CATALOG) {
const def = TEMPLATES[entry.id];
expect(def.name).toBe(entry.name);
expect(def.family).toBe(entry.family);
expect(def.thumbnail).toBe(entry.thumbnail);
expect(def.badge).toBe(entry.badge);
expect(existsSync(path.join(process.cwd(), "public", entry.thumbnail)), entry.thumbnail).toBe(true);
}
});
it("only Linea carries the badge and the opt-in logo", () => {
expect(TEMPLATE_CATALOG.filter((e) => e.badge).map((e) => e.id)).toEqual(["linea"]);
expect(linea.badge).toBe("Matches your original format");
expect(linea.logo.optIn).toBe(true);
expect(linea.logo.defaultOn).toBe(false);
expect(monogram.logo.defaultOn && serenity.logo.defaultOn).toBe(true);
expect(monogram.logo.slot?.mark).toBeTruthy();
expect(serenity.logo.slot?.mark).toBeTruthy();
});
it("every Neutral template declares its own role table in a bundled face", () => {
expect(linea.roles.body.face).toBe("Montserrat");
expect(monogram.roles.body.face).toBe("Open Sans");
expect(serenity.roles.body.face).toBe("Open Sans");
expect(serenity.roles).not.toBe(monogram.roles);
for (const t of NEUTRAL) for (const [name, token] of Object.entries(t.roles)) expect(token.size, `${t.id}.${name}`).toBeGreaterThan(0);
});
});
describe("page geometry", () => {
it("template margins and floors", () => {
expect(marginsFor(linea, "template")).toEqual({ top: 56, right: 59.55, bottom: 60, left: 59.55 });
expect(marginsFor(monogram, "template")).toEqual({ top: 58, right: 69.64, bottom: 60, left: 69.64 });
expect(marginsFor(serenity, "template")).toEqual({ top: 56, right: 64.1, bottom: 60, left: 162.91 });
// Serenity's left margin never goes under the decorative band, whatever the preset.
expect(marginsFor(serenity, "narrow").left).toBe(162.91);
expect(marginsFor(serenity, "normal").left).toBe(162.91);
// Narrow and Normal are absolute presets, only lifted to the floors (36 pt here).
expect(marginsFor(linea, "narrow")).toEqual({ top: 36, right: 36, bottom: 36, left: 36 });
expect(marginsFor(monogram, "normal")).toEqual({ top: 54, right: 54, bottom: 54, left: 54 });
});
it("the rules sit where the Canva ones do on A4 (x 69.64-525.86 for Linea and Monogram, 162.91-531.39 for Serenity)", () => {
for (const [t, left, right] of [
[linea, 69.64, 525.86],
[monogram, 69.64, 525.86],
[serenity, 162.91, 531.39],
] as const) {
const frame = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const v = VARIANTS[t.id as keyof typeof VARIANTS];
const g = geometry(v, frame);
const x0 = frame.pad.left + v.ruleInset;
// Canva's page is 0.22 pt wider than A4, so the right end is allowed that much.
expect(Math.abs(x0 - left), t.id).toBeLessThanOrEqual(0.3);
expect(Math.abs(x0 + g.ruleW - right), t.id).toBeLessThanOrEqual(0.3);
expect(frame.pad.bottom - frame.footerReserve).toBe(60);
}
});
it("Letter keeps the physical margins and gives the extra width to the content", () => {
for (const t of NEUTRAL) {
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.contentWidth - a4.contentWidth).toBeCloseTo(PAGE_SIZES.LETTER.width - PAGE_SIZES.A4.width, 2);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
}
});
it("elastic gaps give back exactly the deficit and never pass their minimum", () => {
const ranges = { a: { nominal: 70, min: 34 }, b: { nominal: 40, min: 20 }, c: { nominal: 50, min: 24 } };
expect(elastic(ranges, 0)).toEqual({ a: 70, b: 40, c: 50 });
const e = elastic(ranges, 49.89);
const given = 160 - (e.a + e.b + e.c);
expect(given).toBeCloseTo(49.89, 1);
expect(e.a).toBeGreaterThanOrEqual(34);
expect(elastic(ranges, 1000)).toEqual({ a: 34, b: 20, c: 24 });
});
});
describe("helpers", () => {
it("initialsOf takes the first and last word", () => {
expect(initialsOf("Test Vendor")).toBe("AP");
expect(initialsOf("Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt")).toBe("AP");
expect(initialsOf("Bongbetic")).toBe("B");
expect(initialsOf(" ")).toBe("");
expect(initialsOf("& Co")).toBe("C");
});
it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => {
const body = linea.roles.tableBody;
expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr");
expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]);
expect(rateCell(null, 90, body)).toBe("—");
expect(getMeasurer()).toBeTruthy();
});
});
describe("what the layout does with a logo", () => {
const withLogo = { name: "logo", items: 2, logo: "real" as const };
const imageCount = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout[0].nodes).filter((n) => n.type === "image" && !n.decor).length;
it("Monogram and Serenity draw the logo inside the slot; with no logo nothing, or the initials", async () => {
for (const t of [monogram, serenity]) {
const r = await renderFixture(withLogo, prefsFor("A4", { templateId: t.id }));
expect(imageCount(r), t.id).toBe(1);
const img = nodes(r.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.x + img.box.w, t.id).toBeLessThanOrEqual(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right + 0.5);
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(imageCount(none), t.id).toBe(0);
const text = none.pdf.pages[0].items.map((i) => i.str).join(" ");
if (t.id === "monogram") expect(text).toContain("AP");
// No Canva ring or stamp is ever drawn.
expect(nodes(none.result.core.layout[0].nodes).filter((n) => n.type === "svg" && !n.decor), t.id).toHaveLength(0);
}
});
it("Linea is logo-free until the user turns the logo on", async () => {
const off = await renderFixture(withLogo, prefsFor("A4", { templateId: "linea" }));
expect(imageCount(off)).toBe(0);
const on = await renderFixture(withLogo, prefsFor("A4", { templateId: "linea", logo: { show: true } }));
expect(imageCount(on)).toBe(1);
const img = nodes(on.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.h).toBeLessThanOrEqual(48 + 0.01);
expect(img.box.w).toBeLessThanOrEqual(150 + 0.01);
expect(img.box.x).toBeCloseTo(59.55, 1);
});
it("a logo wide as a wordmark stays inside its box in Monogram, a tall one too", async () => {
for (const logo of ["wide", "square"] as const) {
const r = await renderFixture({ ...withLogo, logo }, prefsFor("A4", { templateId: "monogram" }));
const img = nodes(r.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.w).toBeLessThanOrEqual(142.75 + 0.01);
expect(img.box.h).toBeLessThanOrEqual(116.5 + 0.01);
}
});
});
describe("the fonts the templates ask for are the ones embedded", () => {
it("Linea embeds Montserrat, Monogram and Serenity embed Open Sans, and none falls back to Helvetica", async () => {
for (const [t, family] of [
[linea, "Montserrat"],
[monogram, "OpenSans"],
[serenity, "OpenSans"],
] as const) {
const r = await renderFixture({ name: "fonts", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(r.pdf.raw, t.id).toContain(family);
expect(r.pdf.raw, t.id).not.toContain("/BaseFont /Helvetica");
}
});
});
describe("plan matches the laid-out blocks", () => {
// The totals tail and the closing block carry minPresenceAhead values predicted from the measurer;
// too low and a block can start a page without its spacer.
for (const t of NEUTRAL) {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${t.id}/${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: t.id });
const { model } = buildFixture(spec);
const frame = frameForPrefs(prefs);
const r = await renderFixture(spec, prefs);
const logo = null;
void logo;
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];
};
// The same plan the layout used: logo-less here, so it only differs in the header (not asserted).
const plan = planNeutral(model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null);
const tailGap = plan.tailH - find(TOTALS_ID);
expect(tailGap, "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
// Wrap is predicted greedily and can only err by whole lines, on the safe side.
expect(tailGap).toBeLessThanOrEqual(15);
const closingGap = plan.closingH - find(CLOSING_ID);
expect(closingGap, "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(closingGap).toBeLessThanOrEqual(15);
});
}
}
}
});
describe("design docs", () => {
it("each Neutral doc records its implementation notes", () => {
for (const f of ["04-linea.md", "05-monogram.md", "06-serenity.md"]) {
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
expect(text, f).toContain("## Implementation notes");
}
});
});
+360
View File
@@ -0,0 +1,360 @@
import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import { guardPresence } from "../../blocks/TableGuard";
import type { RenderModel } from "../../model/build";
import type { PlacedLogo } from "../contract";
import type { GapRange, NeutralVariant } from "./variant";
export const CANCELLED_INK = "#a2191f";
export const CANCELLED_SENTENCE = "This invoice has been cancelled.";
export const INFO_GAP = 24;
export const CLIENT_LABEL_GAP = 2.47;
/** Space between the title's last glyph and the hairline that runs to the page edge (Linea). */
export const TITLE_RULE_GAP = 20;
export const TITLE_RULE_MIN = 40;
/** Space between the opt-in logo band and Linea's title box. */
export const LOGO_TITLE_GAP = 4.85;
export const META_VALUE_GAP = 18;
export const META_ROW_GAP = 1.5;
export const WORDS_GAP = 12;
export const CLOSING_GAP = 16;
/** Space above the amount in words when it sits beside the totals column, under the SUBTOTAL row. */
export const WORDS_TOP = 8;
export const SERENITY_LABEL_GAP = 7.5;
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Each range shrinks by the same share of its own slack until `deficit` is given back (Letter's missing height). */
export function elastic<K extends string>(ranges: Record<K, GapRange>, deficit: number): Record<K, number> {
const keys = Object.keys(ranges) as K[];
const slack = keys.reduce((sum, k) => sum + ranges[k].nominal - ranges[k].min, 0);
const share = slack > 0 ? Math.min(1, Math.max(0, deficit) / slack) : 0;
const out = {} as Record<K, number>;
for (const k of keys) out[k] = round2(ranges[k].nominal - share * (ranges[k].nominal - ranges[k].min));
return out;
}
export interface NeutralGeometry {
/** Content width of the page. */
W: number;
/** Table and rule width. */
ruleW: number;
/** Text column (client, meta, supplier). */
T: number;
/** Inner width of the totals: the rule width less the cell padding. */
innerW: number;
}
export function geometry(v: NeutralVariant, frame: Pick<PageFrame, "contentWidth">): NeutralGeometry {
const W = frame.contentWidth;
const ruleW = W - 2 * v.ruleInset;
return { W, ruleW, T: W - v.textInset.left - v.textInset.right, innerW: ruleW - 2 * v.padX };
}
export function initialsOf(name: string): string {
const words = name.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w));
const letter = (w: string) => Array.from(w.replace(/^[^\p{L}\p{N}]+/u, ""))[0] ?? "";
const picked = words.length >= 2 ? [words[0], words[words.length - 1]] : words.slice(0, 1);
return picked.map(letter).join("").toUpperCase();
}
/** Lines of the bank block as printed: bank name, account name, then the rest as "Label: value". */
export function bankLines(model: RenderModel): string[] {
const bank = model.bank;
if (!bank) return [];
const out: string[] = [];
for (const l of bank.lines) {
if (l.label === "Bank") {
out.push(l.value, `Account Name: ${bank.accountName}`);
} else out.push(`${l.label}: ${l.value}`);
}
if (!bank.lines.some((l) => l.label === "Bank")) out.unshift(`Account Name: ${bank.accountName}`);
return out;
}
export function signatureOptions(v: NeutralVariant): SignatoryOptions {
const r = v.roles;
return {
roles: { name: r.bodyStrong, designation: r.body, fine: r.fine },
align: "right",
rule: { width: v.rule, color: v.ink },
color: v.ink,
imageHeight: v.signatureImageH,
};
}
export interface MetaRowSpec {
label: string;
value: string;
strong: boolean;
}
export function metaRows(model: RenderModel): MetaRowSpec[] {
const rows: MetaRowSpec[] = [
{ label: model.meta.numberLabel === "Invoice No." ? "Invoice No:" : model.meta.numberLabel, value: model.meta.number, strong: true },
{ label: "Date:", value: model.meta.date, strong: false },
];
if (model.meta.dueDate) rows.push({ label: "Due Date:", value: model.meta.dueDate, strong: false });
if (model.client.poNumber) rows.push({ label: "PO No:", value: model.client.poNumber, strong: false });
return rows;
}
const SHORT_UNIT: Record<string, string> = { second: "/sec", minute: "/min", hour: "/hr", session: "/session", unit: "/unit" };
/**
* The rate cell: "₹1,200.00/hr" on one line when it fits the column, else the amount with its unit
* ("per hour") on a second line, which keeps one-line rows one line tall.
*/
export function rateCell(rateText: string | null, width: number, role: NeutralVariant["roles"]["tableBody"]): TableCellValue {
if (!rateText) return "—";
const m = /^(.*) per (second|minute|hour|session|unit)$/.exec(rateText);
if (!m) return rateText;
const short = `${m[1]}${SHORT_UNIT[m[2]]}`;
return measureToken(getMeasurer(), role, short) <= width ? short : [m[1], `per ${m[2]}`];
}
export interface NeutralPlan {
v: NeutralVariant;
geo: NeutralGeometry;
gaps: { top: number; table: number; notes: number; info: number };
/** Title tracking override when "TAX INVOICE" would not leave the hairline its minimum. */
titleToken: NeutralVariant["roles"]["title"];
titleW: number;
/** Header region height (title box, header row, logo box) that bandToInfo is measured from. */
headerH: number;
fromH: number;
/** Monogram: vertical offsets inside the header row. */
markTop: number;
titleTop: number;
metaW: number;
metaValueW: number;
columns: ColSpec[];
header: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
table: ItemsTablePlan;
guardH: number;
headerTailH: number;
tailH: number;
/** Words go beside the totals column (false: below it, full width). */
wordsBeside: boolean;
wordsW: number;
closingH: number;
payH: number;
signH: number;
}
/** Monogram's design offsets inside its header row (Canva A4): title box top and logo box top below the row top. */
const MONOGRAM_TITLE_TOP = 22.1;
/** Where the ring centre sat below the row top (139.5 - 81.25); the no-logo initials are centred there. */
const MONOGRAM_RING_CENTRE = 58.25;
const leading = (t: { leading?: number }) => t.leading as number;
/**
* Everything the layout must know before rendering: elastic gaps, header geometry and the predicted
* heights of the totals tail and the closing block (their minPresenceAhead values). Each prediction is
* deliberately a hair high: too low and a block can start a page without its spacer.
*/
export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVariant, logo: PlacedLogo | null): NeutralPlan {
const m = getMeasurer();
const r = v.roles;
const geo = geometry(v, frame);
const { W, T, ruleW, innerW } = geo;
const hsn = Slot.HsnSac(model);
// Narrow templates print the code as a small second line under the description instead of a column.
const hsnCol = hsn && v.hsnMode === "column" ? hsn : null;
// --- header ----------------------------------------------------------------------------------------
let titleToken = r.title;
let titleW = Math.ceil(measureToken(m, titleToken, model.docTitle)) + 1;
if (v.id === "linea" && W - titleW - TITLE_RULE_GAP < TITLE_RULE_MIN) {
titleToken = { ...r.title, tracking: 0.12 };
titleW = Math.ceil(measureToken(m, titleToken, model.docTitle)) + 1;
}
const logoH = logo?.h ?? 0;
let headerH: number;
let markTop = 0;
let titleTop = 0;
let topRange = v.topGap;
if (v.id === "linea") {
headerH = leading(r.title);
if (logo) topRange = { nominal: Math.max(v.topGap.nominal, logoH + LOGO_TITLE_GAP), min: Math.max(v.topGap.min, logoH + LOGO_TITLE_GAP) };
} else if (v.id === "monogram") {
titleTop = MONOGRAM_TITLE_TOP;
// The logo's offset below the row top is what computeLogoPlacement made of the slot's vertical alignment.
const mark = logo ? logoH : leading(r.initials);
markTop = logo ? (logo.slotOffset?.y ?? 0) : MONOGRAM_RING_CENTRE - leading(r.initials) / 2;
headerH = Math.max(titleTop + leading(r.title), markTop + mark);
} else {
markTop = logo?.slotOffset?.y ?? 0;
headerH = logo ? markTop + logoH : 0;
}
// --- supplier block ----------------------------------------------------------------------------------
const s = model.supplier;
const fromW = Math.min(v.fromW, T);
let fromH = textHeight(r.bodyStrong, s.name, fromW);
for (const l of s.addressLines) fromH += textHeight(r.address, l, fromW);
const idLines = [s.email, s.phone, s.pan].filter(Boolean).length + slotHeights.gstinLines(model, "supplier");
fromH += idLines * leading(r.address);
const infoNominal = Math.max(v.infoMinGap, v.bandToInfo - headerH - v.fromGap - fromH);
const ranges = {
top: topRange,
info: { nominal: infoNominal, min: Math.max(v.infoMinGap, infoNominal - 20) },
table: v.tableGap,
notes: v.notesGap,
};
// --- info row: meta width ---------------------------------------------------------------------------
const rows = metaRows(model);
const labelMax = Math.max(...rows.map((x) => measureToken(m, r.label, x.label, { weight: x.strong ? r.label.weight : r.body.weight })));
const valMax = Math.max(54, ...rows.map((x) => measureToken(m, x.strong ? r.bodyStrong : r.body, x.value)));
const metaValueW = Math.min(Math.ceil(valMax) + 1, Math.floor(T * 0.45));
const metaW = Math.min(Math.max(143, Math.ceil(labelMax) + META_VALUE_GAP + metaValueW), Math.floor(T * 0.6));
// --- table -------------------------------------------------------------------------------------------
// Fixed columns grow to fit their widest single token (a money amount or HSN code cannot wrap), so
// the variant's widths are minimums. Descriptions take the rest.
const widest = (role: NeutralVariant["roles"]["tableBody"], texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string) =>
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const hsnHead = hsnCol?.header ?? "";
const c = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit price"),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty"),
amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"),
};
const columns: ColSpec[] = [
// No `min`: a table row must never stack its cells; the fixed columns' widths are what keeps this wide enough.
{ fill: true },
...(hsnCol ? [{ fixed: c.hsn } as ColSpec] : []),
{ fixed: c.rate },
{ fixed: c.qty },
{ fixed: c.amount },
];
const header = ["Description", ...(hsnCol ? [hsnCol.header] : []), v.id === "monogram" ? "Rate" : "Unit price", "Qty", "Total"];
const align: NeutralPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "center", "center", "right"];
const rateW = c.rate - 2 * v.padX;
const tableRows: TableCellValue[][] = model.items.map((item, i) => [
hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines,
...(hsnCol ? [hsnCol.values[i]] : []),
rateCell(item.rateText, rateW, r.tableBody),
item.qtyText,
item.amountText,
]);
// --- totals ------------------------------------------------------------------------------------------
const t = model.totals;
const rightRows = 1 + (t.discountText ? 1 : 0) + (t.taxableText ? 1 : 0) + slotHeights.taxRowCount(model);
const rowH = leading(r.body) + v.totalsRowGap;
const rightH = rightRows * rowH;
const wordsGap = 16;
const wordsBeside = v.wordsPlacement === "beside" && innerW - v.totalsW - wordsGap >= 150;
const wordsW = wordsBeside ? innerW - v.totalsW - wordsGap : innerW;
const wordsH = (wordsBeside ? WORDS_TOP : 0) + slotHeights.amountInWords(model, wordsW, { role: r.small, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
const leftH = wordsH + (reverseH ? 4 + reverseH : 0);
let tailH = v.closeGap + v.rule + v.subtotalPad + leading(r.subtotal);
tailH += wordsBeside ? Math.max(rightH, leftH) : rightH + (leftH ? WORDS_GAP + leftH : 0);
tailH += 1;
const headerTailH = v.headTail.before + v.rule + v.headTail.after;
const table = planItemsTable({
columns,
gap: 0,
width: ruleW,
header,
rows: tableRows,
measurer: m,
roles: { head: r.tableHead, body: r.tableBody },
padX: v.padX,
padY: v.padY,
border: 0,
headerTailH,
tailH,
contentH: frame.contentHeight,
});
const guardH = guardPresence(table.headH + 1, table.firstRowH, leading(r.tableBody));
// --- closing -----------------------------------------------------------------------------------------
const signOpts = signatureOptions(v);
const signW = v.signatureW;
let signH = signatoryHeight(model, signW, signOpts);
let payH = 0;
const lines = bankLines(model);
const payW = v.payTo.where === "closing" ? W - v.textInset.left - v.textInset.right - signW - 16 - v.payTo.inset : T;
if (model.bank) {
payH = leading(r.label) + CLIENT_LABEL_GAP;
for (const l of lines) payH += textHeight(r.address, l, payW);
}
if (v.id === "serenity") signH += leading(r.label) + 4;
let closingH = 0;
if (v.payTo.where === "closing") closingH = Math.max(payH, signH);
else closingH = signH;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
closingH += CLOSING_GAP + 1.5;
// --- elastic gaps --------------------------------------------------------------------------------------
// Letter gives its missing height back from the gaps (docs: top band, header-to-info gap). On any page a
// one-page invoice that overflows by less than the gaps' slack is squeezed the same way, so a typical
// invoice keeps its totals and signature on page 1 instead of spilling over for a few points.
const clientW = Math.max(60, T - metaW - INFO_GAP);
const c0 = model.client;
let clientH = leading(r.label) + CLIENT_LABEL_GAP + textHeight(r.address, c0.name, clientW);
for (const l of c0.addressLines) clientH += textHeight(r.address, l, clientW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.address) + slotHeights.placeOfSupplyLines(model, r.address, clientW) * leading(r.address);
if (v.payTo.where === "under-client" && model.bank) {
clientH += v.payGap + leading(r.label) + CLIENT_LABEL_GAP;
for (const l of lines) clientH += textHeight(r.address, l, clientW);
}
const metaH = rows.length * (leading(r.body) + META_ROW_GAP);
const infoRowH = Math.max(clientH, metaH);
const labelH = v.id === "serenity" ? leading(r.label) + SERENITY_LABEL_GAP : 0;
const notesH = model.notesLines.length
? leading(r.label) + CLIENT_LABEL_GAP + model.notesLines.reduce((h, l) => h + textHeight(r.small, l, T), 0)
: 0;
const tableH = table.headH + table.rowHeights.reduce((a, b) => a + b, 0);
const nominalTotal =
ranges.top.nominal + headerH + v.fromGap + fromH + ranges.info.nominal + labelH + infoRowH + ranges.table.nominal + tableH + tailH + (notesH ? ranges.notes.nominal + notesH : 0) + closingH;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
const overflow = nominalTotal - frame.contentHeight;
const deficit = Math.max(frame.heightDeficit, overflow > 0 && overflow <= slack ? overflow : 0);
const g = elastic(ranges, deficit);
return {
v,
geo,
gaps: { top: g.top, table: g.table, notes: g.notes, info: g.info },
titleToken,
titleW,
headerH,
fromH,
markTop,
titleTop,
metaW,
metaValueW,
columns,
header,
rows: tableRows,
align,
table,
guardH,
headerTailH,
tailH,
wordsBeside,
wordsW,
closingH,
payH,
signH,
};
}
export { cols, textLines };
+93
View File
@@ -0,0 +1,93 @@
import { defineRoles } from "../../fonts/roles";
import { scriptWordBounds, scriptWordDecor } from "../../decor/ScriptWord";
import { FOOTER_RESERVE } from "./constants";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NeutralLayoutFor } from "./layoutFor";
import { MONOGRAM_ROLES } from "./monogram";
import type { NeutralRoles, NeutralVariant } from "./variant";
const INK = "#3D3B3A";
/** The paper colour measured from Canva's texture (its average); the texture itself is stock and is not bundled. */
const PAPER = "#ECE8E1";
/** Serenity shares Monogram's Open Sans type system (docs/templates/06-serenity.md); its own object so it can drift. */
export const SERENITY_ROLES = defineRoles({
...MONOGRAM_ROLES,
} satisfies NeutralRoles);
/** Letter keeps the band's top and takes this share of the page-height deficit off its position (docs: shift up about 32 pt). */
const BAND_SHIFT_SHARE = 0.64;
/**
* Canva A4 numbers: the rules start on the 162.91 pt left margin (the band occupies x 55-155), the
* stamp slot is 82 x 82 at y 131.75 on the right.
*/
export const SERENITY_VARIANT: NeutralVariant = {
id: "serenity",
roles: SERENITY_ROLES,
ink: INK,
margins: { top: 56, right: 64.1, bottom: 60, left: 162.91 },
// The left margin never goes under the band's right edge plus 8 pt, whatever the preset.
marginFloor: { top: 36, right: 36, bottom: 36, left: 162.91 },
ruleInset: 0,
padX: 8.9,
textInset: { left: 8.9, right: 6.7 },
rule: 0.75,
topGap: { nominal: 75.75, min: 40 },
fromGap: 12,
bandToInfo: 156.85,
infoMinGap: 14,
tableGap: { nominal: 61, min: 28 },
headTail: { before: 3.47, after: 6.98 },
padY: 5.8,
closeGap: 7.9,
subtotalPad: 8.06,
totalsRowGap: 5.1,
labelGap: 8,
hsnMode: "subline",
cols: { hsn: 0, rate: 90, qty: 36, amount: 80, descMin: 60 },
totalsW: 210,
amountW: 90,
wordsPlacement: "below",
notesGap: { nominal: 14, min: 8 },
fromW: 250,
payTo: { where: "closing", label: "Bank details", inset: 10.9 },
signatureW: 150,
payGap: 0,
chromeInset: 8.9,
footerLeftW: 130,
signatureImageH: 44,
};
export const serenity: TemplateDefinition = {
...catalogEntry("serenity"),
version: 1,
roles: SERENITY_ROLES,
margins: SERENITY_VARIANT.margins,
marginFloor: SERENITY_VARIANT.marginFloor,
decor: {
ink: INK,
background: PAPER,
everyPage: (frame) => scriptWordDecor(INK, frame.heightDeficit * BAND_SHIFT_SHARE),
keepOuts: (frame) => [scriptWordBounds(frame.heightDeficit * BAND_SHIFT_SHARE)],
},
chrome: {
color: INK,
rule: null,
role: SERENITY_ROLES.footer,
inset: { left: SERENITY_VARIANT.chromeInset, right: SERENITY_VARIANT.chromeInset },
reserve: FOOTER_RESERVE,
leftWidth: SERENITY_VARIANT.footerLeftW,
},
logo: {
box: { w: 82, h: 81.75 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => 82,
slot: slotFor("serenity"),
available: () => ({ w: 82, h: 81.75 }),
},
Layout: NeutralLayoutFor(SERENITY_VARIANT),
};
+98
View File
@@ -0,0 +1,98 @@
import type { Sides } from "../../engine/geometry";
import type { TypeToken } from "../../fonts/roles";
/** Type roles every Neutral template declares (Linea in Montserrat, Monogram and Serenity in Open Sans). */
export interface NeutralRoles {
title: TypeToken;
/** Bold, tracked caps: "ISSUED TO:", "PAY TO:", meta labels. */
label: TypeToken;
body: TypeToken;
bodyStrong: TypeToken;
/** Client, supplier and bank lines (Medium in the Open Sans templates). */
address: TypeToken;
tableHead: TypeToken;
tableBody: TypeToken;
/** The SUBTOTAL label and value (bold in Monogram and Serenity). */
subtotal: TypeToken;
/** The TOTAL label and value. */
total: TypeToken;
small: TypeToken;
wordsLabel: TypeToken;
fine: TypeToken;
/** Monogram's no-logo initials. */
initials: TypeToken;
/** Footer and continuation band: a render-prop role (no leading). */
footer: TypeToken;
}
export interface GapRange {
nominal: number;
min: number;
}
export type NeutralId = "linea" | "monogram" | "serenity";
/**
* Everything that differs between Linea, Monogram and Serenity. All y values are distances in pt
* measured on the Canva A4 page and converted into flow gaps; x values are relative to the content
* edge (the margin), so the same tokens serve every margin preset and page size.
*/
export interface NeutralVariant {
id: NeutralId;
roles: NeutralRoles;
ink: string;
margins: Sides;
marginFloor: Sides;
/** Content edge to where the table rules start (Linea's title and rule overhang the text column). */
ruleInset: number;
/** Horizontal padding of table cells: rule edge to text. */
padX: number;
/** Content edge to the text column (client, meta, supplier, totals). */
textInset: { left: number; right: number };
rule: number;
/** Space above the first header element (margin to title top, logo top ...); Letter takes some of it. */
topGap: GapRange;
/** Header anchor bottom to the supplier block. */
fromGap: number;
/** Design distance from the header anchor bottom to the first info line (supplier block included). */
bandToInfo: number;
/** Floor of the gap between the supplier block and the info row. */
infoMinGap: number;
/** Info row bottom to the table header. */
tableGap: GapRange;
/** The table rule's gap above and below (the header tail): header text bottom to rule, rule to first row. */
headTail: { before: number; after: number };
padY: number;
/** Last row bottom to the closing rule. */
closeGap: number;
/** Closing rule to the SUBTOTAL text top. */
subtotalPad: number;
/** Space between the stacked totals rows. */
totalsRowGap: number;
/** Distance of the tax/total column labels from the amounts column. */
labelGap: number;
/** HSN/SAC as a table column, or (narrow templates) a small line under each description. */
hsnMode: "column" | "subline";
cols: { hsn: number; rate: number; qty: number; amount: number; descMin: number };
/** Width of the right-anchored tax/total column (labels + amounts). */
totalsW: number;
amountW: number;
wordsPlacement: "beside" | "below";
notesGap: GapRange;
/** Width of the supplier (FROM) block, right-anchored. */
fromW: number;
/** Where PAY TO sits: under ISSUED TO (Linea) or in the closing row with the signature. */
payTo: { where: "under-client" | "closing"; label: string; inset: number };
/** Width of the signature column of the closing row. */
signatureW: number;
/** Gap between PAY TO and the info block above it (Linea). */
payGap: number;
/** Footer and continuation band inset inside the content width, each side. */
chromeInset: number;
/** Footer fitted-name width. */
footerLeftW: number;
/** Letter takes the page-height deficit from the elastic gaps only. */
signatureImageH: number;
}
+8 -1
View File
@@ -1,13 +1,20 @@
import type { TemplateDefinition } from "./contract";
import { classic } from "./classic";
import { linea } from "./neutral/linea";
import { monogram } from "./neutral/monogram";
import { serenity } from "./neutral/serenity";
/** Picker order: Classic first, Linea (the vendor's original format) second. */
export const TEMPLATES: Record<string, TemplateDefinition> = {
classic,
linea,
monogram,
serenity,
};
export const DEFAULT_TEMPLATE_ID = "classic";
/** The template for an id; an unknown id (a template removed later, a corrupt pref) falls back to Classic. */
export function getTemplate(id: string | undefined): TemplateDefinition {
return (id !== undefined ? TEMPLATES[id] : undefined) ?? TEMPLATES[DEFAULT_TEMPLATE_ID];
return (id !== undefined && Object.hasOwn(TEMPLATES, id) ? TEMPLATES[id] : undefined) ?? TEMPLATES[DEFAULT_TEMPLATE_ID];
}
+9 -9
View File
@@ -8,15 +8,15 @@ export const SLOTS: Record<TemplateId, TemplateSlot | null> = {
"classic": null,
"marble": null,
"highlighter": null,
"monolith": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":513.25,"y":54.75,"w":46.75,"h":9},"markDensity":0,"lockupDensity":0.579,"anchor":"right","vAlign":"middle","darkSurface":false},
"monolith": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":513.25,"y":54.75,"w":46.75,"h":9},"markDensity":0,"lockupDensity":0.579,"anchor":"right","vAlign":"top","darkSurface":false,"textOnly":true,"allowance":{"x":400,"y":54.75,"w":160,"h":28}},
"linea": null,
"monogram": {"page":{"w":595.5,"h":842.25},"mark":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"lockup":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"markDensity":0.142,"lockupDensity":0.142,"anchor":"right","vAlign":"middle","darkSurface":false},
"serenity": {"page":{"w":595.5,"h":842.25},"mark":{"x":444.5,"y":131.75,"w":82,"h":81.75},"lockup":{"x":444.5,"y":131.75,"w":82,"h":81.75},"markDensity":0.069,"lockupDensity":0.069,"anchor":"right","vAlign":"middle","darkSurface":false},
"crimson-grid": {"page":{"w":595.5,"h":842.25},"mark":{"x":420.5,"y":53,"w":31.25,"h":45.75},"lockup":{"x":420.5,"y":53,"w":113.25,"h":45.75},"markDensity":0.466,"lockupDensity":0.261,"anchor":"right","vAlign":"bottom","darkSurface":false},
"cobalt-stripe": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":34.25,"h":34.25},"lockup":{"x":59.5,"y":59.5,"w":181.5,"h":34.25},"markDensity":0.479,"lockupDensity":0.287,"anchor":"left","vAlign":"middle","darkSurface":false},
"slate-band": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":55.75,"w":25,"h":31.25},"lockup":{"x":59.5,"y":55.75,"w":99.25,"h":31.25},"markDensity":0.492,"lockupDensity":0.181,"anchor":"left","vAlign":"middle","darkSurface":true},
"teal-swoosh": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":41,"h":27},"lockup":{"x":59.5,"y":59.5,"w":125.75,"h":29.25},"markDensity":0.445,"lockupDensity":0.233,"anchor":"left","vAlign":"middle","darkSurface":false},
"tangerine-ledger": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":60.5,"y":69.75,"w":82.5,"h":14.75},"markDensity":0,"lockupDensity":0.486,"anchor":"left","vAlign":"middle","darkSurface":false},
"monogram": {"page":{"w":595.5,"h":842.25},"mark":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"lockup":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"markDensity":0.142,"lockupDensity":0.142,"anchor":"right","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":387.75,"y":81.25,"w":142.75,"h":116.5}},
"serenity": {"page":{"w":595.5,"h":842.25},"mark":{"x":444.5,"y":131.75,"w":82,"h":81.75},"lockup":{"x":444.5,"y":131.75,"w":82,"h":81.75},"markDensity":0.069,"lockupDensity":0.069,"anchor":"right","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":444.5,"y":131.75,"w":82,"h":81.75}},
"crimson-grid": {"page":{"w":595.5,"h":842.25},"mark":{"x":420.5,"y":53,"w":31.25,"h":45.75},"lockup":{"x":420.5,"y":53,"w":113.25,"h":45.75},"markDensity":0.466,"lockupDensity":0.261,"anchor":"right","vAlign":"bottom","darkSurface":false,"textOnly":false,"allowance":{"x":420.5,"y":53,"w":113.25,"h":45.75}},
"cobalt-stripe": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":34.25,"h":34.25},"lockup":{"x":59.5,"y":59.5,"w":181.5,"h":34.25},"markDensity":0.479,"lockupDensity":0.287,"anchor":"left","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":59.5,"y":59.5,"w":181.5,"h":34.25}},
"slate-band": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":55.75,"w":25,"h":31.25},"lockup":{"x":59.5,"y":55.75,"w":99.25,"h":31.25},"markDensity":0.492,"lockupDensity":0.181,"anchor":"left","vAlign":"middle","darkSurface":true,"textOnly":false,"allowance":{"x":59.5,"y":55.75,"w":99.25,"h":31.25}},
"teal-swoosh": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":41,"h":27},"lockup":{"x":59.5,"y":59.5,"w":125.75,"h":29.25},"markDensity":0.445,"lockupDensity":0.233,"anchor":"left","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":59.5,"y":59.5,"w":125.75,"h":29.25}},
"tangerine-ledger": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":60.5,"y":69.75,"w":82.5,"h":14.75},"markDensity":0,"lockupDensity":0.486,"anchor":"left","vAlign":"middle","darkSurface":false,"textOnly":true,"allowance":{"x":60.5,"y":62,"w":220,"h":36}},
"purple-pop": null,
"citrus-split": {"page":{"w":595.5,"h":842.25},"mark":{"x":337.5,"y":74.5,"w":34,"h":32.75},"lockup":{"x":337.5,"y":73.25,"w":144.5,"h":34},"markDensity":0.543,"lockupDensity":0.259,"anchor":"left","vAlign":"middle","darkSurface":false},
"citrus-split": {"page":{"w":595.5,"h":842.25},"mark":{"x":337.5,"y":74.5,"w":34,"h":32.75},"lockup":{"x":337.5,"y":73.25,"w":144.5,"h":34},"markDensity":0.543,"lockupDensity":0.259,"anchor":"left","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":337.5,"y":73.25,"w":144.5,"h":34}},
};
+2
View File
@@ -21,6 +21,8 @@ function slot(lockup: Box, anchor: TemplateSlot["anchor"], vAlign: TemplateSlot[
anchor,
vAlign,
darkSurface: false,
textOnly: false,
allowance: lockup,
optIn: true,
defaultOn: false,
};
+7
View File
@@ -40,6 +40,13 @@ export interface TemplateSlot {
lockupDensity: number;
anchor: SlotAnchor;
vAlign: SlotVAlign;
/** The Canva placeholder is plain text (a brand name), so its own box is far too small for a real logo. */
textOnly: boolean;
/**
* The box a logo may fill. For a text-only placeholder it is an allowance from the template docs
* (the text's anchor edge kept, room for a legible logo); otherwise it equals the lockup box.
*/
allowance: Box;
/** The slot sits on a dark fill: use the knockout logo, or a white chip behind a logo without alpha. */
darkSurface: boolean;
/** The template has no placeholder; the position is offered as an option. */