From 03ca22cf0adc94f921ed7a592323240dde777c59 Mon Sep 17 00:00:00 2001 From: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:13:10 +0530 Subject: [PATCH 1/3] fix(fonts): supplement alias faces from the canonical family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildFontFaceCss` emits a bundled canonical's faces under the authored family name, then fills the weights and styles the bundle lacks by querying Google Fonts. That supplementary query used the authored name. For a cross-typeface alias — `helvetica`, `noto sans`, `georgia` and the other FONT_ALIAS_MAP entries that do not point at themselves — the authored name is a different typeface from the canonical the alias resolves to, and Google now serves many of those names. No canonical bundle ships an italic face, so every italic Google returns for the authored name is injected: `font-family: Helvetica` renders upright as Inter and italic as real Helvetica, two typefaces under one family. Query the canonical display name instead. The faces are still emitted under the authored family, so authored CSS keeps matching, and self-referencing aliases are unaffected. Closes #3083 --- ...deterministicFonts-aliasSupplement.test.ts | 146 ++++++++++++++++++ .../src/services/deterministicFonts.ts | 12 +- 2 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts diff --git a/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts new file mode 100644 index 0000000000..1eb7b04642 --- /dev/null +++ b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts @@ -0,0 +1,146 @@ +/** + * Regression test for cross-typeface alias supplementation. + * + * A family in FONT_ALIAS_MAP resolves to a canonical bundled typeface — e.g. + * `Noto Sans` → Inter. `buildFontFaceCss` emits the canonical's embedded faces + * under the authored family name, then queries Google Fonts to fill the weights + * and styles the bundle lacks. + * + * The bug: that supplementary query used the *authored* name, so a + * cross-typeface alias regained faces from the very typeface the alias exists to + * replace. No canonical bundle ships an italic face, so every italic Google + * served for the authored name was injected — `Noto Sans` came out as Inter + * upright plus real Noto Sans italic, two typefaces under one `font-family`. + * + * 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"; +import { EMBEDDED_FONT_DATA } from "./fontData.generated.js"; + +let cacheDir: string; +let prevCacheEnv: string | undefined; + +beforeAll(() => { + prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR; + cacheDir = mkdtempSync(join(tmpdir(), "hf-font-alias-")); + 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 }); +}); + +// One woff2 per typeface Google could serve, with identifiable bodies so every +// injected `src` can be traced back to the family that was queried. +const SERVED_FACES: Record = { + Inter: { + url: "https://fonts.gstatic.com/s/inter/v1/inter-supplement.woff2", + bytes: "INTER_FETCHED_BYTES", + }, + "Noto Sans": { + url: "https://fonts.gstatic.com/s/notosans/v1/notosans-supplement.woff2", + bytes: "NOTO_SANS_FETCHED_BYTES", + }, + Montserrat: { + url: "https://fonts.gstatic.com/s/montserrat/v1/montserrat-supplement.woff2", + bytes: "MONTSERRAT_FETCHED_BYTES", + }, +}; + +const b64 = (s: string) => Buffer.from(s).toString("base64"); + +// 300 normal + 400 italic: no CANONICAL_FONTS bundle ships either, so both are +// classified "missing from the bundle" and injected. +function cssFor(family: string): string { + const url = SERVED_FACES[family]?.url; + if (!url) return ""; + return `@font-face { + font-family: '${family}'; + font-style: normal; + font-weight: 300; + src: url(${url}) format('woff2'); +} +@font-face { + font-family: '${family}'; + font-style: italic; + font-weight: 400; + src: url(${url}) format('woff2'); +}`; +} + +function makeGoogleFetch(queriedFamilies: string[]): typeof fetch { + return (async (input: unknown) => { + const url = String(input); + if (url.startsWith("https://fonts.googleapis.com/")) { + const family = new URL(url).searchParams.get("family")?.split(":", 1)[0] ?? ""; + queriedFamilies.push(family); + return new Response(cssFor(family), { status: 200 }); + } + const served = Object.values(SERVED_FACES).find((face) => face.url === url); + if (served) return new Response(served.bytes, { status: 200 }); + return new Response("", { status: 404 }); + }) as unknown as typeof fetch; +} + +function htmlRequesting(family: string): string { + return `

Upright

