mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat: font resolution pipeline — compositions capture and embed their own fonts (#1255)
Compositions are now self-contained: the compiler captures font files and embeds them as woff2 data URIs, eliminating silent render-time fallback when the render environment lacks the author's fonts. Resolution order (each tier falls through to the next): 1. Existing @font-face → use as-is 2. Bundled alias (38 cross-platform mappings) → embed data URI 3. Google Fonts → fetch, cache, embed 4. Local system font → locate on OS, compress to woff2, embed 5. Local @font-face paths → read file, compress, inline as data URI 6. External CDN stylesheets → fetch CSS, extract @font-face, inline 7. Alias map fallback → closest bundled equivalent 8. Actionable error with guidance Key changes: - System font locator (macOS/Windows/Linux) with path-bounding and symlink defense (realpathSync + O_NOFOLLOW) - woff2 compression via wawoff2 (WASM, cross-platform) - Multi-weight/style variant capture with length-sorted token matching - External stylesheet inlining with SSRF defense (assertPublicHttpsUrl, HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit) - Studio auto-import via GET /fonts/file API + renderAliasFor() derived from shared FONT_ALIAS_MAP (no more hand-curated drift) - failClosedFontFetch throws on unresolved fonts in distributed renders - Single source of truth: @hyperframes/core/fonts/aliases - system_font_will_alias lint rule (escalates to warning for distributed) - Default to Inter + JetBrains Mono in templates and CSS reset
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Single source of truth for the deterministic font alias map. Both the
|
||||
* producer's @font-face injector and the core lint rules import from here,
|
||||
* eliminating manual drift between the two.
|
||||
*
|
||||
* Keys are lowercase font family names. Values are canonical font slugs
|
||||
* matching CANONICAL_FONTS keys in the producer's deterministicFonts module.
|
||||
*/
|
||||
export const FONT_ALIAS_MAP = {
|
||||
// ── Canonical bundled fonts (self-referencing) ────────────────────────
|
||||
inter: "inter",
|
||||
montserrat: "montserrat",
|
||||
outfit: "outfit",
|
||||
nunito: "nunito",
|
||||
oswald: "oswald",
|
||||
"league gothic": "league-gothic",
|
||||
"archivo black": "archivo-black",
|
||||
"space mono": "space-mono",
|
||||
"ibm plex mono": "ibm-plex-mono",
|
||||
"jetbrains mono": "jetbrains-mono",
|
||||
"eb garamond": "eb-garamond",
|
||||
"playfair display": "playfair-display",
|
||||
"source code pro": "source-code-pro",
|
||||
"noto sans jp": "noto-sans-jp",
|
||||
roboto: "roboto",
|
||||
"open sans": "open-sans",
|
||||
lato: "lato",
|
||||
poppins: "poppins",
|
||||
|
||||
// ── Common aliases → nearest canonical ────────────────────────────────
|
||||
"helvetica neue": "inter",
|
||||
helvetica: "inter",
|
||||
arial: "inter",
|
||||
"helvetica bold": "inter",
|
||||
futura: "montserrat",
|
||||
"din alternate": "montserrat",
|
||||
"arial black": "montserrat",
|
||||
"bebas neue": "league-gothic",
|
||||
"courier new": "jetbrains-mono",
|
||||
courier: "jetbrains-mono",
|
||||
garamond: "eb-garamond",
|
||||
"noto sans japanese": "noto-sans-jp",
|
||||
"segoe ui": "roboto",
|
||||
|
||||
// ── macOS sans-serif system fonts → inter ─────────────────────────────
|
||||
"sf pro": "inter",
|
||||
"sf pro display": "inter",
|
||||
"sf pro text": "inter",
|
||||
"sf pro rounded": "inter",
|
||||
avenir: "inter",
|
||||
"avenir next": "inter",
|
||||
"lucida grande": "inter",
|
||||
geneva: "inter",
|
||||
optima: "inter",
|
||||
|
||||
// ── Windows sans-serif system fonts → inter ───────────────────────────
|
||||
verdana: "inter",
|
||||
tahoma: "inter",
|
||||
"trebuchet ms": "inter",
|
||||
calibri: "inter",
|
||||
candara: "inter",
|
||||
corbel: "inter",
|
||||
"lucida sans": "inter",
|
||||
"lucida sans unicode": "inter",
|
||||
|
||||
// ── Linux sans-serif system fonts → inter ─────────────────────────────
|
||||
"noto sans": "inter",
|
||||
"dejavu sans": "inter",
|
||||
"liberation sans": "inter",
|
||||
|
||||
// ── Monospace system fonts → jetbrains-mono ───────────────────────────
|
||||
"sf mono": "jetbrains-mono",
|
||||
menlo: "jetbrains-mono",
|
||||
monaco: "jetbrains-mono",
|
||||
consolas: "jetbrains-mono",
|
||||
"lucida console": "jetbrains-mono",
|
||||
"lucida sans typewriter": "jetbrains-mono",
|
||||
"andale mono": "jetbrains-mono",
|
||||
"dejavu sans mono": "jetbrains-mono",
|
||||
"liberation mono": "jetbrains-mono",
|
||||
|
||||
// ── Serif system fonts → eb-garamond ──────────────────────────────────
|
||||
georgia: "eb-garamond",
|
||||
palatino: "eb-garamond",
|
||||
"palatino linotype": "eb-garamond",
|
||||
"book antiqua": "eb-garamond",
|
||||
cambria: "eb-garamond",
|
||||
times: "eb-garamond",
|
||||
"times new roman": "eb-garamond",
|
||||
"dejavu serif": "eb-garamond",
|
||||
"liberation serif": "eb-garamond",
|
||||
} satisfies Readonly<Record<string, string>>;
|
||||
|
||||
export const FONT_ALIAS_KEYS: ReadonlySet<string> = new Set(Object.keys(FONT_ALIAS_MAP));
|
||||
|
||||
/**
|
||||
* Human-readable display names for canonical font slugs. Used by the lint
|
||||
* rule to tell authors what their aliased font will render as.
|
||||
*/
|
||||
export const CANONICAL_FONT_DISPLAY_NAMES: Readonly<Record<string, string>> = {
|
||||
inter: "Inter",
|
||||
montserrat: "Montserrat",
|
||||
outfit: "Outfit",
|
||||
nunito: "Nunito",
|
||||
oswald: "Oswald",
|
||||
"league-gothic": "League Gothic",
|
||||
"archivo-black": "Archivo Black",
|
||||
"space-mono": "Space Mono",
|
||||
"ibm-plex-mono": "IBM Plex Mono",
|
||||
"jetbrains-mono": "JetBrains Mono",
|
||||
"eb-garamond": "EB Garamond",
|
||||
"playfair-display": "Playfair Display",
|
||||
"source-code-pro": "Source Code Pro",
|
||||
"noto-sans-jp": "Noto Sans JP",
|
||||
roboto: "Roboto",
|
||||
"open-sans": "Open Sans",
|
||||
lato: "Lato",
|
||||
poppins: "Poppins",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a font alias to its canonical display name, or undefined if the
|
||||
* alias is not in the map.
|
||||
*/
|
||||
export function resolveAliasDisplayName(alias: string): string | undefined {
|
||||
const slug = (FONT_ALIAS_MAP as Record<string, string>)[alias.toLowerCase()];
|
||||
if (!slug) return undefined;
|
||||
return CANONICAL_FONT_DISPLAY_NAMES[slug];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import {
|
||||
locateSystemFont,
|
||||
clearSystemFontCache,
|
||||
SYSTEM_FONT_SIZE_LIMIT,
|
||||
} from "./systemFontLocator";
|
||||
|
||||
describe("systemFontLocator", { timeout: 15_000 }, () => {
|
||||
beforeAll(() => {
|
||||
clearSystemFontCache();
|
||||
});
|
||||
|
||||
it("returns null for nonexistent fonts", () => {
|
||||
const result = locateSystemFont("nonexistent-font-xyz-12345");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes case when looking up fonts", () => {
|
||||
const lower = locateSystemFont("helvetica");
|
||||
const upper = locateSystemFont("HELVETICA");
|
||||
if (lower === null) {
|
||||
expect(upper).toBeNull();
|
||||
} else {
|
||||
expect(upper).not.toBeNull();
|
||||
expect(upper!.path).toBe(lower.path);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a valid format field when a font is found", () => {
|
||||
const result =
|
||||
locateSystemFont("Helvetica") ?? locateSystemFont("Arial") ?? locateSystemFont("DejaVu Sans");
|
||||
if (result) {
|
||||
expect(["ttf", "otf", "woff2", "woff", "ttc"]).toContain(result.format);
|
||||
expect(result.path).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("caches results across calls", () => {
|
||||
const first = locateSystemFont("nonexistent-font-xyz-12345");
|
||||
const second = locateSystemFont("nonexistent-font-xyz-12345");
|
||||
expect(first).toBeNull();
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it("clearSystemFontCache resets the cache", () => {
|
||||
locateSystemFont("nonexistent-font-xyz-12345");
|
||||
clearSystemFontCache();
|
||||
const result = locateSystemFont("nonexistent-font-xyz-12345");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("exports SYSTEM_FONT_SIZE_LIMIT as 5MB", () => {
|
||||
expect(SYSTEM_FONT_SIZE_LIMIT).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("strips quotes from family name input", () => {
|
||||
const result = locateSystemFont('"nonexistent-font-xyz-12345"');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(locateSystemFont("")).toBeNull();
|
||||
expect(locateSystemFont(" ")).toBeNull();
|
||||
});
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
it("finds Helvetica on macOS", () => {
|
||||
const result = locateSystemFont("Helvetica");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.path).toMatch(/\.(ttf|ttc|otf)$/i);
|
||||
});
|
||||
|
||||
it("finds Courier on macOS", () => {
|
||||
const result = locateSystemFont("Courier");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export const SYSTEM_FONT_SIZE_LIMIT = 5 * 1024 * 1024;
|
||||
|
||||
export type FontFileFormat = "ttf" | "otf" | "woff2" | "woff" | "ttc";
|
||||
|
||||
export interface LocatedFont {
|
||||
path: string;
|
||||
format: FontFileFormat;
|
||||
}
|
||||
|
||||
export const FONT_EXT_RE = /\.(otf|ttf|ttc|woff2?)$/i;
|
||||
|
||||
const FORMAT_PRIORITY: Record<FontFileFormat, number> = {
|
||||
woff2: 0,
|
||||
otf: 1,
|
||||
ttf: 2,
|
||||
woff: 3,
|
||||
ttc: 4,
|
||||
};
|
||||
|
||||
const STYLE_SUFFIXES = new Set([
|
||||
"black",
|
||||
"bold",
|
||||
"book",
|
||||
"condensed",
|
||||
"demi",
|
||||
"demibold",
|
||||
"display",
|
||||
"extra",
|
||||
"extrabold",
|
||||
"hairline",
|
||||
"heavy",
|
||||
"italic",
|
||||
"light",
|
||||
"medium",
|
||||
"normal",
|
||||
"regular",
|
||||
"roman",
|
||||
"semibold",
|
||||
"thin",
|
||||
"ultra",
|
||||
"ultralight",
|
||||
]);
|
||||
|
||||
const REGULAR_TOKENS = new Set(["regular", "roman", "normal", "book"]);
|
||||
|
||||
const cache = new Map<string, LocatedFont | null>();
|
||||
|
||||
let allowedDirsCache: string[] | null = null;
|
||||
|
||||
function getAllowedFontDirs(): string[] {
|
||||
if (allowedDirsCache) return allowedDirsCache;
|
||||
allowedDirsCache = fontDirectories()
|
||||
.filter((d) => existsSync(d))
|
||||
.map((d) => {
|
||||
try {
|
||||
return realpathSync(d);
|
||||
} catch {
|
||||
return resolve(d);
|
||||
}
|
||||
});
|
||||
return allowedDirsCache;
|
||||
}
|
||||
|
||||
function isPathBounded(filePath: string): boolean {
|
||||
try {
|
||||
const real = realpathSync(filePath);
|
||||
const allowed = getAllowedFontDirs();
|
||||
return allowed.some((dir) => real.startsWith(dir + "/") || real.startsWith(dir + "\\"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRegularFile(filePath: string): boolean {
|
||||
try {
|
||||
const lst = lstatSync(filePath);
|
||||
if (lst.isSymbolicLink())
|
||||
return isPathBounded(filePath) && lstatSync(realpathSync(filePath)).isFile();
|
||||
return lst.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function extensionToFormat(ext: string): FontFileFormat {
|
||||
const lower = ext.toLowerCase().replace(/^\./, "");
|
||||
if (lower === "woff2") return "woff2";
|
||||
if (lower === "woff") return "woff";
|
||||
if (lower === "otf") return "otf";
|
||||
if (lower === "ttc") return "ttc";
|
||||
return "ttf";
|
||||
}
|
||||
|
||||
export function toFamilyName(fileName: string): string | null {
|
||||
const withoutExt = fileName.replace(FONT_EXT_RE, "");
|
||||
if (!withoutExt || withoutExt.startsWith(".")) return null;
|
||||
const spaced = withoutExt
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const words = spaced.split(" ").filter(Boolean);
|
||||
while (words.length > 1 && STYLE_SUFFIXES.has((words.at(-1) ?? "").toLowerCase())) {
|
||||
words.pop();
|
||||
}
|
||||
const family = words.join(" ").trim();
|
||||
return family.length >= 2 ? family : null;
|
||||
}
|
||||
|
||||
function isRegularWeight(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase();
|
||||
if (REGULAR_TOKENS.has(lower.replace(FONT_EXT_RE, "").split(/[-_ ]/).pop() ?? "")) return true;
|
||||
return !lower.includes("bold") && !lower.includes("italic") && !lower.includes("light");
|
||||
}
|
||||
|
||||
export function fontDirectories(): string[] {
|
||||
const home = homedir();
|
||||
if (platform() === "darwin") {
|
||||
return [
|
||||
join(home, "Library", "Fonts"),
|
||||
"/Library/Fonts",
|
||||
"/System/Library/Fonts",
|
||||
"/System/Library/Fonts/Supplemental",
|
||||
];
|
||||
}
|
||||
if (platform() === "win32") {
|
||||
return [
|
||||
join(process.env.WINDIR || "C:\\Windows", "Fonts"),
|
||||
join(
|
||||
process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"),
|
||||
"Microsoft",
|
||||
"Windows",
|
||||
"Fonts",
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
join(home, ".fonts"),
|
||||
join(home, ".local", "share", "fonts"),
|
||||
"/usr/local/share/fonts",
|
||||
"/usr/share/fonts",
|
||||
];
|
||||
}
|
||||
|
||||
export interface FontFileEntry {
|
||||
path: string;
|
||||
fileName: string;
|
||||
family: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates font files in a directory (up to `depth` 2) and yields each file's
|
||||
* path, filename, and derived family name. Shared by the font listing route
|
||||
* and the per-family locator to avoid duplicating the directory scan loop.
|
||||
*/
|
||||
export function collectFontFileEntries(dir: string, depth = 0): FontFileEntry[] {
|
||||
if (!existsSync(dir) || depth > 2) return [];
|
||||
const entries: FontFileEntry[] = [];
|
||||
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
entries.push(...collectFontFileEntries(fullPath, depth + 1));
|
||||
continue;
|
||||
}
|
||||
if (!FONT_EXT_RE.test(entry.name)) continue;
|
||||
if (!isRegularFile(fullPath)) continue;
|
||||
const family = toFamilyName(entry.name);
|
||||
if (family) entries.push({ path: fullPath, fileName: entry.name, family });
|
||||
}
|
||||
} catch {
|
||||
// Directory read failed — skip
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
interface FontCandidate {
|
||||
path: string;
|
||||
format: FontFileFormat;
|
||||
isRegular: boolean;
|
||||
}
|
||||
|
||||
function collectCandidatesFromDir(dir: string, targetFamily: string, depth = 0): FontCandidate[] {
|
||||
return collectFontFileEntries(dir, depth)
|
||||
.filter((e) => normalizeName(e.family) === targetFamily)
|
||||
.map((e) => {
|
||||
const ext = e.fileName.match(FONT_EXT_RE)?.[1] ?? "ttf";
|
||||
return {
|
||||
path: e.path,
|
||||
format: extensionToFormat(ext),
|
||||
isRegular: isRegularWeight(e.fileName),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pickBestCandidate(candidates: FontCandidate[]): LocatedFont | null {
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort((a, b) => {
|
||||
if (a.isRegular !== b.isRegular) return a.isRegular ? -1 : 1;
|
||||
return (FORMAT_PRIORITY[a.format] ?? 9) - (FORMAT_PRIORITY[b.format] ?? 9);
|
||||
});
|
||||
const best = candidates[0]!;
|
||||
return { path: best.path, format: best.format };
|
||||
}
|
||||
|
||||
type SystemProfilerEntry = {
|
||||
family: string;
|
||||
path: string;
|
||||
format: FontFileFormat;
|
||||
isRegular: boolean;
|
||||
};
|
||||
|
||||
let profilerCache: Map<string, SystemProfilerEntry[]> | null = null;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function getSystemProfilerIndex(): Map<string, SystemProfilerEntry[]> {
|
||||
if (profilerCache) return profilerCache;
|
||||
profilerCache = new Map();
|
||||
if (platform() !== "darwin") return profilerCache;
|
||||
|
||||
try {
|
||||
const raw = execFileSync("system_profiler", ["SPFontsDataType", "-json"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 12 * 1024 * 1024,
|
||||
timeout: 5000,
|
||||
});
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed?.SPFontsDataType || !Array.isArray(parsed.SPFontsDataType)) return profilerCache;
|
||||
|
||||
for (const fontEntry of parsed.SPFontsDataType) {
|
||||
if (!fontEntry?.typefaces || !Array.isArray(fontEntry.typefaces)) continue;
|
||||
for (const typeface of fontEntry.typefaces) {
|
||||
if (!typeface) continue;
|
||||
const family = typeface.family ?? typeface.fullname ?? typeface._name;
|
||||
if (typeof family !== "string") continue;
|
||||
const filePath = typeface.path;
|
||||
if (typeof filePath !== "string" || !FONT_EXT_RE.test(filePath)) continue;
|
||||
const normalized = normalizeName(family);
|
||||
const ext = filePath.match(FONT_EXT_RE)?.[1] ?? "ttf";
|
||||
const entry: SystemProfilerEntry = {
|
||||
family: normalized,
|
||||
path: filePath,
|
||||
format: extensionToFormat(ext),
|
||||
isRegular: isRegularWeight(filePath),
|
||||
};
|
||||
const list = profilerCache.get(normalized) ?? [];
|
||||
list.push(entry);
|
||||
profilerCache.set(normalized, list);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// system_profiler unavailable
|
||||
}
|
||||
|
||||
return profilerCache;
|
||||
}
|
||||
|
||||
function locateViaSystemProfiler(targetFamily: string): LocatedFont | null {
|
||||
const index = getSystemProfilerIndex();
|
||||
const entries = index.get(targetFamily);
|
||||
if (!entries || entries.length === 0) return null;
|
||||
|
||||
const candidates: FontCandidate[] = entries
|
||||
.filter((e) => isRegularFile(e.path) && isPathBounded(e.path))
|
||||
.map((e) => ({ path: e.path, format: e.format, isRegular: e.isRegular }));
|
||||
|
||||
return pickBestCandidate(candidates);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function locateViaFcMatch(targetFamily: string): LocatedFont | null {
|
||||
if (platform() !== "linux") return null;
|
||||
try {
|
||||
const result = execFileSync("fc-match", [targetFamily, "--format=%{file}"], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
}).trim();
|
||||
if (!result || !isRegularFile(result) || !isPathBounded(result)) return null;
|
||||
const fileName = result.split("/").pop() ?? "";
|
||||
const derivedFamily = toFamilyName(fileName);
|
||||
if (!derivedFamily || normalizeName(derivedFamily) !== targetFamily) return null;
|
||||
const ext = fileName.match(FONT_EXT_RE)?.[1] ?? "ttf";
|
||||
return { path: result, format: extensionToFormat(ext) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function locateSystemFont(family: string): LocatedFont | null {
|
||||
const normalized = normalizeName(family);
|
||||
if (!normalized) return null;
|
||||
|
||||
const cached = cache.get(normalized);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let result: LocatedFont | null = null;
|
||||
|
||||
result = locateViaSystemProfiler(normalized);
|
||||
|
||||
if (!result) {
|
||||
result = locateViaFcMatch(normalized);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const allCandidates: FontCandidate[] = [];
|
||||
for (const dir of fontDirectories()) {
|
||||
allCandidates.push(...collectCandidatesFromDir(dir, normalized));
|
||||
}
|
||||
result = pickBestCandidate(allCandidates);
|
||||
}
|
||||
|
||||
cache.set(normalized, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface LocatedFontVariant extends LocatedFont {
|
||||
weight: string;
|
||||
style: "normal" | "italic";
|
||||
}
|
||||
|
||||
const WEIGHT_TOKENS: Record<string, string> = {
|
||||
thin: "100",
|
||||
hairline: "100",
|
||||
ultralight: "200",
|
||||
extralight: "200",
|
||||
light: "300",
|
||||
regular: "400",
|
||||
normal: "400",
|
||||
book: "400",
|
||||
roman: "400",
|
||||
medium: "500",
|
||||
demibold: "600",
|
||||
semibold: "600",
|
||||
bold: "700",
|
||||
extrabold: "800",
|
||||
ultrabold: "800",
|
||||
heavy: "800",
|
||||
black: "900",
|
||||
ultrablack: "950",
|
||||
};
|
||||
|
||||
const WEIGHT_TOKENS_SORTED = Object.entries(WEIGHT_TOKENS).sort(([a], [b]) => b.length - a.length);
|
||||
|
||||
function inferWeightAndStyle(fileName: string): { weight: string; style: "normal" | "italic" } {
|
||||
const lower = fileName.toLowerCase().replace(FONT_EXT_RE, "");
|
||||
const style = lower.includes("italic") || lower.includes("oblique") ? "italic" : "normal";
|
||||
for (const [token, weight] of WEIGHT_TOKENS_SORTED) {
|
||||
if (lower.includes(token)) return { weight, style };
|
||||
}
|
||||
return { weight: "400", style };
|
||||
}
|
||||
|
||||
export function locateSystemFontVariants(family: string): LocatedFontVariant[] {
|
||||
const normalized = normalizeName(family);
|
||||
if (!normalized) return [];
|
||||
|
||||
const variants: LocatedFontVariant[] = [];
|
||||
|
||||
const profilerIndex = getSystemProfilerIndex();
|
||||
const profilerEntries = profilerIndex.get(normalized);
|
||||
if (profilerEntries && profilerEntries.length > 0) {
|
||||
for (const e of profilerEntries) {
|
||||
if (!isRegularFile(e.path) || !isPathBounded(e.path)) continue;
|
||||
const { weight, style } = inferWeightAndStyle(e.path);
|
||||
variants.push({ path: e.path, format: e.format, weight, style });
|
||||
}
|
||||
if (variants.length > 0) return dedupeVariants(variants);
|
||||
}
|
||||
|
||||
const allCandidates: FontCandidate[] = [];
|
||||
for (const dir of fontDirectories()) {
|
||||
allCandidates.push(...collectCandidatesFromDir(dir, normalized));
|
||||
}
|
||||
for (const c of allCandidates) {
|
||||
const { weight, style } = inferWeightAndStyle(c.path);
|
||||
variants.push({ path: c.path, format: c.format, weight, style });
|
||||
}
|
||||
return dedupeVariants(variants);
|
||||
}
|
||||
|
||||
function dedupeVariants(variants: LocatedFontVariant[]): LocatedFontVariant[] {
|
||||
const seen = new Map<string, LocatedFontVariant>();
|
||||
for (const v of variants) {
|
||||
const key = `${v.weight}:${v.style}`;
|
||||
if (!seen.has(key)) seen.set(key, v);
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
export function clearSystemFontCache(): void {
|
||||
cache.clear();
|
||||
profilerCache = null;
|
||||
allowedDirsCache = null;
|
||||
}
|
||||
Reference in New Issue
Block a user