Files
hyperframes/packages/cli/src/capture/fontMetadataExtractor.test.ts
T
Xuanru LiandClaude Opus 4.8 6cc87312d4 feat(capture): extract chips/stat-cells/tabs, detect icon fonts, transparent grounds
designStyleExtractor now also extracts chip/pill/badge/tag, stat/metric cells, and
tab components — by class-substring selector plus a shape fallback (small + fully
rounded + short text) so hashed/utility class names (Tailwind, CSS-modules) are
still caught. It also emits a "transparent" sentinel for fully-transparent
(rgba(...,0)) grounds instead of collapsing them to #000000, so a transparent
chip/tab/stat on a light-ground site no longer reads as solid black.

fontMetadataExtractor now flags icon fonts (isIcon) by glyph coverage: a font is an
icon font only when it BOTH lacks a real Latin alphabet (<26 of A-Za-z) AND is
mostly (>50%) Private-Use-Area glyphs. The Latin gate matters — some text fonts pack
thousands of PUA glyphs yet are plainly text (Apple SF Pro is ~81% PUA but ships a
full alphabet; Descript's Booton ~50%); flagging by PUA ratio alone would strip a
brand's real typeface. Measured icon fonts: "hushly" 63% PUA / 7 letters, Font
Awesome 95% / 0 letters. Names alone can't identify icon fonts ("hushly",
"swiper-icons"), hence the glyph-based test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:55:28 -07:00