`; +} + +function injectedSrcs(html: string): string[] { + return [...html.matchAll(/src:\s*url\("([^"]+)"\)/g)].map((match) => match[1] ?? ""); +} + +function bundledUris(packageName: string): Set { + return new Set( + [...EMBEDDED_FONT_DATA] + .filter(([key]) => key.startsWith(`${packageName}:`)) + .map(([, uri]) => uri), + ); +} + +describe("aliased font-family supplementation", () => { + it("supplements a cross-typeface alias from the canonical family", async () => { + const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); + const queriedFamilies: string[] = []; + const result = await injectDeterministicFontFaces(htmlRequesting("Noto Sans"), { + allowSystemFontCapture: false, + fetchImpl: makeGoogleFetch(queriedFamilies), + }); + + // "Noto Sans" aliases to Inter, so the supplementary query asks for Inter. + expect(queriedFamilies).toEqual(["Inter"]); + + // The authored spelling still names the family, so authored CSS keeps matching. + expect(result).toContain('font-family: "Noto Sans"'); + + // Every injected face is Inter — either the embedded bundle or the Inter + // fetch. None carry the real Noto Sans the alias exists to replace. + const canonicalUris = bundledUris("@fontsource/inter"); + canonicalUris.add(`data:font/woff2;base64,${b64(SERVED_FACES.Inter!.bytes)}`); + const srcs = injectedSrcs(result); + expect(srcs.length).toBeGreaterThan(0); + for (const src of srcs) expect(canonicalUris.has(src)).toBe(true); + expect(result).not.toContain(b64(SERVED_FACES["Noto Sans"]!.bytes)); + }); + + it("still supplements a self-referencing alias from its own family", async () => { + const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); + const queriedFamilies: string[] = []; + const result = await injectDeterministicFontFaces(htmlRequesting("Montserrat"), { + allowSystemFontCapture: false, + fetchImpl: makeGoogleFetch(queriedFamilies), + }); + + expect(queriedFamilies).toEqual(["Montserrat"]); + expect(result).toContain(b64(SERVED_FACES.Montserrat!.bytes)); + }); +}); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 85e3e27d8a..c4c87bdead 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -4,7 +4,7 @@ import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { defaultLogger } from "../logger.js"; -import { FONT_ALIAS_MAP } from "@hyperframes/core/fonts/aliases"; +import { CANONICAL_FONT_DISPLAY_NAMES, FONT_ALIAS_MAP } from "@hyperframes/core/fonts/aliases"; import { locateSystemFontVariants, SYSTEM_FONT_SIZE_LIMIT, @@ -487,8 +487,14 @@ async function buildFontFaceCss( // Fetch all weights from Google Fonts and add any that aren't // already covered by the embedded bundle. This ensures that // compositions requesting e.g. wght@200 get that weight even - // if the bundle only ships 400/700/900. - const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText); + // if the bundle only ships 400/700/900. Query the CANONICAL + // family, not the authored one: for a cross-typeface alias + // (helvetica → inter) the authored name is a different typeface, + // so supplementing from it would mix two typefaces under one + // font-family. The faces are still emitted under + // `originalCaseFamily` so the authored CSS keeps matching. + const canonicalFamily = CANONICAL_FONT_DISPLAY_NAMES[canonicalKey] ?? originalCaseFamily; + const googleFaces = await fetchGoogleFont(canonicalFamily, options, fontText); for (const face of googleFaces) { // A weight covered by the embedded bundle is already full-coverage — // skip it. For weights the bundle lacks, add EVERY subset face (a From 1a87dcb0283c982f05602f84227ddbdcb9c1f7bf Mon Sep 17 00:00:00 2001 From: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:40:15 +0530 Subject: [PATCH 2/3] fix(fonts): collapse variable-font supplement weights into one rule Google serves several canonical families as a variable font, so every static weight in the css2 response points at the same woff2. Emitting one rule per weight embedded that identical blob once per weight, which this branch made fire on every cross-typeface alias. Collapse a consecutive run of supplementary faces sharing a src, style and unicode-range into a single weight-range rule, which is also the correct declaration for a variable font. A run stops at any weight the embedded bundle already covers, so a range can never shadow a bundled face, and the pair is sorted low-to-high rather than trusting Google's ordering. Also resolve the canonical family through `resolveAliasDisplayName` and drop the `?? originalCaseFamily` fallback: when resolution fails the supplement is now skipped instead of silently querying the authored family again. A test asserts every alias resolves, so the branch is provably unreachable. --- ...deterministicFonts-aliasSupplement.test.ts | 47 +++++++++++ .../src/services/deterministicFonts.ts | 84 +++++++++++++++++-- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts index 1eb7b04642..58213e0224 100644 --- a/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts +++ b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts @@ -132,6 +132,53 @@ describe("aliased font-family supplementation", () => { expect(result).not.toContain(b64(SERVED_FACES["Noto Sans"]!.bytes)); }); + it("collapses a variable font's shared source into one weight-range rule", async () => { + const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); + // Google serves Inter as a variable font: one woff2 for every static weight. + const sharedUrl = "https://fonts.gstatic.com/s/inter/v1/inter-variable.woff2"; + const css = [100, 200, 300, 500] + .map( + (weight) => `@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: ${weight}; + src: url(${sharedUrl}) format('woff2'); +}`, + ) + .join("\n"); + const fetchImpl = (async (input: unknown) => { + const url = String(input); + if (url.startsWith("https://fonts.googleapis.com/")) + return new Response(css, { status: 200 }); + if (url === sharedUrl) return new Response("INTER_VARIABLE_BYTES", { status: 200 }); + return new Response("", { status: 404 }); + }) as unknown as typeof fetch; + + const result = await injectDeterministicFontFaces(htmlRequesting("Noto Sans"), { + allowSystemFontCapture: false, + fetchImpl, + }); + + // One rule spanning the run, not four rules carrying the same blob. + const variableUri = `data:font/woff2;base64,${b64("INTER_VARIABLE_BYTES")}`; + // 100-300 collapse into one rule; 500 stays separate because the bundle + // serves 400 and a 100-500 range would shadow that embedded face. + const occurrences = result.split(variableUri).length - 1; + expect(occurrences).toBe(2); + expect(result).toContain("font-weight: 100 300;"); + expect(result).toContain("font-weight: 500;"); + }); + + it("has a canonical display name for every alias target", async () => { + const { FONT_ALIAS_MAP, resolveAliasDisplayName } = + await import("@hyperframes/core/fonts/aliases"); + // The supplementary fetch is skipped outright when this lookup fails, so + // every alias must resolve or its family silently loses Google's weights. + for (const alias of Object.keys(FONT_ALIAS_MAP)) { + expect(resolveAliasDisplayName(alias)).toBeString(); + } + }); + it("still supplements a self-referencing alias from its own family", async () => { const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); const queriedFamilies: string[] = []; diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index c4c87bdead..5dd516482e 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -4,7 +4,7 @@ import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { defaultLogger } from "../logger.js"; -import { CANONICAL_FONT_DISPLAY_NAMES, FONT_ALIAS_MAP } from "@hyperframes/core/fonts/aliases"; +import { FONT_ALIAS_MAP, resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases"; import { locateSystemFontVariants, SYSTEM_FONT_SIZE_LIMIT, @@ -457,6 +457,41 @@ function buildFontFaceRule( ].join("\n"); } +/** + * Google serves several canonical families as a variable font: every static + * weight resolves to the same woff2. Faces sharing a source can be emitted as + * one weight-range rule instead of embedding that blob once per weight. + */ +function sharesSource(face: GoogleFontFace, next: GoogleFontFace | undefined): boolean { + return ( + next !== undefined && + next.dataUri === face.dataUri && + next.style === face.style && + next.unicodeRange === face.unicodeRange + ); +} + +/** + * A weight range must not span a weight the embedded bundle already serves, or + * the later rule would win for that weight and shadow the bundled face. + */ +function spansCoveredWeight( + from: GoogleFontFace, + to: GoogleFontFace, + coveredWeights: ReadonlySet, +): boolean { + const low = Number(from.weight); + const high = Number(to.weight); + if (!Number.isFinite(low) || !Number.isFinite(high)) return true; + for (const covered of coveredWeights) { + const [weight, style] = covered.split(":"); + if (style !== from.style) continue; + const value = Number(weight); + if (Number.isFinite(value) && value > low && value < high) return true; + } + return false; +} + async function buildFontFaceCss( requestedFamilies: Map, options: InternalFontFetchOptions, @@ -493,22 +528,53 @@ async function buildFontFaceCss( // so supplementing from it would mix two typefaces under one // font-family. The faces are still emitted under // `originalCaseFamily` so the authored CSS keeps matching. - const canonicalFamily = CANONICAL_FONT_DISPLAY_NAMES[canonicalKey] ?? originalCaseFamily; - const googleFaces = await fetchGoogleFont(canonicalFamily, options, fontText); - for (const face of googleFaces) { - // A weight covered by the embedded bundle is already full-coverage — - // skip it. For weights the bundle lacks, add EVERY subset face (a - // weight has one face per unicode-range subset), not just the first. - if (coveredWeights.has(`${face.weight}:${face.style}`)) continue; + const canonicalFamily = resolveAliasDisplayName(normalizedFamily); + const googleFaces = canonicalFamily + ? 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. + const supplementary = googleFaces.filter( + (face) => !coveredWeights.has(`${face.weight}:${face.style}`), + ); + for (let index = 0; index < supplementary.length; index += 1) { + const face = supplementary[index]; + if (!face) continue; + // Collapse a consecutive run sharing one source into a single + // weight-range rule, so a variable font is embedded once rather than + // once per weight. + let end = index; + let next = supplementary[end + 1]; + while ( + sharesSource(face, next) && + next && + !spansCoveredWeight(face, next, coveredWeights) + ) { + end += 1; + next = supplementary[end + 1]; + } + const lastFace = supplementary[end]; + // A weight range is written low-to-high; Google's ordering is not + // guaranteed, so sort the pair rather than trusting it. + const weight = + end > index && lastFace + ? [face.weight, lastFace.weight] + .map(Number) + .sort((a, b) => a - b) + .join(" ") + : face.weight; rules.push( buildFontFaceRule( originalCaseFamily, face.dataUri, - face.weight, + weight, face.style, face.unicodeRange, ), ); + index = end; } continue; } From 4900b2b32a9f52ccfad8a98816fe9dd88b9bde5f Mon Sep 17 00:00:00 2001 From: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:02:05 +0530 Subject: [PATCH 3/3] fix(fonts): group supplement faces by source, not adjacency Without `text=` Google orders the response weight-major, subset-minor, so faces sharing a variable font's source are never adjacent and the previous consecutive-run scan collapsed nothing: every blob was still embedded once per weight. Group supplementary faces by (source, style, unicode-range), sort each group numerically, and split it wherever the embedded bundle already covers a weight inside the span. Each collapsed run is emitted at the position of its first face in the response, because overlapping unicode-range rules resolve last-defined-first and collapsing must not reorder the subsets. Coverage keys are compared numerically so a differently spelled weight cannot slip past and shadow a bundled face. Tests cover the no-`text=` interleaved shape and the overlapping-subset ordering; both fail against the previous implementation. --- ...deterministicFonts-aliasSupplement.test.ts | 113 +++++++++++++++++ .../src/services/deterministicFonts.ts | 115 +++++++++++------- 2 files changed, 186 insertions(+), 42 deletions(-) diff --git a/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts index 58213e0224..b9cccf486a 100644 --- a/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts +++ b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts @@ -169,6 +169,119 @@ describe("aliased font-family supplementation", () => { expect(result).toContain("font-weight: 500;"); }); + it("collapses shared sources when Google interleaves subsets without text=", async () => { + const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); + // Without `text=` Google orders the response weight-major, subset-minor, + // so the faces sharing one variable source are never adjacent. + const latinUrl = "https://fonts.gstatic.com/s/inter/v1/inter-latin-variable.woff2"; + const latinExtUrl = "https://fonts.gstatic.com/s/inter/v1/inter-latinext-variable.woff2"; + const LATIN = "U+0000-00FF"; + const LATIN_EXT = "U+0100-024F"; + const subsets = [ + { url: latinUrl, range: LATIN }, + { url: latinExtUrl, range: LATIN_EXT }, + ]; + const css = [100, 200, 300, 500] + .flatMap((weight) => + subsets.map( + (subset) => `@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: ${weight}; + src: url(${subset.url}) format('woff2'); + unicode-range: ${subset.range}; +}`, + ), + ) + .join("\n"); + + const fetchImpl = (async (input: unknown) => { + const url = String(input); + if (url.startsWith("https://fonts.googleapis.com/")) { + // The request must not carry `text=`, or Google would return one + // subset and the interleaving under test would not occur. + expect(new URL(url).searchParams.get("text")).toBeNull(); + return new Response(css, { status: 200 }); + } + if (url === latinUrl) return new Response("LATIN_VARIABLE_BYTES", { status: 200 }); + if (url === latinExtUrl) return new Response("LATINEXT_VARIABLE_BYTES", { status: 200 }); + return new Response("", { status: 404 }); + }) as unknown as typeof fetch; + + // Enough unique characters that extractGoogleFontsText exceeds its budget + // and returns undefined, which is what drops `text=` in production. + const manyUniqueChars = Array.from({ length: 400 }, (_, index) => + String.fromCodePoint(0x4e00 + index), + ).join(""); + const html = `

