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..b9cccf486a --- /dev/null +++ b/packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts @@ -0,0 +1,306 @@ +/** + * 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("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("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"); + // 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[] = []; + 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..b6a6a53351 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 { FONT_ALIAS_MAP, resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases"; import { locateSystemFontVariants, SYSTEM_FONT_SIZE_LIMIT, @@ -457,6 +457,86 @@ 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. + * + * 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 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()]; +} + +/** + * 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 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; + const value = Number(weight); + if (Number.isFinite(value) && value > low && value < high) return true; + } + 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, @@ -481,26 +561,49 @@ 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 // 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); - 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; + // 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 = 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(coverageKey(face.weight, face.style)), + ); + 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, - face.weight, - face.style, - face.unicodeRange, + first.dataUri, + weight, + first.style, + first.unicodeRange, ), ); }