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 reference 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 eddf27629f
commit aa7a407944
61 changed files with 3278 additions and 110 deletions
+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 reference 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;
// the reference design'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 reference 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");
}
});
});