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
+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());