${manyUniqueChars}

`; + + const result = await injectDeterministicFontFaces(html, { + allowSystemFontCapture: false, + fetchImpl, + }); + + // Per subset: 100-300 collapse into one rule, 500 stays separate because + // the bundle covers 400. Two rules per subset, not one per weight. + for (const bytes of ["LATIN_VARIABLE_BYTES", "LATINEXT_VARIABLE_BYTES"]) { + const uri = `data:font/woff2;base64,${b64(bytes)}`; + expect(result.split(uri).length - 1).toBe(2); + } + expect(result.split("font-weight: 100 300;").length - 1).toBe(2); + expect(result.split("font-weight: 500;").length - 1).toBe(2); + }); + + it("keeps overlapping subsets in response order when collapsing", async () => { + const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); + // Real Google output has codepoints in more than one subset (U+0304 and + // friends). Overlapping `unicode-range` rules resolve last-defined-first, + // so collapsing must not move a subset ahead of one declared after it. + const firstUrl = "https://fonts.gstatic.com/s/inter/v1/inter-first.woff2"; + const secondUrl = "https://fonts.gstatic.com/s/inter/v1/inter-second.woff2"; + const OVERLAPPING = "U+0000-00FF, U+0304"; + const css = [100, 200, 500] + .flatMap((weight) => + [ + { url: firstUrl, range: "U+0000-00FF" }, + { url: secondUrl, range: OVERLAPPING }, + ].map( + (subset) => `@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: ${weight}; + src: url(${subset.url}) format('woff2'); + unicode-range: ${subset.range}; +}`, + ), + ) + .join("\n"); + + const fetchImpl = (async (input: unknown) => { + const url = String(input); + if (url.startsWith("https://fonts.googleapis.com/")) + return new Response(css, { status: 200 }); + if (url === firstUrl) return new Response("FIRST_BYTES", { status: 200 }); + if (url === secondUrl) return new Response("SECOND_BYTES", { status: 200 }); + return new Response("", { status: 404 }); + }) as unknown as typeof fetch; + + const result = await injectDeterministicFontFaces(htmlRequesting("Noto Sans"), { + allowSystemFontCapture: false, + fetchImpl, + }); + + // 100-200 and 500 are separate runs per source because the bundle covers + // 400, so each source is emitted twice. The interleaving must survive + // collapsing: source-major grouping would emit FIRST, FIRST, SECOND, SECOND + // and hand the shared codepoints to the wrong subset. + const order = [...result.matchAll(/base64,([A-Za-z0-9+/=]+)/g)] + .map((match) => match[1] ?? "") + .filter((data) => data === b64("FIRST_BYTES") || data === b64("SECOND_BYTES")) + .map((data) => (data === b64("FIRST_BYTES") ? "FIRST" : "SECOND")); + expect(order).toEqual(["FIRST", "SECOND", "FIRST", "SECOND"]); + }); + it("has a canonical display name for every alias target", async () => { const { FONT_ALIAS_MAP, resolveAliasDisplayName } = await import("@hyperframes/core/fonts/aliases"); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 5dd516482e..b6a6a53351 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -461,14 +461,29 @@ function buildFontFaceRule( * Google serves several canonical families as a variable font: every static * weight resolves to the same woff2. Faces sharing a source can be emitted as * one weight-range rule instead of embedding that blob once per weight. + * + * Without `text=` the response is ordered weight-major, subset-minor, so those + * faces are not adjacent — group by source rather than scanning neighbours. + * Insertion order keeps the emitted CSS deterministic. */ -function sharesSource(face: GoogleFontFace, next: GoogleFontFace | undefined): boolean { - return ( - next !== undefined && - next.dataUri === face.dataUri && - next.style === face.style && - next.unicodeRange === face.unicodeRange - ); +function normalizeWeightKey(weight: string): string { + const numeric = Number(weight); + return Number.isFinite(numeric) ? String(numeric) : weight.trim().toLowerCase(); +} + +function coverageKey(weight: string, style: string): string { + return `${normalizeWeightKey(weight)}:${style}`; +} + +function groupFacesBySource(faces: readonly GoogleFontFace[]): GoogleFontFace[][] { + const groups = new Map(); + for (const face of faces) { + const key = [face.dataUri, face.style, face.unicodeRange ?? ""].join("\u0000"); + const existing = groups.get(key); + if (existing) existing.push(face); + else groups.set(key, [face]); + } + return [...groups.values()]; } /** @@ -480,9 +495,11 @@ function spansCoveredWeight( to: GoogleFontFace, coveredWeights: ReadonlySet, ): boolean { - const low = Number(from.weight); - const high = Number(to.weight); - if (!Number.isFinite(low) || !Number.isFinite(high)) return true; + const start = Number(from.weight); + const end = Number(to.weight); + if (!Number.isFinite(start) || !Number.isFinite(end)) return true; + const low = Math.min(start, end); + const high = Math.max(start, end); for (const covered of coveredWeights) { const [weight, style] = covered.split(":"); if (style !== from.style) continue; @@ -492,6 +509,34 @@ function spansCoveredWeight( return false; } +/** + * Split one source group into ascending runs, breaking wherever the embedded + * bundle already covers a weight inside the span. A weight that is not a plain + * number (a variable `100 900` range, say) cannot be ordered, so it stays on + * its own. + */ +function partitionWeightRuns( + faces: readonly GoogleFontFace[], + coveredWeights: ReadonlySet, +): GoogleFontFace[][] { + const runs: GoogleFontFace[][] = []; + const sortable = faces.filter((face) => Number.isFinite(Number(face.weight))); + const unsortable = faces.filter((face) => !Number.isFinite(Number(face.weight))); + + let current: GoogleFontFace[] = []; + for (const face of [...sortable].sort((a, b) => Number(a.weight) - Number(b.weight))) { + const previous = current[current.length - 1]; + if (previous && spansCoveredWeight(previous, face, coveredWeights)) { + runs.push(current); + current = []; + } + current.push(face); + } + if (current.length > 0) runs.push(current); + for (const face of unsortable) runs.push([face]); + return runs; +} + async function buildFontFaceCss( requestedFamilies: Map, options: InternalFontFetchOptions, @@ -516,7 +561,7 @@ async function buildFontFaceCss( const style = face.style || "normal"; const src = fontDataUri(canonical.packageName, face.weight, style); rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style)); - coveredWeights.add(`${face.weight}:${style}`); + coveredWeights.add(coverageKey(face.weight, style)); } // Fetch all weights from Google Fonts and add any that aren't @@ -537,44 +582,30 @@ async function buildFontFaceCss( // skip it. For weights the bundle lacks, keep EVERY subset face (a // weight has one face per unicode-range subset), not just the first. const supplementary = googleFaces.filter( - (face) => !coveredWeights.has(`${face.weight}:${face.style}`), + (face) => !coveredWeights.has(coverageKey(face.weight, face.style)), ); - for (let index = 0; index < supplementary.length; index += 1) { - const face = supplementary[index]; - if (!face) continue; - // Collapse a consecutive run sharing one source into a single - // weight-range rule, so a variable font is embedded once rather than - // once per weight. - let end = index; - let next = supplementary[end + 1]; - while ( - sharesSource(face, next) && - next && - !spansCoveredWeight(face, next, coveredWeights) - ) { - end += 1; - next = supplementary[end + 1]; - } - const lastFace = supplementary[end]; - // A weight range is written low-to-high; Google's ordering is not - // guaranteed, so sort the pair rather than trusting it. - const weight = - end > index && lastFace - ? [face.weight, lastFace.weight] - .map(Number) - .sort((a, b) => a - b) - .join(" ") - : face.weight; + const runs = groupFacesBySource(supplementary).flatMap((group) => + partitionWeightRuns(group, coveredWeights), + ); + // Overlapping `unicode-range` rules resolve last-defined-first, so a run + // is emitted where its first face appeared in the response rather than + // grouped by source. Collapsing must not reorder the faces. + const firstAppearance = (run: readonly GoogleFontFace[]): number => + Math.min(...run.map((face) => supplementary.indexOf(face))); + for (const run of [...runs].sort((a, b) => firstAppearance(a) - firstAppearance(b))) { + const first = run[0]; + const last = run[run.length - 1]; + if (!first || !last) continue; + const weight = run.length > 1 ? `${first.weight} ${last.weight}` : first.weight; rules.push( buildFontFaceRule( originalCaseFamily, - face.dataUri, + first.dataUri, weight, - face.style, - face.unicodeRange, + first.style, + first.unicodeRange, ), ); - index = end; } continue; }