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; 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(); /** * 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(); 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(); 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, }); }