diff --git a/packages/fallbacks/README.md b/packages/fallbacks/README.md index 3572fba..c9f77dd 100644 --- a/packages/fallbacks/README.md +++ b/packages/fallbacks/README.md @@ -107,6 +107,27 @@ getRenderableFallbackForFace("Baskerville Old Face", "bold", opts); The full structured rows are exported as `SUBSTITUTION_EVIDENCE` for richer reporting (faces, per-face verdicts, glyph exceptions). +## Local tools + +These maintainer tools use ignored `.cache` files and are not shipped in the package. + +`bun run acquire` downloads open-font candidate archives into `.cache/sources`. Set `DOCFONTS_SOURCE_CACHE` to use another cache directory. + +`bun run compare` checks a private reference font against acquired OTF/TTF candidates and prints a ranked Latin advance-width table. It writes no fonts, paths, or results to the tree. + +```sh +bun run --cwd packages/fallbacks compare -- \ + --reference /path/to/reference.ttf \ + --family "Bookman Old Style" \ + --source tex-gyre-bonum +``` + +- `--reference` (required) - path to the font to measure against. +- `--family` - a label shown in the report header. +- `--source` - restrict to one or more acquired source ids (repeat the flag or comma-separate). Defaults to every acquired source. + +The comparison is a lead finder, not an automatic verdict. It measures Latin advance widths over a fixed sample and reports the tier, coverage, outlier counts, and worst glyphs for each candidate. + ## Provenance The data comes from reviewed docfonts evidence. Measurements are produced against licensed originals, but this package distributes no proprietary binaries or raw proprietary metrics. diff --git a/packages/fallbacks/compare.test.ts b/packages/fallbacks/compare.test.ts new file mode 100644 index 0000000..dd19b0a --- /dev/null +++ b/packages/fallbacks/compare.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, test } from "bun:test"; +import { + classifyTier, + type FontMetrics, + LATIN_SAMPLE, + parseFont, + renderReport, + sampleMetrics, + scoreAdvances, +} from "./scripts/compare"; + +// --- Synthetic SFNT builder ------------------------------------------------- +// +// Assemble a minimal valid TrueType font with the five tables the parser reads (head, maxp, hhea, +// hmtx, cmap). Four glyphs: gid0 .notdef, gid1..3 advances 600/300/750 over a 1000 unit em. The +// format-4 cmap maps space -> gid3, 'A' -> gid1, 'B' -> gid2. + +const UNITS_PER_EM = 1000; +const ADVANCES = [500, 600, 300, 750]; // by glyph id + +function u16(value: number): number[] { + return [(value >> 8) & 0xff, value & 0xff]; +} +function i16(value: number): number[] { + return u16(value & 0xffff); +} +function u32(value: number): number[] { + return [ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]; +} +function tag(name: string): number[] { + return [...name].map((c) => c.charCodeAt(0)); +} + +function headTable(): number[] { + return [ + ...u32(0x00010000), // version + ...u32(0), // fontRevision + ...u32(0), // checkSumAdjustment + ...u32(0x5f0f3cf5), // magicNumber + ...u16(0), // flags + ...u16(UNITS_PER_EM), // unitsPerEm @ offset 18 + ...u32(0), + ...u32(0), // created + ...u32(0), + ...u32(0), // modified + ...i16(0), + ...i16(0), + ...i16(0), + ...i16(0), // bbox + ...u16(0), // macStyle + ...u16(8), // lowestRecPPEM + ...i16(0), // fontDirectionHint + ...i16(0), // indexToLocFormat + ...i16(0), // glyphDataFormat + ]; +} + +function maxpTable(numGlyphs: number): number[] { + return [...u32(0x00005000), ...u16(numGlyphs)]; // version 0.5 +} + +function hheaTable(numberOfHMetrics: number): number[] { + const bytes = new Array(36).fill(0); + bytes.splice(0, 4, ...u32(0x00010000)); // version + bytes.splice(34, 2, ...u16(numberOfHMetrics)); // numberOfHMetrics @ offset 34 + return bytes; +} + +function hmtxTable(): number[] { + return ADVANCES.flatMap((advance) => [...u16(advance), ...i16(0)]); +} + +function cmapFormat4(): number[] { + // Segments: [0x20,0x20]->gid3, [0x41,0x42]->gid1..2, [0xFFFF,0xFFFF] sentinel. + const endCodes = [0x20, 0x42, 0xffff]; + const startCodes = [0x20, 0x41, 0xffff]; + const idDeltas = [(3 - 0x20) & 0xffff, (1 - 0x41) & 0xffff, 1]; + const idRangeOffsets = [0, 0, 0]; + const segCount = endCodes.length; + const segX2 = segCount * 2; + const sub = [ + ...u16(4), // format + ...u16(14 + segX2 * 4 + 2), // length + ...u16(0), // language + ...u16(segX2), // segCountX2 + ...u16(4), // searchRange + ...u16(1), // entrySelector + ...u16(2), // rangeShift + ...endCodes.flatMap(u16), + ...u16(0), // reservedPad + ...startCodes.flatMap(u16), + ...idDeltas.flatMap(i16), + ...idRangeOffsets.flatMap(u16), + ]; + const header = [ + ...u16(0), // version + ...u16(1), // numTables + ...u16(3), // platformID (Windows) + ...u16(1), // encodingID (BMP) + ...u32(12), // subtable offset + ]; + return [...header, ...sub]; +} + +/** Same format-4 data, but advertised under a non-Unicode (Macintosh) platform/encoding. */ +function cmapMacintoshOnly(): number[] { + const sub = cmapFormat4().slice(12); // drop the Windows header, keep the subtable bytes + const header = [ + ...u16(0), // version + ...u16(1), // numTables + ...u16(1), // platformID (Macintosh) + ...u16(0), // encodingID (Roman) + ...u32(12), // subtable offset + ]; + return [...header, ...sub]; +} + +function buildFont(tables: { name: string; data: number[] }[]): Uint8Array { + const numTables = tables.length; + const headerSize = 12 + numTables * 16; + let offset = headerSize; + const placed = tables.map((t) => { + const at = offset; + offset += t.data.length; + offset = (offset + 3) & ~3; // 4-byte align + return { ...t, offset: at }; + }); + + const header = [ + ...u32(0x00010000), // sfntVersion + ...u16(numTables), + ...u16(0), // searchRange + ...u16(0), // entrySelector + ...u16(0), // rangeShift + ]; + const directory = placed.flatMap((t) => [ + ...tag(t.name), + ...u32(0), // checksum (ignored) + ...u32(t.offset), + ...u32(t.data.length), + ]); + + const bytes = new Uint8Array(offset); + bytes.set([...header, ...directory], 0); + for (const t of placed) bytes.set(t.data, t.offset); + return bytes; +} + +function syntheticFont(): Uint8Array { + return buildFont([ + { name: "cmap", data: cmapFormat4() }, + { name: "head", data: headTable() }, + { name: "hhea", data: hheaTable(ADVANCES.length) }, + { name: "hmtx", data: hmtxTable() }, + { name: "maxp", data: maxpTable(ADVANCES.length) }, + ]); +} + +// --- Latin sample ----------------------------------------------------------- + +describe("LATIN_SAMPLE", () => { + test("covers printable ASCII and is sorted and unique", () => { + expect(LATIN_SAMPLE).toContain(0x20); // space + expect(LATIN_SAMPLE).toContain(0x7e); // tilde + expect(LATIN_SAMPLE).toContain(0x41); // 'A' + for (let cp = 0x20; cp <= 0x7e; cp++) expect(LATIN_SAMPLE).toContain(cp); + expect(new Set(LATIN_SAMPLE).size).toBe(LATIN_SAMPLE.length); + expect([...LATIN_SAMPLE]).toEqual([...LATIN_SAMPLE].sort((a, b) => a - b)); + }); + + test("includes common Latin-1 and General Punctuation marks", () => { + expect(LATIN_SAMPLE).toContain(0x00c0); // A with grave + expect(LATIN_SAMPLE).toContain(0x00e9); // e with acute + expect(LATIN_SAMPLE).toContain(0x00a7); // section sign + expect(LATIN_SAMPLE).toContain(0x00bf); // inverted question + expect(LATIN_SAMPLE).toContain(0x2014); // em dash codepoint + expect(LATIN_SAMPLE).toContain(0x20ac); // euro + expect(LATIN_SAMPLE).not.toContain(0x00ad); // soft hyphen is not a visible glyph + }); +}); + +// --- Tiers ------------------------------------------------------------------ + +describe("classifyTier", () => { + test("matches the verdict thresholds at the boundaries", () => { + expect(classifyTier(0.005, 0.01)).toBe("metric_safe"); + expect(classifyTier(0.0051, 0.01)).toBe("near_metric"); + expect(classifyTier(0.005, 0.011)).toBe("near_metric"); + expect(classifyTier(0.01, 0.025)).toBe("near_metric"); + expect(classifyTier(0.0101, 0.025)).toBe("visual_only"); + expect(classifyTier(0.01, 0.026)).toBe("visual_only"); + expect(classifyTier(0, 0)).toBe("metric_safe"); + }); +}); + +// --- Scoring ---------------------------------------------------------------- + +describe("scoreAdvances", () => { + const sample = [0x41, 0x42, 0x43]; + + test("compares only shared codepoints and reports coverage", () => { + const reference = new Map([ + [0x41, 0.5], + [0x42, 0.6], + [0x43, 0.7], + ]); + const candidate = new Map([ + [0x41, 0.5], + [0x42, 0.6], + // 0x43 unmapped by candidate + ]); + const score = scoreAdvances(reference, candidate, sample); + expect(score.compared).toBe(2); + expect(score.total).toBe(3); + expect(score.missing).toBe(1); + expect(score.meanDelta).toBe(0); + expect(score.maxDelta).toBe(0); + expect(score.over1Percent).toBe(0); + expect(score.over2_5Percent).toBe(0); + expect(score.tier).toBe("metric_safe"); + expect(score.worstGlyphs).toEqual([]); + }); + + test("computes mean and max deltas and worst glyphs", () => { + const reference = new Map([ + [0x41, 0.5], + [0x42, 0.5], + [0x43, 0.5], + ]); + const candidate = new Map([ + [0x41, 0.5], // delta 0 + [0x42, 0.52], // delta 0.02 + [0x43, 0.56], // delta 0.06 + ]); + const score = scoreAdvances(reference, candidate, sample); + expect(score.compared).toBe(3); + expect(score.maxDelta).toBeCloseTo(0.06, 10); + expect(score.meanDelta).toBeCloseTo((0 + 0.02 + 0.06) / 3, 10); + expect(score.over1Percent).toBe(2); + expect(score.over2_5Percent).toBe(1); + expect(score.tier).toBe("visual_only"); + expect(score.worstGlyphs.map((g) => g.codepoint)).toEqual([0x43, 0x42]); + }); + + test("reports the floor tier when nothing overlaps", () => { + const score = scoreAdvances(new Map([[0x41, 0.5]]), new Map(), sample); + expect(score.compared).toBe(0); + expect(score.tier).toBe("visual_only"); + expect(Number.isNaN(score.meanDelta)).toBe(true); + expect(Number.isNaN(score.maxDelta)).toBe(true); + }); +}); + +// --- SFNT parsing ----------------------------------------------------------- + +describe("parseFont", () => { + test("reads unitsPerEm and normalized advances from a synthetic SFNT", () => { + const font = parseFont(syntheticFont()); + expect(font.unitsPerEm).toBe(UNITS_PER_EM); + expect(font.normalizedAdvance(0x41)).toBeCloseTo(600 / 1000, 10); // 'A' -> gid1 + expect(font.normalizedAdvance(0x42)).toBeCloseTo(300 / 1000, 10); // 'B' -> gid2 + expect(font.normalizedAdvance(0x20)).toBeCloseTo(750 / 1000, 10); // space -> gid3 + expect(font.normalizedAdvance(0x43)).toBeUndefined(); // 'C' unmapped + }); + + test("sampleMetrics + scoreAdvances against itself is identical", () => { + const font = parseFont(syntheticFont()); + const metrics = sampleMetrics(font, [0x20, 0x41, 0x42]); + const score = scoreAdvances(metrics, metrics, [0x20, 0x41, 0x42]); + expect(score.compared).toBe(3); + expect(score.meanDelta).toBe(0); + expect(score.tier).toBe("metric_safe"); + }); + + test("throws on a missing required table", () => { + const noCmap = buildFont([ + { name: "head", data: headTable() }, + { name: "hhea", data: hheaTable(ADVANCES.length) }, + { name: "hmtx", data: hmtxTable() }, + { name: "maxp", data: maxpTable(ADVANCES.length) }, + ]); + expect(() => parseFont(noCmap)).toThrow(/missing required table/); + }); + + test("throws on a font collection container", () => { + const ttcf = new Uint8Array([...u32(0x74746366), ...u32(0), ...u32(0)]); + expect(() => parseFont(ttcf)).toThrow(/collection/); + }); + + test("throws on bytes that are not an SFNT", () => { + expect(() => parseFont(new Uint8Array([1, 2, 3, 4]))).toThrow(/too small/); + expect(() => + parseFont(new Uint8Array([...u32(0xdeadbeef), ...u32(0), ...u32(0)])), + ).toThrow(/not an SFNT/); + }); + + test("throws when the only cmap subtable is non-Unicode", () => { + const macOnly = buildFont([ + { name: "cmap", data: cmapMacintoshOnly() }, + { name: "head", data: headTable() }, + { name: "hhea", data: hheaTable(ADVANCES.length) }, + { name: "hmtx", data: hmtxTable() }, + { name: "maxp", data: maxpTable(ADVANCES.length) }, + ]); + expect(() => parseFont(macOnly)).toThrow(/no readable Unicode cmap/); + }); +}); + +// --- Report ----------------------------------------------------------------- + +describe("renderReport", () => { + const mockFont = (advance: number): FontMetrics => ({ + unitsPerEm: 1000, + normalizedAdvance: () => advance, + }); + /** A font that maps only `mapped`, with a constant advance, so we can force a missing count. */ + const partialFont = (advance: number, mapped: number[]): FontMetrics => ({ + unitsPerEm: 1000, + normalizedAdvance: (cp) => (mapped.includes(cp) ? advance : undefined), + }); + + test("ranks metric_safe above visual_only and includes the columns", () => { + const reference = sampleMetrics(mockFont(0.5), [0x41]); + const close = scoreAdvances( + reference, + sampleMetrics(mockFont(0.5), [0x41]), + [0x41], + ); + const far = scoreAdvances( + reference, + sampleMetrics(mockFont(0.7), [0x41]), + [0x41], + ); + const report = renderReport([ + { sourceId: "far-src", file: "far.otf", score: far }, + { sourceId: "near-src", file: "near.otf", score: close }, + ]); + const lines = report.split("\n"); + expect(lines[0]).toContain("source"); + expect(lines[0]).toContain("coverage"); + expect(lines[0]).toContain("missing"); + expect(lines[0]).toContain("over1"); + expect(lines[0]).toContain("over2.5"); + expect(lines[0]).toContain("worst"); + // metric_safe row sorts first. + expect(lines[1]).toContain("near-src"); + expect(lines[1]).toContain("metric_safe"); + expect(lines[2]).toContain("far-src"); + expect(lines[2]).toContain("visual_only"); + }); + + test("prints coverage and the missing count", () => { + const sample = [0x41, 0x42, 0x43]; + const reference = sampleMetrics(mockFont(0.5), sample); + // Candidate maps only 0x41 and 0x42, so one sample codepoint is missing. + const score = scoreAdvances( + reference, + sampleMetrics(partialFont(0.5, [0x41, 0x42]), sample), + sample, + ); + expect(score.compared).toBe(2); + expect(score.missing).toBe(1); + const report = renderReport([{ sourceId: "src", file: "c.otf", score }]); + const headers = report.split("\n")[0].split(/\s+/); + const row = report.split("\n")[1].split(/\s+/); + expect(row[headers.indexOf("coverage")]).toBe("2/3"); + expect(row[headers.indexOf("missing")]).toBe("1"); + expect(row[headers.indexOf("over1")]).toBe("0"); + expect(row[headers.indexOf("over2.5")]).toBe("0"); + }); +}); diff --git a/packages/fallbacks/package.json b/packages/fallbacks/package.json index c513ab8..f38938e 100644 --- a/packages/fallbacks/package.json +++ b/packages/fallbacks/package.json @@ -37,6 +37,7 @@ "scripts": { "gen:data": "bun run scripts/generate-data.ts", "acquire": "bun run scripts/acquire.ts", + "compare": "bun run scripts/compare.ts", "build": "tsc -p tsconfig.build.json", "prepack": "bun run build" }, diff --git a/packages/fallbacks/scripts/compare.ts b/packages/fallbacks/scripts/compare.ts new file mode 100644 index 0000000..10a35da --- /dev/null +++ b/packages/fallbacks/scripts/compare.ts @@ -0,0 +1,573 @@ +/** + * Local maintainer tool: compare a private reference font with acquired open-font archives. + * Reads ignored cache files, prints to stdout, and writes nothing to the tree. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +const PKG_DIR = join(import.meta.dir, ".."); +const DEFAULT_CACHE_DIR = join(PKG_DIR, ".cache", "sources"); +const SNAPSHOT_FILE = "source-snapshot.json"; +const RAW_SFNT_EXTENSIONS = [".otf", ".ttf"]; + +// --- Latin sample ----------------------------------------------------------- + +/** Inclusive codepoint range helper for building the sample. */ +function codepointRange(start: number, end: number): number[] { + const out: number[] = []; + for (let cp = start; cp <= end; cp++) out.push(cp); + return out; +} + +/** + * Fixed Latin sample for advance comparison: every printable ASCII codepoint (U+0020 space through + * U+007E tilde), Latin-1 letters with diacritics, and common punctuation/symbols a document is likely + * to use. Named and tested so the metric is reproducible. Stored as numeric codepoints, sorted and + * unique. + */ +export const LATIN_SAMPLE: readonly number[] = (() => { + const latin1 = codepointRange(0x00a0, 0x00ff).filter((cp) => cp !== 0x00ad); + const generalPunctuation = [ + 0x2013, 0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2020, 0x2021, 0x2022, + 0x2026, 0x2030, 0x2039, 0x203a, 0x20ac, 0x2122, + ]; + const all = [...codepointRange(0x20, 0x7e), ...latin1, ...generalPunctuation]; + return [...new Set(all)].sort((a, b) => a - b); +})(); + +// --- Tiers ------------------------------------------------------------------ + +/** + * Advance-fidelity tier. Thresholds mirror the package's verdict language (see `src/types.ts`): + * metric_safe is the DIRECT band, near_metric the LIKELY band, everything else visual_only. + */ +export type CompareTier = "metric_safe" | "near_metric" | "visual_only"; + +const TIER_RANK: Record = { + metric_safe: 0, + near_metric: 1, + visual_only: 2, +}; + +/** Classify a (mean, max) advance-delta pair into a fidelity tier. Deltas are fractions of the em. */ +export function classifyTier(meanDelta: number, maxDelta: number): CompareTier { + if (meanDelta <= 0.005 && maxDelta <= 0.01) return "metric_safe"; + if (meanDelta <= 0.01 && maxDelta <= 0.025) return "near_metric"; + return "visual_only"; +} + +// --- SFNT parsing ----------------------------------------------------------- + +const REQUIRED_TABLES = ["head", "maxp", "hhea", "hmtx", "cmap"] as const; + +/** A parsed font's em size plus a normalized advance lookup over its Unicode `cmap`. */ +export interface FontMetrics { + unitsPerEm: number; + /** Advance width of a codepoint as a fraction of the em, or undefined when the font does not map it. */ + normalizedAdvance(codepoint: number): number | undefined; +} + +function tagAt(view: DataView, offset: number): string { + return String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3), + ); +} + +/** Resolve a codepoint to a glyph id within one `cmap` subtable, for the formats we support (4, 6, 12). */ +function makeCmapLookup( + view: DataView, + subOffset: number, +): (codepoint: number) => number | undefined { + const format = view.getUint16(subOffset); + + if (format === 4) { + const segX2 = view.getUint16(subOffset + 6); + const segCount = segX2 / 2; + const endOffset = subOffset + 14; + const startOffset = endOffset + segX2 + 2; // skip reservedPad + const deltaOffset = startOffset + segX2; + const rangeOffsetBase = deltaOffset + segX2; + return (cp) => { + if (cp > 0xffff) return undefined; + for (let i = 0; i < segCount; i++) { + const end = view.getUint16(endOffset + i * 2); + if (cp > end) continue; + const start = view.getUint16(startOffset + i * 2); + if (cp < start) return undefined; + const delta = view.getInt16(deltaOffset + i * 2); + const rangeOffset = view.getUint16(rangeOffsetBase + i * 2); + if (rangeOffset === 0) { + const gid = (cp + delta) & 0xffff; + return gid === 0 ? undefined : gid; + } + const glyphOffset = + rangeOffsetBase + i * 2 + rangeOffset + (cp - start) * 2; + const raw = view.getUint16(glyphOffset); + if (raw === 0) return undefined; + const gid = (raw + delta) & 0xffff; + return gid === 0 ? undefined : gid; + } + return undefined; + }; + } + + if (format === 6) { + const firstCode = view.getUint16(subOffset + 6); + const entryCount = view.getUint16(subOffset + 8); + return (cp) => { + if (cp < firstCode || cp >= firstCode + entryCount) return undefined; + const gid = view.getUint16(subOffset + 10 + (cp - firstCode) * 2); + return gid === 0 ? undefined : gid; + }; + } + + if (format === 12) { + const numGroups = view.getUint32(subOffset + 12); + const groupsOffset = subOffset + 16; + return (cp) => { + let lo = 0; + let hi = numGroups - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const g = groupsOffset + mid * 12; + const start = view.getUint32(g); + const end = view.getUint32(g + 4); + if (cp < start) hi = mid - 1; + else if (cp > end) lo = mid + 1; + else { + const gid = view.getUint32(g + 8) + (cp - start); + return gid === 0 ? undefined : gid; + } + } + return undefined; + }; + } + + throw new Error(`unsupported cmap subtable format: ${format}`); +} + +/** Pick the best Unicode `cmap` subtable and return its glyph lookup. */ +function readCmap( + view: DataView, + cmapOffset: number, +): (codepoint: number) => number | undefined { + const numSubtables = view.getUint16(cmapOffset + 2); + const candidates: { score: number; offset: number }[] = []; + for (let i = 0; i < numSubtables; i++) { + const recordOffset = cmapOffset + 4 + i * 8; + const platformId = view.getUint16(recordOffset); + const encodingId = view.getUint16(recordOffset + 2); + const score = cmapPreference(platformId, encodingId); + // Skip non-Unicode subtables (Macintosh, Windows symbol, ...): their codepoints are not Unicode, + // so reading Latin advances through them would be wrong. We never fall back to one. + if (score === null) continue; + candidates.push({ + score, + offset: cmapOffset + view.getUint32(recordOffset + 4), + }); + } + candidates.sort((a, b) => b.score - a.score); + + for (const candidate of candidates) { + const format = view.getUint16(candidate.offset); + if (format === 4 || format === 6 || format === 12) + return makeCmapLookup(view, candidate.offset); + } + throw new Error("unsupported font: no readable Unicode cmap subtable"); +} + +/** Rank Unicode `cmap` subtables (full Unicode first, then BMP); null for non-Unicode subtables. */ +function cmapPreference(platformId: number, encodingId: number): number | null { + if (platformId === 3 && encodingId === 10) return 4; // Windows Unicode UCS-4 + if (platformId === 0 && (encodingId === 4 || encodingId === 6)) return 3; // Unicode full + if (platformId === 3 && encodingId === 1) return 2; // Windows Unicode BMP + if (platformId === 0) return 1; // Unicode BMP and earlier + return null; // Macintosh, Windows symbol, and anything else: not a Unicode cmap +} + +/** + * Parse just enough of an SFNT font (TrueType or CFF/OTF) to read normalized advance widths by + * codepoint. Throws an explicit error when the container is a collection or a required table is missing. + */ +export function parseFont(bytes: Uint8Array): FontMetrics { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.byteLength < 12) + throw new Error("unsupported font: file is too small to be an SFNT"); + + const sfntVersion = view.getUint32(0); + if (sfntVersion === 0x74746366) + throw new Error("unsupported font: TrueType/OpenType collections (ttcf)"); + const isSfnt = + sfntVersion === 0x00010000 || // TrueType outlines + sfntVersion === 0x4f54544f || // 'OTTO' - CFF outlines + sfntVersion === 0x74727565; // 'true' + if (!isSfnt) + throw new Error( + `unsupported font: not an SFNT (sfntVersion 0x${sfntVersion.toString(16)})`, + ); + + const numTables = view.getUint16(4); + const tables = new Map(); + for (let i = 0; i < numTables; i++) { + const recordOffset = 12 + i * 16; + tables.set(tagAt(view, recordOffset), view.getUint32(recordOffset + 8)); + } + + const missing = REQUIRED_TABLES.filter((tag) => !tables.has(tag)); + if (missing.length > 0) + throw new Error( + `unsupported font: missing required table(s): ${missing.join(", ")}`, + ); + + const headOffset = tables.get("head") as number; + const unitsPerEm = view.getUint16(headOffset + 18); + if (unitsPerEm === 0) + throw new Error("unsupported font: head.unitsPerEm is zero"); + + const numberOfHMetrics = view.getUint16((tables.get("hhea") as number) + 34); + if (numberOfHMetrics === 0) + throw new Error("unsupported font: hhea.numberOfHMetrics is zero"); + + const hmtxOffset = tables.get("hmtx") as number; + const advanceOfGlyph = (glyphId: number): number => { + const index = glyphId < numberOfHMetrics ? glyphId : numberOfHMetrics - 1; + return view.getUint16(hmtxOffset + index * 4); + }; + + const lookup = readCmap(view, tables.get("cmap") as number); + + return { + unitsPerEm, + normalizedAdvance(codepoint: number): number | undefined { + const glyphId = lookup(codepoint); + if (glyphId === undefined) return undefined; + return advanceOfGlyph(glyphId) / unitsPerEm; + }, + }; +} + +// --- Scoring ---------------------------------------------------------------- + +/** One codepoint whose advance diverges, for the "worst glyphs" column. */ +export interface GlyphDelta { + codepoint: number; + delta: number; +} + +/** The advance-parity score of one candidate font against the reference, over a fixed sample. */ +export interface CompareScore { + /** codepoints in the sample that both fonts map. */ + compared: number; + /** sample size. */ + total: number; + /** sample codepoints not mapped by both fonts. */ + missing: number; + meanDelta: number; + maxDelta: number; + /** shared sample codepoints whose advance delta exceeds the metric_safe max threshold. */ + over1Percent: number; + /** shared sample codepoints whose advance delta exceeds the near_metric max threshold. */ + over2_5Percent: number; + tier: CompareTier; + worstGlyphs: GlyphDelta[]; +} + +/** + * Score one candidate against the reference over the sample. Both inputs are normalized advance maps + * (codepoint -> advance/unitsPerEm); only codepoints present in both are compared. Pure, so it can be + * tested with mocked metric maps and never needs a real font. + */ +export function scoreAdvances( + reference: ReadonlyMap, + candidate: ReadonlyMap, + sample: readonly number[] = LATIN_SAMPLE, + worstCount = 3, +): CompareScore { + const deltas: GlyphDelta[] = []; + let sum = 0; + let max = 0; + let over1Percent = 0; + let over2_5Percent = 0; + for (const cp of sample) { + const a = reference.get(cp); + const b = candidate.get(cp); + if (a === undefined || b === undefined) continue; + const delta = Math.abs(a - b); + deltas.push({ codepoint: cp, delta }); + sum += delta; + if (delta > max) max = delta; + if (delta > 0.01) over1Percent++; + if (delta > 0.025) over2_5Percent++; + } + + const compared = deltas.length; + const meanDelta = compared === 0 ? Number.NaN : sum / compared; + const maxDelta = compared === 0 ? Number.NaN : max; + const worstGlyphs = [...deltas] + .sort((x, y) => y.delta - x.delta) + .slice(0, worstCount) + .filter((g) => g.delta > 0); + + return { + compared, + total: sample.length, + missing: sample.length - compared, + meanDelta, + maxDelta, + over1Percent, + over2_5Percent, + // With no shared codepoints there is nothing to vouch for: report the floor tier. + tier: compared === 0 ? "visual_only" : classifyTier(meanDelta, maxDelta), + worstGlyphs, + }; +} + +/** Build a font's normalized-advance map over the sample (only codepoints it maps are included). */ +export function sampleMetrics( + font: FontMetrics, + sample: readonly number[] = LATIN_SAMPLE, +): Map { + const map = new Map(); + for (const cp of sample) { + const advance = font.normalizedAdvance(cp); + if (advance !== undefined) map.set(cp, advance); + } + return map; +} + +// --- Source cache + archives ------------------------------------------------ + +interface SnapshotSource { + sourceId: string; + family: string; + targetFamilies: string[]; +} + +function requireUnzip(): void { + try { + execFileSync("unzip", ["-v"], { stdio: "ignore" }); + } catch { + throw new Error("`unzip` is required on PATH."); + } +} + +function isFontFile(path: string): boolean { + return RAW_SFNT_EXTENSIONS.some((ext) => path.toLowerCase().endsWith(ext)); +} + +/** Font members inside a source archive, by their in-archive path. */ +function listFontMembers(zipPath: string): string[] { + return execFileSync("unzip", ["-Z1", zipPath], { encoding: "utf8" }) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .filter(isFontFile); +} + +function readArchiveMember(zipPath: string, member: string): Uint8Array { + return new Uint8Array( + execFileSync("unzip", ["-p", zipPath, member], { + maxBuffer: 256 * 1024 * 1024, + }), + ); +} + +/** Load the acquire snapshot, failing explicitly when the cache or snapshot is absent. */ +function loadSnapshot(cacheDir: string): SnapshotSource[] { + if (!existsSync(cacheDir)) + throw new Error( + `source cache not found at ${cacheDir}. Run \`bun run acquire\` first.`, + ); + const snapshotPath = join(cacheDir, SNAPSHOT_FILE); + if (!existsSync(snapshotPath)) + throw new Error( + `${SNAPSHOT_FILE} not found in ${cacheDir}. Run \`bun run acquire\` first.`, + ); + const parsed = JSON.parse(readFileSync(snapshotPath, "utf8")) as { + snapshots?: SnapshotSource[]; + }; + const snapshots = parsed.snapshots ?? []; + if (snapshots.length === 0) + throw new Error(`${SNAPSHOT_FILE} lists no acquired sources.`); + return snapshots; +} + +// --- CLI -------------------------------------------------------------------- + +interface CompareRow { + sourceId: string; + file: string; + score: CompareScore; +} + +interface ParsedArgs { + reference?: string; + family?: string; + sources: string[]; +} + +function parseArgs(argv: string[]): ParsedArgs { + const args: ParsedArgs = { sources: [] }; + const readValue = (flag: string, index: number): string => { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) + throw new Error(`${flag} requires a value`); + return value; + }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + switch (flag) { + case "--reference": + args.reference = readValue(flag, i); + i++; + break; + case "--family": + args.family = readValue(flag, i); + i++; + break; + case "--source": + for (const id of readValue(flag, i) + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) + args.sources.push(id); + i++; + break; + default: + throw new Error(`unknown argument: ${flag}`); + } + } + return args; +} + +function formatCodepoint(cp: number): string { + return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`; +} + +function formatDelta(value: number): string { + return Number.isNaN(value) ? "n/a" : value.toFixed(4); +} + +function formatWorst(worst: GlyphDelta[]): string { + if (worst.length === 0) return "-"; + return worst + .map((g) => `${formatCodepoint(g.codepoint)} ${g.delta.toFixed(4)}`) + .join("; "); +} + +/** Render the ranked table. Returned as a string so it can be tested without capturing stdout. */ +export function renderReport(rows: CompareRow[]): string { + const ranked = [...rows].sort((a, b) => { + const tierDiff = TIER_RANK[a.score.tier] - TIER_RANK[b.score.tier]; + if (tierDiff !== 0) return tierDiff; + const aMean = Number.isNaN(a.score.meanDelta) + ? Infinity + : a.score.meanDelta; + const bMean = Number.isNaN(b.score.meanDelta) + ? Infinity + : b.score.meanDelta; + return aMean - bMean; + }); + + const header = [ + "source", + "file", + "mean", + "max", + "tier", + "coverage", + "missing", + "over1", + "over2.5", + "worst", + ]; + const body = ranked.map((row) => [ + row.sourceId, + row.file, + formatDelta(row.score.meanDelta), + formatDelta(row.score.maxDelta), + row.score.tier, + `${row.score.compared}/${row.score.total}`, + String(row.score.missing), + String(row.score.over1Percent), + String(row.score.over2_5Percent), + formatWorst(row.score.worstGlyphs), + ]); + + const widths = header.map((h, col) => + Math.max(h.length, ...body.map((r) => r[col].length)), + ); + const line = (cells: string[]) => + cells + .map((cell, col) => cell.padEnd(widths[col])) + .join(" ") + .trimEnd(); + return [line(header), ...body.map(line)].join("\n"); +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + + if (!args.reference) + throw new Error( + "missing --reference: pass the path to the reference font file.", + ); + if (!existsSync(args.reference)) + throw new Error(`reference font not found: ${args.reference}`); + + requireUnzip(); + + const cacheDir = process.env.DOCFONTS_SOURCE_CACHE ?? DEFAULT_CACHE_DIR; + const snapshot = loadSnapshot(cacheDir); + const byId = new Map(snapshot.map((source) => [source.sourceId, source])); + + let selected: SnapshotSource[]; + if (args.sources.length > 0) { + const unknown = args.sources.filter((id) => !byId.has(id)); + if (unknown.length > 0) + throw new Error( + `source(s) not in cache: ${unknown.join(", ")}. Acquired: ${[...byId.keys()].join(", ")}`, + ); + selected = args.sources.map((id) => byId.get(id) as SnapshotSource); + } else { + selected = snapshot; + } + + const reference = sampleMetrics(parseFont(readFileSync(args.reference))); + + const rows: CompareRow[] = []; + for (const source of selected) { + const zipPath = join(cacheDir, `${source.sourceId}.zip`); + if (!existsSync(zipPath)) + throw new Error( + `candidate archive missing for ${source.sourceId}: ${zipPath}. Run \`bun run acquire\` first.`, + ); + const members = listFontMembers(zipPath); + if (members.length === 0) + throw new Error(`no candidate font files in ${zipPath}`); + for (const member of members) { + const font = parseFont(readArchiveMember(zipPath, member)); + const score = scoreAdvances(reference, sampleMetrics(font)); + rows.push({ sourceId: source.sourceId, file: basename(member), score }); + } + } + + const label = args.family ?? "(family not specified)"; + console.log( + `reference ${basename(args.reference)} as "${label}" vs ${rows.length} candidate(s) over ${LATIN_SAMPLE.length} Latin codepoints\n`, + ); + console.log(renderReport(rows)); +} + +if (import.meta.main) { + try { + main(); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +}