Add Ledger and Band template families with decor primitives
Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from the reference PDFs with grid and band tables that survive page breaks, full-bleed fixed bands, pennant tabs, gradient corner swooshes with keep-outs, Carbon icon circles, and the user's logo in the measured slot (knockout or white chip on the dark Slate band). Adds static Poppins 500-700 and Inter fonts. Thumbnails regenerated for all eight templates; the item-count sweeps run in their own test file.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { swooshKeepOuts } from "../../decor/CornerSwoosh";
|
||||
import { CHIP_PADDING } from "../../engine/logo";
|
||||
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
||||
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
||||
import { frameForPrefs } from "../../render/invoice";
|
||||
import { loadCanvas, pagePng } from "../../testing/rasterize";
|
||||
import { SLOT_ID } from "../../blocks/slots";
|
||||
import { catalogEntry } from "../catalog";
|
||||
import { marginsFor } from "../contract";
|
||||
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
|
||||
import { getTemplate, TEMPLATES } from "../registry";
|
||||
import { SLOTS } from "../slots.generated";
|
||||
import { planBand } from "./plan";
|
||||
import { slateBand, SLATE_VARIANT } from "./slate";
|
||||
import { tealSwoosh, TEAL_VARIANT } from "./teal";
|
||||
|
||||
ensureSetup();
|
||||
|
||||
const BAND = [slateBand, tealSwoosh];
|
||||
const VARIANTS = { "slate-band": SLATE_VARIANT, "teal-swoosh": TEAL_VARIANT } as const;
|
||||
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
|
||||
const out: LayoutNode[] = [];
|
||||
for (const p of layout) walk(p.nodes, (n) => out.push(n));
|
||||
return out;
|
||||
};
|
||||
const withLogo = { name: "logo", items: 2, logo: "real" as const };
|
||||
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
|
||||
|
||||
/** The colour of one pixel of page 1, at 4x. */
|
||||
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Promise<number[][]> {
|
||||
const lib = await loadCanvas();
|
||||
if (!lib) return [];
|
||||
const png = await pagePng(lib, bytes, 1, 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 isWhite = ([r, g, b]: number[]) => r > 245 && g > 245 && b > 245;
|
||||
const isSlate = ([r, g, b]: number[]) => Math.abs(r - 0x4d) < 6 && Math.abs(g - 0x58) < 6 && Math.abs(b - 0x69) < 6;
|
||||
|
||||
describe("registry and catalog", () => {
|
||||
it("lists Slate Band and Teal Swoosh in the Band family, with their thumbnails on disk", () => {
|
||||
for (const t of BAND) {
|
||||
expect(getTemplate(t.id)).toBe(t);
|
||||
expect(t.family).toBe("band");
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("both are Montserrat throughout, and their slots are the measured reference ones", () => {
|
||||
for (const t of BAND) for (const [name, token] of Object.entries(t.roles)) expect(token.face, `${t.id}.${name}`).toBe("Montserrat");
|
||||
expect(slateBand.logo.slot).toBe(SLOTS["slate-band"]);
|
||||
expect(tealSwoosh.logo.slot).toBe(SLOTS["teal-swoosh"]);
|
||||
expect(slateBand.logo.slot?.darkSurface).toBe(true);
|
||||
expect(tealSwoosh.logo.slot?.darkSurface).toBe(false);
|
||||
expect(slateBand.roles.title).toMatchObject({ weight: 800, size: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("page geometry", () => {
|
||||
it("template margins", () => {
|
||||
expect(marginsFor(slateBand, "template")).toEqual({ top: 55.75, right: 59.55, bottom: 20, left: 59.55 });
|
||||
expect(marginsFor(tealSwoosh, "template")).toEqual({ top: 55, right: 59.55, bottom: 24, left: 59.55 });
|
||||
});
|
||||
|
||||
it("the flow stays above the footer rule (Slate) and the bottom-left swoosh (Teal) on every preset and size", () => {
|
||||
for (const margins of ["template", "narrow", "normal"] as const) {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
expect(frameForPrefs(prefsFor(size, { templateId: "slate-band", margins })).pad.bottom, `slate ${margins}/${size}`).toBeGreaterThanOrEqual(86.7 + 12 - 1e-6);
|
||||
expect(frameForPrefs(prefsFor(size, { templateId: "teal-swoosh", margins })).pad.bottom, `teal ${margins}/${size}`).toBeGreaterThanOrEqual(110 - 1e-6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("Letter keeps the physical margins", () => {
|
||||
for (const t of BAND) {
|
||||
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
|
||||
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
|
||||
expect(letter.pad).toEqual(a4.pad);
|
||||
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
||||
}
|
||||
});
|
||||
|
||||
it("the table text is inset 9.8 pt and the header band bleeds to both page edges, on every page", async () => {
|
||||
for (const t of BAND) {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture({ name: "p", items: 30, logo: "none" }, prefsFor(size, { templateId: t.id }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
expect(r.result.pages).toBeGreaterThan(1);
|
||||
r.result.core.layout.forEach((page, i) => {
|
||||
const heads = nodes([page]).filter((n) => n.id === "tbl-head");
|
||||
if (heads.length === 0) return;
|
||||
expect(heads[0].box.x, `${t.id}/${size} p${i + 1} head x`).toBeCloseTo(frame.pad.left + 9.8, 1);
|
||||
// The fixed group around the header row is the band: page edge to page edge.
|
||||
const band = nodes([page]).find((n) => n.type === "view" && n.fixed && n.box.x < 0.5 && n.box.w > frame.page.width - 0.5 && n.box.h > 40);
|
||||
expect(band, `${t.id}/${size} p${i + 1} band`).toBeTruthy();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slate Band: dark band and logo", () => {
|
||||
it("the two full-bleed bands are page 1 only, from the page top, as wide as the page", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor(size, { templateId: "slate-band" }));
|
||||
const decor = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.decor && n.type === "view" && n.box.x < 0.5 && n.box.w > frameForPrefs(r.prefs).page.width - 0.5 && n.box.h > 20);
|
||||
expect(decor(0).length, size).toBeGreaterThanOrEqual(2);
|
||||
expect(Math.min(...decor(0).map((n) => n.box.y)), size).toBeCloseTo(0, 1);
|
||||
// 103.5 pt of slate, then 53.8 of blue, at the design's margins.
|
||||
const heights = decor(0).map((n) => n.box.h).sort((a, b) => a - b);
|
||||
expect(heights[0]).toBeGreaterThan(50);
|
||||
// 103.5 on A4; Letter takes up to 8.5 pt of the band's bottom padding.
|
||||
expect(heights[heights.length - 1]).toBeGreaterThan(94);
|
||||
expect(decor(1).filter((n) => n.box.y < 5), `${size} no band on page 2`).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("a logo with a white knockout draws the knockout on the band", async () => {
|
||||
const lib = await loadCanvas();
|
||||
const r = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
const [img] = imageBoxes(r);
|
||||
expect(imageBoxes(r)).toHaveLength(1);
|
||||
const slot = SLOTS["slate-band"]!.lockup!;
|
||||
expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6);
|
||||
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.01);
|
||||
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.01);
|
||||
if (!lib) return;
|
||||
const [centre, beside] = await colourAt(r.result.bytes, [[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2], [img.box.x - 3, img.box.y + img.box.h / 2]]);
|
||||
expect(isWhite(centre), `knockout ink ${centre}`).toBe(true);
|
||||
expect(isSlate(beside), `band beside the logo ${beside}`).toBe(true);
|
||||
});
|
||||
|
||||
it("a logo without alpha sits on a white chip, padded by CHIP_PADDING, still inside the band", async () => {
|
||||
const lib = await loadCanvas();
|
||||
const r = await renderFixture({ ...withLogo, logo: "wide" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
const [img] = imageBoxes(r);
|
||||
if (!lib) return;
|
||||
const [inChip, outside, centre] = await colourAt(r.result.bytes, [
|
||||
[img.box.x - CHIP_PADDING + 1, img.box.y + img.box.h / 2],
|
||||
[img.box.x - CHIP_PADDING - 3, img.box.y + img.box.h / 2],
|
||||
[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2],
|
||||
]);
|
||||
expect(isWhite(inChip), `chip ${inChip}`).toBe(true);
|
||||
expect(isSlate(outside), `band outside the chip ${outside}`).toBe(true);
|
||||
// The print image (navy) is what is drawn on the chip, not a white knockout.
|
||||
expect(isWhite(centre) || centre[2] > centre[0], `print ink ${centre}`).toBe(true);
|
||||
});
|
||||
|
||||
it("a mark keeps the business name beside it, in white; a wordmark replaces the name", async () => {
|
||||
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1);
|
||||
const wide = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(0);
|
||||
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1);
|
||||
// The number sits in the band, right-aligned to the content edge.
|
||||
const no = none.pdf.pages[0].items.find((i) => i.str.startsWith("NO."))!;
|
||||
expect(no.bottom).toBeLessThan(110);
|
||||
expect(no.x1).toBeLessThanOrEqual(frameForPrefs(none.prefs).page.width - frameForPrefs(none.prefs).pad.right + 0.6);
|
||||
});
|
||||
|
||||
it("the contact footer is on every page", async () => {
|
||||
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
expect(r.result.pages).toBeGreaterThan(1);
|
||||
for (const [i, page] of r.pdf.pages.entries()) {
|
||||
const text = page.items.map((t) => t.str).join(" ");
|
||||
expect(text, `page ${i + 1} phone`).toContain("9000000000");
|
||||
expect(text, `page ${i + 1} e-mail`).toContain("vendor@example.com");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teal Swoosh: corner art and keep-outs", () => {
|
||||
it("the bottom-left swoosh repeats on every page; the top-right one is page 1 only", async () => {
|
||||
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const svgs = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.type === "svg" && n.decor);
|
||||
expect(svgs(0).length).toBe(2);
|
||||
for (let i = 1; i < r.result.pages; i++) {
|
||||
const s = svgs(i);
|
||||
expect(s, `page ${i + 1}`).toHaveLength(1);
|
||||
expect(s[0].box.x).toBeCloseTo(0, 1);
|
||||
expect(s[0].box.y + s[0].box.h).toBeCloseTo(frame.page.height, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("keep-outs: bottom-left on every page, top-right on page 1 only; both clusters on Letter too", () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const frame = frameForPrefs(prefsFor(size, { templateId: "teal-swoosh" }));
|
||||
const k = tealSwoosh.decor.keepOuts!(frame);
|
||||
const bl = swooshKeepOuts(frame, "bottom-left");
|
||||
const tr = swooshKeepOuts(frame, "top-right");
|
||||
expect(k).toHaveLength(bl.length + tr.length);
|
||||
expect(k.filter((r) => r.page === undefined)).toHaveLength(bl.length);
|
||||
expect(k.filter((r) => r.page === 1)).toHaveLength(tr.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("no flowing text or image enters the keep-outs, with a long vendor name, 60 rows, on A4 and Letter", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
for (const spec of [FIXTURES.find((f) => f.name === "id-tokens")!, FIXTURES.find((f) => f.name === "rows-60")!, FIXTURES.find((f) => f.name === "long-address")!] satisfies FixtureSpec[]) {
|
||||
const r = await renderFixture(spec, prefsFor(size, { templateId: "teal-swoosh" }));
|
||||
const keep = r.result.issues.filter((i) => i.code === "keepout");
|
||||
expect(keep.map((i) => i.message), `${size}/${spec.name}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("the keep-out audit does fire for a block placed in the swoosh", async () => {
|
||||
// Teal with page margins pulled in so the closing block would reach the swoosh: the audit must say so.
|
||||
const r = await renderFixture({ name: "k", items: 2, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const { auditLayout } = await import("../../engine/audit");
|
||||
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
|
||||
const inside = { x: 0, y: closing.box.y - 1, w: frame.page.width, h: closing.box.h + 2 };
|
||||
expect(auditLayout({ pages: r.result.core.layout }, frame, { keepOuts: [inside] }).some((i) => i.code === "keepout")).toBe(true);
|
||||
});
|
||||
|
||||
it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => {
|
||||
const slot = SLOTS["teal-swoosh"]!.lockup!;
|
||||
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
const [img] = imageBoxes(wide);
|
||||
expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6);
|
||||
expect(img.box.y).toBeGreaterThanOrEqual(slot.y - 0.6);
|
||||
expect(img.box.x + img.box.w).toBeLessThanOrEqual(slot.x + slot.w + 0.6);
|
||||
expect(img.box.y + img.box.h).toBeLessThanOrEqual(slot.y + slot.h + 0.6);
|
||||
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(0);
|
||||
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1);
|
||||
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("the continuation band and footer stay out of the swoosh on page 2", async () => {
|
||||
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
const band = r.pdf.pages[1].items.find((t) => t.str.startsWith("Tax Invoice"))!;
|
||||
expect(band.x0).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 0);
|
||||
const footer = r.pdf.pages[1].items.filter((t) => t.top > 790);
|
||||
for (const t of footer) expect(t.x0, t.str).toBeGreaterThan(190);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the TOTAL row and the totals stack", () => {
|
||||
it("TOTAL carries the accent dash and every tax row sits above it", async () => {
|
||||
for (const t of BAND) {
|
||||
const r = await renderFixture({ name: "t", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id }));
|
||||
const text = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str));
|
||||
// Tracked labels are extracted letter by letter, so compare without spaces.
|
||||
const flat = text.join("").replace(/\s+/g, "").toUpperCase();
|
||||
expect(flat, t.id).toContain("TOTAL");
|
||||
for (const word of ["SUBTOTAL", "CGST@9%", "SGST@9%", "TAXABLEVALUE"]) expect(flat, `${t.id} ${word}`).toContain(word);
|
||||
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.amountInWords, SLOT_ID.signatory]) expect(ids.has(id), `${t.id} #${id}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("an unregistered invoice carries none of the GST slots", async () => {
|
||||
for (const t of BAND) {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: t.id }));
|
||||
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), `${t.id} #${id}`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("long text", () => {
|
||||
it("long names, addresses and unbroken tokens raise no audit error", async () => {
|
||||
for (const t of BAND) {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
for (const name of ["id-tokens", "long-address", "long-client"]) {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: t.id }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error"), `${t.id}/${size}/${name}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan matches the laid-out blocks", () => {
|
||||
for (const t of BAND) {
|
||||
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 frame = frameForPrefs(prefs);
|
||||
// Logo-less, so the plan made here is the one the layout made (a logo only changes the header).
|
||||
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 = planBand(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false);
|
||||
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(15 + plan.gaps.tail);
|
||||
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(15);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("design docs", () => {
|
||||
it("each Band doc records its implementation notes", () => {
|
||||
for (const f of ["09-slate-band.md", "10-teal-swoosh.md"]) {
|
||||
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
|
||||
expect(text, f).toContain("## Implementation notes");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
void buildFixture;
|
||||
Reference in New Issue
Block a user