mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
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>
216 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|