Add font import for Now, Gotham and Open Sauce One overrides; package fonts for Void
Font import: - Migration M6 adds user_fonts; files are stored content-addressed under the local data dir. New Rust commands inspect, import, list, read and remove fonts, refusing WOFF2, collections, variable fonts and restricted fsType, and unpacking WOFF1 to plain sfnt. - Imported fonts register in the render worker as user:<Face> families with an italic alias, and are pinned in the frozen render prefs so issued invoices can still re-render. Fonts referenced by issued invoices are hidden, never deleted. - Size and tracking compensations tuned for the substitutes apply only while the face is not overridden; default output and fingerprint goldens are unchanged. - Settings gets a Fonts section with a licence acknowledgement. Void packaging: - Install fonts to /usr/share/fonts/voiced with per-family OFL licences, set font_dirs, and add INSTALL/REMOVE scripts that run fc-cache. xbps-create carries both scripts (verified in a void-glibc container). - Fix xbps-create dependency specs in build-xbps.sh.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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("<htm"))).toBe("unknown");
|
||||
expect(sniffFontFormat(new Uint8Array([0, 1]))).toBe("unknown");
|
||||
});
|
||||
|
||||
it("refuses WOFF2, WOFF, collections and non-fonts before fontkit sees them", async () => {
|
||||
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("<!doctype html>"))).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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user