fonts.test.ts
3.4 kB · typescript · 87 lines
1import { expect, test } from "bun:test";2import { existsSync, readFileSync, readdirSync } from "node:fs";3import { join, resolve } from "node:path";45const site = resolve(import.meta.dir, "../..");6const dist = process.env.MRLY_DIST ? resolve(process.env.MRLY_DIST) : join(site, "dist");7const master = resolve(site, "..", "files", "fonts", "symbols.ttf");8const READS = ["wiki", "papers"];9const FACE = /@font-face\s*\{[^}]*?font-family:\s*"Noto Sans Symbols 2"[^}]*?unicode-range:\s*([^;]+);[^}]*\}/;10const DROP = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;11const TAG = /<[^>]*>/g;12const NAMED: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };1314function pages(dir: string): string[] {15 if (!existsSync(dir)) return [];16 const out: string[] = [];17 for (const item of readdirSync(dir, { withFileTypes: true })) {18 const path = join(dir, item.name);19 if (item.isDirectory()) out.push(...pages(path));20 else if (item.name.endsWith(".html")) out.push(path);21 }22 return out;23}2425function words(file: string): string {26 return readFileSync(file, "utf8")27 .replace(DROP, " ")28 .replace(TAG, " ")29 .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))30 .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))31 .replace(/&([a-z]+);/gi, (whole, name: string) => NAMED[name.toLowerCase()] ?? whole);32}3334function ranged(): Set<number> {35 const found = readFileSync(join(import.meta.dir, "fonts", "fonts.css"), "utf8").match(FACE);36 if (!found) throw new Error("fonts: the symbols face has no unicode-range");37 const out = new Set<number>();38 for (const part of found[1].split(",")) {39 const [a, b] = part.trim().toLowerCase().replace("u+", "").split("-");40 for (let cp = parseInt(a, 16); cp <= parseInt(b ?? a, 16); cp++) out.add(cp);41 }42 return out;43}4445function cmap(file: string): Set<number> {46 const data = readFileSync(file);47 const at = (n: number) => data.readUInt32BE(n);48 let head = 0;49 for (let i = 0; i < data.readUInt16BE(4); i++) {50 const row = 12 + i * 16;51 if (data.toString("latin1", row, row + 4) === "cmap") head = at(row + 8);52 }53 const out = new Set<number>();54 for (let i = 0; i < data.readUInt16BE(head + 2); i++) {55 const off = head + at(head + 4 + i * 8 + 4);56 const format = data.readUInt16BE(off);57 if (format === 4) {58 const segs = data.readUInt16BE(off + 6) / 2;59 for (let s = 0; s < segs; s++) {60 const end = data.readUInt16BE(off + 14 + s * 2);61 for (let cp = data.readUInt16BE(off + 16 + segs * 2 + s * 2); cp <= end && cp !== 0xffff; cp++) out.add(cp);62 }63 } else if (format === 12) {64 for (let g = 0; g < at(off + 12); g++) {65 const row = off + 16 + g * 12;66 for (let cp = at(row); cp <= at(row + 4); cp++) out.add(cp);67 }68 }69 }70 return out;71}7273test("the shipped symbols subset draws every symbol the wiki and the papers print", () => {74 const files = READS.flatMap((name) => pages(join(dist, name)));75 if (!files.length || !existsSync(master)) return;76 const drawn = cmap(master);77 const kept = ranged();78 const missing = new Set<string>();79 for (const file of files) {80 for (const ch of words(file)) {81 const cp = ch.codePointAt(0)!;82 if (cp <= 0x7f || !drawn.has(cp) || kept.has(cp)) continue;83 missing.add(`U+${cp.toString(16).padStart(4, "0")}`);84 }85 }86 expect([...missing].sort()).toEqual([]);87});