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.
202 lines
7.9 KiB
TypeScript
202 lines
7.9 KiB
TypeScript
import { create, type Font } from "fontkit";
|
|
import { FONT_MANIFEST } from "../fonts/manifest";
|
|
import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack";
|
|
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
|
import { userFamilySources } from "../fonts/userFontStore";
|
|
|
|
export interface MeasureInput {
|
|
text: string;
|
|
/** A registered family name (after resolveFace). */
|
|
face: string;
|
|
weight: number;
|
|
italic?: boolean;
|
|
size: number;
|
|
/** pt added after every glyph, including the last one (textkit does the same). */
|
|
letterSpacing?: number;
|
|
}
|
|
|
|
export type WrapInput = Omit<MeasureInput, "text">;
|
|
|
|
export interface Measurer {
|
|
/** Advance width of a single line in pt, with kerning and font fallback as textkit applies them. */
|
|
measureWidth(input: MeasureInput): number;
|
|
/**
|
|
* Greedy word-wrap that predicts textkit's line breaks. Estimate for planning only: textkit's
|
|
* Knuth-Plass breaker lets spaces shrink on an overfull line, so it can fit a word this routine
|
|
* pushes to the next line (and the other way round for very narrow boxes).
|
|
*/
|
|
wrapLines(text: string, width: number, input: WrapInput): string[];
|
|
/** Code points that no family in the stack covers (they would render as .notdef). */
|
|
missing(text: string, face?: string): number[];
|
|
}
|
|
|
|
const userFonts = new Map<string, Font>();
|
|
|
|
/**
|
|
* The fontkit font of a user-imported family at a weight and style (the file react-pdf resolves for it), or
|
|
* undefined when `familyName` is not a user family. Parsed once per file and shared by every measurer.
|
|
*/
|
|
export function userFontkitFont(familyName: string, weight: number, italic: boolean): Font | null | undefined {
|
|
const sources = userFamilySources(familyName);
|
|
if (sources.length === 0) return undefined;
|
|
const src = resolveSource(
|
|
sources.map((s) => ({ file: s.id, fontWeight: s.fontWeight, fontStyle: s.fontStyle })),
|
|
weight,
|
|
italic ? "italic" : "normal",
|
|
);
|
|
if (!src) return null;
|
|
let font = userFonts.get(src.file);
|
|
if (!font) {
|
|
font = create((sources.find((s) => s.id === src.file) as { bytes: Uint8Array }).bytes) as Font;
|
|
userFonts.set(src.file, font);
|
|
}
|
|
return font;
|
|
}
|
|
|
|
/** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */
|
|
function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined {
|
|
const styled = sources.filter((s) => s.fontStyle === style);
|
|
const exact = styled.find((s) => s.fontWeight === weight);
|
|
if (exact) return exact;
|
|
const byWeight = (a: FamilySource, b: FamilySource) => a.fontWeight - b.fontWeight;
|
|
const lt = styled.filter((s) => s.fontWeight < weight).sort(byWeight);
|
|
const gt = styled.filter((s) => s.fontWeight > weight).sort(byWeight);
|
|
if (weight >= 400 && weight <= 500) {
|
|
const left = styled.filter((s) => s.fontWeight <= weight);
|
|
const right = styled.filter((s) => s.fontWeight > 500);
|
|
const fit = styled.filter((s) => s.fontWeight >= weight && s.fontWeight <= 500);
|
|
return fit[0] ?? left[left.length - 1] ?? right[0];
|
|
}
|
|
if (weight < 400) return lt[lt.length - 1] ?? gt[0];
|
|
return gt[0] ?? lt[lt.length - 1];
|
|
}
|
|
|
|
const SOFT_HYPHEN = 0xad;
|
|
|
|
/**
|
|
* `loadBytes` receives a path relative to the fonts folder ("ibm-plex-sans/IBMPlexSans-Regular.ttf")
|
|
* and must be synchronous; in a worker, preload the files first and serve them from memory.
|
|
*/
|
|
export function createMeasurer(loadBytes: (file: string) => Uint8Array): Measurer {
|
|
const fonts = new Map<string, Font>();
|
|
|
|
function load(familyName: string, weight: number, italic: boolean): Font | null {
|
|
const own = userFontkitFont(familyName, weight, italic);
|
|
if (own !== undefined) return own;
|
|
const fam = FONT_MANIFEST.families.find((f) => f.family === familyName);
|
|
if (!fam) return null;
|
|
const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal");
|
|
if (!src) return null;
|
|
const file = `${fam.folder}/${src.file}`;
|
|
let font = fonts.get(file);
|
|
if (!font) {
|
|
font = create(loadBytes(file));
|
|
fonts.set(file, font);
|
|
}
|
|
return font;
|
|
}
|
|
|
|
function stackFonts(face: string, weight: number, italic: boolean): Font[] {
|
|
return stack(face)
|
|
.map((f) => load(f, weight, italic))
|
|
.filter((f): f is Font => f !== null);
|
|
}
|
|
|
|
// Mirrors textkit's fontSubstitution: the first stack font with the glyph wins, and the last stack
|
|
// font takes code points nobody covers.
|
|
function runsOf(text: string, fontStack: Font[]): Array<{ font: Font; text: string }> {
|
|
const runs: Array<{ font: Font; text: string }> = [];
|
|
let last: Font | null = null;
|
|
for (const ch of text) {
|
|
const cp = ch.codePointAt(0) as number;
|
|
let font: Font | undefined;
|
|
if (cp === SOFT_HYPHEN && last) font = last;
|
|
else font = [...fontStack, ...(last ? [last] : [])].find((f) => f.hasGlyphForCodePoint(cp));
|
|
font ??= fontStack[fontStack.length - 1];
|
|
const tail = runs[runs.length - 1];
|
|
if (tail && tail.font === font) tail.text += ch;
|
|
else runs.push({ font, text: ch });
|
|
last = font;
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
function measureWidth({ text, face, weight, italic = false, size, letterSpacing = 0 }: MeasureInput): number {
|
|
if (!text) return 0;
|
|
const fontStack = stackFonts(face, weight, italic);
|
|
let width = 0;
|
|
for (const run of runsOf(text, fontStack)) {
|
|
// 'ltr' as textkit passes it, so fontkit does not reverse anything.
|
|
const laid = run.font.layout(run.text, undefined, undefined, undefined, "ltr");
|
|
const scale = size / run.font.unitsPerEm;
|
|
for (const pos of laid.positions) width += pos.xAdvance * scale + letterSpacing;
|
|
}
|
|
return width;
|
|
}
|
|
|
|
function wrapLines(text: string, width: number, input: WrapInput): string[] {
|
|
const lines: string[] = [];
|
|
// Break opportunities: after a space run, and after a hyphen that is followed by more text.
|
|
const tokens = text.match(/[^ -]*-(?=[^ ])|[^ ]+ *|^ +/g) ?? [];
|
|
let line = "";
|
|
for (const token of tokens) {
|
|
const candidate = line + token;
|
|
if (!line || measureWidth({ ...input, text: candidate.trimEnd() }) <= width + 1e-6) {
|
|
line = candidate;
|
|
} else {
|
|
lines.push(line.trimEnd());
|
|
line = token;
|
|
}
|
|
}
|
|
if (line) lines.push(line.trimEnd());
|
|
return lines;
|
|
}
|
|
|
|
function missing(text: string, face = FONT_STACK[0]): number[] {
|
|
const fontStack = stackFonts(face, 400, false);
|
|
const out = new Set<number>();
|
|
for (const ch of text) {
|
|
const cp = ch.codePointAt(0) as number;
|
|
if (cp === SOFT_HYPHEN || cp === 0x200c || cp === 0x200d) continue;
|
|
if (!fontStack.some((f) => f.hasGlyphForCodePoint(cp))) out.add(cp);
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
return { measureWidth, wrapLines, missing };
|
|
}
|
|
|
|
let defaultMeasurer: Measurer | null = null;
|
|
|
|
/** The render core installs one measurer (after preloading font bytes); primitives read it. */
|
|
export function setMeasurer(m: Measurer | null): void {
|
|
defaultMeasurer = m;
|
|
}
|
|
|
|
export function getMeasurer(): Measurer {
|
|
if (!defaultMeasurer) throw new Error("No text measurer installed; call setMeasurer(createMeasurer(...)) first");
|
|
return defaultMeasurer;
|
|
}
|
|
|
|
/** The text as it will be drawn: caps applied. */
|
|
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text);
|
|
|
|
/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
|
|
export function measureToken(
|
|
m: Measurer,
|
|
role: RoleName | TypeToken,
|
|
text: string,
|
|
opts: { size?: number; weight?: number; italic?: boolean; family?: string } = {},
|
|
): number {
|
|
const t = roleToken(role);
|
|
const size = opts.size ?? t.size;
|
|
return m.measureWidth({
|
|
text: drawnText(t, text),
|
|
face: opts.family ?? resolveFace(t.face),
|
|
weight: opts.weight ?? t.weight,
|
|
italic: opts.italic ?? t.italic ?? false,
|
|
size,
|
|
letterSpacing: (t.tracking ?? 0) * size,
|
|
});
|
|
}
|