mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
fix(fonts): supplement alias faces from the canonical family (#3230)
A family resolving through FONT_ALIAS_MAP could emit @font-face rules drawn from two unrelated typefaces under one font-family name, split by weight and style. The supplementation fetch was passed the authored name, so for a cross-typeface alias (helvetica -> inter) it asked Google for the very typeface the alias exists to replace. Diagnosed, reported and fixed by Akshay Kumar Sharma (@akzarma) in #3083 / #3085. This PR carries that work because the fix requires re-recorded regression baselines, which are LFS objects we cannot push to a fork's LFS store. Baselines re-recorded for style-15-prod and style-3-prod, each verified text-only before acceptance. All 9 regression shards pass. Closes #3083. Co-authored-by: Akshay Kumar Sharma <25038017+akzarma@users.noreply.github.com>
This commit is contained in:
co-authored by
Akshay Kumar Sharma
parent
e0ba41c024
commit
a16222d9a2
@@ -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<string, { url: string; bytes: string }> = {
|
||||||
|
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 `<!doctype html><html><head><style>
|
||||||
|
h1 { font-family: "${family}"; }
|
||||||
|
</style></head><body><h1>Upright</h1></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectedSrcs(html: string): string[] {
|
||||||
|
return [...html.matchAll(/src:\s*url\("([^"]+)"\)/g)].map((match) => match[1] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundledUris(packageName: string): Set<string> {
|
||||||
|
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 = `<!doctype html><html><head><style>
|
||||||
|
h1 { font-family: "Noto Sans"; }
|
||||||
|
</style></head><body><h1>${manyUniqueChars}</h1></body></html>`;
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { homedir, tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { defaultLogger } from "../logger.js";
|
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 {
|
import {
|
||||||
locateSystemFontVariants,
|
locateSystemFontVariants,
|
||||||
SYSTEM_FONT_SIZE_LIMIT,
|
SYSTEM_FONT_SIZE_LIMIT,
|
||||||
@@ -491,6 +491,86 @@ function buildFontFaceRule(
|
|||||||
].join("\n");
|
].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<string, GoogleFontFace[]>();
|
||||||
|
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<string>,
|
||||||
|
): 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<string>,
|
||||||
|
): 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(
|
async function buildFontFaceCss(
|
||||||
requestedFamilies: Map<string, string>,
|
requestedFamilies: Map<string, string>,
|
||||||
options: InternalFontFetchOptions,
|
options: InternalFontFetchOptions,
|
||||||
@@ -515,26 +595,49 @@ async function buildFontFaceCss(
|
|||||||
const style = face.style || "normal";
|
const style = face.style || "normal";
|
||||||
const src = fontDataUri(canonical.packageName, face.weight, style);
|
const src = fontDataUri(canonical.packageName, face.weight, style);
|
||||||
rules.push(buildFontFaceRule(originalCaseFamily, src, 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
|
// Fetch all weights from Google Fonts and add any that aren't
|
||||||
// already covered by the embedded bundle. This ensures that
|
// already covered by the embedded bundle. This ensures that
|
||||||
// compositions requesting e.g. wght@200 get that weight even
|
// compositions requesting e.g. wght@200 get that weight even
|
||||||
// if the bundle only ships 400/700/900.
|
// if the bundle only ships 400/700/900. Query the CANONICAL
|
||||||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText);
|
// family, not the authored one: for a cross-typeface alias
|
||||||
for (const face of googleFaces) {
|
// (helvetica → inter) the authored name is a different typeface,
|
||||||
// A weight covered by the embedded bundle is already full-coverage —
|
// so supplementing from it would mix two typefaces under one
|
||||||
// skip it. For weights the bundle lacks, add EVERY subset face (a
|
// font-family. The faces are still emitted under
|
||||||
// weight has one face per unicode-range subset), not just the first.
|
// `originalCaseFamily` so the authored CSS keeps matching.
|
||||||
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(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(
|
rules.push(
|
||||||
buildFontFaceRule(
|
buildFontFaceRule(
|
||||||
originalCaseFamily,
|
originalCaseFamily,
|
||||||
face.dataUri,
|
first.dataUri,
|
||||||
face.weight,
|
weight,
|
||||||
face.style,
|
first.style,
|
||||||
face.unicodeRange,
|
first.unicodeRange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:da0cafd18e7bcb5ce862a6d7a1362b62866342c360fe83b37cb199b7d8a0717c
|
oid sha256:13e7dd6d0dbeca86db4b69ca00d985f331572166aeacfd1af7d0fb4abf6abe84
|
||||||
size 22377910
|
size 22376857
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a3312c37eec0a979d020700beae78ddf7ff2d67d0cf1f7920ad91ce256f0f1de
|
oid sha256:463b1034d15bd54b7c44ddfbed9620f045c329c3bf4f665ec104fc5cb2b26c39
|
||||||
size 7191465
|
size 7144977
|
||||||
|
|||||||
Reference in New Issue
Block a user