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
+101 -16
View File
@@ -1,6 +1,15 @@
import { Font } from "@react-pdf/renderer";
import { FONT_MANIFEST } from "./manifest";
import { familySources } from "./stack";
import {
assertSfnt,
familyNameFor,
isUserFamilyKnown,
rememberUserFiles,
userFamilySources,
type UserFace,
type UserFontFile,
} from "./userFontStore";
export { FONT_STACK, familySources, stack, type FamilySource } from "./stack";
@@ -71,7 +80,9 @@ function primeComponents(font: FkFont): void {
for (const cp of lowest.values()) font.glyphForCodePoint(cp);
}
let primed: Promise<void> | null = null;
/** Descriptors already primed; a font registered after the first render (a user font) is primed on the next call. */
const primedKeys = new Set<string>();
let primeChain: Promise<void> = Promise.resolve();
/**
* Loads every registered face and gives the component glyphs of its composites their code points. fontkit
@@ -79,30 +90,104 @@ let primed: Promise<void> | null = null;
* time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.'
* would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00",
* textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders
* before it. Memoised; await it before the first render.
* before it. Covers the bundled families and any user family registered so far, each descriptor once, so a
* font registered later is not skipped. Await it before the first render.
*/
export function primeFontGlyphs(): Promise<void> {
if (!registered) return Promise.resolve();
if (!primed) {
primed = run().catch((err) => {
primed = null;
throw err;
});
}
return primed;
if (!registered && primedKeys.size === 0 && userFamilies.length === 0) return Promise.resolve();
const next = primeChain.then(run);
primeChain = next.catch(() => undefined);
return next;
async function run(): Promise<void> {
for (const fam of FONT_MANIFEST.families) {
for (const s of familySources(fam)) {
const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle };
await Font.load(descriptor);
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
if (data) primeComponents(data);
const descriptors: Array<{ fontFamily: string; fontWeight: number; fontStyle: "normal" | "italic" }> = [];
if (registered) {
for (const fam of FONT_MANIFEST.families) {
for (const s of familySources(fam)) descriptors.push({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
}
}
for (const family of userFamilies) {
for (const s of userFamilySources(family)) descriptors.push({ fontFamily: family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
}
for (const descriptor of descriptors) {
const key = `${descriptor.fontFamily}|${descriptor.fontWeight}|${descriptor.fontStyle}`;
if (primedKeys.has(key)) continue;
await Font.load(descriptor);
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
if (data) primeComponents(data);
primedKeys.add(key);
}
}
}
// ---- User-imported fonts (Now, Gotham, Open Sauce One) ----
const userFamilies: string[] = [];
const isNode = typeof process !== "undefined" && !!process.versions?.node;
/**
* A src react-pdf can load the bytes from. In a browser or worker that is a blob: URL (react-pdf fetches it;
* a data URI would be decoded character by character, which is slow for a multi-MB font). Under Node it is a
* temporary file, because react-pdf's Node build opens anything that is not an http(s) URL as a path.
*/
async function sourceFor(bytes: Uint8Array, id: string): Promise<string> {
if (!isNode) return URL.createObjectURL(new Blob([bytes as BlobPart], { type: "font/ttf" }));
const nodeFs = "node:fs";
const nodeOs = "node:os";
const nodePath = "node:path";
const [fs, os, path] = await Promise.all([import(/* @vite-ignore */ nodeFs), import(/* @vite-ignore */ nodeOs), import(/* @vite-ignore */ nodePath)]);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "voiced-userfont-"));
const file = path.join(dir, `${id.slice(0, 16)}.ttf`);
fs.writeFileSync(file, bytes);
return file;
}
/**
* Registers one face's files as the family `user:<Face>:<set id>` and returns that name. Every file's magic bytes
* are checked before fontkit sees it. Each weight without an italic gets its upright file as the italic. Idempotent:
* the same set registers once. Never resets the font store; a different set is a different family.
*/
export async function registerUserFontSet(face: UserFace, files: UserFontFile[]): Promise<string> {
if (files.length === 0) throw new Error(`No font files were given for ${face}`);
for (const f of files) assertSfnt(f.bytes);
const family = familyNameFor(face, files.map((f) => f.sha256));
if (isUserFamilyKnown(family)) return family;
rememberUserFiles(family, files);
const srcs = new Map<string, string>();
for (const s of userFamilySources(family)) {
if (!srcs.has(s.id)) srcs.set(s.id, await sourceFor(s.bytes, s.id));
}
Font.register({
family,
fonts: userFamilySources(family).map((s) => ({ src: srcs.get(s.id) as string, fontWeight: s.fontWeight as never, fontStyle: s.fontStyle })),
});
userFamilies.push(family);
await primeFontGlyphs();
return family;
}
/** Cheap non-cryptographic id (two FNV-1a passes, 16 hex digits) for callers that have no sha256, i.e. tests. */
function fallbackId(bytes: Uint8Array): string {
let a = 0x811c9dc5;
let b = 0x01000193;
for (let i = 0; i < bytes.length; i++) {
a = Math.imul(a ^ bytes[i], 0x01000193) >>> 0;
b = Math.imul(b ^ bytes[i], 0x811c9dc5) >>> 0;
}
return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
}
/** One-file convenience for Node tests and single-weight imports: the font as the only member of the face's set. */
export async function registerUserFont(
face: UserFace,
bytes: Uint8Array,
opts: { weight?: number; italic?: boolean; sha256?: string } = {},
): Promise<{ family: string; sha256: string }> {
const sha256 = opts.sha256 ?? fallbackId(bytes);
const family = await registerUserFontSet(face, [{ face, weight: opts.weight ?? 400, italic: opts.italic ?? false, sha256, bytes }]);
return { family, sha256 };
}
const MIN_FONT_BYTES = 10 * 1024;
function hasTrueTypeMagic(b: Uint8Array): boolean {