The editor waits for every face in FACES before it starts. Source Sans 3 in 700 was one of them, but nothing in src/ sets a weight above 600, so it cost a download and a wait for nothing. - fonts.ts loads Source Sans 3 in 400, 500 and 600 plus Source Code Pro 400. The 700 file (15 596 bytes) is gone from src/ui/fonts. - A search for bold, font-weight and HTML headings in src/, web/*.html and the player found nothing that would fall back to 700. - fonts.test.ts now checks both ways. A loaded Sans face must be set somewhere in src/, and every .woff2 in the font folder must be in FACES, so a stray file the glyph test would still read shows up too. - The weight scan moved into usedWeights(), since two tests need it. Tests: - Gate on melektaus-atx: 197 files, 2856 tests (2855 + 1), exit 0. - e2e 1-14: all passed, 492 checks, loaded faces ["400","500","600"]. - e2e 14 alone fails its two tab width checks, as known: it needs the documents 7-13 open. The font check was green there as well. - dist/assets holds no 700 file after the build. - Counter check: loading 700 again with its file fails the new "loads no face the editor never sets". - Counter check: the file back without a FACES entry fails "... and no other" and names the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
6.6 KiB
TypeScript
162 lines
6.6 KiB
TypeScript
/**
|
||
* 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<number> {
|
||
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<number>();
|
||
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;
|
||
}
|
||
|
||
/** The weights `src/` sets as `fontWeight`, plus the 400 a style without one draws in. */
|
||
function usedWeights(): Set<string> {
|
||
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!);
|
||
return used;
|
||
}
|
||
|
||
describe('editor fonts', () => {
|
||
const loaded = new Set(
|
||
FACES.filter(([family]) => family === 'Source Sans 3').map(([, , weight]) => weight),
|
||
);
|
||
|
||
it('loads a face for every weight the editor sets (#214)', () => {
|
||
const used = usedWeights();
|
||
expect(used.has('500')).toBe(true);
|
||
for (const weight of used) expect(loaded, `weight ${weight}`).toContain(weight);
|
||
});
|
||
|
||
// Every face is waited for before the editor starts (#273).
|
||
it('loads no face the editor never sets', () => {
|
||
const used = usedWeights();
|
||
for (const weight of loaded) expect(used, `weight ${weight}`).toContain(weight);
|
||
});
|
||
|
||
it('ships a file for every face it loads, and no other', () => {
|
||
const files = readdirSync(FONT_DIR).filter((f) => f.endsWith('.woff2'));
|
||
for (const [, url, weight] of FACES)
|
||
expect(
|
||
files.some((f) => url.endsWith(f)),
|
||
`${url} (${weight})`,
|
||
).toBe(true);
|
||
for (const file of files)
|
||
expect(
|
||
FACES.some(([, url]) => url.endsWith(file)),
|
||
file,
|
||
).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([]);
|
||
});
|
||
});
|