Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 157 additions & 8 deletions packages/producer/src/services/deterministicFonts-textSubset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,52 +15,54 @@ async function requestedGoogleFontUrl(html: string): Promise<URL> {
return new URL(requestedUrl);
}

async function subsetTextFor(html: string): Promise<string> {
const url = await requestedGoogleFontUrl(html);
return url.searchParams.get("text") ?? "";
}

describe("Google Fonts text subsetting", () => {
it("sends the composition character set to the CSS API", async () => {
const url = await requestedGoogleFontUrl(
const text = await subsetTextFor(
`<!doctype html><html><head><style>
h1 { font-family: "Noto Performance Test", sans-serif; }
</style></head><body><h1>旅行ランキング</h1></body></html>`,
);

const text = url.searchParams.get("text") ?? "";
for (const character of new Set("旅行ランキング")) {
expect(text).toContain(character);
}
});

it("includes decoded HTML entities from visible composition text", async () => {
const url = await requestedGoogleFontUrl(
const text = await subsetTextFor(
`<!doctype html><html><head><style>
h1 { font-family: "Noto Performance Test", sans-serif; }
</style></head><body><h1>&#x65C5;&#34892;</h1></body></html>`,
);

expect(url.searchParams.get("text")).toContain("旅行");
expect(text).toContain("旅行");
});

it("includes case variants for transformed supplemental alias weights", async () => {
const url = await requestedGoogleFontUrl(
const text = await subsetTextFor(
`<!doctype html><html><head><style>
h1 { font-family: "Inter", sans-serif; font-weight: 800; text-transform: uppercase; }
</style></head><body><h1>Your Kidney Transplant:<br/>What Happens Next</h1></body></html>`,
);

const text = url.searchParams.get("text") ?? "";
expect(encodeURIComponent(text).length).toBeLessThan(700);
for (const character of new Set("YOUR KIDNEY TRANSPLANT:WHAT HAPPENS NEXT")) {
expect(text).toContain(character);
}
});

it("covers capitalized words through the same case closure", async () => {
const url = await requestedGoogleFontUrl(
const text = await subsetTextFor(
`<!doctype html><html><head><style>
h1 { font-family: "Inter", sans-serif; font-weight: 800; text-transform: capitalize; }
</style></head><body><h1>hello world</h1></body></html>`,
);

const text = url.searchParams.get("text") ?? "";
expect(text).toContain("H");
expect(text).toContain("W");
});
Expand All @@ -81,4 +83,151 @@ describe("Google Fonts text subsetting", () => {

expect(url.searchParams.has("text")).toBe(false);
});

it("includes Turkish İ and ı when lang=tr is present", async () => {
const text = await subsetTextFor(
`<!doctype html><html lang="tr"><head><style>
h1 { font-family: "Inter", sans-serif; text-transform: uppercase; }
</style></head><body><h1>istanbul</h1></body></html>`,
);

expect(text).toContain("İ");
expect(text).toContain("ı");
});

it("does not include Turkish İ/ı without a Turkish lang attribute", async () => {
const text = await subsetTextFor(
`<!doctype html><html lang="en"><head><style>
h1 { font-family: "Inter", sans-serif; text-transform: uppercase; }
</style></head><body><h1>istanbul</h1></body></html>`,
);

expect(text).not.toContain("İ");
expect(text).not.toContain("ı");
});

it("includes Azeri locale variants when lang=az is present", async () => {
const text = await subsetTextFor(
`<!doctype html><html lang="az"><head><style>
p { font-family: "Inter", sans-serif; }
</style></head><body><p>iyi</p></body></html>`,
);

expect(text).toContain("İ");
expect(text).toContain("ı");
});

it("maps ASCII to fullwidth equivalents when full-width appears in the source", async () => {
const text = await subsetTextFor(
`<!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>`,
);

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 text = await subsetTextFor(
`<!doctype html><html><head><style>
p { font-family: "Noto Performance Test", sans-serif; }
</style></head><body><p>ABC</p></body></html>`,
);

expect(text).not.toContain("A");
});

it("maps small kana to full-size equivalents when full-size-kana appears in the source", async () => {
const text = await subsetTextFor(
`<!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>`,
);

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 text = await subsetTextFor(
`<!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>`,
);

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 withTransforms = await subsetTextFor(
`<!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 withoutTransforms = await subsetTextFor(
`<!doctype html><html lang="tr"><head><style>
h1 { font-family: "Noto Performance Test", sans-serif; }
p { font-family: "Noto Performance Test", sans-serif; }
</style></head><body><h1>${latin}</h1><p>${cjk}${kana}</p></body></html>`,
);

const transformCost =
encodeURIComponent(withTransforms).length - encodeURIComponent(withoutTransforms).length;
expect(transformCost).toBeLessThan(900);
expect(encodeURIComponent(withTransforms).length).toBeLessThanOrEqual(1700);
});

it("collects lang from nested elements, not just the root", async () => {
const text = await subsetTextFor(
`<!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>`,
);

expect(text).toContain("İ");
expect(text).toContain("ı");
});

it("skips invalid lang attributes without crashing", async () => {
const text = await subsetTextFor(
`<!doctype html><html lang="en_US"><head><style>
p { font-family: "Inter", sans-serif; }
</style></head><body><p lang="x">hello</p><p lang="123">world</p></body></html>`,
);

expect(text.length).toBeGreaterThan(0);
expect(text).toContain("h");
});

it("does not trigger fullwidth from an unrelated text-transform plus a full-width class", async () => {
const text = await subsetTextFor(
`<!doctype html><html><head><style>
h1 { font-family: "Noto Performance Test", sans-serif; text-transform: uppercase }
.full-width { width: 100% }
</style></head><body><div class="full-width"><h1>ABC</h1></div></body></html>`,
);

expect(text).not.toContain("A");
});

it("triggers fullwidth for case-insensitive text-transform: FULL-WIDTH", async () => {
const text = await subsetTextFor(
`<!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>`,
);

expect(text).toContain("A");
});
});
127 changes: 116 additions & 11 deletions packages/producer/src/services/deterministicFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1198,22 +1198,127 @@ 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) continue;
const primary = lang.split("-")[0]!.toLowerCase();
try {
Intl.getCanonicalLocales(primary);
locales.add(primary);
} catch {
// Invalid BCP-47 tag (e.g. lang="en_US", lang="x") — skip silently.
}
}
return locales;
}

function addCaseClosure(out: Set<string>, character: string, locales: ReadonlySet<string>): void {
out.add(character);
for (const variant of `${character.toUpperCase()}${character.toLowerCase()}`) {
out.add(variant);
}
for (const locale of locales) {
for (const variant of `${character.toLocaleUpperCase(locale)}${character.toLocaleLowerCase(locale)}`) {
out.add(variant);
}
}
}

function addFullwidthVariants(chars: Set<string>): void {
for (const character of [...chars]) {
const code = character.codePointAt(0) ?? 0;
if (code >= 0x0021 && code <= 0x007e) {
chars.add(String.fromCodePoint(code + 0xfee0));
}
}
}

function addFullSizeKanaVariants(chars: Set<string>): void {
for (const character of [...chars]) {
const full = SMALL_TO_FULL_KANA.get(character);
if (full) chars.add(full);
}
}

const FULL_WIDTH_KEYWORD_RE = /\bfull-width\b/;
const FULL_SIZE_KANA_KEYWORD_RE = /\bfull-size-kana\b/;
const DECLARATION_BOUNDARY_RE = /[;{}]/;

function skipWhitespace(s: string, pos: number): number {
while (pos < s.length && " \t\n\r\f\v".includes(s[pos]!)) pos += 1;
return pos;
}

function findDeclarationEnd(s: string, pos: number): number {
const match = DECLARATION_BOUNDARY_RE.exec(s.slice(pos));
return match ? pos + match.index : s.length;
}

// Linear indexOf/slice scan: a `text-transform\s*:[^;{}]*\bkw\b` regex backtracks
// O(n²) on input with many `text-transform:` runs (js/polynomial-redos).
function hasTextTransformKeyword(html: string, keyword: RegExp): boolean {
const haystack = html.toLowerCase();
const property = "text-transform";
let from = 0;
for (;;) {
const at = haystack.indexOf(property, from);
if (at === -1) return false;
const afterProp = skipWhitespace(haystack, at + property.length);
if (haystack[afterProp] !== ":") {
from = at + property.length;
continue;
}
const end = findDeclarationEnd(haystack, afterProp + 1);
if (keyword.test(haystack.slice(afterProp + 1, end))) return true;
from = end;
}
}

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 characters = [...Array.from(html), ...Array.from(decodedBodyText)];
const locales = collectLangAttributes(document);

// Intentional over-approximation: raw html includes base64, scripts, and
// class names, but they collapse in the Set and the budget gate catches bloat.
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 character of new Set([...Array.from(html), ...Array.from(decodedBodyText)])) {
addCaseClosure(uniqueCharacters, character, locales);
}

if (hasTextTransformKeyword(html, FULL_WIDTH_KEYWORD_RE)) addFullwidthVariants(uniqueCharacters);
if (hasTextTransformKeyword(html, FULL_SIZE_KANA_KEYWORD_RE))
addFullSizeKanaVariants(uniqueCharacters);

const fontText = [...uniqueCharacters].join("");
return encodeURIComponent(fontText).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH
? fontText
Expand Down
Loading