import { readFileSync } from "node:fs"; import path from "node:path"; import { createElement as h } from "react"; import { Document, Page, renderToBuffer, Text } from "@react-pdf/renderer"; import { describe, expect, it } from "vitest"; import { createMeasurer } from "../engine/measure"; import { DEFAULT_PREFS, parsePrefs } from "../model/prefs"; import { registerUserFont, registerUserFontSet } from "./register"; import { resolveFace, withFaceOverrides } from "./roles"; import { assertSfnt, faceOverridesFor, sniffFontFormat } from "./userFontStore"; import { CITRUS_ROLES } from "../templates/northline/citrus"; import { PURPLE_ROLES } from "../templates/northline/purple"; import { MARBLE_ROLES } from "../templates/marble/tokens"; import { TEMPLATE_CATALOG } from "../templates/catalog"; import { TEMPLATES } from "../templates/registry"; import { ensureSetup, prefsFor, renderFixture } from "../templates/harness"; import { gotham } from "../templates/northline/variant"; import { FONTS_DIR } from "../testing/setup"; ensureSetup(); const font = (folder: string, file: string) => new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file))); const latin1 = (b: Uint8Array) => Buffer.from(b).toString("latin1"); const SPEC = { name: "user-font", items: 3, logo: "none" as const }; describe("font sniffing and refusal", () => { it("recognises sfnt, WOFF, WOFF2 and collections by their magic bytes", () => { const tag = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)).concat([0, 0, 0, 0])); expect(sniffFontFormat(new Uint8Array([0, 1, 0, 0, 0, 0]))).toBe("ttf"); expect(sniffFontFormat(tag("true"))).toBe("ttf"); expect(sniffFontFormat(tag("OTTO"))).toBe("otf"); expect(sniffFontFormat(tag("wOFF"))).toBe("woff"); expect(sniffFontFormat(tag("wOF2"))).toBe("woff2"); expect(sniffFontFormat(tag("ttcf"))).toBe("ttc"); expect(sniffFontFormat(tag(" { const jost = font("jost", "Jost-Regular.ttf"); expect(assertSfnt(jost)).toBe("ttf"); const patched = (magic: string) => { const b = jost.slice(); b.set([...magic].map((c) => c.charCodeAt(0))); return b; }; expect(() => assertSfnt(patched("wOF2"))).toThrow(/WOFF2/); expect(() => assertSfnt(patched("ttcf"))).toThrow(/collections/); expect(() => assertSfnt(patched("wOFF"))).toThrow(/WOFF/); expect(() => assertSfnt(new TextEncoder().encode(""))).toThrow(/not a TrueType/); await expect(registerUserFont("Gotham", patched("wOF2"))).rejects.toThrow(/WOFF2/); }); }); describe("prefs carry user fonts", () => { it("keeps valid face/hash lists and drops everything else", () => { const sha = "a".repeat(64); const prefs = parsePrefs(JSON.stringify({ version: 1, templateId: "marble", fonts: { Now: [sha, "zz", 5], Gotham: [], Poppins: [sha], "Open Sauce One": "nope" } })); expect(prefs.fonts).toEqual({ Now: [sha] }); expect(parsePrefs(JSON.stringify({ version: 1 })).fonts).toBeUndefined(); expect(parsePrefs("{}")).toEqual(DEFAULT_PREFS); }); }); describe("override compensation", () => { it("leaves the tuned values alone without an override", () => { expect(PURPLE_ROLES.label.size).toBe(gotham(12)); expect(CITRUS_ROLES.tableBody.size).toBe(gotham(12)); expect(MARBLE_ROLES.body.tracking).toBe(0.105); expect(Object.keys(MARBLE_ROLES.body)).toContain("tracking"); }); it("takes the design size and tracking back only while a user font replaces the face", async () => { await withFaceOverrides({ Gotham: "user:Gotham:x", Now: "user:Now:x" }, async () => { expect(PURPLE_ROLES.label.size).toBe(12); expect(CITRUS_ROLES.tableBody.size).toBe(12); expect(MARBLE_ROLES.body.tracking).toBe(0.04); expect({ ...MARBLE_ROLES.body }.tracking).toBe(0.04); // A face that is not overridden keeps its tuned values (Open Sauce One stays Poppins). expect(PURPLE_ROLES.wordmark.size).toBe(94.07); }); await withFaceOverrides({ Now: "user:Now:x" }, async () => { expect(PURPLE_ROLES.label.size).toBe(gotham(12)); expect(MARBLE_ROLES.body.tracking).toBe(0.04); }); expect(PURPLE_ROLES.label.size).toBe(gotham(12)); expect(MARBLE_ROLES.body.tracking).toBe(0.105); expect(resolveFace("Gotham")).toBe("Montserrat"); }); it("the catalog lists exactly the licensed faces each template's roles use", () => { for (const entry of TEMPLATE_CATALOG) { const used = new Set(Object.values(TEMPLATES[entry.id].roles).map((t) => t.face).filter((f) => f === "Now" || f === "Gotham" || f === "Open Sauce One")); expect([...(entry.faces ?? [])].sort(), entry.id).toEqual([...used].sort()); } }); }); describe("a user font replaces a template face", () => { it("embeds the imported font in a Purple Pop invoice, and only then", async () => { const lustria = font("lustria", "Lustria-Regular.ttf"); const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" })); expect(latin1(plain.result.bytes)).not.toContain("Lustria"); expect(plain.result.bytes.length).toBeGreaterThan(1000); const { family, sha256 } = await registerUserFont("Gotham", lustria); expect(family).toMatch(/^user:Gotham:[0-9a-f]{8}$/); const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: [sha256] } })); expect(latin1(over.result.bytes)).toContain("Lustria"); expect(over.result.fingerprint).not.toBe(plain.result.fingerprint); expect(over.result.issues.filter((i) => i.severity === "error")).toEqual([]); // The override ended with the render: the same prefs without fonts give the original fingerprint again. const after = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" })); expect(after.result.fingerprint).toBe(plain.result.fingerprint); }, 60_000); it("a Marble invoice with Now replaced by a user font lays out and embeds it", async () => { const { sha256 } = await registerUserFont("Now", font("dm-sans", "DMSans-Regular.ttf")); const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble" })); const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble", fonts: { Now: [sha256] } })); expect(latin1(over.result.bytes)).toContain("DMSans"); expect(latin1(plain.result.bytes)).not.toContain("DMSans"); expect(over.pdf.pages[0].items.map((i) => i.str).join(" ")).toContain("₹"); }, 60_000); it("fails clearly when prefs name a font the renderer was never given", async () => { const prefs = prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: ["f".repeat(64)] } }); await expect(renderFixture(SPEC, prefs)).rejects.toThrow(/not loaded/); }); it("every weight gets an italic: a set registers its upright files as italics, and italic text does not throw", async () => { const regular = font("montserrat", "Montserrat-Regular.ttf"); const bold = font("montserrat", "Montserrat-Bold.ttf"); const family = await registerUserFontSet("Open Sauce One", [ { face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular }, { face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold }, ]); const render = (fontWeight: number) => renderToBuffer(h(Document, null, h(Page, { size: "A4" }, h(Text, { style: { fontFamily: [family], fontStyle: "italic", fontWeight } }, "Total ₹1,200.00")))); for (const w of [400, 700]) expect((await render(w)).subarray(0, 5).toString()).toBe("%PDF-"); // Registering the same set again is a no-op. expect(await registerUserFontSet("Open Sauce One", [ { face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold }, { face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular }, ])).toBe(family); expect(faceOverridesFor({ "Open Sauce One": ["1".repeat(16), "2".repeat(16)] })).toEqual({ "Open Sauce One": family }); }); it("the measurer reads user families from memory, bold and italic included", async () => { const m = createMeasurer((file) => new Uint8Array(readFileSync(path.join(FONTS_DIR, file)))); const poppins = font("poppins", "Poppins-Regular.ttf"); const { family } = await registerUserFont("Gotham", poppins, { weight: 400 }); const own = m.measureWidth({ text: "Consultation", face: family, weight: 400, size: 12 }); const plex = m.measureWidth({ text: "Consultation", face: "Poppins", weight: 400, size: 12 }); expect(own).toBeCloseTo(plex, 6); expect(m.measureWidth({ text: "Consultation", face: family, weight: 700, italic: true, size: 12 })).toBeCloseTo(plex, 6); expect(m.missing("₹", family)).toEqual([]); }); });