Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
154 changes: 146 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,140 @@ 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 expansion from a CSS class named full-width", async () => {
const text = await subsetTextFor(
`<!doctype html><html><head><style>
.full-width { font-family: "Noto Performance Test", sans-serif; width: 100%; }
</style></head><body><div class="full-width">ABC</div></body></html>`,
);

expect(text).not.toContain("A");
});
});
95 changes: 84 additions & 11 deletions packages/producer/src/services/deterministicFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1198,22 +1198,95 @@
// 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 TEXT_TRANSFORM_FULL_WIDTH_RE = /text-transform\s*:[^;]*full-width/;
const TEXT_TRANSFORM_FULL_SIZE_KANA_RE = /text-transform\s*:[^;]*full-size-kana/;

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 (TEXT_TRANSFORM_FULL_WIDTH_RE.test(html)) addFullwidthVariants(uniqueCharacters);
if (TEXT_TRANSFORM_FULL_SIZE_KANA_RE.test(html)) addFullSizeKanaVariants(uniqueCharacters);

const fontText = [...uniqueCharacters].join("");
return encodeURIComponent(fontText).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH
? fontText
Expand All @@ -1228,7 +1301,7 @@
1,
Math.floor(configured?.maxAttempts ?? DEFAULT_FONT_FETCH_RETRY_POLICY.maxAttempts),
),
attemptTimeoutMs: Math.max(

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with 'text-transform:' and with many repetitions of 'text-transform:'.
1,
configured?.attemptTimeoutMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.attemptTimeoutMs,
),
Expand Down
Loading