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 f7fd0e099c
commit 63422d9060
37 changed files with 2436 additions and 72 deletions
+17
View File
@@ -10,6 +10,7 @@ import type {
Settings,
} from "./types";
import type { LogoAsset } from "./logo";
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
export interface ArchiveStatus {
archived: boolean;
@@ -70,6 +71,22 @@ export const api = {
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
// Imported fonts (Now, Gotham, Open Sauce One). The font bytes are the raw body; metadata goes in headers.
inspectFont: (bytes: Uint8Array) => invoke<FontInspection>("inspect_font", bytes),
importFont: (bytes: Uint8Array, meta: ImportMeta) =>
invoke<UserFontRow>("import_font", bytes, {
headers: {
"x-face": encodeURIComponent(meta.face),
"x-file-name": encodeURIComponent(meta.fileName),
"x-licence-ack": String(meta.licenceAcknowledged),
...(meta.weight !== undefined ? { "x-weight": String(meta.weight) } : {}),
...(meta.italic !== undefined ? { "x-style": meta.italic ? "italic" : "normal" } : {}),
},
}),
listUserFonts: (includeHidden = false) => invoke<UserFontRow[]>("list_user_fonts", { includeHidden }),
getUserFontBytes: async (sha256: string): Promise<Uint8Array> =>
new Uint8Array(await invoke<ArrayBuffer>("get_user_font_bytes", { sha256 })),
removeUserFont: (id: number) => invoke<RemoveOutcome>("remove_user_font", { id }),
writeExportFile: (path: string, bytes: Uint8Array) =>
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
getLastExportDir: () => invoke<string>("get_last_export_dir"),
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PREFS } from "../pdf/model/prefs";
import { clientRefusal, selectionFrom, withUserFonts, type UserFontRow } from "./fontImport";
const row = (id: number, face: UserFontRow["face"], sha: string, over: Partial<UserFontRow> = {}): UserFontRow => ({
id, face, familyName: "F", fullName: "F", styleName: "", weight: 400, style: "normal", sha256: sha, fileName: "f.ttf",
format: "ttf", size: 1, fsType: 0, hasRupee: true, licenceAckAt: "t", importedAt: "t", hidden: false, ...over,
});
const A = "a".repeat(64);
const B = "b".repeat(64);
const C = "c".repeat(64);
describe("clientRefusal", () => {
const bytes = (magic: string) => new Uint8Array([...magic].map((c) => c.charCodeAt(0)).concat(new Array(20).fill(0)));
it("passes TTF, OTF and WOFF and refuses the rest with a reason", () => {
expect(clientRefusal(new Uint8Array([0, 1, 0, 0, 0, 0, 0, 0]))).toBeNull();
expect(clientRefusal(bytes("OTTO"))).toBeNull();
expect(clientRefusal(bytes("wOFF"))).toBeNull();
expect(clientRefusal(bytes("wOF2"))).toMatch(/WOFF2/);
expect(clientRefusal(bytes("ttcf"))).toMatch(/collections/);
expect(clientRefusal(bytes("<htm"))).toMatch(/not a TrueType/);
});
});
describe("withUserFonts", () => {
const fonts = [row(1, "Gotham", B), row(2, "Gotham", A, { weight: 700 }), row(3, "Now", C), row(4, "Now", "d".repeat(64), { hidden: true })];
it("lists visible fonts per face", () => {
expect(selectionFrom(fonts)).toEqual({ Gotham: [B, A], Now: [C] });
});
it("adds only the faces the template draws, sorted, and nothing for a template without them", () => {
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "purple-pop" }, fonts).fonts).toEqual({ Gotham: [A, B] });
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "citrus-split" }, fonts).fonts).toEqual({ Now: [C], Gotham: [A, B] });
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, fonts).fonts).toEqual({ Now: [C] });
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "classic" }, fonts)).toEqual({ ...DEFAULT_PREFS, templateId: "classic" });
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, [])).toEqual({ ...DEFAULT_PREFS, templateId: "marble" });
});
it("replaces fonts already in the prefs rather than merging", () => {
const stale = { ...DEFAULT_PREFS, templateId: "marble", fonts: { Now: ["e".repeat(64)] } };
expect(withUserFonts(stale, fonts).fonts).toEqual({ Now: [C] });
expect(withUserFonts(stale, []).fonts).toBeUndefined();
});
});
+125
View File
@@ -0,0 +1,125 @@
import type { FontFormat, UserFace } from "../pdf/fonts/userFontStore";
import { sniffFontFormat, USER_FACES } from "../pdf/fonts/userFontStore";
import type { RenderPrefsV1 } from "../pdf/model/prefs";
import { catalogEntry } from "../pdf/templates/catalog";
export { USER_FACES, type UserFace };
/** The three licensed faces and the bundled look-alike each one is drawn with until a font is imported. */
export const FACE_SUBSTITUTES: Record<UserFace, string> = {
Now: "Jost",
Gotham: "Montserrat",
"Open Sauce One": "Poppins",
};
/** What the backend found in a font file (src-tauri/src/commands/fonts.rs `FontInspection`). */
export interface FontInspection {
format: "ttf" | "otf";
sourceFormat: "ttf" | "otf" | "woff";
family: string;
subfamily: string;
fullName: string;
postscriptName: string;
weight: number;
italic: boolean;
fsType: number;
numGlyphs: number;
hasRupee: boolean;
hasBasicLatin: boolean;
size: number;
sha256: string;
warnings: string[];
}
/** One stored font file (`UserFont`). */
export interface UserFontRow {
id: number;
face: UserFace;
familyName: string;
fullName: string;
styleName: string;
weight: number;
style: "normal" | "italic";
sha256: string;
fileName: string;
format: "ttf" | "otf";
size: number;
fsType: number;
hasRupee: boolean;
licenceAckAt: string;
importedAt: string;
hidden: boolean;
}
export interface RemoveOutcome {
deleted: boolean;
hidden: boolean;
}
export interface ImportMeta {
face: UserFace;
fileName: string;
weight?: number;
italic?: boolean;
licenceAcknowledged: boolean;
}
/**
* The same magic-byte check the backend makes, to refuse early with a plain message. TTF, OTF and WOFF pass (the
* backend unpacks WOFF); the backend stays the authority and repeats every check.
*/
export function clientRefusal(bytes: Uint8Array): string | null {
const format: FontFormat = sniffFontFormat(bytes);
switch (format) {
case "ttf":
case "otf":
case "woff":
return null;
case "woff2":
return "WOFF2 cannot be used: it embeds in a PDF without glyph outlines (invisible text). Import the TTF, OTF or WOFF file instead.";
case "ttc":
return "Font collections (.ttc) are not supported. Import a single TTF or OTF file.";
default:
return "This is not a TrueType, OpenType or WOFF font file.";
}
}
export const weightName = (weight: number, italic: boolean): string => {
const names: Record<number, string> = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
return `${names[weight] ?? weight}${italic ? " Italic" : ""}`;
};
/** Visible fonts grouped by face, as the sha256 list `prefs.fonts` carries. */
export function selectionFrom(fonts: readonly UserFontRow[]): NonNullable<RenderPrefsV1["fonts"]> {
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
for (const f of fonts) {
if (f.hidden) continue;
(out[f.face] ??= []).push(f.sha256);
}
return out;
}
/**
* The prefs to render and issue with: `prefs` plus the imported fonts for the faces this template draws. Fonts for
* faces it does not use stay out, so an invoice only pins (and so only protects) the fonts it really uses. The
* result is for rendering; keep the clean `prefs` for the page-setup controls and the saved default.
*/
export function withUserFonts(prefs: RenderPrefsV1, fonts: readonly UserFontRow[]): RenderPrefsV1 {
const { fonts: _drop, ...rest } = prefs;
const faces = catalogEntry(prefs.templateId).faces ?? [];
const selection = selectionFrom(fonts);
const used: NonNullable<RenderPrefsV1["fonts"]> = {};
for (const face of faces) {
const ids = selection[face];
if (ids && ids.length > 0) used[face] = [...ids].sort();
}
return Object.keys(used).length > 0 ? { ...rest, fonts: used } : rest;
}
// Settings and the invoice view are separate parts of the UI; this lets the list in one refresh the other.
const listeners = new Set<() => void>();
export const onUserFontsChanged = (cb: () => void): (() => void) => {
listeners.add(cb);
return () => listeners.delete(cb);
};
export const notifyUserFontsChanged = (): void => listeners.forEach((cb) => cb());
+12 -1
View File
@@ -1,11 +1,22 @@
import { createAutoClient, type RenderClient } from "../pdf/render/client";
import type { UserFace } from "../pdf/fonts/userFontStore";
import { api } from "./api";
let client: RenderClient | null = null;
let lastId = 0;
/** The one render client (and so one worker) shared by the preview and export. */
export function getRenderClient(): RenderClient {
client ??= createAutoClient();
client ??= createAutoClient({
// Font metadata comes from the database row, the bytes over the binary IPC; both by the file's sha256.
loadUserFont: async (sha256) => {
const rows = await api.listUserFonts(true);
const row = rows.find((r) => r.sha256 === sha256);
if (!row) throw new Error("An imported font used by this invoice is no longer in the font library");
const bytes = await api.getUserFontBytes(sha256);
return { face: row.face as UserFace, weight: row.weight, italic: row.style === "italic", sha256, bytes };
},
});
return client;
}