mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +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 { 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,
|
||||
@@ -491,6 +491,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<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(
|
||||
requestedFamilies: Map<string, string>,
|
||||
options: InternalFontFetchOptions,
|
||||
@@ -515,26 +595,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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user