Skip to content

Commit 12808fd

Browse files
authored
Merge pull request heygen-com#987 from heygen-com/feat/capture-font-extractor
feat(capture): identify hashed fonts via OpenType name table
2 parents 90a4e4b + 5e7a7a8 commit 12808fd

5 files changed

Lines changed: 578 additions & 9 deletions

File tree

bun.lock

Lines changed: 33 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"citty": "^0.2.1",
3131
"compare-versions": "^6.1.1",
3232
"esbuild": "^0.25.12",
33+
"fontkit": "^2.0.4",
3334
"giget": "^3.2.0",
3435
"hono": "^4.0.0",
3536
"onnxruntime-node": "^1.20.0",
@@ -47,6 +48,7 @@
4748
"@hyperframes/producer": "workspace:*",
4849
"@hyperframes/studio": "workspace:*",
4950
"@types/adm-zip": "^0.5.7",
51+
"@types/fontkit": "^2.0.9",
5052
"@types/mime-types": "^3.0.1",
5153
"@types/node": "^25.0.10",
5254
"linkedom": "^0.18.12",
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, expect, it } from "vitest";
2+
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import {
6+
canonicalizeFamily,
7+
extractFontMetadata,
8+
inferWeightFromSubfamily,
9+
} from "./fontMetadataExtractor.js";
10+
11+
describe("inferWeightFromSubfamily", () => {
12+
// The concatenated forms were always handled. The spaced and hyphenated
13+
// forms were the bug Copilot flagged on PR #987 — "Extra Light" used to
14+
// fall through to the 400 default before the whitespace-normalization fix.
15+
describe("concatenated forms (already handled)", () => {
16+
it.each([
17+
["Thin", 100],
18+
["ExtraLight", 200],
19+
["UltraLight", 200],
20+
["Light", 300],
21+
["Regular", 400],
22+
["Medium", 500],
23+
["SemiBold", 600],
24+
["DemiBold", 600],
25+
["Bold", 700],
26+
["ExtraBold", 800],
27+
["UltraBold", 800],
28+
["Black", 900],
29+
["Heavy", 900],
30+
])("%s → %d", (subfamily, expected) => {
31+
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
32+
});
33+
});
34+
35+
describe("spaced forms (the bug fix)", () => {
36+
it.each([
37+
["Extra Light", 200],
38+
["Ultra Light", 200],
39+
["Semi Bold", 600],
40+
["Demi Bold", 600],
41+
["Extra Bold", 800],
42+
["Ultra Bold", 800],
43+
])("%s → %d", (subfamily, expected) => {
44+
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
45+
});
46+
});
47+
48+
describe("hyphenated forms (the bug fix)", () => {
49+
it.each([
50+
["Extra-Light", 200],
51+
["Semi-Bold", 600],
52+
["Extra-Bold", 800],
53+
])("%s → %d", (subfamily, expected) => {
54+
expect(inferWeightFromSubfamily(subfamily)).toBe(expected);
55+
});
56+
});
57+
58+
describe("composite styles", () => {
59+
it("Bold Italic still detects Bold", () => {
60+
expect(inferWeightFromSubfamily("Bold Italic")).toBe(700);
61+
});
62+
it("Semi Bold Italic still detects SemiBold (priority over Bold)", () => {
63+
expect(inferWeightFromSubfamily("Semi Bold Italic")).toBe(600);
64+
});
65+
it("ExtraBold Italic still detects ExtraBold (priority over Bold)", () => {
66+
expect(inferWeightFromSubfamily("ExtraBold Italic")).toBe(800);
67+
});
68+
});
69+
70+
it("unknown subfamily falls back to 400 (Regular)", () => {
71+
expect(inferWeightFromSubfamily("Headline")).toBe(400);
72+
expect(inferWeightFromSubfamily("")).toBe(400);
73+
expect(inferWeightFromSubfamily("Some Random Style")).toBe(400);
74+
});
75+
76+
it("is case-insensitive", () => {
77+
expect(inferWeightFromSubfamily("EXTRA LIGHT")).toBe(200);
78+
expect(inferWeightFromSubfamily("extra light")).toBe(200);
79+
expect(inferWeightFromSubfamily("ExTrA LiGhT")).toBe(200);
80+
});
81+
});
82+
83+
describe("canonicalizeFamily", () => {
84+
it("returns family unchanged when no weight token is trailing", () => {
85+
expect(canonicalizeFamily("Inter")).toEqual({
86+
canonical: "Inter",
87+
inferredWeight: null,
88+
});
89+
expect(canonicalizeFamily("Tiempos Headline")).toEqual({
90+
canonical: "Tiempos Headline",
91+
inferredWeight: null,
92+
});
93+
expect(canonicalizeFamily("Söhne Breit")).toEqual({
94+
canonical: "Söhne Breit",
95+
inferredWeight: null,
96+
});
97+
});
98+
99+
it("strips trailing weight tokens and surfaces the implied weight", () => {
100+
expect(canonicalizeFamily("Inter Medium")).toEqual({
101+
canonical: "Inter",
102+
inferredWeight: 500,
103+
});
104+
expect(canonicalizeFamily("Inter Light")).toEqual({
105+
canonical: "Inter",
106+
inferredWeight: 300,
107+
});
108+
expect(canonicalizeFamily("Inter Bold")).toEqual({
109+
canonical: "Inter",
110+
inferredWeight: 700,
111+
});
112+
expect(canonicalizeFamily("Funnel Display Light")).toEqual({
113+
canonical: "Funnel Display",
114+
inferredWeight: 300,
115+
});
116+
});
117+
118+
it("preserves width modifiers before the weight token", () => {
119+
expect(canonicalizeFamily("Inter Tight Medium")).toEqual({
120+
canonical: "Inter Tight",
121+
inferredWeight: 500,
122+
});
123+
});
124+
125+
it("emits 950 for ExtraBlack / UltraBlack (mirrors foundry intent)", () => {
126+
expect(canonicalizeFamily("Inter ExtraBlack")).toEqual({
127+
canonical: "Inter",
128+
inferredWeight: 950,
129+
});
130+
});
131+
132+
it("returns empty input unchanged", () => {
133+
expect(canonicalizeFamily("")).toEqual({
134+
canonical: "",
135+
inferredWeight: null,
136+
});
137+
});
138+
});
139+
140+
describe("extractFontMetadata", () => {
141+
// Light integration tests against the public surface — uses a real
142+
// temp directory and verifies the manifest shape. Doesn't require
143+
// fixture font binaries; the non-existent and empty-directory cases
144+
// exercise the happy paths for the surrounding pipeline.
145+
146+
it("returns an empty manifest when the fonts directory doesn't exist", () => {
147+
const tmp = mkdtempSync(join(tmpdir(), "hf-font-test-"));
148+
try {
149+
const outputPath = join(tmp, "manifest.json");
150+
const manifest = extractFontMetadata(join(tmp, "does-not-exist"), outputPath);
151+
expect(manifest.files).toEqual([]);
152+
expect(manifest.families).toEqual([]);
153+
expect(manifest.unidentified).toEqual([]);
154+
expect(existsSync(outputPath)).toBe(true);
155+
const written = JSON.parse(readFileSync(outputPath, "utf-8")) as typeof manifest;
156+
expect(written.files).toEqual([]);
157+
expect(written.meta.tool).toBe("fontkit");
158+
expect(typeof written.meta.generatedAt).toBe("string");
159+
} finally {
160+
rmSync(tmp, { recursive: true, force: true });
161+
}
162+
});
163+
164+
it("writes a manifest with the documented meta shape", () => {
165+
const tmp = mkdtempSync(join(tmpdir(), "hf-font-test-"));
166+
try {
167+
const outputPath = join(tmp, "manifest.json");
168+
const manifest = extractFontMetadata(tmp, outputPath);
169+
expect(manifest.meta.tool).toBe("fontkit"); // no version hardcoded — moves with the dep
170+
// generatedAt is an ISO string
171+
expect(manifest.meta.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
172+
} finally {
173+
rmSync(tmp, { recursive: true, force: true });
174+
}
175+
});
176+
});

0 commit comments

Comments
 (0)