diff --git a/.changeset/font-readiness-resolved-stack.md b/.changeset/font-readiness-resolved-stack.md new file mode 100644 index 00000000..54d19683 --- /dev/null +++ b/.changeset/font-readiness-resolved-stack.md @@ -0,0 +1,9 @@ +--- +"@stll/folio-core": patch +--- + +Wait for the fonts the renderer actually uses, not just the names the document wrote. + +`resolveFontFamily` turns an authored family into a CSS stack that appends folio's bundled metric-compatible substitutes and a script fallback, so an authored `Arial` run paints its Arabic in the bundled Arabic face. The font-readiness gate collected only authored names, so it released the first layout before those faces had loaded; measurement taken against the pre-load fallback then disagreed with what was ultimately painted, by as much as a third of a line's width. + +The gate now expands each family through the resolver and waits for every concrete face in the stack. That also removes a hand-kept substitute table which duplicated the resolver's own mapping and was free to drift from it. diff --git a/packages/core/src/controller/fontReadiness.test.ts b/packages/core/src/controller/fontReadiness.test.ts index 904d1cbd..7317e249 100644 --- a/packages/core/src/controller/fontReadiness.test.ts +++ b/packages/core/src/controller/fontReadiness.test.ts @@ -129,6 +129,30 @@ describe("initial layout font loading", () => { expect(collectInitialLayoutFontFamilies(null, pmDoc)).toContain(family); }); + // The 64px measure/paint divergence this fixed: every resolved stack ends with + // folio's bundled Arabic face, so an authored "Arial" run paints its Arabic in + // that face. Waiting only for authored names released the first layout before + // it had loaded, and the measurement taken then disagreed with what was drawn. + test("waits for the substitutes and fallbacks the renderer actually uses", () => { + const fontFamily = schema.marks["fontFamily"]?.create({ ascii: "Arial", hAnsi: "Arial" }); + if (!fontFamily) { + throw new Error("Expected a fontFamily mark in schema"); + } + const pmDoc = schema.node("doc", null, [ + schema.node("paragraph", null, [schema.text("text", [fontFamily])]), + ]); + + const families = collectInitialLayoutFontFamilies(null, pmDoc); + + // The authored name, its metric-compatible substitute, and the script + // fallback the resolver appends. + expect(families).toContain("Arial"); + expect(families).toContain("Arimo"); + expect(families).toContain("Noto Sans Arabic"); + // Generic CSS families are not loadable faces and must not be requested. + expect(families).not.toContain("sans-serif"); + }); + test("always includes the default layout font family for a null document model", () => { const pmDoc = schema.node("doc", null, [ schema.node("paragraph", null, [schema.text("plain")]), diff --git a/packages/core/src/controller/fontReadiness.ts b/packages/core/src/controller/fontReadiness.ts index 1fafa0aa..64ea8b2f 100644 --- a/packages/core/src/controller/fontReadiness.ts +++ b/packages/core/src/controller/fontReadiness.ts @@ -13,6 +13,7 @@ import type { Mark, Node as PMNode } from "prosemirror-model"; import type { EditorState } from "prosemirror-state"; import { expectFontFamilyMarkAttrs } from "../prosemirror/attrs"; +import { resolveFontFamily } from "../utils/fontResolver"; import type { Document, TextFormatting } from "../types/document"; export function getDocumentFontSet(): FontFaceSet | null { @@ -29,15 +30,6 @@ export function documentFontsAreLoaded(): boolean { const INITIAL_LAYOUT_FONT_TIMEOUT_MS = 2000; const DEFAULT_LAYOUT_FONT_FAMILY = "Calibri"; -const OFFICE_FONT_FAMILY_MAP: Record = { - Aptos: "Lato", - "Aptos Display": "Lato", - Arial: "Arimo", - Calibri: "Carlito", - Cambria: "Caladea", - "Times New Roman": "Tinos", - "Courier New": "Cousine", -}; const CSS_GENERIC_FONT_FAMILIES = new Set([ "serif", "sans-serif", @@ -259,10 +251,38 @@ function addLayoutFontFamilyNameFace( } addLayoutFontFace(faces, normalized, descriptor); - const mappedFamily = OFFICE_FONT_FAMILY_MAP[normalized]; - if (mappedFamily) { - addLayoutFontFace(faces, mappedFamily, descriptor); + + // Wait for the whole stack the renderer will actually use, not just the name + // the document wrote. `resolveFontFamily` appends folio's bundled substitutes + // and a script fallback, so an authored "Arial" run paints its Arabic in the + // bundled Arabic face. Collecting only authored names meant the gate released + // the first layout before that face had loaded, and measurement taken against + // the pre-load fallback disagreed with what was ultimately painted. + // + // Derived rather than listed: a hand-kept table of substitutes would be a + // second copy of the resolver's mapping, free to drift from it. + for (const stackFamily of resolvedStackFamilies(normalized)) { + addLayoutFontFace(faces, stackFamily, descriptor); + } +} + +/** + * The concrete families in a resolved CSS font stack, generics dropped. + * + * Parsed from the stack rather than read from a map because the stack is what + * the painter and the measurer put in `ctx.font` and `style.fontFamily`. + */ +function resolvedStackFamilies(family: string): string[] { + const { cssFallback } = resolveFontFamily(family); + const families: string[] = []; + for (const entry of cssFallback.split(",")) { + const name = entry.trim().replace(/^["']|["']$/gu, ""); + if (!name || CSS_GENERIC_FONT_FAMILIES.has(name)) { + continue; + } + families.push(name); } + return families; } function addLayoutFontFace(