Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/font-readiness-resolved-stack.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/core/src/controller/fontReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")]),
Expand Down
44 changes: 32 additions & 12 deletions packages/core/src/controller/fontReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, string> = {
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",
Expand Down Expand Up @@ -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;
Comment on lines +275 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 'quoteFontName|cssFallback|FONT_MAPPINGS|DEFAULT_FALLBACKS' \
  packages/core/src/utils/fontResolver.ts

Repository: stella/folio

Length of output: 8530


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== fontReadiness relevant slice =="
sed -n '240,310p' packages/core/src/controller/fontReadiness.ts

echo
echo "== fontResolver quote/build fallbacks slice =="
sed -n '760,910p' packages/core/src/utils/fontResolver.ts

echo
echo "== search callers of resolveFontFamily and cssexpression CSS parsing =="
rg -n -C 4 'resolveFontFamily\(|resolvedStackFamilies\(|CSS_.*FONT|withArabicFallback|isGeneric|quoteFontName|buildFontFamilyString' packages/core/src

Repository: stella/folio

Length of output: 38267


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path("packages/core/src/controller/fontReadiness.ts")
text = path.read_text()
m = re.search(r"function resolvedStackFamilies\(family: string\): string\[\] \{(?P<body>.*?)\n\}", text, re.S)
print("resolvedStackFamilies body:")
print(m.group("body") if m else "not found")

text2 = Path("packages/core/src/utils/fontResolver.ts").read_text()
m = re.search(r"function quoteFontName\(fontName: string\): string \{(?P<body>.*?)\n\}", text2, re.S)
print("quoteFontName body:")
print(m.group("body") if m else "not found")

# Reproduce the exact comma-split logic from the body if present
def quoteFontName(name:str) -> str:
    generics=["serif","sans-serif","monospace","cursive","fantasy","system-ui"]
    if name.lower() in generics:
        return name
    result = []
    for char in name:
        if char in ('"', "\\"):
            result.append("\\")
        if char in ("\n","\r","\f"):
            result.append("\\a ")
        else:
            result.append(char)
    return '"' + "".join(result) + '"'

def current(family:str) -> list[str]:
    cssFallback = quoteFontName(family) + ", Arial, sans-serif"
    families=[]
    generic={"serif","sans-serif","monospace","cursive","fantasy","system-ui"}
    for entry in cssFallback.split(","):
        name=entry.strip().removeprefix('"').removesuffix('"')
        if not name or name in generic:
            continue
        families.append(name)
    return families

for input_ in ["Font, Name", "Arial", "Caveat Brush"]:
    print(input_, "=>", current(input_))
PY

Repository: stella/folio

Length of output: 996


Parse cssFallback with a quote-aware parser.

quoteFontName accepts commas in font-family strings and includes commas in its quoting rules, but resolvedStackFamilies splits resolveFontFamily(family).cssFallback at every comma while only stripping leading/trailing quotes. If cssFallback can contain "Font, Name", this creates two split families instead of one and miscounts the concrete fallback set, which blocks font-readiness for that family. Reject comma-containing fonts here or parse cssFallback with a CSS comma/string-aware parser.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/controller/fontReadiness.ts` around lines 275 - 285, Update
resolvedStackFamilies to handle commas inside quoted font-family names instead
of splitting cssFallback at every comma; use a CSS-aware parser that preserves
entries such as "Font, Name" as one family, or explicitly reject
comma-containing names consistently with quoteFontName. Ensure concrete fallback
counting remains correct.

}

function addLayoutFontFace(
Expand Down
Loading