152 lines
8.3 KiB
TypeScript
152 lines
8.3 KiB
TypeScript
// 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<number> {
|
|
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<number>();
|
|
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<number>): 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<number> => 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<number>): string[] {
|
|
const out = new Set<string>();
|
|
for (const ch of text) if (needsGlyph(ch) && !cps.has(ch.codePointAt(0)!)) out.add(ch);
|
|
return [...out];
|
|
}
|