fix(fonts): retain non-Latin subsets for bundled weights (#3652)

Builds on #3086 with canonical alias supplementation and bundled Latin precedence.

Co-authored-by: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com>
This commit is contained in:
James Russo
2026-09-04 17:38:24 -04:00
committed by GitHub
co-authored by Akshay Kumar Sharma
parent bec11b1293
commit ef63e02936
2 changed files with 163 additions and 6 deletions
@@ -0,0 +1,131 @@
/**
* Regression test for the coverage a bundled face claims.
*
* `scripts/generate-font-data.ts` embeds the `-latin-` subset file of every
* canonical family, so an embedded face carries only Google's `latin` subset.
* The emitted `@font-face` used to omit `unicode-range` — advertising full
* coverage — and the supplementary Google fetch then skipped every subset of a
* weight the bundle "covered". A bundled family therefore could not render the
* scripts its own subset omits: `Noto Sans JP` weight 400 carries 218
* codepoints with no kana and no kanji.
*
* 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";
let cacheDir: string;
let prevCacheEnv: string | undefined;
beforeAll(() => {
prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
cacheDir = mkdtempSync(join(tmpdir(), "hf-font-subset-"));
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 });
});
const LATIN_RANGE =
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, " +
"U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD";
const JAPANESE_RANGE = "U+3041-3096, U+30A0-30FF, U+4E00-9FFF";
const LATIN_URL = "https://fonts.gstatic.com/s/notosansjp/v1/notosansjp-latin.woff2";
const JAPANESE_URL = "https://fonts.gstatic.com/s/notosansjp/v1/notosansjp-japanese.woff2";
// Weight 400 is in the embedded bundle; Google serves it as two subset faces.
const GOOGLE_CSS = `@font-face {
font-family: 'Noto Sans JP';
font-style: normal;
font-weight: 400;
src: url(${JAPANESE_URL}) format('woff2');
unicode-range: ${JAPANESE_RANGE};
}
@font-face {
font-family: 'Noto Sans JP';
font-style: normal;
font-weight: 400;
src: url(${LATIN_URL}) format('woff2');
unicode-range: ${LATIN_RANGE};
}`;
const googleFetch = (async (input: unknown) => {
const url = String(input);
if (url.startsWith("https://fonts.googleapis.com/")) {
return new Response(GOOGLE_CSS, { status: 200 });
}
if (url === JAPANESE_URL) return new Response("JAPANESE_SUBSET_BYTES", { status: 200 });
if (url === LATIN_URL) return new Response("LATIN_SUBSET_BYTES", { status: 200 });
return new Response("", { status: 404 });
}) as unknown as typeof fetch;
const HTML = `<!doctype html><html><head><style>
h1 { font-family: "Noto Sans JP"; }
</style></head><body><h1>日本語</h1></body></html>`;
const b64 = (s: string) => Buffer.from(s).toString("base64");
describe("bundled subset coverage", () => {
it("declares the bundle's subset and keeps the subsets it omits", async () => {
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
const result = await injectDeterministicFontFaces(HTML, {
allowSystemFontCapture: false,
fetchImpl: googleFetch,
});
// Every emitted face declares a unicode-range: none claims full coverage.
const faces = result.match(/@font-face \{[\s\S]*?\}/g) ?? [];
expect(faces.length).toBeGreaterThan(0);
for (const face of faces) expect(face).toContain("unicode-range:");
// The embedded faces declare the latin subset they actually ship.
expect(result).toContain(`unicode-range: ${LATIN_RANGE};`);
// Weight 400 is in the bundle, but its Japanese subset is still injected —
// otherwise a family documented as CJK cannot render Japanese.
expect(result).toContain(b64("JAPANESE_SUBSET_BYTES"));
expect(result).toContain(`unicode-range: ${JAPANESE_RANGE};`);
// The latin face for that same weight is still skipped: the bundle has it.
expect(result).not.toContain(b64("LATIN_SUBSET_BYTES"));
});
});
describe("bundled Latin precedence", () => {
it("retains text subsets and places bundled Latin after overlapping fetched faces", async () => {
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
const { EMBEDDED_FONT_DATA } = await import("./fontData.generated.js");
const fetchImpl = (async (input: unknown) => {
const url = String(input);
if (url.startsWith("https://fonts.googleapis.com/")) {
expect(new URL(url).searchParams.get("family")).toStartWith("Inter:");
return new Response(`@font-face {
font-family: 'Inter'; font-style: normal; font-weight: 400;
src: url(https://fonts.gstatic.com/s/inter/text-subset.woff2) format('woff2');
}`);
}
return new Response("TEXT_SUBSET_CYRILLIC");
}) as unknown as typeof fetch;
const html = HTML.replaceAll("Noto Sans JP", "Helvetica").replace("日本語", "Hello Привет");
const result = await injectDeterministicFontFaces(html, {
fetchImpl,
allowSystemFontCapture: false,
});
const fetched = b64("TEXT_SUBSET_CYRILLIC");
const embedded = EMBEDDED_FONT_DATA.get("@fontsource/inter:400:normal")!;
expect(result).toContain(fetched);
expect(result.indexOf(embedded)).toBeGreaterThan(result.indexOf(fetched));
const faces = result.match(/@font-face \{[\s\S]*?\}/g) ?? [];
const bundled = faces.find((face) => face.includes(embedded));
expect(bundled).toContain('font-family: "Helvetica"');
expect(bundled).toContain(`unicode-range: ${LATIN_RANGE};`);
});
});
@@ -470,6 +470,19 @@ export function fontFormatHint(src: string): "collection" | "woff2" {
return src.startsWith("data:font/collection;") ? "collection" : "woff2";
}
// generate-font-data.ts embeds Fontsource's -latin- subset for every family.
const BUNDLED_SUBSET_UNICODE_RANGE =
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, " +
"U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD";
function isBundledSubsetRange(unicodeRange: string | undefined): boolean {
const normalize = (range: string) => range.toLowerCase().replace(/\s+/g, "");
return (
unicodeRange !== undefined &&
normalize(unicodeRange) === normalize(BUNDLED_SUBSET_UNICODE_RANGE)
);
}
function buildFontFaceRule(
familyName: string,
src: string,
@@ -584,17 +597,26 @@ async function buildFontFaceCss(
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
// Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
// then fetch from Google Fonts to fill any weights not in the bundle.
// then fetch from Google Fonts to fill missing weights and character subsets.
const canonicalKey = FONT_ALIASES[normalizedFamily];
if (canonicalKey) {
const canonical = CANONICAL_FONTS[canonicalKey];
if (!canonical) continue;
const coveredWeights = new Set<string>();
const bundledRules: string[] = [];
for (const face of canonical.faces) {
const style = face.style || "normal";
const src = fontDataUri(canonical.packageName, face.weight, style);
rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style));
bundledRules.push(
buildFontFaceRule(
originalCaseFamily,
src,
face.weight,
style,
BUNDLED_SUBSET_UNICODE_RANGE,
),
);
coveredWeights.add(coverageKey(face.weight, style));
}
@@ -612,11 +634,12 @@ async function buildFontFaceCss(
? 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.
// Bundled weights only cover Latin. Keep other subsets (including
// text= responses without a range), even for weights already embedded.
const supplementary = googleFaces.filter(
(face) => !coveredWeights.has(coverageKey(face.weight, face.style)),
(face) =>
!coveredWeights.has(coverageKey(face.weight, face.style)) ||
!isBundledSubsetRange(face.unicodeRange),
);
const runs = groupFacesBySource(supplementary).flatMap((group) =>
partitionWeightRuns(group, coveredWeights),
@@ -641,6 +664,9 @@ async function buildFontFaceCss(
),
);
}
// Broader or text-subset responses can overlap Latin. Emit the bundle
// last so existing Latin glyphs keep their deterministic bundled source.
rules.push(...bundledRules);
continue;
}