mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
co-authored by
Akshay Kumar Sharma
parent
bec11b1293
commit
ef63e02936
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user