fix(fonts): supplement alias faces from the canonical family (#3230)

A family resolving through FONT_ALIAS_MAP could emit @font-face rules drawn from two
unrelated typefaces under one font-family name, split by weight and style. The
supplementation fetch was passed the authored name, so for a cross-typeface alias
(helvetica -> inter) it asked Google for the very typeface the alias exists to replace.

Diagnosed, reported and fixed by Akshay Kumar Sharma (@akzarma) in #3083 / #3085. This
PR carries that work because the fix requires re-recorded regression baselines, which
are LFS objects we cannot push to a fork's LFS store.

Baselines re-recorded for style-15-prod and style-3-prod, each verified text-only
before acceptance. All 9 regression shards pass.

Closes #3083.

Co-authored-by: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com>
This commit is contained in:
James Russo
2026-08-11 16:01:55 -07:00
committed by GitHub
co-authored by Akshay Kumar Sharma
parent e0ba41c024
commit a16222d9a2
4 changed files with 426 additions and 17 deletions
@@ -0,0 +1,306 @@
/**
* Regression test for cross-typeface alias supplementation.
*
* A family in FONT_ALIAS_MAP resolves to a canonical bundled typeface — e.g.
* `Noto Sans` → Inter. `buildFontFaceCss` emits the canonical's embedded faces
* under the authored family name, then queries Google Fonts to fill the weights
* and styles the bundle lacks.
*
* The bug: that supplementary query used the *authored* name, so a
* cross-typeface alias regained faces from the very typeface the alias exists to
* replace. No canonical bundle ships an italic face, so every italic Google
* served for the authored name was injected — `Noto Sans` came out as Inter
* upright plus real Noto Sans italic, two typefaces under one `font-family`.
*
* These tests inject `fetchImpl` (no network) and a temp `HYPERFRAMES_FONT_CACHE_DIR`
* so they are hermetic.
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
let cacheDir: string;
let prevCacheEnv: string | undefined;
beforeAll(() => {
prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
cacheDir = mkdtempSync(join(tmpdir(), "hf-font-alias-"));
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
});
afterAll(() => {
if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv;
rmSync(cacheDir, { recursive: true, force: true });
});
// One woff2 per typeface Google could serve, with identifiable bodies so every
// injected `src` can be traced back to the family that was queried.
const SERVED_FACES: Record<string, { url: string; bytes: string }> = {
Inter: {
url: "https://fonts.gstatic.com/s/inter/v1/inter-supplement.woff2",
bytes: "INTER_FETCHED_BYTES",
},
"Noto Sans": {
url: "https://fonts.gstatic.com/s/notosans/v1/notosans-supplement.woff2",
bytes: "NOTO_SANS_FETCHED_BYTES",
},
Montserrat: {
url: "https://fonts.gstatic.com/s/montserrat/v1/montserrat-supplement.woff2",
bytes: "MONTSERRAT_FETCHED_BYTES",
},
};
const b64 = (s: string) => Buffer.from(s).toString("base64");
// 300 normal + 400 italic: no CANONICAL_FONTS bundle ships either, so both are
// classified "missing from the bundle" and injected.
function cssFor(family: string): string {
const url = SERVED_FACES[family]?.url;
if (!url) return "";
return `@font-face {
font-family: '${family}';
font-style: normal;
font-weight: 300;
src: url(${url}) format('woff2');
}
@font-face {
font-family: '${family}';
font-style: italic;
font-weight: 400;
src: url(${url}) format('woff2');
}`;
}
function makeGoogleFetch(queriedFamilies: string[]): typeof fetch {
return (async (input: unknown) => {
const url = String(input);
if (url.startsWith("https://fonts.googleapis.com/")) {
const family = new URL(url).searchParams.get("family")?.split(":", 1)[0] ?? "";
queriedFamilies.push(family);
return new Response(cssFor(family), { status: 200 });
}
const served = Object.values(SERVED_FACES).find((face) => face.url === url);
if (served) return new Response(served.bytes, { status: 200 });
return new Response("", { status: 404 });
}) as unknown as typeof fetch;
}
function htmlRequesting(family: string): string {
return `<!doctype html><html><head><style>
h1 { font-family: "${family}"; }
</style></head><body><h1>Upright</h1></body></html>`;
}
function injectedSrcs(html: string): string[] {
return [...html.matchAll(/src:\s*url\("([^"]+)"\)/g)].map((match) => match[1] ?? "");
}
function bundledUris(packageName: string): Set<string> {
return new Set(
[...EMBEDDED_FONT_DATA]
.filter(([key]) => key.startsWith(`${packageName}:`))
.map(([, uri]) => uri),
);
}
describe("aliased font-family supplementation", () => {
it("supplements a cross-typeface alias from the canonical family", async () => {
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
const queriedFamilies: string[] = [];
const result = await injectDeterministicFontFaces(htmlRequesting("Noto Sans"), {
allowSystemFontCapture: false,
fetchImpl: makeGoogleFetch(queriedFamilies),
});
// "Noto Sans" aliases to Inter, so the supplementary query asks for Inter.
expect(queriedFamilies).toEqual(["Inter"]);
// The authored spelling still names the family, so authored CSS keeps matching.
expect(result).toContain('font-family: "Noto Sans"');
// Every injected face is Inter — either the embedded bundle or the Inter
// fetch. None carry the real Noto Sans the alias exists to replace.
const canonicalUris = bundledUris("@fontsource/inter");
canonicalUris.add(`data:font/woff2;base64,${b64(SERVED_FACES.Inter!.bytes)}`);
const srcs = injectedSrcs(result);
expect(srcs.length).toBeGreaterThan(0);
for (const src of srcs) expect(canonicalUris.has(src)).toBe(true);
expect(result).not.toContain(b64(SERVED_FACES["Noto Sans"]!.bytes));
});
it("collapses a variable font's shared source into one weight-range rule", async () => {
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
// Google serves Inter as a variable font: one woff2 for every static weight.
const sharedUrl = "https://fonts.gstatic.com/s/inter/v1/inter-variable.woff2";
const css = [100, 200, 300, 500]
.map(
(weight) => `@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: ${weight};
src: url(${sharedUrl}) format('woff2');
}`,
)
.join("\n");
const fetchImpl = (async (input: unknown) => {
const url = String(input);
if (url.startsWith("https://fonts.googleapis.com/"))
return new Response(css, { status: 200 });
if (url === sharedUrl) return new Response("INTER_VARIABLE_BYTES", { status: 200 });
return new Response("", { status: 404 });
}) as unknown as typeof fetch;
const result = await injectDeterministicFontFaces(htmlRequesting("Noto Sans"), {
allowSystemFontCapture: false,
fetchImpl,
});
// One rule spanning the run, not four rules carrying the same blob.
const variableUri = `data:font/woff2;base64,${b64("INTER_VARIABLE_BYTES")}`;
// 100-300 collapse into one rule; 500 stays separate because the bundle
// serves 400 and a 100-500 range would shadow that embedded face.
const occurrences = result.split(variableUri).length - 1;
expect(occurrences).toBe(2);
expect(result).toContain("font-weight: 100 300;");
expect(result).toContain("font-weight: 500;");
});
it("collapses shared sources when Google interleaves subsets without text=", async () => {
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
// Without `text=` Google orders the response weight-major, subset-minor,
// so the faces sharing one variable source are never adjacent.
const latinUrl = "https://fonts.gstatic.com/s/inter/v1/inter-latin-variable.woff2";
const latinExtUrl = "https://fonts.gstatic.com/s/inter/v1/inter-latinext-variable.woff2";
const LATIN = "U+0000-00FF";
const LATIN_EXT = "U+0100-024F";
const subsets = [
{ url: latinUrl, range: LATIN },
{ url: latinExtUrl, range: LATIN_EXT },
];
const css = [100, 200, 300, 500]
.flatMap((weight) =>
subsets.map(
(subset) => `@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: ${weight};
src: url(${subset.url}) format('woff2');
unicode-range: ${subset.range};
}`,
),
)
.join("\n");
const fetchImpl = (async (input: unknown) => {
const url = String(input);
if (url.startsWith("https://fonts.googleapis.com/")) {
// The request must not carry `text=`, or Google would return one
// subset and the interleaving under test would not occur.
expect(new URL(url).searchParams.get("text")).toBeNull();
return new Response(css, { status: 200 });
}
if (url === latinUrl) return new Response("LATIN_VARIABLE_BYTES", { status: 200 });
if (url === latinExtUrl) return new Response("LATINEXT_VARIABLE_BYTES", { status: 200 });
return new Response("", { status: 404 });
}) as unknown as typeof fetch;
// Enough unique characters that extractGoogleFontsText exceeds its budget
// and returns undefined, which is what drops `text=` in production.
const manyUniqueChars = Array.from({ length: 400 }, (_, index) =>
String.fromCodePoint(0x4e00 + index),
).join("");
const html = `<!doctype html><html><head><style>
h1 { font-family: "Noto Sans"; }
</style></head><body><h1>${manyUniqueChars}</h1></body></html>`;
const result = await injectDeterministicFontFaces(html, {
allowSystemFontCapture: false,
fetchImpl,
});
// Per subset: 100-300 collapse into one rule, 500 stays separate because
// the bundle covers 400. Two rules per subset, not one per weight.
for (const bytes of ["LATIN_VARIABLE_BYTES", "LATINEXT_VARIABLE_BYTES"]) {
const uri = `data:font/woff2;base64,${b64(bytes)}`;
expect(result.split(uri).length - 1).toBe(2);
}
expect(result.split("font-weight: 100 300;").length - 1).toBe(2);
expect(result.split("font-weight: 500;").length - 1).toBe(2);
});
it("keeps overlapping subsets in response order when collapsing", async () => {
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
// Real Google output has codepoints in more than one subset (U+0304 and
// friends). Overlapping `unicode-range` rules resolve last-defined-first,
// so collapsing must not move a subset ahead of one declared after it.
const firstUrl = "https://fonts.gstatic.com/s/inter/v1/inter-first.woff2";
const secondUrl = "https://fonts.gstatic.com/s/inter/v1/inter-second.woff2";
const OVERLAPPING = "U+0000-00FF, U+0304";
const css = [100, 200, 500]
.flatMap((weight) =>
[
{ url: firstUrl, range: "U+0000-00FF" },
{ url: secondUrl, range: OVERLAPPING },
].map(
(subset) => `@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: ${weight};
src: url(${subset.url}) format('woff2');
unicode-range: ${subset.range};
}`,
),
)
.join("\n");
const fetchImpl = (async (input: unknown) => {
const url = String(input);
if (url.startsWith("https://fonts.googleapis.com/"))
return new Response(css, { status: 200 });
if (url === firstUrl) return new Response("FIRST_BYTES", { status: 200 });
if (url === secondUrl) return new Response("SECOND_BYTES", { status: 200 });
return new Response("", { status: 404 });
}) as unknown as typeof fetch;
const result = await injectDeterministicFontFaces(htmlRequesting("Noto Sans"), {
allowSystemFontCapture: false,
fetchImpl,
});
// 100-200 and 500 are separate runs per source because the bundle covers
// 400, so each source is emitted twice. The interleaving must survive
// collapsing: source-major grouping would emit FIRST, FIRST, SECOND, SECOND
// and hand the shared codepoints to the wrong subset.
const order = [...result.matchAll(/base64,([A-Za-z0-9+/=]+)/g)]
.map((match) => match[1] ?? "")
.filter((data) => data === b64("FIRST_BYTES") || data === b64("SECOND_BYTES"))
.map((data) => (data === b64("FIRST_BYTES") ? "FIRST" : "SECOND"));
expect(order).toEqual(["FIRST", "SECOND", "FIRST", "SECOND"]);
});
it("has a canonical display name for every alias target", async () => {
const { FONT_ALIAS_MAP, resolveAliasDisplayName } =
await import("@hyperframes/core/fonts/aliases");
// The supplementary fetch is skipped outright when this lookup fails, so
// every alias must resolve or its family silently loses Google's weights.
for (const alias of Object.keys(FONT_ALIAS_MAP)) {
expect(resolveAliasDisplayName(alias)).toBeString();
}
});
it("still supplements a self-referencing alias from its own family", async () => {
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
const queriedFamilies: string[] = [];
const result = await injectDeterministicFontFaces(htmlRequesting("Montserrat"), {
allowSystemFontCapture: false,
fetchImpl: makeGoogleFetch(queriedFamilies),
});
expect(queriedFamilies).toEqual(["Montserrat"]);
expect(result).toContain(b64(SERVED_FACES.Montserrat!.bytes));
});
});
@@ -4,7 +4,7 @@ import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { defaultLogger } from "../logger.js";
import { FONT_ALIAS_MAP } from "@hyperframes/core/fonts/aliases";
import { FONT_ALIAS_MAP, resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases";
import {
locateSystemFontVariants,
SYSTEM_FONT_SIZE_LIMIT,
@@ -491,6 +491,86 @@ function buildFontFaceRule(
].join("\n");
}
/**
* Google serves several canonical families as a variable font: every static
* weight resolves to the same woff2. Faces sharing a source can be emitted as
* one weight-range rule instead of embedding that blob once per weight.
*
* Without `text=` the response is ordered weight-major, subset-minor, so those
* faces are not adjacent — group by source rather than scanning neighbours.
* Insertion order keeps the emitted CSS deterministic.
*/
function normalizeWeightKey(weight: string): string {
const numeric = Number(weight);
return Number.isFinite(numeric) ? String(numeric) : weight.trim().toLowerCase();
}
function coverageKey(weight: string, style: string): string {
return `${normalizeWeightKey(weight)}:${style}`;
}
function groupFacesBySource(faces: readonly GoogleFontFace[]): GoogleFontFace[][] {
const groups = new Map<string, GoogleFontFace[]>();
for (const face of faces) {
const key = [face.dataUri, face.style, face.unicodeRange ?? ""].join("\u0000");
const existing = groups.get(key);
if (existing) existing.push(face);
else groups.set(key, [face]);
}
return [...groups.values()];
}
/**
* A weight range must not span a weight the embedded bundle already serves, or
* the later rule would win for that weight and shadow the bundled face.
*/
function spansCoveredWeight(
from: GoogleFontFace,
to: GoogleFontFace,
coveredWeights: ReadonlySet<string>,
): boolean {
const start = Number(from.weight);
const end = Number(to.weight);
if (!Number.isFinite(start) || !Number.isFinite(end)) return true;
const low = Math.min(start, end);
const high = Math.max(start, end);
for (const covered of coveredWeights) {
const [weight, style] = covered.split(":");
if (style !== from.style) continue;
const value = Number(weight);
if (Number.isFinite(value) && value > low && value < high) return true;
}
return false;
}
/**
* Split one source group into ascending runs, breaking wherever the embedded
* bundle already covers a weight inside the span. A weight that is not a plain
* number (a variable `100 900` range, say) cannot be ordered, so it stays on
* its own.
*/
function partitionWeightRuns(
faces: readonly GoogleFontFace[],
coveredWeights: ReadonlySet<string>,
): GoogleFontFace[][] {
const runs: GoogleFontFace[][] = [];
const sortable = faces.filter((face) => Number.isFinite(Number(face.weight)));
const unsortable = faces.filter((face) => !Number.isFinite(Number(face.weight)));
let current: GoogleFontFace[] = [];
for (const face of [...sortable].sort((a, b) => Number(a.weight) - Number(b.weight))) {
const previous = current[current.length - 1];
if (previous && spansCoveredWeight(previous, face, coveredWeights)) {
runs.push(current);
current = [];
}
current.push(face);
}
if (current.length > 0) runs.push(current);
for (const face of unsortable) runs.push([face]);
return runs;
}
async function buildFontFaceCss(
requestedFamilies: Map<string, string>,
options: InternalFontFetchOptions,
@@ -515,26 +595,49 @@ async function buildFontFaceCss(
const style = face.style || "normal";
const src = fontDataUri(canonical.packageName, face.weight, style);
rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style));
coveredWeights.add(`${face.weight}:${style}`);
coveredWeights.add(coverageKey(face.weight, style));
}
// Fetch all weights from Google Fonts and add any that aren't
// already covered by the embedded bundle. This ensures that
// compositions requesting e.g. wght@200 get that weight even
// if the bundle only ships 400/700/900.
const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText);
for (const face of googleFaces) {
// A weight covered by the embedded bundle is already full-coverage —
// skip it. For weights the bundle lacks, add EVERY subset face (a
// weight has one face per unicode-range subset), not just the first.
if (coveredWeights.has(`${face.weight}:${face.style}`)) continue;
// if the bundle only ships 400/700/900. Query the CANONICAL
// family, not the authored one: for a cross-typeface alias
// (helvetica → inter) the authored name is a different typeface,
// so supplementing from it would mix two typefaces under one
// font-family. The faces are still emitted under
// `originalCaseFamily` so the authored CSS keeps matching.
const canonicalFamily = resolveAliasDisplayName(normalizedFamily);
const googleFaces = canonicalFamily
? await fetchGoogleFont(canonicalFamily, options, fontText)
: [];
// A weight covered by the embedded bundle is already full-coverage —
// skip it. For weights the bundle lacks, keep EVERY subset face (a
// weight has one face per unicode-range subset), not just the first.
const supplementary = googleFaces.filter(
(face) => !coveredWeights.has(coverageKey(face.weight, face.style)),
);
const runs = groupFacesBySource(supplementary).flatMap((group) =>
partitionWeightRuns(group, coveredWeights),
);
// Overlapping `unicode-range` rules resolve last-defined-first, so a run
// is emitted where its first face appeared in the response rather than
// grouped by source. Collapsing must not reorder the faces.
const firstAppearance = (run: readonly GoogleFontFace[]): number =>
Math.min(...run.map((face) => supplementary.indexOf(face)));
for (const run of [...runs].sort((a, b) => firstAppearance(a) - firstAppearance(b))) {
const first = run[0];
const last = run[run.length - 1];
if (!first || !last) continue;
const weight = run.length > 1 ? `${first.weight} ${last.weight}` : first.weight;
rules.push(
buildFontFaceRule(
originalCaseFamily,
face.dataUri,
face.weight,
face.style,
face.unicodeRange,
first.dataUri,
weight,
first.style,
first.unicodeRange,
),
);
}