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:
ukimsanov
2026-05-20 13:55:07 -07:00
parent da38de1b12
commit db94b505dd
4 changed files with 372 additions and 10 deletions
@@ -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 (100900). 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();
}
+28
View File
@@ -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