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});