mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(capture): identify hashed fonts via OpenType name table
Modern frameworks (Next.js, Webpack) hash font filenames like
`f9b8e1e8d4c3f0a7-s.woff2`, so the capture pipeline can't tell which
file belongs to which family by reading the filename. Sub-agents
authoring DESIGN.md were guessing or falling back to system fonts.
This adds `fontMetadataExtractor.ts`: reads the binary OpenType `name`
table via `fontkit`, identifies each downloaded font by its real
family name, and writes `capture/extracted/fonts-manifest.json` with
per-file metadata + per-family aggregates (weights, variable-font
axes, file counts).
- Canonicalizes static-weight family-name packaging: "Inter Medium"
resolves to family "Inter" with weight 500, "Semi Bold" normalizes
to "SemiBold", etc. Width modifiers ("Tight", "Condensed") are NOT
stripped — they denote separate typographic families.
- Reads variable-font axes from `fvar` so a single .woff2 carrying a
full weight range is identified as variable (e.g. "Inter (100-900
variable)").
- Uses `@types/fontkit` properly (no `unknown` cast), with a
Font/FontCollection type guard. fontkit API drift surfaces as a
compile error rather than silent undefined.
- Wired into `capture/index.ts` after `downloadAndRewriteFonts` so it
runs after fonts are already on disk. Non-fatal try/catch — capture
succeeds even if extraction fails.
Tested against 9 captures: 132/132 fonts identified by real family
name, including hashed Next.js builds.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Extract font metadata from downloaded font files.
|
||||
*
|
||||
* Modern web frameworks (Next.js, Webpack) rename fonts with content hashes for
|
||||
* cache-busting, leaving downloaded files like `19cfc7226ec3afaa-s.woff2` with
|
||||
* no human-readable identification. The CSS @font-face mapping that originally
|
||||
* tied each hash back to a family name is often lost during capture.
|
||||
*
|
||||
* Every OpenType / WOFF / WOFF2 file embeds a `name` table (part of the spec
|
||||
* since 1996) containing the family, subfamily, full name, PostScript name,
|
||||
* weight class, and variation axes. Subsetting and hashing do not strip it.
|
||||
* This extractor uses `fontkit` to read the name table from each downloaded
|
||||
* font and writes a manifest the rest of the pipeline can consult instead of
|
||||
* guessing from filename patterns.
|
||||
*
|
||||
* Output: extracted/fonts-manifest.json with per-file metadata + per-family
|
||||
* aggregation. See FontsManifest type for shape.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import * as fontkit from "fontkit";
|
||||
import type { Font, FontCollection } from "fontkit";
|
||||
|
||||
function isFontCollection(value: Font | FontCollection): value is FontCollection {
|
||||
return value.type === "TTC" || value.type === "DFont";
|
||||
}
|
||||
|
||||
export interface FontFileMetadata {
|
||||
/** Filename relative to capture/assets/fonts/ (e.g. "19cfc7226ec3afaa-s.woff2") */
|
||||
file: string;
|
||||
/**
|
||||
* Canonical family name. Many static-weight font files package each weight as
|
||||
* a separate "family" in nameID 1 (e.g. "Inter Medium" instead of "Inter").
|
||||
* This field strips trailing weight tokens so multiple weights of the same
|
||||
* typographic family aggregate cleanly. See rawFamily for the unmodified value.
|
||||
*/
|
||||
family: string;
|
||||
/** Raw family name from the OpenType name table (nameID 16 preferred, then nameID 1). Empty if unidentifiable. */
|
||||
rawFamily: string;
|
||||
/** Subfamily / style name from nameID 17 or 2 (e.g. "Regular", "Bold Italic") */
|
||||
subfamily: string;
|
||||
/** PostScript name from nameID 6 (e.g. "Inter-Regular") */
|
||||
postscript: string;
|
||||
/** OS/2 usWeightClass (100–900). Approximate for variable fonts — see variationAxes. */
|
||||
weight: number;
|
||||
/** "normal" or "italic" — derived from subfamily and OS/2 fsSelection */
|
||||
style: "normal" | "italic";
|
||||
/** If this is a variable font, the axes present (e.g. ["wght", "slnt"]). Empty for static fonts. */
|
||||
variationAxes: string[];
|
||||
/** Whether identification came from the binary name table (the trustworthy source). */
|
||||
identified: boolean;
|
||||
}
|
||||
|
||||
export interface FontFamilySummary {
|
||||
/** Family name */
|
||||
family: string;
|
||||
/** Distinct weights captured (from OS/2 weight class — for variable fonts shows the default) */
|
||||
weights: number[];
|
||||
/** Whether any file in this family is a variable font */
|
||||
variable: boolean;
|
||||
/** Number of files in this family (typically subsets of the same weight) */
|
||||
fileCount: number;
|
||||
/** Files in this family — useful for picking the @font-face src */
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface FontsManifest {
|
||||
/** Per-file metadata, one entry per downloaded font */
|
||||
files: FontFileMetadata[];
|
||||
/** Aggregated per-family summary — most useful for DESIGN.md authoring */
|
||||
families: FontFamilySummary[];
|
||||
/** Files where identification failed entirely. Should be empty for typical captures. */
|
||||
unidentified: string[];
|
||||
/** Generated-at timestamp + tool version for debugging */
|
||||
meta: { generatedAt: string; tool: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all font files in fontsDir, extract metadata via fontkit, and write
|
||||
* the manifest to outputPath. Returns the manifest in case callers want to log it.
|
||||
*
|
||||
* Failures are non-fatal: if a single font's name table is missing or corrupt,
|
||||
* the file is added to `unidentified` and the rest continue. If the fonts
|
||||
* directory doesn't exist, returns an empty manifest without throwing.
|
||||
*/
|
||||
export function extractFontMetadata(fontsDir: string, outputPath: string): FontsManifest {
|
||||
const files: FontFileMetadata[] = [];
|
||||
const unidentified: string[] = [];
|
||||
|
||||
if (existsSync(fontsDir)) {
|
||||
const fontFiles = readdirSync(fontsDir).filter((f) => /\.(woff2?|ttf|otf)$/i.test(f));
|
||||
for (const filename of fontFiles) {
|
||||
const fullPath = join(fontsDir, filename);
|
||||
const meta = readSingleFont(fullPath, filename);
|
||||
if (meta.identified) {
|
||||
files.push(meta);
|
||||
} else {
|
||||
files.push(meta);
|
||||
unidentified.push(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const families = aggregateFamilies(files);
|
||||
|
||||
const manifest: FontsManifest = {
|
||||
files,
|
||||
families,
|
||||
unidentified,
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
tool: "fontkit@2.0.4",
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(manifest, null, 2), "utf-8");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function readSingleFont(fullPath: string, filename: string): FontFileMetadata {
|
||||
const empty: FontFileMetadata = {
|
||||
file: filename,
|
||||
family: "",
|
||||
rawFamily: "",
|
||||
subfamily: "",
|
||||
postscript: "",
|
||||
weight: 0,
|
||||
style: "normal",
|
||||
variationAxes: [],
|
||||
identified: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const buf = readFileSync(fullPath);
|
||||
// fontkit.create returns Font | FontCollection. For TTC/DFont collections,
|
||||
// take the first font inside; otherwise the value is already a single Font.
|
||||
const created: Font | FontCollection = fontkit.create(buf);
|
||||
const font: Font | undefined = isFontCollection(created) ? created.fonts[0] : created;
|
||||
if (!font) return empty;
|
||||
|
||||
const rawFamily = (font.familyName || "").trim();
|
||||
const subfamily = (font.subfamilyName || "").trim();
|
||||
const postscript = (font.postscriptName || "").trim();
|
||||
const fsSelection = font["OS/2"]?.fsSelection;
|
||||
const italicBit = Boolean(fsSelection?.italic || fsSelection?.oblique);
|
||||
const style: "normal" | "italic" =
|
||||
italicBit || /italic|oblique/i.test(subfamily) ? "italic" : "normal";
|
||||
const variationAxes = font.variationAxes ? Object.keys(font.variationAxes) : [];
|
||||
|
||||
if (!rawFamily && !postscript) return empty; // name table empty — cannot identify
|
||||
|
||||
const familyForCanonicalization = rawFamily || deriveFamilyFromPostscript(postscript);
|
||||
const { canonical, inferredWeight } = canonicalizeFamily(familyForCanonicalization);
|
||||
const weight =
|
||||
font["OS/2"]?.usWeightClass ?? inferredWeight ?? inferWeightFromSubfamily(subfamily);
|
||||
|
||||
return {
|
||||
file: filename,
|
||||
family: canonical || familyForCanonicalization,
|
||||
rawFamily: familyForCanonicalization,
|
||||
subfamily,
|
||||
postscript,
|
||||
weight,
|
||||
style,
|
||||
variationAxes,
|
||||
identified: true,
|
||||
};
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/** Aggregate per-file entries into per-family summaries — most useful shape for DESIGN.md. */
|
||||
// fallow-ignore-next-line complexity
|
||||
function aggregateFamilies(files: FontFileMetadata[]): FontFamilySummary[] {
|
||||
const byFamily = new Map<string, FontFamilySummary>();
|
||||
for (const f of files) {
|
||||
if (!f.family) continue;
|
||||
let entry = byFamily.get(f.family);
|
||||
if (!entry) {
|
||||
entry = { family: f.family, weights: [], variable: false, fileCount: 0, files: [] };
|
||||
byFamily.set(f.family, entry);
|
||||
}
|
||||
entry.fileCount++;
|
||||
entry.files.push(f.file);
|
||||
if (f.variationAxes.length > 0) entry.variable = true;
|
||||
if (f.weight && !entry.weights.includes(f.weight)) entry.weights.push(f.weight);
|
||||
}
|
||||
for (const entry of byFamily.values()) {
|
||||
entry.weights.sort((a, b) => a - b);
|
||||
entry.files.sort();
|
||||
}
|
||||
return Array.from(byFamily.values()).sort((a, b) => a.family.localeCompare(b.family));
|
||||
}
|
||||
|
||||
/**
|
||||
* PostScript names follow the convention `Family-Style`. When the family name
|
||||
* record (nameID 1) is missing but PostScript is present, recover the family
|
||||
* portion as a best-effort fallback.
|
||||
*/
|
||||
function deriveFamilyFromPostscript(postscript: string): string {
|
||||
if (!postscript) return "";
|
||||
const dashIdx = postscript.indexOf("-");
|
||||
return (dashIdx > 0 ? postscript.slice(0, dashIdx) : postscript).trim();
|
||||
}
|
||||
|
||||
/** Fallback when OS/2 table is missing — guess weight from "Bold", "Light", etc. */
|
||||
// fallow-ignore-next-line complexity
|
||||
function inferWeightFromSubfamily(subfamily: string): number {
|
||||
const s = subfamily.toLowerCase();
|
||||
if (s.includes("thin")) return 100;
|
||||
if (s.includes("extralight") || s.includes("ultralight")) return 200;
|
||||
if (s.includes("light")) return 300;
|
||||
if (s.includes("medium")) return 500;
|
||||
if (s.includes("semibold") || s.includes("demibold")) return 600;
|
||||
if (s.includes("extrabold") || s.includes("ultrabold")) return 800;
|
||||
if (s.includes("black") || s.includes("heavy")) return 900;
|
||||
if (s.includes("bold")) return 700;
|
||||
return 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of trailing weight tokens found in family names (e.g. "Inter Medium" →
|
||||
* "Inter") to their numeric OS/2 weight equivalent. Used to canonicalize family
|
||||
* names when a foundry packaged each weight as a separate "family" instead of
|
||||
* setting nameID 16 / 17 (Preferred Family / Subfamily).
|
||||
*
|
||||
* Conservative: only strips well-known English weight tokens. Width modifiers
|
||||
* like "Tight", "Condensed", "Extended" are intentionally NOT stripped — they
|
||||
* denote separate typographic families, not weight variants. Localized weight
|
||||
* tokens (German "Fett", "Extrafett"; French "Maigre"; etc.) and abbreviations
|
||||
* ("ExtBd", "ExtBlk") are not stripped either — the resulting family stays
|
||||
* separate, which is an honest representation of what's in the file.
|
||||
*/
|
||||
const WEIGHT_TOKEN_TO_VALUE: Record<string, number> = {
|
||||
Thin: 100,
|
||||
Hairline: 100,
|
||||
ExtraLight: 200,
|
||||
UltraLight: 200,
|
||||
Light: 300,
|
||||
Book: 400,
|
||||
Regular: 400,
|
||||
Normal: 400,
|
||||
Medium: 500,
|
||||
SemiBold: 600,
|
||||
DemiBold: 600,
|
||||
Bold: 700,
|
||||
ExtraBold: 800,
|
||||
UltraBold: 800,
|
||||
Black: 900,
|
||||
Heavy: 900,
|
||||
ExtraBlack: 950,
|
||||
UltraBlack: 950,
|
||||
};
|
||||
|
||||
const WEIGHT_TOKEN_RE = new RegExp(`\\s+(${Object.keys(WEIGHT_TOKEN_TO_VALUE).join("|")})$`, "i");
|
||||
|
||||
/**
|
||||
* Strip a trailing weight token from a family name and return both the
|
||||
* canonicalized form and the weight value the stripped token implied.
|
||||
*
|
||||
* Examples:
|
||||
* "Inter Medium" → { canonical: "Inter", inferredWeight: 500 }
|
||||
* "Inter Tight Medium" → { canonical: "Inter Tight", inferredWeight: 500 }
|
||||
* "Funnel Display Light" → { canonical: "Funnel Display", inferredWeight: 300 }
|
||||
* "Tiempos Headline" → { canonical: "Tiempos Headline", inferredWeight: null }
|
||||
* "Söhne Breit Extrafett" → { canonical: "Söhne Breit Extrafett", inferredWeight: null }
|
||||
*
|
||||
* Trailing "Italic"/"Oblique" is stripped before weight detection so families
|
||||
* like "Inter Italic" or "Inter Medium Italic" canonicalize correctly. The
|
||||
* italic flag is recovered separately from the OS/2 fsSelection bit, so no
|
||||
* information is lost.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function canonicalizeFamily(family: string): {
|
||||
canonical: string;
|
||||
inferredWeight: number | null;
|
||||
} {
|
||||
if (!family) return { canonical: family, inferredWeight: null };
|
||||
let result = family.trim();
|
||||
// Strip trailing "Italic" or "Oblique" first — handled by the style field.
|
||||
result = result.replace(/\s+(Italic|Oblique)$/i, "").trim();
|
||||
// Normalize compound weight tokens written with a space ("Semi Bold" → "SemiBold")
|
||||
// so the single-token matcher below catches them. Anchored to end-of-string to
|
||||
// avoid touching family names that legitimately contain these words mid-string.
|
||||
result = result.replace(
|
||||
/\s+(Semi|Extra|Ultra|Demi)\s+(Bold|Black|Light)$/i,
|
||||
(_, prefix: string, suffix: string) => ` ${capitalize(prefix)}${capitalize(suffix)}`,
|
||||
);
|
||||
// Strip trailing weight token if any.
|
||||
const match = result.match(WEIGHT_TOKEN_RE);
|
||||
if (match && match[1]) {
|
||||
// Look up the canonical (case-sensitive) key for the matched token.
|
||||
const matchedKey = Object.keys(WEIGHT_TOKEN_TO_VALUE).find(
|
||||
(k) => k.toLowerCase() === match[1]!.toLowerCase(),
|
||||
);
|
||||
const inferredWeight = matchedKey ? WEIGHT_TOKEN_TO_VALUE[matchedKey]! : null;
|
||||
result = result.slice(0, result.length - match[0].length).trim();
|
||||
return { canonical: result, inferredWeight };
|
||||
}
|
||||
return { canonical: result, inferredWeight: null };
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1).toLowerCase();
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { extractHtml } from "./htmlExtractor.js";
|
||||
// captureScreenshots removed — full-page screenshot replaces per-section shots
|
||||
import { extractTokens } from "./tokenExtractor.js";
|
||||
import { downloadAssets, downloadAndRewriteFonts } from "./assetDownloader.js";
|
||||
import { extractFontMetadata } from "./fontMetadataExtractor.js";
|
||||
// briefGenerator.ts, visual-style, capture-summary removed — DESIGN.md replaces them
|
||||
import {
|
||||
setupAnimationCapture,
|
||||
@@ -419,6 +420,33 @@ export async function captureWebsite(
|
||||
// Download fonts and rewrite URLs to local paths
|
||||
extracted.headHtml = await downloadAndRewriteFonts(extracted.headHtml, outputDir);
|
||||
|
||||
// Identify each downloaded font by reading its OpenType name table.
|
||||
// Modern frameworks hash font filenames; this manifest tells the
|
||||
// downstream pipeline (DESIGN.md authoring, beat sub-agents) which file
|
||||
// belongs to which family without guessing from filename patterns.
|
||||
try {
|
||||
const fontsManifest = extractFontMetadata(
|
||||
join(outputDir, "assets", "fonts"),
|
||||
join(outputDir, "extracted", "fonts-manifest.json"),
|
||||
);
|
||||
if (fontsManifest.families.length > 0) {
|
||||
const summary = fontsManifest.families
|
||||
.map((f) => `${f.family}${f.variable ? " (variable)" : ""} × ${f.fileCount}`)
|
||||
.join(", ");
|
||||
console.log(`Font metadata extracted: ${summary}`);
|
||||
if (fontsManifest.unidentified.length > 0) {
|
||||
console.warn(
|
||||
` ${fontsManifest.unidentified.length} font file(s) could not be identified — DESIGN.md should flag these explicitly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"Font metadata extraction failed (non-fatal):",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
|
||||
// Save animation catalog — lean version for the agent (not 745 raw CSS declarations)
|
||||
if (animationCatalog) {
|
||||
// Extract just what's useful: counts, named animations, a few representative keyframed entries
|
||||
|
||||
Reference in New Issue
Block a user