Skip to content

Commit ef63e02

Browse files
jrusso1020akzarma
andauthored
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>
1 parent bec11b1 commit ef63e02

2 files changed

Lines changed: 163 additions & 6 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Regression test for the coverage a bundled face claims.
3+
*
4+
* `scripts/generate-font-data.ts` embeds the `-latin-` subset file of every
5+
* canonical family, so an embedded face carries only Google's `latin` subset.
6+
* The emitted `@font-face` used to omit `unicode-range` — advertising full
7+
* coverage — and the supplementary Google fetch then skipped every subset of a
8+
* weight the bundle "covered". A bundled family therefore could not render the
9+
* scripts its own subset omits: `Noto Sans JP` weight 400 carries 218
10+
* codepoints with no kana and no kanji.
11+
*
12+
* These tests inject `fetchImpl` (no network) and a temp
13+
* `HYPERFRAMES_FONT_CACHE_DIR` so they are hermetic.
14+
*/
15+
16+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
17+
import { mkdtempSync, rmSync } from "node:fs";
18+
import { tmpdir } from "node:os";
19+
import { join } from "node:path";
20+
21+
let cacheDir: string;
22+
let prevCacheEnv: string | undefined;
23+
24+
beforeAll(() => {
25+
prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
26+
cacheDir = mkdtempSync(join(tmpdir(), "hf-font-subset-"));
27+
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
28+
});
29+
30+
afterAll(() => {
31+
if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
32+
else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv;
33+
rmSync(cacheDir, { recursive: true, force: true });
34+
});
35+
36+
const LATIN_RANGE =
37+
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, " +
38+
"U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD";
39+
const JAPANESE_RANGE = "U+3041-3096, U+30A0-30FF, U+4E00-9FFF";
40+
41+
const LATIN_URL = "https://fonts.gstatic.com/s/notosansjp/v1/notosansjp-latin.woff2";
42+
const JAPANESE_URL = "https://fonts.gstatic.com/s/notosansjp/v1/notosansjp-japanese.woff2";
43+
44+
// Weight 400 is in the embedded bundle; Google serves it as two subset faces.
45+
const GOOGLE_CSS = `@font-face {
46+
font-family: 'Noto Sans JP';
47+
font-style: normal;
48+
font-weight: 400;
49+
src: url(${JAPANESE_URL}) format('woff2');
50+
unicode-range: ${JAPANESE_RANGE};
51+
}
52+
@font-face {
53+
font-family: 'Noto Sans JP';
54+
font-style: normal;
55+
font-weight: 400;
56+
src: url(${LATIN_URL}) format('woff2');
57+
unicode-range: ${LATIN_RANGE};
58+
}`;
59+
60+
const googleFetch = (async (input: unknown) => {
61+
const url = String(input);
62+
if (url.startsWith("https://fonts.googleapis.com/")) {
63+
return new Response(GOOGLE_CSS, { status: 200 });
64+
}
65+
if (url === JAPANESE_URL) return new Response("JAPANESE_SUBSET_BYTES", { status: 200 });
66+
if (url === LATIN_URL) return new Response("LATIN_SUBSET_BYTES", { status: 200 });
67+
return new Response("", { status: 404 });
68+
}) as unknown as typeof fetch;
69+
70+
const HTML = `<!doctype html><html><head><style>
71+
h1 { font-family: "Noto Sans JP"; }
72+
</style></head><body><h1>日本語</h1></body></html>`;
73+
74+
const b64 = (s: string) => Buffer.from(s).toString("base64");
75+
76+
describe("bundled subset coverage", () => {
77+
it("declares the bundle's subset and keeps the subsets it omits", async () => {
78+
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
79+
const result = await injectDeterministicFontFaces(HTML, {
80+
allowSystemFontCapture: false,
81+
fetchImpl: googleFetch,
82+
});
83+
84+
// Every emitted face declares a unicode-range: none claims full coverage.
85+
const faces = result.match(/@font-face \{[\s\S]*?\}/g) ?? [];
86+
expect(faces.length).toBeGreaterThan(0);
87+
for (const face of faces) expect(face).toContain("unicode-range:");
88+
89+
// The embedded faces declare the latin subset they actually ship.
90+
expect(result).toContain(`unicode-range: ${LATIN_RANGE};`);
91+
92+
// Weight 400 is in the bundle, but its Japanese subset is still injected —
93+
// otherwise a family documented as CJK cannot render Japanese.
94+
expect(result).toContain(b64("JAPANESE_SUBSET_BYTES"));
95+
expect(result).toContain(`unicode-range: ${JAPANESE_RANGE};`);
96+
97+
// The latin face for that same weight is still skipped: the bundle has it.
98+
expect(result).not.toContain(b64("LATIN_SUBSET_BYTES"));
99+
});
100+
});
101+
102+
describe("bundled Latin precedence", () => {
103+
it("retains text subsets and places bundled Latin after overlapping fetched faces", async () => {
104+
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
105+
const { EMBEDDED_FONT_DATA } = await import("./fontData.generated.js");
106+
const fetchImpl = (async (input: unknown) => {
107+
const url = String(input);
108+
if (url.startsWith("https://fonts.googleapis.com/")) {
109+
expect(new URL(url).searchParams.get("family")).toStartWith("Inter:");
110+
return new Response(`@font-face {
111+
font-family: 'Inter'; font-style: normal; font-weight: 400;
112+
src: url(https://fonts.gstatic.com/s/inter/text-subset.woff2) format('woff2');
113+
}`);
114+
}
115+
return new Response("TEXT_SUBSET_CYRILLIC");
116+
}) as unknown as typeof fetch;
117+
const html = HTML.replaceAll("Noto Sans JP", "Helvetica").replace("日本語", "Hello Привет");
118+
const result = await injectDeterministicFontFaces(html, {
119+
fetchImpl,
120+
allowSystemFontCapture: false,
121+
});
122+
const fetched = b64("TEXT_SUBSET_CYRILLIC");
123+
const embedded = EMBEDDED_FONT_DATA.get("@fontsource/inter:400:normal")!;
124+
expect(result).toContain(fetched);
125+
expect(result.indexOf(embedded)).toBeGreaterThan(result.indexOf(fetched));
126+
const faces = result.match(/@font-face \{[\s\S]*?\}/g) ?? [];
127+
const bundled = faces.find((face) => face.includes(embedded));
128+
expect(bundled).toContain('font-family: "Helvetica"');
129+
expect(bundled).toContain(`unicode-range: ${LATIN_RANGE};`);
130+
});
131+
});

