/** * The editor draws with the faces it ships (#202). A weight without a file falls back to the next * one the browser has, and a character outside the Latin subset comes from the system font – in CI * a different one than on the desk (#214). Both are held here against the files themselves. */ import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { brotliDecompressSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { FACES } from '../../src/ui/fonts'; const FONT_DIR = 'src/ui/fonts'; /** Every `.ts` file under `dir`. */ function sources(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const path = join(dir, entry.name); if (entry.isDirectory()) return sources(path); return entry.name.endsWith('.ts') ? [path] : []; }); } /** The code of a file without its comments – a character in a comment is never drawn. */ function codeLines(file: string): { line: number; code: string }[] { return readFileSync(file, 'utf8') .split('\n') .map((text, index) => ({ line: index + 1, text: text.trim() })) .filter(({ text }) => !text.startsWith('//') && !text.startsWith('*') && !text.startsWith('/*')) .map(({ line, text }) => ({ line, code: text.replace(/\s\/\/.*$/, '') })); } /** Reads a WOFF2 variable-length number (UIntBase128). */ function base128(bytes: Uint8Array, at: { i: number }): number { let value = 0; for (let n = 0; n < 5; n++) { const byte = bytes[at.i++]!; value = value * 128 + (byte & 0x7f); if ((byte & 0x80) === 0) return value; } throw new Error('UIntBase128 longer than five bytes'); } /** The code points a WOFF2 file maps to a glyph, read from its `cmap` table (format 4 and 12). */ function codePoints(file: string): Set { const bytes = new Uint8Array(readFileSync(file)); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); expect(view.getUint32(0)).toBe(0x774f4632); // 'wOF2' const numTables = view.getUint16(12); const at = { i: 48 }; let cmap: { offset: number; length: number } | null = null; let offset = 0; for (let t = 0; t < numTables; t++) { const flags = bytes[at.i++]!; const tag = flags & 0x3f; if (tag === 0x3f) at.i += 4; const version = flags >> 6; const origLength = base128(bytes, at); // glyf (10) and loca (11) are transformed at version 0, every other table at any other version. const transformed = tag === 10 || tag === 11 ? version === 0 : version !== 0; const length = transformed ? base128(bytes, at) : origLength; if (tag === 0) cmap = { offset, length }; offset += length; } const tables = brotliDecompressSync(bytes.subarray(at.i, at.i + view.getUint32(20))); if (!cmap) throw new Error(`${file} has no cmap`); const table = new DataView(tables.buffer, tables.byteOffset + cmap.offset, cmap.length); const found = new Set(); for (let s = 0; s < table.getUint16(2); s++) { const sub = table.getUint32(4 + s * 8 + 4); const format = table.getUint16(sub); if (format === 12) { for (let g = 0; g < table.getUint32(sub + 12); g++) { const start = table.getUint32(sub + 16 + g * 12); const end = table.getUint32(sub + 20 + g * 12); for (let c = start; c <= end; c++) found.add(c); } } else if (format === 4) { const segments = table.getUint16(sub + 6) / 2; const ends = sub + 14; const starts = ends + segments * 2 + 2; const deltas = starts + segments * 2; const ranges = deltas + segments * 2; for (let g = 0; g < segments; g++) { const start = table.getUint16(starts + g * 2); const end = table.getUint16(ends + g * 2); const delta = table.getUint16(deltas + g * 2); const range = table.getUint16(ranges + g * 2); for (let c = start; c <= end && c !== 0xffff; c++) { const glyph = range === 0 ? (c + delta) & 0xffff : table.getUint16(ranges + g * 2 + range + (c - start) * 2); if (glyph !== 0) found.add(c); } } } } return found; } describe('editor fonts', () => { it('loads a face for every weight the editor sets (#214)', () => { const loaded = new Set( FACES.filter(([family]) => family === 'Source Sans 3').map(([, , weight]) => weight), ); const used = new Set(['400']); for (const file of sources('src')) for (const [, setting] of readFileSync(file, 'utf8').matchAll(/fontWeight:([^,\n}]*)/g)) for (const [, weight] of setting!.matchAll(/'(\d{3}|bold|normal)'/g)) used.add(weight === 'bold' ? '700' : weight === 'normal' ? '400' : weight!); expect(used.has('500')).toBe(true); for (const weight of used) expect(loaded, `weight ${weight}`).toContain(weight); }); it('ships a file for every face it loads', () => { const files = readdirSync(FONT_DIR); for (const [, url, weight] of FACES) expect( files.some((f) => url.endsWith(f)), `${url} (${weight})`, ).toBe(true); }); it('reads the Latin subset from the files themselves', () => { const sans = codePoints(join(FONT_DIR, 'source-sans-3-latin-400-normal.woff2')); expect(sans.has('A'.charCodeAt(0))).toBe(true); expect(sans.has('é'.charCodeAt(0))).toBe(true); expect(sans.has('—'.charCodeAt(0))).toBe(true); expect(sans.has('▶'.charCodeAt(0))).toBe(false); }); // A symbol the subset lacks is drawn from whatever the system has – so it is an SVG icon instead. it('has every character the editor writes in each shipped face of its UI font (#214)', () => { const faces = readdirSync(FONT_DIR).filter((f) => f.startsWith('source-sans-3-')); const covered = faces.map((f) => codePoints(join(FONT_DIR, f))); const missing: string[] = []; for (const file of sources('src')) for (const { line, code } of codeLines(file)) for (const char of new Set(code)) if (char.codePointAt(0)! > 0x7e && covered.some((set) => !set.has(char.codePointAt(0)!))) missing.push(`${file}:${line} ${char}`); expect(missing).toEqual([]); }); });