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:
2026-10-04 13:58:06 +05:30
parent 4d18a50753
commit afbc0aa56f
37 changed files with 2436 additions and 72 deletions
+160
View File
@@ -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([]);
});
});