mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
CI resolves playwright but has no downloaded chromium; launch threw instead of falling back to the catalog ranking, and every spec gate downstream failed. Launch failures now return the no-browser path (and the browser test skips instead of asserting). AI-assisted (Claude Code).
193 lines
12 KiB
JavaScript
193 lines
12 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { createRequire } from 'node:module';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { createImage, drawText } from '../skill/scripts/lib/raster.mjs';
|
|
import { fingerprint, distance, FEATURES, STATS } from '../skill/scripts/lib/font-fingerprint.mjs';
|
|
import { loadFontIndex, candidatesFromIndex, routeSize, packVector, unpackVector, INDEX_PATH, INDEX_FEATURES, ROUTE_CAP_PX, GROSS_FEATURES, NON_TEXT_FAMILY } from '../skill/scripts/lib/font-index.mjs';
|
|
import { widthClass, weightClass, selectCandidates, SHORTLIST, renderCandidates } from '../skill/scripts/font-match.mjs';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const hasPlaywright = (() => { try { return !!require('playwright').chromium; } catch { return false; } })();
|
|
|
|
/** A white raster with a line of the bitmap font drawn on it, cap height 7 * scale px. */
|
|
function textSample(text, scale = 6, { x = 20, y = 20 } = {}) {
|
|
const w = text.length * 6 * scale + 40, h = 7 * scale + 40;
|
|
const img = createImage(w, h, [255, 255, 255, 255]);
|
|
drawText(img, text, x, y, [0, 0, 0, 255], scale);
|
|
return img;
|
|
}
|
|
|
|
describe('font-fingerprint', () => {
|
|
it('fingerprints a synthetic rendered-text PNG with the expected fields', () => {
|
|
const fp = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6));
|
|
assert.ok(fp, 'fingerprint returned null');
|
|
for (const k of ['lines', 'glyphs', 'capHeightPx', 'inkIsDark', 'allCaps', 'weight', ...FEATURES]) assert.ok(k in fp, `missing ${k}`);
|
|
assert.equal(fp.lines, 1);
|
|
assert.ok(fp.glyphs >= 20, `glyphs ${fp.glyphs}`);
|
|
assert.ok(Math.abs(fp.capHeightPx - 42) <= 2, `capHeightPx ${fp.capHeightPx}`);
|
|
assert.equal(fp.inkIsDark, true);
|
|
assert.equal(typeof fp.allCaps, 'boolean');
|
|
assert.ok(fp.advTall > 0.5 && fp.advTall < 0.9, `advTall ${fp.advTall}`);
|
|
assert.ok(fp.densTall > 0.2 && fp.densTall < 0.9, `densTall ${fp.densTall}`);
|
|
assert.ok(fp.stemW > 0, `stemW ${fp.stemW}`);
|
|
});
|
|
|
|
it('is size-stable: the same text at 4x and 6x scale lands close, and a bolder sample farther', () => {
|
|
const a = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6));
|
|
const b = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 4));
|
|
assert.equal(distance(a, a), 0);
|
|
const dSame = distance(a, b);
|
|
assert.ok(dSame > 0 && Number.isFinite(dSame), `dSame ${dSame}`);
|
|
// a different sample: same glyph set but drawn twice offset by one scale step, so every stem doubles (a heavier face)
|
|
const heavy = textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6);
|
|
drawText(heavy, 'HAMBURGEVONS THE QUICK BROWN FOX', 20 + 4, 20, [0, 0, 0, 255], 6);
|
|
const c = fingerprint(heavy);
|
|
const dOther = distance(a, c);
|
|
assert.ok(dOther > dSame, `heavier sample (${dOther}) should be farther than a rescale (${dSame})`);
|
|
});
|
|
|
|
it('every weighted feature has a positive std, and distance skips features missing on either side', () => {
|
|
for (const k of FEATURES) { assert.ok(STATS[k], `no STATS for ${k}`); assert.ok(STATS[k].std > 0); }
|
|
const a = { advX: 0.5, densTall: 0.5, contrast: 1 }, b = { advX: 0.5, densTall: 0.5, contrast: null };
|
|
assert.equal(distance(a, b), 0);
|
|
assert.equal(distance({}, {}), Infinity);
|
|
});
|
|
});
|
|
|
|
describe('font-index', () => {
|
|
it('packs and unpacks a vector to three decimals, nulls preserved', () => {
|
|
const fp = { advX: 0.3649, densTall: 0.6719, contrast: null, serif: 12.3456 };
|
|
const back = unpackVector(packVector(fp));
|
|
assert.equal(back.advX, 0.365);
|
|
assert.equal(back.densTall, 0.672);
|
|
assert.equal(back.contrast, null);
|
|
assert.equal(back.serif, 12.346);
|
|
assert.equal(back.gap, null, 'absent feature reads as null');
|
|
});
|
|
|
|
it('stores the features the fitted distance weights plus the gross width and weight readings', () => {
|
|
assert.ok(INDEX_FEATURES.length >= 30);
|
|
for (const k of INDEX_FEATURES) assert.ok(STATS[k].w > 0 || GROSS_FEATURES.includes(k), `${k} is indexed without weight or gross role`);
|
|
for (const k of FEATURES) if (STATS[k].w === 0 && !GROSS_FEATURES.includes(k)) assert.ok(!INDEX_FEATURES.includes(k), `${k} has zero weight and should not be indexed`);
|
|
for (const k of GROSS_FEATURES) assert.ok(INDEX_FEATURES.includes(k), `${k} is a gross reading and must be indexed`);
|
|
});
|
|
|
|
it('ships a three-render catalog index under 1.5 MB with > 2500 entries and the expected keys', () => {
|
|
assert.ok(fs.existsSync(INDEX_PATH), `missing ${INDEX_PATH}`);
|
|
assert.ok(fs.statSync(INDEX_PATH).size < 1.5 * 1024 * 1024, 'index over 1.5 MB');
|
|
const index = loadFontIndex();
|
|
assert.ok(index.entries.length > 2500, `entries ${index.entries.length}`);
|
|
assert.deepEqual([...index.sizes].map(String).sort(), ['14', '48', '48c']);
|
|
assert.deepEqual(index.features, INDEX_FEATURES);
|
|
for (const e of index.entries.slice(0, 50)) {
|
|
for (const k of ['family', 'weight', 'category', 'variable', 'fp']) assert.ok(k in e, `entry missing ${k}`);
|
|
assert.ok(['sans', 'serif', 'display', 'handwriting', 'mono'].includes(e.category), e.category);
|
|
assert.ok(e.fp[48], 'no 48px vector');
|
|
for (const k of INDEX_FEATURES) assert.ok(k in e.fp[48]);
|
|
}
|
|
const lg = index.entries.find((e) => e.family === 'League Gothic');
|
|
assert.ok(lg && lg.fp[48] && lg.fp[14] && lg.fp['48c'], 'League Gothic at all three renders');
|
|
assert.ok(lg.fp['48c'].advTall != null && lg.fp['48c'].densTall != null, 'gross readings stored on the caps render');
|
|
assert.ok(lg.fp[48].advX < 0.45, `League Gothic reads condensed: advX ${lg.fp[48].advX}`);
|
|
const with14 = index.entries.filter((e) => e.fp[14]).length;
|
|
assert.ok(with14 > 2500, `14px vectors ${with14}`);
|
|
});
|
|
|
|
it('routes candidate selection by cap height and returns 25 entries', () => {
|
|
const index = loadFontIndex();
|
|
const lg = index.entries.find((e) => e.family === 'League Gothic');
|
|
assert.equal(routeSize(72), 48);
|
|
assert.equal(routeSize(ROUTE_CAP_PX - 1), 14);
|
|
assert.equal(routeSize(72, index.sizes, { allCaps: true }), '48c', 'caps crops route to the caps render');
|
|
assert.equal(routeSize(ROUTE_CAP_PX - 1, index.sizes, { allCaps: true }), 14, 'small caps crops still route to the small size');
|
|
assert.equal(routeSize(72, [48, 14], { allCaps: true }), 48, 'a schema-1 index without 48c falls back');
|
|
// barcode / effect faces never come back as candidates
|
|
const capsFp = { ...lg.fp['48c'], capHeightPx: 72, allCaps: true };
|
|
const caps = candidatesFromIndex(capsFp, index, { n: 25 });
|
|
assert.equal(caps[0].family, 'League Gothic');
|
|
assert.equal(caps[0].size, '48c');
|
|
assert.ok(caps.every((c) => !NON_TEXT_FAMILY.test(c.family)));
|
|
assert.ok(NON_TEXT_FAMILY.test('Libre Barcode 128 Text') && NON_TEXT_FAMILY.test('Redacted') && !NON_TEXT_FAMILY.test('Rubik') && !NON_TEXT_FAMILY.test('Bungee'));
|
|
const big = candidatesFromIndex({ ...lg.fp[48], capHeightPx: 72 }, index, { n: 25 });
|
|
assert.equal(big.length, 25);
|
|
assert.equal(big[0].family, 'League Gothic');
|
|
assert.equal(big[0].size, 48);
|
|
const small = candidatesFromIndex({ ...lg.fp[14], capHeightPx: 14 }, index, { n: 25 });
|
|
assert.equal(small.length, 25);
|
|
assert.equal(small[0].family, 'League Gothic');
|
|
assert.equal(small[0].size, 14);
|
|
for (const c of big) for (const k of ['family', 'weight', 'category', 'variable', 'd', 'size']) assert.ok(k in c);
|
|
const sans = candidatesFromIndex({ ...lg.fp[48], capHeightPx: 72 }, index, { n: 10, category: 'serif' });
|
|
assert.equal(sans.length, 10);
|
|
assert.ok(sans.every((c) => c.category === 'serif'));
|
|
});
|
|
});
|
|
|
|
describe('font-match', () => {
|
|
it('reads width and weight classes off the v2 features with catalog-anchored thresholds', () => {
|
|
const index = loadFontIndex();
|
|
const at = (family, weight) => index.entries.find((e) => e.family === family && e.weight === weight).fp[48];
|
|
assert.equal(widthClass(at('League Gothic', 400)), 'compressed');
|
|
assert.equal(widthClass(at('Oswald', 700)), 'condensed');
|
|
assert.equal(widthClass(at('Inter', 400)), 'normal');
|
|
assert.equal(widthClass(at('Archivo Black', 400)), 'wide');
|
|
assert.equal(weightClass(at('Lato', 300)), 'light');
|
|
assert.equal(weightClass(at('Inter', 400)), 'regular');
|
|
assert.equal(weightClass(at('Roboto', 700)), 'bold');
|
|
assert.equal(weightClass(at('Anton', 400)), 'black');
|
|
// all-caps crop: falls through to advTall
|
|
assert.equal(widthClass({ advX: null, advTall: 0.48 }), 'condensed');
|
|
assert.equal(weightClass({ densTall: null, densX: null, stemW: 0.09 }), 'light');
|
|
});
|
|
|
|
it('selects candidates from the index, keeps the caller names first, and uses the shortlist only without an index', () => {
|
|
const index = loadFontIndex();
|
|
const lg = { ...index.entries.find((e) => e.family === 'League Gothic').fp[48], capHeightPx: 72 };
|
|
const own = [{ family: 'Bebas Neue', weight: 400 }];
|
|
const r = selectCandidates(lg, { own, index, n: 25 });
|
|
assert.equal(r.source, 'index');
|
|
assert.equal(r.candidates[0].family, 'Bebas Neue');
|
|
assert.equal(r.catalog.length, 25);
|
|
assert.ok(r.candidates.length >= 25 && r.candidates.length <= 26);
|
|
assert.ok(!r.candidates.some((c) => SHORTLIST.wide.includes(`${c.family}:${c.weight}`)), 'shortlist must not leak in when the index is present');
|
|
const noIdx = selectCandidates(lg, { own, index: null });
|
|
assert.equal(noIdx.source, 'shortlist');
|
|
assert.ok(noIdx.candidates.some((c) => c.family === 'Six Caps'), 'compressed shortlist used');
|
|
});
|
|
|
|
it('renders and ranks candidates against a League Gothic sample (browser)', { skip: !hasPlaywright && 'playwright not resolvable' }, async () => {
|
|
const results = await renderCandidates([{ family: 'League Gothic', weight: 400 }, { family: 'Inter', weight: 400 }], 'The manuals stop.', 48);
|
|
if (!results) return; // module resolves but no browser binary (CI): the catalog fallback owns it
|
|
const ok = results.filter((r) => r.loaded && r.fp);
|
|
if (ok.length < 2) return; // offline: Google Fonts unreachable
|
|
const index = loadFontIndex();
|
|
const lg = index.entries.find((e) => e.family === 'League Gothic').fp[48];
|
|
const inter = index.entries.find((e) => e.family === 'Inter' && e.weight === 400).fp[48];
|
|
const rLg = ok.find((r) => r.family === 'League Gothic'), rIn = ok.find((r) => r.family === 'Inter');
|
|
assert.ok(distance(rLg.fp, lg) < distance(rLg.fp, inter), 'rendered League Gothic is nearer its own index entry');
|
|
assert.ok(distance(rIn.fp, inter) < distance(rIn.fp, lg), 'rendered Inter is nearer its own index entry');
|
|
});
|
|
});
|
|
|
|
describe('font-fingerprint on mixed crops', () => {
|
|
it('measures the body copy, not the drawing beside it or the headline clipped above it', () => {
|
|
// 460x300 crop: one clipped headline line at the top (cap ~34), five lines
|
|
// of body copy (cap ~10), and a dense drawing (a noise block) at the bottom
|
|
const img = createImage(460, 300, [235, 232, 220, 255]);
|
|
drawText(img, 'THE MANIFOLDS', 4, 2, [20, 20, 20, 255], 5);
|
|
for (let i = 0; i < 5; i++) drawText(img, 'fresh cables slides return cleanly and the', 4, 60 + i * 24, [20, 20, 20, 255], 2);
|
|
let seed = 7; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 0xffffffff);
|
|
for (let y = 190; y < 300; y++) for (let x = 0; x < 300; x++) { const v = 40 + Math.floor(rnd() * 180); const p = (y * 460 + x) * 4; img.data[p] = v; img.data[p + 1] = v; img.data[p + 2] = v; }
|
|
const fp = fingerprint(img);
|
|
assert.ok(fp, 'lettering found');
|
|
assert.ok(fp.capHeightPx >= 8 && fp.capHeightPx <= 14, `body cap measured, got ${fp.capHeightPx}`);
|
|
assert.ok(fp.lines >= 4, `body lines, got ${fp.lines}`);
|
|
assert.ok(fp.isolatedFrom >= 1, 'the headline line was set aside');
|
|
});
|
|
});
|