packages/producer/src/services/deterministicFonts.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,19 @@ export function fontFormatHint(src: string): "collection" | "woff2" {
470470
return src.startsWith("data:font/collection;") ? "collection" : "woff2";
471471
}
472472

473+
// generate-font-data.ts embeds Fontsource's -latin- subset for every family.
474+
const BUNDLED_SUBSET_UNICODE_RANGE =
475+
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, " +
476+
"U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD";
477+
478+
function isBundledSubsetRange(unicodeRange: string | undefined): boolean {
479+
const normalize = (range: string) => range.toLowerCase().replace(/\s+/g, "");
480+
return (
481+
unicodeRange !== undefined &&
482+
normalize(unicodeRange) === normalize(BUNDLED_SUBSET_UNICODE_RANGE)
483+
);
484+
}
485+
473486
function buildFontFaceRule(
474487
familyName: string,
475488
src: string,
@@ -584,17 +597,26 @@ async function buildFontFaceCss(
584597

585598
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
586599
// Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
587-
// then fetch from Google Fonts to fill any weights not in the bundle.
600+
// then fetch from Google Fonts to fill missing weights and character subsets.
588601
const canonicalKey = FONT_ALIASES[normalizedFamily];
589602
if (canonicalKey) {
590603
const canonical = CANONICAL_FONTS[canonicalKey];
591604
if (!canonical) continue;
592605

593606
const coveredWeights = new Set<string>();
607+
const bundledRules: string[] = [];
594608
for (const face of canonical.faces) {
595609
const style = face.style || "normal";
596610
const src = fontDataUri(canonical.packageName, face.weight, style);
597-
rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style));
611+
bundledRules.push(
612+
buildFontFaceRule(
613+
originalCaseFamily,
614+
src,
615+
face.weight,
616+
style,
617+
BUNDLED_SUBSET_UNICODE_RANGE,
618+
),
619+
);
598620
coveredWeights.add(coverageKey(face.weight, style));
599621
}
600622

@@ -612,11 +634,12 @@ async function buildFontFaceCss(
612634
? await fetchGoogleFont(canonicalFamily, options, fontText)
613635
: [];
614636

615-
// A weight covered by the embedded bundle is already full-coverage —
616-
// skip it. For weights the bundle lacks, keep EVERY subset face (a
617-
// weight has one face per unicode-range subset), not just the first.
637+
// Bundled weights only cover Latin. Keep other subsets (including
638+
// text= responses without a range), even for weights already embedded.
618639
const supplementary = googleFaces.filter(
619-
(face) => !coveredWeights.has(coverageKey(face.weight, face.style)),
640+
(face) =>
641+
!coveredWeights.has(coverageKey(face.weight, face.style)) ||
642+
!isBundledSubsetRange(face.unicodeRange),
620643
);
621644
const runs = groupFacesBySource(supplementary).flatMap((group) =>
622645
partitionWeightRuns(group, coveredWeights),
@@ -641,6 +664,9 @@ async function buildFontFaceCss(
641664
),
642665
);
643666
}
667+
// Broader or text-subset responses can overlap Latin. Emit the bundle
668+
// last so existing Latin glyphs keep their deterministic bundled source.
669+
rules.push(...bundledRules);
644670
continue;
645671
}
646672

0 commit comments

Comments
 (0)