mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(fonts): make Google Fonts subsetting CSS text-transform aware
Extends the subset character closure to cover locale/context-sensitive case transforms and non-case CSS text-transform values: - Parse lang attributes from authored HTML and apply toLocaleUpperCase/ toLocaleLowerCase for each detected locale (covers Turkish İ/ı, Azeri, German ẞ, and other locale-dependent casing) - Map ASCII U+0021–U+007E to fullwidth equivalents U+FF01–U+FF5E when full-width appears in the source - Map small hiragana/katakana to full-size equivalents when full-size-kana appears in the source - Preserve the existing 1700-char encoded URL budget and full-font fallback Closes #3496 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
44c90dd7ff
commit
92d9af274f
@@ -81,4 +81,118 @@ describe("Google Fonts text subsetting", () => {
|
||||
|
||||
expect(url.searchParams.has("text")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes Turkish İ and ı when lang=tr is present", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html lang="tr"><head><style>
|
||||
h1 { font-family: "Inter", sans-serif; text-transform: uppercase; }
|
||||
</style></head><body><h1>istanbul</h1></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).toContain("İ");
|
||||
expect(text).toContain("ı");
|
||||
});
|
||||
|
||||
it("does not include Turkish İ/ı without a Turkish lang attribute", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html lang="en"><head><style>
|
||||
h1 { font-family: "Inter", sans-serif; text-transform: uppercase; }
|
||||
</style></head><body><h1>istanbul</h1></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).not.toContain("İ");
|
||||
expect(text).not.toContain("ı");
|
||||
});
|
||||
|
||||
it("includes Azeri locale variants when lang=az is present", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html lang="az"><head><style>
|
||||
p { font-family: "Inter", sans-serif; }
|
||||
</style></head><body><p>iyi</p></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).toContain("İ");
|
||||
expect(text).toContain("ı");
|
||||
});
|
||||
|
||||
it("maps ASCII to fullwidth equivalents when full-width appears in the source", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
p { font-family: "Noto Performance Test", sans-serif; text-transform: full-width; }
|
||||
</style></head><body><p>ABC</p></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).toContain("A");
|
||||
expect(text).toContain("B");
|
||||
expect(text).toContain("C");
|
||||
});
|
||||
|
||||
it("does not add fullwidth variants without full-width in the source", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
p { font-family: "Noto Performance Test", sans-serif; }
|
||||
</style></head><body><p>ABC</p></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).not.toContain("A");
|
||||
});
|
||||
|
||||
it("maps small kana to full-size equivalents when full-size-kana appears in the source", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
p { font-family: "Noto Performance Test", sans-serif; text-transform: full-size-kana; }
|
||||
</style></head><body><p>ぁっょ</p></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).toContain("あ");
|
||||
expect(text).toContain("つ");
|
||||
expect(text).toContain("よ");
|
||||
});
|
||||
|
||||
it("maps small katakana to full-size when full-size-kana appears in the source", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html><head><style>
|
||||
p { font-family: "Noto Performance Test", sans-serif; text-transform: full-size-kana; }
|
||||
</style></head><body><p>ァヵ</p></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).toContain("ア");
|
||||
expect(text).toContain("カ");
|
||||
});
|
||||
|
||||
it("stays within the URL budget for a realistic mixed-script composition with all transforms", async () => {
|
||||
const latin = "The Quick Brown Fox Jumps Over The Lazy Dog — Your Kidney Transplant: What Happens Next";
|
||||
const cjk = "旅行ランキング東京大阪京都名古屋福岡";
|
||||
const kana = "ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ";
|
||||
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html lang="tr"><head><style>
|
||||
h1 { font-family: "Noto Performance Test", sans-serif; text-transform: full-width; }
|
||||
p { font-family: "Noto Performance Test", sans-serif; text-transform: full-size-kana; }
|
||||
</style></head><body><h1>${latin}</h1><p>${cjk}${kana}</p></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
expect(encodeURIComponent(text).length).toBeLessThanOrEqual(1700);
|
||||
});
|
||||
|
||||
it("collects lang from nested elements, not just the root", async () => {
|
||||
const url = await requestedGoogleFontUrl(
|
||||
`<!doctype html><html lang="en"><head><style>
|
||||
p { font-family: "Inter", sans-serif; }
|
||||
</style></head><body><p>hello</p><p lang="tr">istanbul</p></body></html>`,
|
||||
);
|
||||
|
||||
const text = url.searchParams.get("text") ?? "";
|
||||
expect(text).toContain("İ");
|
||||
expect(text).toContain("ı");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1198,22 +1198,62 @@ export interface InjectDeterministicFontFacesOptions {
|
||||
// collapsing repeated prose and base64 assets to a tiny set.
|
||||
const GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH = 1_700;
|
||||
|
||||
const SMALL_TO_FULL_KANA: ReadonlyMap<string, string> = new Map([
|
||||
["ぁ", "あ"], ["ぃ", "い"], ["ぅ", "う"], ["ぇ", "え"], ["ぉ", "お"],
|
||||
["っ", "つ"], ["ゃ", "や"], ["ゅ", "ゆ"], ["ょ", "よ"], ["ゎ", "わ"],
|
||||
["ァ", "ア"], ["ィ", "イ"], ["ゥ", "ウ"], ["ェ", "エ"], ["ォ", "オ"],
|
||||
["ッ", "ツ"], ["ャ", "ヤ"], ["ュ", "ユ"], ["ョ", "ヨ"], ["ヮ", "ワ"],
|
||||
["ヵ", "カ"], ["ヶ", "ケ"],
|
||||
]);
|
||||
|
||||
function collectLangAttributes(document: { querySelectorAll(selector: string): Iterable<{ getAttribute(name: string): string | null }> }): Set<string> {
|
||||
const locales = new Set<string>();
|
||||
for (const element of document.querySelectorAll("[lang]")) {
|
||||
const lang = element.getAttribute("lang");
|
||||
if (lang) {
|
||||
locales.add(lang.split("-")[0]!.toLowerCase());
|
||||
}
|
||||
}
|
||||
return locales;
|
||||
}
|
||||
|
||||
function extractGoogleFontsText(html: string): string | undefined {
|
||||
const { document } = parseHTML(html);
|
||||
const decodedBodyText = document.body?.textContent ?? "";
|
||||
// Source + decoded text is an intentional over-approximation: base64, scripts, and class names
|
||||
// collapse in the Set, while decoded entities contribute the glyphs the browser actually paints.
|
||||
const locales = collectLangAttributes(document);
|
||||
const hasFullWidth = html.includes("full-width");
|
||||
const hasFullSizeKana = html.includes("full-size-kana");
|
||||
|
||||
const characters = [...Array.from(html), ...Array.from(decodedBodyText)];
|
||||
const uniqueCharacters = new Set<string>();
|
||||
for (const character of characters) {
|
||||
uniqueCharacters.add(character);
|
||||
// This closes locale-independent Unicode casing, including multi-code-point expansions such as
|
||||
// ß -> SS. Locale/context transforms (for example Turkish İ) and CSS full-width/full-size-kana
|
||||
// need a transform-aware follow-up rather than pretending this code-point closure is exhaustive.
|
||||
for (const variant of `${character.toUpperCase()}${character.toLowerCase()}`) {
|
||||
uniqueCharacters.add(variant);
|
||||
}
|
||||
for (const locale of locales) {
|
||||
for (const variant of `${character.toLocaleUpperCase(locale)}${character.toLocaleLowerCase(locale)}`) {
|
||||
uniqueCharacters.add(variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFullWidth) {
|
||||
for (const character of [...uniqueCharacters]) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code >= 0x0021 && code <= 0x007e) {
|
||||
uniqueCharacters.add(String.fromCodePoint(code + 0xfee0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFullSizeKana) {
|
||||
for (const character of [...uniqueCharacters]) {
|
||||
const full = SMALL_TO_FULL_KANA.get(character);
|
||||
if (full) uniqueCharacters.add(full);
|
||||
}
|
||||
}
|
||||
|
||||
const fontText = [...uniqueCharacters].join("");
|
||||
return encodeURIComponent(fontText).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH
|
||||
? fontText
|
||||
|
||||
Reference in New Issue
Block a user