// Character coverage of a font file, read from its cmap table in pure TypeScript (no font library): // TrueType / OpenType (sfnt), TrueType collections (first font), WOFF 1 (zlib) and WOFF 2 (brotli, cmap is never // transformed so it is read straight from the decompressed stream). node:zlib ships with Bun, so no dependency. // cmap subtables: formats 0, 4, 6, 12 and 13 of the Unicode / Windows Unicode encodings. import { brotliDecompressSync, inflateSync } from 'node:zlib'; const tag = (d: DataView, o: number): string => String.fromCharCode(d.getUint8(o), d.getUint8(o + 1), d.getUint8(o + 2), d.getUint8(o + 3)); const view = (b: Uint8Array): DataView => new DataView(b.buffer, b.byteOffset, b.byteLength); /** The raw cmap table of a font file (any of the formats above). Throws on anything else. */ export const cmapTable = (file: Uint8Array): Uint8Array => fontTable(file, 'cmap'); /** One raw (untransformed) table of a font file, e.g. 'cmap' or 'name'. Throws when the file is not a font or the * table is missing. */ export function fontTable(file: Uint8Array, want: string): Uint8Array { const d = view(file); if (file.byteLength < 12) throw new Error('not a font file (too short)'); const sig = tag(d, 0); if (sig === 'wOF2') return woff2Table(file, want); if (sig === 'wOFF') { const n = d.getUint16(12); for (let i = 0; i < n; i++) { const o = 44 + i * 20; if (tag(d, o) !== want) continue; const off = d.getUint32(o + 4), comp = d.getUint32(o + 8), orig = d.getUint32(o + 12); const raw = file.subarray(off, off + comp); return comp < orig ? new Uint8Array(inflateSync(raw)) : raw; } throw new Error(`woff: no ${want} table`); } let base = 0; if (sig === 'ttcf') base = d.getUint32(12); else if (!(d.getUint32(0) === 0x00010000 || sig === 'OTTO' || sig === 'true')) throw new Error(`not a font file (signature ${JSON.stringify(sig)})`); const n = d.getUint16(base + 4); for (let i = 0; i < n; i++) { const o = base + 12 + i * 16; if (tag(d, o) === want) { const off = d.getUint32(o + 8); return file.subarray(off, off + d.getUint32(o + 12)); } } throw new Error(`sfnt: no ${want} table`); } const WOFF2_TAGS = ['cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ', 'VORG', 'EBDT', 'EBLC', 'gasp', 'hdmx', 'kern', 'LTSH', 'PCLT', 'VDMX', 'vhea', 'vmtx', 'BASE', 'GDEF', 'GPOS', 'GSUB', 'EBSC', 'JSTF', 'MATH', 'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc', 'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar', 'gvar', 'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop', 'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill']; function woff2Table(file: Uint8Array, want: string): Uint8Array { const d = view(file); const n = d.getUint16(12), compLen = d.getUint32(20); let p = 48; const base128 = (): number => { let v = 0; for (let i = 0; i < 5; i++) { const b = d.getUint8(p++); v = v * 128 + (b & 0x7f); if (!(b & 0x80)) return v; } throw new Error('woff2: bad UIntBase128'); }; let off = 0, found: [number, number] | null = null; for (let i = 0; i < n; i++) { const flags = d.getUint8(p++); const t = (flags & 0x3f) === 63 ? (p += 4, tag(d, p - 4)) : WOFF2_TAGS[flags & 0x3f]!; const orig = base128(); const version = (flags >> 6) & 3; const transformed = t === 'glyf' || t === 'loca' ? version === 0 : version !== 0; const len = transformed ? base128() : orig; if (t === want) found = [off, orig]; off += len; } if (!found) throw new Error(`woff2: no ${want} table`); if (tag(d, 4) === 'ttcf') { // collection directory: skip it (version, numFonts 255UInt16, per font numTables + indices) p += 4; const u16 = (): number => { const c = d.getUint8(p++); return c === 253 ? (p += 2, d.getUint16(p - 2)) : c === 255 ? 253 + d.getUint8(p++) : c === 254 ? 506 + d.getUint8(p++) : c; }; const fonts = u16(); for (let f = 0; f < fonts; f++) { const k = u16(); p += 4; for (let j = 0; j < k; j++) u16(); } } const data = new Uint8Array(brotliDecompressSync(file.subarray(p, p + compLen))); return data.subarray(found[0], found[0] + found[1]); } /** Every Unicode code point the cmap maps to a real glyph (glyph id > 0). */ export function cmapCodepoints(cmap: Uint8Array): Set { const d = view(cmap); const n = d.getUint16(2); const subs: { pid: number; eid: number; off: number }[] = []; for (let i = 0; i < n; i++) subs.push({ pid: d.getUint16(4 + i * 8), eid: d.getUint16(6 + i * 8), off: d.getUint32(8 + i * 8) }); const rank = (s: { pid: number; eid: number }): number => (s.pid === 3 && s.eid === 10) || (s.pid === 0 && (s.eid === 4 || s.eid === 6)) ? 3 : (s.pid === 3 && s.eid === 1) || (s.pid === 0 && s.eid <= 3) ? 2 : s.pid === 3 && s.eid === 0 ? 1 : 0; const out = new Set(); for (const s of subs.filter(x => rank(x) > 0).sort((a, b) => rank(b) - rank(a))) readSub(d, s.off, out); return out; } function readSub(d: DataView, o: number, out: Set): void { const fmt = d.getUint16(o); if (fmt === 0) { for (let c = 0; c < 256; c++) if (d.getUint8(o + 6 + c)) out.add(c); return; } if (fmt === 6) { const first = d.getUint16(o + 6), cnt = d.getUint16(o + 8); for (let i = 0; i < cnt; i++) if (d.getUint16(o + 10 + i * 2)) out.add(first + i); return; } if (fmt === 4) { const segs = d.getUint16(o + 6) / 2; const ends = o + 14, starts = ends + segs * 2 + 2, deltas = starts + segs * 2, ranges = deltas + segs * 2; for (let i = 0; i < segs; i++) { const end = d.getUint16(ends + i * 2), start = d.getUint16(starts + i * 2), delta = d.getInt16(deltas + i * 2), ro = d.getUint16(ranges + i * 2); for (let c = start; c <= end && c !== 0xffff; c++) { let g: number; if (!ro) g = (c + delta) & 0xffff; else { g = d.getUint16(ranges + i * 2 + ro + (c - start) * 2); if (g) g = (g + delta) & 0xffff; } if (g) out.add(c); } } return; } if (fmt === 12 || fmt === 13) { const groups = d.getUint32(o + 12); for (let i = 0; i < groups; i++) { const g = o + 16 + i * 12, start = d.getUint32(g), end = d.getUint32(g + 4), gid = d.getUint32(g + 8); for (let c = start; c <= end && c <= 0x10ffff; c++) if (fmt === 13 ? gid : gid + (c - start)) out.add(c); } } } /** Family name from the name table (typographic family 16, else family 1; Windows Unicode, else Mac Roman), or null. */ export function fontFamilyName(file: Uint8Array): string | null { let t: Uint8Array; try { t = fontTable(file, 'name'); } catch { return null; } const d = view(t); const count = d.getUint16(2), strings = d.getUint16(4); const recs: { pid: number; lang: number; id: number; text: string }[] = []; for (let i = 0; i < count && 6 + i * 12 + 12 <= t.byteLength; i++) { const o = 6 + i * 12; const pid = d.getUint16(o), lang = d.getUint16(o + 4), id = d.getUint16(o + 6), len = d.getUint16(o + 8), off = strings + d.getUint16(o + 10); if ((id !== 1 && id !== 16) || off + len > t.byteLength) continue; const raw = t.subarray(off, off + len); let text = ''; if (pid === 3 || pid === 0) for (let k = 0; k + 1 < raw.length; k += 2) text += String.fromCharCode((raw[k]! << 8) | raw[k + 1]!); else if (pid === 1) text = String.fromCharCode(...raw); else continue; recs.push({ pid, lang, id, text: text.trim() }); } const score = (r: { pid: number; lang: number; id: number }): number => (r.id === 16 ? 10 : 0) + (r.pid === 3 ? 4 : r.pid === 0 ? 3 : 1) + (r.lang === 0x409 || r.lang === 0 ? 1 : 0); return recs.filter(r => r.text).sort((a, b) => score(b) - score(a))[0]?.text ?? null; } /** Code points a font file covers. */ export const fontCodepoints = (file: Uint8Array): Set => cmapCodepoints(cmapTable(file)); /** Characters that need a glyph: everything except whitespace, controls, joiners and variation selectors (those * render without one or are handled by shaping). */ export const needsGlyph = (ch: string): boolean => !/^[\s\p{Cc}\p{Cf}\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}]$/u.test(ch); /** Characters of `text` (unique, in first-seen order) a font with `cps` does not cover. */ export function missingChars(text: string, cps: Set): string[] { const out = new Set(); for (const ch of text) if (needsGlyph(ch) && !cps.has(ch.codePointAt(0)!)) out.add(ch); return [...out]; }