216 lines
7.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
canonicalizeFamily,
extractFontMetadata,
inferWeightFromSubfamily,
isIconCharacterSet,
} from "./fontMetadataExtractor.js";
describe("inferWeightFromSubfamily", () => {
// The concatenated forms were always handled. The spaced and hyphenated
// forms were the bug Copilot flagged on PR #987 — "Extra Light" used to
// fall through to the 400 default before the whitespace-normalization fix.
describe("concatenated forms (already handled)", () => {
it.each([
["Thin", 100],
["ExtraLight", 200],
["UltraLight", 200],
["Light", 300],
["Regular", 400],
["Medium", 500],
["SemiBold", 600],
["DemiBold", 600],
["Bold", 700],
["ExtraBold", 800],
["UltraBold", 800],
["Black", 900],
["Heavy", 900],
])("%s → %d", (subfamily, expected) => {
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
});
});
describe("spaced forms (the bug fix)", () => {
it.each([
["Extra Light", 200],
["Ultra Light", 200],
["Semi Bold", 600],
["Demi Bold", 600],
["Extra Bold", 800],
["Ultra Bold", 800],
])("%s → %d", (subfamily, expected) => {
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
});
});
describe("hyphenated forms (the bug fix)", () => {
it.each([
["Extra-Light", 200],
["Semi-Bold", 600],
["Extra-Bold", 800],
])("%s → %d", (subfamily, expected) => {
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
});
});
describe("composite styles", () => {
it("Bold Italic still detects Bold", () => {
expect(inferWeightFromSubfamily("Bold Italic")).toBe(700);
});
it("Semi Bold Italic still detects SemiBold (priority over Bold)", () => {
expect(inferWeightFromSubfamily("Semi Bold Italic")).toBe(600);
});
it("ExtraBold Italic still detects ExtraBold (priority over Bold)", () => {
expect(inferWeightFromSubfamily("ExtraBold Italic")).toBe(800);
});
});
it("unknown subfamily falls back to 400 (Regular)", () => {
expect(inferWeightFromSubfamily("Headline")).toBe(400);
expect(inferWeightFromSubfamily("")).toBe(400);
expect(inferWeightFromSubfamily("Some Random Style")).toBe(400);
});
it("is case-insensitive", () => {
expect(inferWeightFromSubfamily("EXTRA LIGHT")).toBe(200);
expect(inferWeightFromSubfamily("extra light")).toBe(200);
expect(inferWeightFromSubfamily("ExTrA LiGhT")).toBe(200);
});
});
describe("canonicalizeFamily", () => {
it("returns family unchanged when no weight token is trailing", () => {
expect(canonicalizeFamily("Inter")).toEqual({
canonical: "Inter",
inferredWeight: null,
});
expect(canonicalizeFamily("Tiempos Headline")).toEqual({
canonical: "Tiempos Headline",
inferredWeight: null,
});
expect(canonicalizeFamily("Söhne Breit")).toEqual({
canonical: "Söhne Breit",
inferredWeight: null,
});
});
it("strips trailing weight tokens and surfaces the implied weight", () => {
expect(canonicalizeFamily("Inter Medium")).toEqual({
canonical: "Inter",
inferredWeight: 500,
});
expect(canonicalizeFamily("Inter Light")).toEqual({
canonical: "Inter",
inferredWeight: 300,
});
expect(canonicalizeFamily("Inter Bold")).toEqual({
canonical: "Inter",
inferredWeight: 700,
});
expect(canonicalizeFamily("Funnel Display Light")).toEqual({
canonical: "Funnel Display",
inferredWeight: 300,
});
});
it("preserves width modifiers before the weight token", () => {
expect(canonicalizeFamily("Inter Tight Medium")).toEqual({
canonical: "Inter Tight",
inferredWeight: 500,
});
});
it("emits 950 for ExtraBlack / UltraBlack (mirrors foundry intent)", () => {
expect(canonicalizeFamily("Inter ExtraBlack")).toEqual({
canonical: "Inter",
inferredWeight: 950,
});
});
it("returns empty input unchanged", () => {
expect(canonicalizeFamily("")).toEqual({
canonical: "",
inferredWeight: null,
});
});
});
describe("extractFontMetadata", () => {
// Light integration tests against the public surface — uses a real
// temp directory and verifies the manifest shape. Doesn't require
// fixture font binaries; the non-existent and empty-directory cases
// exercise the happy paths for the surrounding pipeline.
it("returns an empty manifest when the fonts directory doesn't exist", () => {
const tmp = mkdtempSync(join(tmpdir(), "hf-font-test-"));
try {
const outputPath = join(tmp, "manifest.json");
const manifest = extractFontMetadata(join(tmp, "does-not-exist"), outputPath);
expect(manifest.files).toEqual([]);
expect(manifest.families).toEqual([]);
expect(manifest.unidentified).toEqual([]);
expect(existsSync(outputPath)).toBe(true);
const written = JSON.parse(readFileSync(outputPath, "utf-8")) as typeof manifest;
expect(written.files).toEqual([]);
expect(written.meta.tool).toBe("fontkit");
expect(typeof written.meta.generatedAt).toBe("string");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("writes a manifest with the documented meta shape", () => {
const tmp = mkdtempSync(join(tmpdir(), "hf-font-test-"));
try {
const outputPath = join(tmp, "manifest.json");
const manifest = extractFontMetadata(tmp, outputPath);
expect(manifest.meta.tool).toBe("fontkit"); // no version hardcoded — moves with the dep
// generatedAt is an ISO string
expect(manifest.meta.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("isIconCharacterSet", () => {
// codepoint helpers
const latin = Array.from({ length: 26 }, (_, i) => 0x41 + i); // A-Z
const pua = Array.from({ length: 20 }, (_, i) => 0xe000 + i); // Private Use Area
const puaSupp = [0xf0000, 0xf0001, 0x10fffd]; // supplementary PUA-A/B
it("flags a font whose glyphs are mostly Private-Use-Area (an icon font)", () => {
// ~63% PUA, mirroring a real "hushly" icon set
expect(isIconCharacterSet([...pua, ...latin.slice(0, 12)])).toBe(true);
});
it("flags a near-100% PUA set (Font-Awesome-style)", () => {
expect(isIconCharacterSet([...pua, ...puaSupp])).toBe(true);
});
it("does NOT flag a normal Latin text font", () => {
expect(isIconCharacterSet(latin)).toBe(false);
});
it("does NOT flag a unicode-range subset with no Latin letters but 0% PUA", () => {
// e.g. a cyrillic/latin-ext subset served by Next.js/Google Fonts — the false-positive case
const cyrillic = Array.from({ length: 40 }, (_, i) => 0x0410 + i);
expect(isIconCharacterSet(cyrillic)).toBe(false);
});
it("does NOT flag a PUA-heavy TEXT font that still has a full Latin alphabet", () => {
// Apple SF Pro (~81% PUA, ships SF Symbols in the PUA) and Descript's Booton (~50% PUA) are
// full text fonts — the Latin alphabet gate must keep them out of the icon bucket.
const fullAlphabet = latin; // A-Z (26)
const sfProLike = [...fullAlphabet, ...Array.from({ length: 200 }, (_, i) => 0xe000 + i)];
expect(isIconCharacterSet(sfProLike)).toBe(false);
});
it("returns false for an empty character set", () => {
expect(isIconCharacterSet([])).toBe(false);
});
});