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