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.
222 lines
9.3 KiB
TypeScript
222 lines
9.3 KiB
TypeScript
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";
|
|
|
|
let registered = false;
|
|
|
|
/**
|
|
* Registers every bundled family from the manifest. Idempotent; never call Font.reset(), the fonts
|
|
* would not load again.
|
|
*
|
|
* `base` is a URL prefix in the app ("/fonts") and a directory under Node (tests). Only static TTF is
|
|
* used: WOFF2 embeds glyphs without outlines (invisible text) and variable fonts embed only their
|
|
* default instance.
|
|
*/
|
|
export function registerBundledFonts(base: string): void {
|
|
if (registered) return;
|
|
registered = true;
|
|
|
|
for (const fam of FONT_MANIFEST.families) {
|
|
const sources = familySources(fam).map((s) => ({
|
|
src: `${base}/${fam.folder}/${s.file}`,
|
|
fontWeight: s.fontWeight,
|
|
fontStyle: s.fontStyle,
|
|
}));
|
|
Font.register({ family: fam.family, fonts: sources });
|
|
}
|
|
|
|
// The default hyphenation inserts "-" into e-mail addresses, URLs and identifiers.
|
|
Font.registerHyphenationCallback((word) => [word]);
|
|
}
|
|
|
|
/** The slice of a fontkit TTF font the priming touches (fontkit's types do not declare it). */
|
|
interface FkFont {
|
|
numGlyphs: number;
|
|
characterSet: number[];
|
|
glyphForCodePoint(cp: number): unknown;
|
|
_getBaseGlyph(id: number): { _decode?(): { components?: Array<{ glyphID: number }> } | null };
|
|
_glyphs: Record<number, unknown>;
|
|
_cmapProcessor: { lookup(cp: number): number };
|
|
}
|
|
|
|
const isPrivateUse = (cp: number) => cp >= 0xe000 && cp <= 0xf8ff;
|
|
|
|
/**
|
|
* Creates, through the cmap and so with their code point, every glyph that a composite glyph uses as a
|
|
* component. Not the whole cmap: a glyph that layout substitutes in (Inter's contextual hyphen between digits)
|
|
* is mapped from a private-use code point, and creating it from there would give it a code point the text
|
|
* never had. Components that only a private-use code point reaches are left alone.
|
|
*/
|
|
function primeComponents(font: FkFont): void {
|
|
const components = new Set<number>();
|
|
// _getBaseGlyph caches what it creates, with no code points; scan into a scratch cache so that does not happen.
|
|
const cache = font._glyphs;
|
|
font._glyphs = {};
|
|
try {
|
|
for (let id = 0; id < font.numGlyphs; id++) {
|
|
const decoded = font._getBaseGlyph(id)._decode?.();
|
|
for (const c of decoded?.components ?? []) components.add(c.glyphID);
|
|
}
|
|
} finally {
|
|
font._glyphs = cache;
|
|
}
|
|
const lowest = new Map<number, number>();
|
|
for (const cp of [...font.characterSet].sort((a, b) => a - b)) {
|
|
if (isPrivateUse(cp)) continue;
|
|
const id = font._cmapProcessor.lookup(cp);
|
|
if (components.has(id) && !lowest.has(id)) lowest.set(id, cp);
|
|
}
|
|
for (const cp of lowest.values()) font.glyphForCodePoint(cp);
|
|
}
|
|
|
|
/** 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
|
|
* caches glyph objects per font for the whole process, and pdfkit creates a composite's components at embed
|
|
* 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. 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 && 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> {
|
|
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 {
|
|
if (b.length < 4) return false;
|
|
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return true;
|
|
return String.fromCharCode(b[0], b[1], b[2], b[3]) === "true";
|
|
}
|
|
|
|
/**
|
|
* Fetches every bundled font and returns a list of problems (empty when all are fine). Tauri answers
|
|
* an unknown path with index.html and HTTP 200, so the response bytes must be checked, not the status.
|
|
*/
|
|
export async function preflightFonts(
|
|
fetchBytes: (url: string) => Promise<Uint8Array>,
|
|
base: string,
|
|
): Promise<string[]> {
|
|
const problems: string[] = [];
|
|
for (const fam of FONT_MANIFEST.families) {
|
|
for (const font of fam.fonts) {
|
|
const url = `${base}/${fam.folder}/${font.file}`;
|
|
try {
|
|
const bytes = await fetchBytes(url);
|
|
if (!hasTrueTypeMagic(bytes)) problems.push(`${url}: not a TrueType font`);
|
|
else if (bytes.length <= MIN_FONT_BYTES) problems.push(`${url}: only ${bytes.length} bytes`);
|
|
} catch (err) {
|
|
problems.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
}
|
|
return problems;
|
|
}
|