From d63797ad26d48f23bc09734670bfd610d1fea286 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sat, 8 Aug 2026 14:36:16 +0200 Subject: [PATCH] fix(core): gate first layout on the resolved font stack, not authored names Root cause of the measure/paint divergence found by the parity oracle: a repaired Arabic line measured 194px and painted 259px, a third of the line. `resolveFontFamily` expands an authored family into a CSS stack that appends folio's bundled substitutes and a script fallback. Every stack ends with the bundled Arabic face, so an authored "Arial" run paints its Arabic in that face: Arial -> Arial, Arimo, Helvetica, "Noto Sans Arabic", sans-serif Calibri -> Calibri, Carlito, Arial, Helvetica, "Noto Sans Arabic", sans-serif The readiness gate collected only the names the document wrote, so it released the first layout before those appended faces had loaded. The measurer then measured a pre-load fallback while the painter later drew the real face. The gate now expands each family through the resolver and waits for every concrete face in the stack, generics excluded. That also retires OFFICE_FONT_FAMILY_MAP, which was a second copy of the resolver's mapping and free to drift from it. --- .changeset/font-readiness-resolved-stack.md | 9 ++++ .../core/src/controller/fontReadiness.test.ts | 24 ++++++++++ packages/core/src/controller/fontReadiness.ts | 44 ++++++++++++++----- 3 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 .changeset/font-readiness-resolved-stack.md 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(