From fd58a9732d1c00323cdf408e827ad1d81835e0ff Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 2 Jun 2026 20:50:33 -0300 Subject: [PATCH 1/4] feat(fonts): face-aware font-load planner (load used faces, not declared families) The load gate waited on every family declared in the docx fontTable and only ever loaded each family's regular face. Two problems: bold/italic text measured against the regular face (or faux-bold) and reflowed once the real face loaded; and a large pack would over-fetch declared-but-unrendered fonts. The planner walks the layout input (blocksForLayout, available before measure) and emits the exact physical faces the document RENDERS - family + weight + style - resolving logical to physical with the same resolver measure and paint use. The gate awaits those faces (weight/style-specific probes) and its late-load handler reflows only when a loaded face matches a required one. Declared-font diagnostics (getDocumentFonts / getReport) are unchanged; the registry now keys load state per face and rolls a family up for the report (a failed used face outranks a loaded sibling, so it is not masked). Prerequisite for scaling to a larger font pack. Does not add the pack, the late-load reflow scheduler, or per-numeric-weight faces. --- .../presentation-editor/PresentationEditor.ts | 15 +- .../fonts/FontReadinessGate.test.ts | 67 +++++- .../fonts/FontReadinessGate.ts | 145 +++++++++++-- .../fonts/font-load-planner.test.ts | 108 ++++++++++ .../fonts/font-load-planner.ts | 109 ++++++++++ shared/font-system/src/index.ts | 2 + shared/font-system/src/registry.test.ts | 62 ++++++ shared/font-system/src/registry.ts | 195 +++++++++++++++++- shared/font-system/src/types.ts | 24 +++ 9 files changed, 706 insertions(+), 21 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index d3d20b16ed..76c9d85031 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -180,6 +180,7 @@ import { measureBlock } from '@superdoc/measuring-dom'; import { resolvePhysicalFamilies, type FontResolutionRecord, type FontLoadSummary } from '@superdoc/font-system'; import { installBundledSubstitutes } from '@superdoc/font-system/bundled'; import { FontReadinessGate } from './fonts/FontReadinessGate'; +import { planRequiredFontFaces } from './fonts/font-load-planner'; import type { FontsChangedPayload } from '../types/EditorEvents'; import type { ColumnLayout, @@ -530,6 +531,8 @@ export class PresentationEditor extends EventEmitter { #selectionSync = new SelectionSyncCoordinator(); /** Load-before-measure gate: awaits required fonts before measurement, reflows on late load. */ #fontGate: FontReadinessGate | null = null; + /** Layout blocks for the current render, stashed so the gate's planner reads the live set. */ + #fontPlanBlocks: FlowBlock[] | null = null; /** Dedup key for `fonts-changed`: epoch + per-face load status. Null until the first emit. */ #lastFontsChangedKey: string | null = null; /** Last emitted `fonts-changed` payload, so a late relay subscriber can replay it. */ @@ -959,8 +962,13 @@ export class PresentationEditor extends EventEmitter { this.#pendingDocChange = true; this.#scheduleRerender(); }, - // Wait on the resolved PHYSICAL families (Calibri -> Carlito), so the gate holds - // measurement until the substitute that measure + paint will use has loaded. + // Face-aware required set: the exact physical faces (family + weight + style) the + // rendered document uses, from the planner walking the current layout blocks. The + // gate awaits these - so bold/italic load before measure and declared-but-unused + // fonts are not fetched. Reads the blocks stashed just before each gate await. + getRequiredFaces: () => planRequiredFontFaces(this.#fontPlanBlocks), + // Fallback family path (used only if getRequiredFaces is unavailable): wait on the + // resolved PHYSICAL families (Calibri -> Carlito). resolveFamilies: resolvePhysicalFamilies, // Register the bundled substitute pack (Carlito) into the document's registry the // first time it resolves, so the substitute is available with no manual setup. @@ -6707,6 +6715,9 @@ export class PresentationEditor extends EventEmitter { // Bounded by a per-font timeout; resolves to the cached summary once fonts are stable; // never throws, so font readiness can never block layout. try { + // Stash the blocks this render will measure so the gate's planner extracts the + // exact used faces (footnote/endnote blocks are already part of blocksForLayout). + this.#fontPlanBlocks = blocksForLayout; const fontSummary = (await this.#fontGate?.ensureReadyForMeasure()) ?? null; // Now that the gate has settled, the font report reflects real load status. Emit // the authoritative `fonts-changed` once the picture first resolves and whenever it diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts index 2a0189ef77..fe01f7bad0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts @@ -1,7 +1,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import type { FontLoadResult, FontLoadStatus, FontRegistry } from '@superdoc/font-system'; +import type { + FontFaceLoadResult, + FontFaceRequest, + FontLoadResult, + FontLoadStatus, + FontRegistry, +} from '@superdoc/font-system'; import { FontReadinessGate, type FontEnvironment } from './FontReadinessGate'; +const faceKey = (r: FontFaceRequest) => `${r.family.toLowerCase()}|${r.weight}|${r.style}`; + /** Minimal FontFace constructor stand-in for the environment (unused when a registry is injected). */ class FakeFontFace { constructor(public readonly family: string) {} @@ -28,6 +36,18 @@ class FakeRegistry { this.awaitCalls.push(unique); return unique.map((family) => ({ family, status: this.getStatus(family) })); } + + // Face-level slice for the face path. + readonly faceStatuses = new Map(); + readonly faceAwaitCalls: string[][] = []; + getFaceStatus(request: FontFaceRequest): FontLoadStatus { + return this.faceStatuses.get(faceKey(request)) ?? 'unloaded'; + } + async awaitFaceRequests(requests: Iterable): Promise { + const unique = [...requests]; + this.faceAwaitCalls.push(unique.map(faceKey)); + return unique.map((request) => ({ request, status: this.getFaceStatus(request) })); + } asRegistry(): FontRegistry { return this as unknown as FontRegistry; } @@ -182,4 +202,49 @@ describe('FontReadinessGate', () => { await expect(gate.ensureReadyForMeasure()).resolves.toMatchObject({ loaded: 0 }); }); + + describe('face-aware path (getRequiredFaces)', () => { + const BOLD: FontFaceRequest = { family: 'Carlito', weight: '700', style: 'normal' }; + + function makeFaceGate(getRequiredFaces: () => FontFaceRequest[]) { + return new FontReadinessGate({ + registry: registry.asRegistry(), + getDocumentFonts: () => [], + getRequiredFaces, + requestReflow, + invalidateCaches, + getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), + timeoutMs: 1000, + }); + } + + it('awaits the exact required faces (family + weight + style), not families', async () => { + registry.faceStatuses.set(faceKey(BOLD), 'loaded'); + const gate = makeFaceGate(() => [BOLD]); + const summary = await gate.ensureReadyForMeasure(); + expect(registry.faceAwaitCalls).toEqual([['carlito|700|normal']]); + expect(summary.loaded).toBe(1); + }); + + it('reflows once when the required bold face loads after a timed-out first paint', async () => { + registry.faceStatuses.set(faceKey(BOLD), 'timed_out'); + const gate = makeFaceGate(() => [BOLD]); + await gate.ensureReadyForMeasure(); + expect(requestReflow).not.toHaveBeenCalled(); + + // A REGULAR Carlito face finishing must NOT reflow - it is not a required face. + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'normal', style: 'normal' }] }); + expect(requestReflow).not.toHaveBeenCalled(); + + // The required BOLD face finishing DOES reflow, exactly once. + registry.faceStatuses.set(faceKey(BOLD), 'loaded'); + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'bold', style: 'normal' }] }); + expect(requestReflow).toHaveBeenCalledTimes(1); + expect(invalidateCaches).toHaveBeenCalledTimes(1); + + // A second loadingdone for the same face does not reflow again (no loop). + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'bold', style: 'normal' }] }); + expect(requestReflow).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index f78d88d195..16f4d1a203 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -5,6 +5,8 @@ import { DEFAULT_FONT_LOAD_TIMEOUT_MS, type FontRegistry, type FontLoadResult, + type FontFaceRequest, + type FontFaceLoadResult, type FontLoadSummary, type FontResolutionRecord, } from '@superdoc/font-system'; @@ -26,8 +28,16 @@ export interface FontEnvironment { } export interface FontReadinessGateOptions { - /** Logical font families the current document uses. Cheap to call per render. */ + /** Logical font families the current document DECLARES (fontTable). Used for diagnostics. */ getDocumentFonts: () => string[]; + /** + * The exact physical FACES (family + weight + style) the current document RENDERS, from + * the planner walking layout input. When provided, the gate awaits these faces instead of + * declared families - so bold/italic load before measure and declared-but-unused fonts are + * not fetched. Falls back to the {@link getDocumentFonts} + {@link resolveFamilies} family + * path when omitted (tests / non-layout callers). + */ + getRequiredFaces?: () => FontFaceRequest[]; /** Trigger a re-measure + re-layout + repaint (PresentationEditor's immediate render). */ requestReflow: () => void; /** @@ -76,6 +86,7 @@ export interface FontReadinessGateOptions { */ export class FontReadinessGate { readonly #getDocumentFonts: () => string[]; + readonly #getRequiredFaces: (() => FontFaceRequest[]) | null; readonly #resolveFamilies: (families: string[]) => string[]; readonly #requestReflow: () => void; readonly #getFontEnvironment: () => FontEnvironment | null; @@ -90,13 +101,18 @@ export class FontReadinessGate { #fontConfigVersion = 0; #requiredSignature = ''; #requiredFamilies = new Set(); - /** Families observed available, so the late-load handler fires at most once per face. */ + /** Required face keys (family|weight|style) for the face path's late-load matching. */ + #requiredFaceKeys = new Set(); + /** Families observed available, so the family-path late-load handler fires once per face. */ readonly #seenAvailable = new Set(); + /** Face keys observed available, so the face-path late-load handler fires once per face. */ + readonly #seenAvailableFaces = new Set(); #lastSummary: FontLoadSummary | null = null; #loadingDoneHandler: ((event: FontFaceSetLoadEvent) => void) | null = null; constructor(options: FontReadinessGateOptions) { this.#getDocumentFonts = options.getDocumentFonts; + this.#getRequiredFaces = options.getRequiredFaces ?? null; this.#resolveFamilies = options.resolveFamilies ?? ((families) => families); this.#requestReflow = options.requestReflow; this.#getFontEnvironment = options.getFontEnvironment ?? defaultFontEnvironment; @@ -143,6 +159,55 @@ export class FontReadinessGate { * must not break layout. */ async ensureReadyForMeasure(): Promise { + if (this.#getRequiredFaces) return this.#ensureFacesReady(this.#getRequiredFaces); + return this.#ensureFamiliesReady(); + } + + /** Face-aware path: await the exact physical faces the rendered document uses. */ + async #ensureFacesReady(getRequiredFaces: () => FontFaceRequest[]): Promise { + const registry = this.#resolveContext().registry; + + let required: FontFaceRequest[]; + try { + required = getRequiredFaces(); + } catch { + return this.#lastSummary ?? emptySummary(); + } + + const keyed = required.map((r) => ({ request: r, key: faceKeyOf(r.family, r.weight, r.style) })); + const signature = keyed + .map((k) => k.key) + .sort() + .join('|'); + const unchangedAndLoaded = + signature === this.#requiredSignature && keyed.every((k) => registry.getFaceStatus(k.request) === 'loaded'); + if (unchangedAndLoaded && this.#lastSummary) { + return this.#lastSummary; + } + + this.#requiredSignature = signature; + this.#requiredFaceKeys = new Set(keyed.map((k) => k.key)); + this.#requiredFamilies = new Set(); + this.#ensureSubscribed(); + + let results: FontFaceLoadResult[] = []; + try { + results = required.length ? await registry.awaitFaceRequests(required, { timeoutMs: this.#timeoutMs }) : []; + } catch { + results = []; + } + + for (const result of results) { + if (result.status === 'loaded') { + this.#seenAvailableFaces.add(faceKeyOf(result.request.family, result.request.weight, result.request.style)); + } + } + this.#lastSummary = summarizeFaces(results); + return this.#lastSummary; + } + + /** Legacy family path: await declared families (tests / non-layout callers). */ + async #ensureFamiliesReady(): Promise { const registry = this.#resolveContext().registry; let required: string[]; @@ -161,6 +226,7 @@ export class FontReadinessGate { this.#requiredSignature = signature; this.#requiredFamilies = new Set(required); + this.#requiredFaceKeys = new Set(); this.#ensureSubscribed(); let results: FontLoadResult[] = []; @@ -185,6 +251,7 @@ export class FontReadinessGate { this.#fontConfigVersion += 1; bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust this.#seenAvailable.clear(); + this.#seenAvailableFaces.clear(); this.#requiredSignature = ''; this.#invalidateCaches(); this.#requestReflow(); @@ -233,20 +300,40 @@ export class FontReadinessGate { } #onLoadingDone(event: FontFaceSetLoadEvent): void { - // A required face that the last measure could not use just finished loading -> that - // paint used a fallback, so invalidate and reflow. We key off the faces the event + // A required face/family that the last measure could not use just finished loading -> + // that paint used a fallback, so invalidate and reflow. We key off the faces the event // actually reports as loaded (reliable), NOT FontFaceSet.check() (which lies for - // unregistered bare families). The seen-set fires this once per face; never a loop. - const loadedKeys = new Set((event?.fontfaces ?? []).map((face) => normalizeFamilyKey(face.family))); - if (loadedKeys.size === 0) return; + // unregistered bare families). The seen-set fires this at most once per face. + const faces = event?.fontfaces ?? []; + if (faces.length === 0) return; let changed = false; - for (const family of this.#requiredFamilies) { - if (this.#seenAvailable.has(family)) continue; - if (loadedKeys.has(normalizeFamilyKey(family))) { - this.#seenAvailable.add(family); - changed = true; + + if (this.#requiredFaceKeys.size > 0) { + // Face path: reflow only when a loaded face matches a REQUIRED face key (family + + // weight + style). "Liberation Sans bold loaded and it was required" - not merely + // "Liberation Sans (regular) loaded". + const loadedFaceKeys = new Set( + faces.map((face) => faceKeyOf(face.family, normalizeWeightToken(face.weight), normalizeStyleToken(face.style))), + ); + for (const key of this.#requiredFaceKeys) { + if (this.#seenAvailableFaces.has(key)) continue; + if (loadedFaceKeys.has(key)) { + this.#seenAvailableFaces.add(key); + changed = true; + } + } + } else { + // Legacy family path. + const loadedFamilies = new Set(faces.map((face) => normalizeFamilyKey(face.family))); + for (const family of this.#requiredFamilies) { + if (this.#seenAvailable.has(family)) continue; + if (loadedFamilies.has(normalizeFamilyKey(family))) { + this.#seenAvailable.add(family); + changed = true; + } } } + if (!changed) return; this.#fontConfigVersion += 1; bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust @@ -263,6 +350,27 @@ function normalizeFamilyKey(family: string): string { .toLowerCase(); } +/** Canonical weight token for face matching: bold/>=600 -> '700', else '400'. */ +function normalizeWeightToken(weight: string | undefined): '400' | '700' { + if (!weight) return '400'; + const w = weight.trim().toLowerCase(); + if (w === 'bold' || w === 'bolder') return '700'; + const n = Number(w); + return Number.isFinite(n) && n >= 600 ? '700' : '400'; +} + +/** Canonical style token for face matching: italic/oblique -> 'italic', else 'normal'. */ +function normalizeStyleToken(style: string | undefined): 'normal' | 'italic' { + if (!style) return 'normal'; + const s = style.trim().toLowerCase(); + return s.startsWith('italic') || s.startsWith('oblique') ? 'italic' : 'normal'; +} + +/** Face key matching the registry's: normalized family + weight + style. */ +function faceKeyOf(family: string, weight: '400' | '700', style: 'normal' | 'italic'): string { + return `${normalizeFamilyKey(family)}|${weight}|${style}`; +} + /** The font-system registry accepts a structural font set + face ctor; the DOM types satisfy them. */ type FontSetLikeArg = Parameters[0]; type FontFaceCtorArg = Parameters[1]; @@ -279,6 +387,19 @@ function summarize(results: FontLoadResult[]): FontLoadSummary { return summary; } +/** Summarize face results (counts are per-FACE; `results` keeps the physical family name). */ +function summarizeFaces(results: FontFaceLoadResult[]): FontLoadSummary { + const summary = emptySummary(); + summary.results = results.map((r) => ({ family: r.request.family, status: r.status })); + for (const result of results) { + if (result.status === 'loaded') summary.loaded += 1; + else if (result.status === 'failed') summary.failed += 1; + else if (result.status === 'timed_out') summary.timedOut += 1; + else if (result.status === 'fallback_used') summary.fallbackUsed += 1; + } + return summary; +} + function emptySummary(): FontLoadSummary { return { loaded: 0, failed: 0, timedOut: 0, fallbackUsed: 0, results: [] }; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.test.ts new file mode 100644 index 0000000000..ad25da172d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { planRequiredFontFaces } from './font-load-planner'; +import type { FlowBlock } from '@superdoc/contracts'; + +const text = (fontFamily: string, opts: { bold?: boolean; italic?: boolean } = {}) => ({ + kind: 'text' as const, + text: 'x', + fontFamily, + fontSize: 12, + ...opts, +}); + +const para = (id: string, runs: ReturnType[]): FlowBlock => + ({ kind: 'paragraph', id, runs }) as unknown as FlowBlock; + +const keyset = (reqs: ReturnType) => + new Set(reqs.map((r) => `${r.family}|${r.weight}|${r.style}`)); + +describe('planRequiredFontFaces', () => { + it('emits one physical face per used weight/style, resolved logical -> physical', () => { + const blocks = [ + para('p', [ + text('Calibri'), + text('Calibri', { bold: true }), + text('Calibri', { italic: true }), + text('Calibri', { bold: true, italic: true }), + ]), + ]; + expect(keyset(planRequiredFontFaces(blocks))).toEqual( + new Set(['Carlito|400|normal', 'Carlito|700|normal', 'Carlito|400|italic', 'Carlito|700|italic']), + ); + }); + + it('only emits faces for fonts actually rendered (declared-but-unused never appears)', () => { + // A doc whose runs use only Calibri -> only Carlito faces, regardless of what the + // fontTable declared (the planner never sees the fontTable). + const reqs = planRequiredFontFaces([para('p', [text('Calibri'), text('Calibri', { bold: true })])]); + expect(keyset(reqs)).toEqual(new Set(['Carlito|400|normal', 'Carlito|700|normal'])); + }); + + it('dedupes repeated faces across runs and blocks', () => { + const reqs = planRequiredFontFaces([ + para('a', [text('Arial'), text('Arial')]), + para('b', [text('Arial', { bold: true }), text('Arial', { bold: true })]), + ]); + expect(reqs).toHaveLength(2); + expect(keyset(reqs)).toEqual(new Set(['Liberation Sans|400|normal', 'Liberation Sans|700|normal'])); + }); + + it('walks table cells (paragraph and multi-block content)', () => { + const table = { + kind: 'table', + id: 't', + rows: [ + { + id: 'r', + cells: [ + { id: 'c1', paragraph: para('cp', [text('Times New Roman', { italic: true })]) }, + { id: 'c2', blocks: [para('cb', [text('Courier New')])] }, + ], + }, + ], + } as unknown as FlowBlock; + expect(keyset(planRequiredFontFaces([table]))).toEqual( + new Set(['Liberation Serif|400|italic', 'Liberation Mono|400|normal']), + ); + }); + + it('walks list item paragraphs', () => { + const list = { + kind: 'list', + id: 'l', + listType: 'bullet', + items: [{ id: 'i', marker: { kind: 'bullet', text: '•', level: 0 }, paragraph: para('ip', [text('Cambria')]) }], + } as unknown as FlowBlock; + expect(keyset(planRequiredFontFaces([list]))).toEqual(new Set(['Caladea|400|normal'])); + }); + + it('passes an unmapped family through as-is (no substitute)', () => { + const reqs = planRequiredFontFaces([para('p', [text('Aptos', { bold: true })])]); + expect(keyset(reqs)).toEqual(new Set(['Aptos|700|normal'])); + }); + + it('resolves a CSS stack to its primary physical family', () => { + const reqs = planRequiredFontFaces([para('p', [text('Calibri, sans-serif')])]); + expect(keyset(reqs)).toEqual(new Set(['Carlito|400|normal'])); + }); + + it('collects the word-layout marker run font (measured separately from item text)', () => { + // A list paragraph whose marker glyph uses a bold mapped family distinct from the text. + const block = { + kind: 'paragraph', + id: 'p', + runs: [text('Calibri')], + attrs: { wordLayout: { marker: { markerText: '1.', run: { fontFamily: 'Arial', fontSize: 12, bold: true } } } }, + } as unknown as FlowBlock; + expect(keyset(planRequiredFontFaces([block]))).toEqual( + new Set(['Carlito|400|normal', 'Liberation Sans|700|normal']), + ); + }); + + it('ignores runs with no fontFamily and empty input', () => { + expect(planRequiredFontFaces([])).toEqual([]); + expect(planRequiredFontFaces(null)).toEqual([]); + const reqs = planRequiredFontFaces([para('p', [{ kind: 'text', text: 'x', fontSize: 12 } as never])]); + expect(reqs).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts new file mode 100644 index 0000000000..7ab41631d2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts @@ -0,0 +1,109 @@ +import { resolvePrimaryPhysicalFamily, type FontFaceRequest } from '@superdoc/font-system'; +import type { FlowBlock, ParagraphBlock, TableBlock, ListBlock, Run } from '@superdoc/contracts'; + +/** + * Face-aware font-load planner. + * + * The load gate must await the exact physical FACES the document RENDERS - family + + * weight + style - not every family declared in the docx fontTable. Two reasons: + * 1. `document.fonts.load('16px "Carlito"')` loads only the regular (400/normal) face, + * so bold/italic text would measure against the wrong face and reflow on late load. + * 2. A docx fontTable declares many fonts that are never rendered; awaiting all of them + * over-fetches and (with a large pack on a slow link) causes a late-load reflow storm. + * + * This walks the layout input (`blocksForLayout`) - which exists BEFORE measurement and + * already carries each run's `fontFamily` + `bold`/`italic` - and emits the deduped set of + * physical face requests. It resolves logical -> physical with `resolvePrimaryPhysicalFamily`, + * the SAME primary resolution measure and paint use, so the planned set cannot disagree + * with what is actually measured/painted. Declared-font diagnostics stay separate + * (`getDocumentFonts()` / `getReport()`); this feeds loading only. + */ + +/** Anything that carries a measurable text font: a run, a list marker run, etc. */ +interface FontBearing { + fontFamily?: unknown; + bold?: unknown; + italic?: unknown; +} + +function faceKey(req: FontFaceRequest): string { + return `${req.family.toLowerCase()}|${req.weight}|${req.style}`; +} + +/** Collect a face request from any font-bearing object into the deduped map. */ +function collect(out: Map, node: FontBearing | null | undefined): void { + if (!node || typeof node.fontFamily !== 'string' || !node.fontFamily) return; + const family = resolvePrimaryPhysicalFamily(node.fontFamily); + if (!family) return; + const req: FontFaceRequest = { + family, + weight: node.bold === true ? '700' : '400', + style: node.italic === true ? 'italic' : 'normal', + }; + const key = faceKey(req); + if (!out.has(key)) out.set(key, req); +} + +function collectRuns(out: Map, runs: Run[] | undefined): void { + if (!runs) return; + // Duck-typed on fontFamily so every font-bearing run kind is covered (text, + // fieldAnnotation, dropCap, ...) - missing one would silently measure against fallback. + for (const run of runs) collect(out, run as unknown as FontBearing); +} + +function collectParagraph(out: Map, paragraph: ParagraphBlock | undefined): void { + if (!paragraph) return; + collectRuns(out, paragraph.runs); + // The word-layout list marker glyph ("1.", "•") is measured with its OWN run font + // (attrs.wordLayout.marker.run, used by the measurer's buildFontString), which can be a + // different family/weight/style than the item text - so it must be planned too. + collect(out, paragraph.attrs?.wordLayout?.marker?.run as FontBearing | undefined); +} + +function collectTable(out: Map, table: TableBlock): void { + for (const row of table.rows) { + for (const cell of row.cells) { + collectParagraph(out, cell.paragraph); + if (cell.blocks) for (const b of cell.blocks) collectBlock(out, b as FlowBlock); + } + } +} + +function collectList(out: Map, list: ListBlock): void { + for (const item of list.items) { + // collectParagraph covers the item text AND any word-layout marker font on the + // paragraph's attrs. The ListBlock-level `item.marker` (ListMarker) carries no font of + // its own - that glyph is measured with the paragraph font, already collected here. + collectParagraph(out, item.paragraph); + } +} + +function collectBlock(out: Map, block: FlowBlock): void { + switch (block.kind) { + case 'paragraph': + // Via collectParagraph (not collectRuns) so a top-level paragraph's word-layout + // marker run font is collected too, not just its text runs. + collectParagraph(out, block); + break; + case 'table': + collectTable(out, block); + break; + case 'list': + collectList(out, block); + break; + default: + // image/drawing/section/page/column breaks carry no measurable text font. + break; + } +} + +/** + * The deduped physical face requests the given layout blocks actually render. Footnote and + * endnote blocks are included by passing them in `blocks` (the caller appends them to the + * layout block list before measurement, so they are ordinary paragraphs here). + */ +export function planRequiredFontFaces(blocks: readonly FlowBlock[] | null | undefined): FontFaceRequest[] { + const out = new Map(); + if (blocks) for (const block of blocks) collectBlock(out, block); + return [...out.values()]; +} diff --git a/shared/font-system/src/index.ts b/shared/font-system/src/index.ts index d3e8586d57..8347a3d434 100644 --- a/shared/font-system/src/index.ts +++ b/shared/font-system/src/index.ts @@ -20,6 +20,8 @@ export type { RegisteredFace, FontLoadResult, FontLoadSummary, + FontFaceRequest, + FontFaceLoadResult, FontAssetUrlContext, FontAssetUrlResolver, RequiredFace, diff --git a/shared/font-system/src/registry.test.ts b/shared/font-system/src/registry.test.ts index f11568f758..8a003bc8b7 100644 --- a/shared/font-system/src/registry.test.ts +++ b/shared/font-system/src/registry.test.ts @@ -214,3 +214,65 @@ describe('getFontRegistryFor', () => { expect(getFontRegistryFor(null, null)).toBe(getFontRegistryFor(null, null)); }); }); + +describe('FontRegistry face-aware APIs', () => { + let fontSet: FakeFontSet; + beforeEach(() => { + fontSet = new FakeFontSet(); + __resetDefaultFontRegistry(); + }); + + it('register seeds a face-level status from weight/style descriptors', () => { + const { registry } = makeRegistry(fontSet); + registry.register({ + family: 'Carlito', + source: 'url(c-bold.woff2)', + descriptors: { weight: 'bold', style: 'normal' }, + }); + expect(registry.getFaceStatus({ family: 'Carlito', weight: '700', style: 'normal' })).toBe('unloaded'); + // A different face of the same family is not implied by registering one. + expect(registry.getFaceStatus({ family: 'Carlito', weight: '400', style: 'italic' })).toBe('unloaded'); + }); + + it('awaitFaceRequest loads the exact face and rolls the family up to loaded', async () => { + fontSet.behaviors.set('Carlito', 'load-ok'); + const { registry } = makeRegistry(fontSet); + const res = await registry.awaitFaceRequest({ family: 'Carlito', weight: '700', style: 'italic' }, 1000); + expect(res.status).toBe('loaded'); + expect(registry.getFaceStatus({ family: 'Carlito', weight: '700', style: 'italic' })).toBe('loaded'); + expect(registry.getStatus('Carlito')).toBe('loaded'); // family rollup for diagnostics + }); + + it('awaitFaceRequests dedupes by face key but keeps distinct weight/style as separate faces', async () => { + fontSet.behaviors.set('Liberation Sans', 'load-ok'); + const { registry } = makeRegistry(fontSet); + const results = await registry.awaitFaceRequests( + [ + { family: 'Liberation Sans', weight: '400', style: 'normal' }, + { family: 'Liberation Sans', weight: '400', style: 'normal' }, // dup + { family: 'Liberation Sans', weight: '700', style: 'normal' }, + ], + { timeoutMs: 1000 }, + ); + expect(results).toHaveLength(2); + expect(results.every((r) => r.status === 'loaded')).toBe(true); + }); + + it('getStatus stays unloaded for a family registered but never awaited (not "missing")', () => { + const { registry } = makeRegistry(fontSet); + registry.register({ + family: 'Carlito', + source: 'url(c.woff2)', + descriptors: { weight: 'normal', style: 'normal' }, + }); + expect(registry.getStatus('Carlito')).toBe('unloaded'); + }); + + it('a failed face surfaces in the family rollup when nothing else loaded', async () => { + fontSet.behaviors.set('Broken', 'reject'); + const { registry } = makeRegistry(fontSet); + const res = await registry.awaitFaceRequest({ family: 'Broken', weight: '400', style: 'normal' }, 1000); + expect(res.status).toBe('failed'); + expect(registry.getStatus('Broken')).toBe('failed'); + }); +}); diff --git a/shared/font-system/src/registry.ts b/shared/font-system/src/registry.ts index dd16f8d009..b5be2fa248 100644 --- a/shared/font-system/src/registry.ts +++ b/shared/font-system/src/registry.ts @@ -1,4 +1,12 @@ -import type { FontFaceDescriptor, FontLoadResult, FontLoadStatus, RegisteredFace, RequiredFace } from './types'; +import type { + FontFaceDescriptor, + FontFaceLoadResult, + FontFaceRequest, + FontLoadResult, + FontLoadStatus, + RegisteredFace, + RequiredFace, +} from './types'; /** * Default per-font budget the gate waits before treating a face as `timed_out` @@ -55,6 +63,41 @@ function quoteFamily(family: string): string { return `"${family.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } +/** Normalize a family name for keying: trim, strip surrounding quotes, lowercase. */ +function normalizeFamilyKey(family: string): string { + return family + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase(); +} + +/** Canonical weight token: `bold`/`700`-ish -> '700', everything else -> '400'. */ +function normalizeWeight(weight: string | number | undefined): '400' | '700' { + if (weight === undefined) return '400'; + const w = String(weight).trim().toLowerCase(); + if (w === 'bold' || w === 'bolder') return '700'; + const n = Number(w); + return Number.isFinite(n) && n >= 600 ? '700' : '400'; +} + +/** Canonical style token: italic/oblique -> 'italic', else 'normal'. */ +function normalizeStyle(style: string | undefined): 'normal' | 'italic' { + if (!style) return 'normal'; + const s = style.trim().toLowerCase(); + return s.startsWith('italic') || s.startsWith('oblique') ? 'italic' : 'normal'; +} + +/** Stable key for a face: normalized family + weight + style. */ +function faceKeyOf(family: string, weight: '400' | '700', style: 'normal' | 'italic'): string { + return `${normalizeFamilyKey(family)}|${weight}|${style}`; +} + +/** CSS `font` shorthand probe for a specific face (style weight size family). */ +function faceProbe(family: string, weight: '400' | '700', style: 'normal' | 'italic', size: string): string { + const stylePart = style === 'italic' ? 'italic ' : ''; + return `${stylePart}${weight} ${size} ${quoteFamily(family)}`; +} + /** * Runtime registry of font faces and their load state. * @@ -86,9 +129,23 @@ export class FontRegistry { readonly #sources = new Map(); /** Families already warned about a load failure, so the warning fires at most once each. */ readonly #warnedFailures = new Set(); - /** In-flight loads, so concurrent awaits of one family share a single probe. */ + /** In-flight family loads, so concurrent awaits of one family share a single probe. */ readonly #inflight = new Map>(); + // Face-level state (family + weight + style). The gate awaits FACES, not families, + // because `load('16px Family')` only loads the regular face. `#status` above stays as + // a family-level rollup (see getStatus) so declared-font diagnostics keep working. + /** Last known load status per face key. */ + readonly #faceStatus = new Map(); + /** In-flight face loads, so concurrent awaits of one face share a single probe. */ + readonly #faceInflight = new Map>(); + /** Registered `url(...)` source per face key, to name the failing URL on a face load error. */ + readonly #faceSources = new Map(); + /** Face keys seen per normalized family, for the family-level status rollup. */ + readonly #facesByFamily = new Map>(); + /** Faces already warned about a load failure, so the warning fires at most once each. */ + readonly #warnedFaceFailures = new Set(); + constructor(options: FontRegistryOptions = {}) { this.#fontSet = options.fontSet ?? null; this.#FontFaceCtor = options.FontFaceCtor ?? null; @@ -117,7 +174,23 @@ export class FontRegistry { this.#sources.set(family, list); } if (!this.#status.has(family)) this.#status.set(family, 'unloaded'); - return { family, status: this.#status.get(family) ?? 'unloaded' }; + // Seed face-level status so the gate can await this exact weight/style. A bare + // register (no weight/style descriptors) seeds the 400/normal face. + const weight = normalizeWeight(descriptors?.weight as string | undefined); + const style = normalizeStyle(descriptors?.style as string | undefined); + const key = faceKeyOf(family, weight, style); + this.#trackFace(family, key); + if (!this.#faceStatus.has(key)) this.#faceStatus.set(key, 'unloaded'); + if (typeof source === 'string' && !this.#faceSources.has(key)) this.#faceSources.set(key, source); + return { family, status: this.getStatus(family) }; + } + + /** Record a face key under its normalized family for the family-status rollup. */ + #trackFace(family: string, key: string): void { + const fam = normalizeFamilyKey(family); + const set = this.#facesByFamily.get(fam) ?? new Set(); + set.add(key); + this.#facesByFamily.set(fam, set); } /** True if this registry created a managed face for the family. */ @@ -125,9 +198,29 @@ export class FontRegistry { return this.#managed.has(family); } - /** Last known load status for a family (`unloaded` if never seen). */ + /** + * Last known status for a family, rolled up from its faces (and any legacy family-path + * load). Used by declared-font diagnostics (`buildFontReport`). + * + * A FAILED/TIMED_OUT/FALLBACK_USED face surfaces OVER a loaded sibling: if a document + * uses Arial regular (Liberation Sans loads) and Arial bold (its face 404s), the family + * reports the failure (`missing: true`), not a misleadingly-clean `loaded`. This is sound + * because the gate only awaits USED faces - an unused face stays `unloaded` (lowest + * priority), so a declared-but-unused family stays `unloaded` (not settled => not missing) + * and a used-but-failed face is never masked. Per-face detail is in `getFaceStatus` and + * the load summary's per-face counts. + */ getStatus(family: string): FontLoadStatus { - return this.#status.get(family) ?? 'unloaded'; + const statuses: FontLoadStatus[] = []; + const faceKeys = this.#facesByFamily.get(normalizeFamilyKey(family)); + if (faceKeys) for (const k of faceKeys) statuses.push(this.#faceStatus.get(k) ?? 'unloaded'); + const legacy = this.#status.get(family); + if (legacy) statuses.push(legacy); + if (statuses.length === 0) return 'unloaded'; + // Settled failures outrank `loaded` so a broken required face is never hidden. + const priority: FontLoadStatus[] = ['failed', 'timed_out', 'fallback_used', 'loaded', 'loading', 'unloaded']; + for (const s of priority) if (statuses.includes(s)) return s; + return 'unloaded'; } /** @@ -192,6 +285,96 @@ export class FontRegistry { return [...this.#status.entries()].map(([family, status]) => ({ family, status })); } + /** Last known status for a specific face (`unloaded` if never seen). */ + getFaceStatus(request: FontFaceRequest): FontLoadStatus { + return this.#faceStatus.get(faceKeyOf(request.family, request.weight, request.style)) ?? 'unloaded'; + } + + /** + * Await one specific face (family + weight + style) with a per-font timeout. Uses a + * weight/style-specific probe (`italic 700 16px "Carlito"`), so unlike {@link awaitFace} + * it loads the EXACT face the run needs, not just the regular one. Concurrent awaits of + * the same face share one probe; an already-`loaded` face resolves immediately. + */ + awaitFaceRequest( + request: FontFaceRequest, + timeoutMs: number = DEFAULT_FONT_LOAD_TIMEOUT_MS, + ): Promise { + const key = faceKeyOf(request.family, request.weight, request.style); + if (this.#faceStatus.get(key) === 'loaded') { + return Promise.resolve({ request, status: 'loaded' }); + } + const existing = this.#faceInflight.get(key); + if (existing) return existing; + const probe = this.#loadOneFace(request, key, timeoutMs).finally(() => { + this.#faceInflight.delete(key); + }); + this.#faceInflight.set(key, probe); + return probe; + } + + /** Await many faces; result preserves input order after de-duplication by face key. */ + async awaitFaceRequests( + requests: Iterable, + options: { timeoutMs?: number } = {}, + ): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_FONT_LOAD_TIMEOUT_MS; + const seen = new Set(); + const unique: FontFaceRequest[] = []; + for (const r of requests) { + const key = faceKeyOf(r.family, r.weight, r.style); + if (seen.has(key)) continue; + seen.add(key); + unique.push(r); + } + return Promise.all(unique.map((r) => this.awaitFaceRequest(r, timeoutMs))); + } + + async #loadOneFace(request: FontFaceRequest, key: string, timeoutMs: number): Promise { + this.#trackFace(request.family, key); + const fontSet = this.#fontSet; + if (!fontSet) { + this.#faceStatus.set(key, 'fallback_used'); + return { request, status: 'fallback_used' }; + } + this.#faceStatus.set(key, 'loading'); + const probe = faceProbe(request.family, request.weight, request.style, this.#probeSize); + const TIMEOUT = Symbol('timeout'); + let handle: unknown; + const timeout = new Promise((resolve) => { + handle = this.#scheduleTimeout(() => resolve(TIMEOUT), timeoutMs); + }); + try { + const settled = await Promise.race([fontSet.load(probe), timeout]); + if (settled === TIMEOUT) { + this.#faceStatus.set(key, 'timed_out'); + return { request, status: 'timed_out' }; + } + const faces = settled as FontFaceLike[]; + const status: FontLoadStatus = faces.length > 0 ? 'loaded' : 'fallback_used'; + this.#faceStatus.set(key, status); + return { request, status }; + } catch { + this.#faceStatus.set(key, 'failed'); + this.#warnFaceFailureOnce(request, key); + return { request, status: 'failed' }; + } finally { + this.#cancelTimeout(handle); + } + } + + /** Warn once per face when its asset fails to load, naming the attempted URL. */ + #warnFaceFailureOnce(request: FontFaceRequest, key: string): void { + if (this.#warnedFaceFailures.has(key)) return; + this.#warnedFaceFailures.add(key); + const src = this.#faceSources.get(key); + const detail = src ? ` from ${src}` : ''; + console.warn( + `[superdoc] font face failed to load: "${request.family}" ${request.weight} ${request.style}${detail}. ` + + `Check fonts.assetBaseUrl / fonts.resolveAssetUrl so the bundled .woff2 are served.`, + ); + } + async #loadOne(family: string, timeoutMs: number): Promise { const fontSet = this.#fontSet; if (!fontSet) { @@ -241,7 +424,7 @@ export class FontRegistry { this.#warnedFailures.add(family); const sources = this.#sources.get(family); const detail = sources && sources.length ? ` from ${sources.join(', ')}` : ''; - + console.warn( `[superdoc] font asset failed to load for "${family}"${detail}. ` + `Check fonts.assetBaseUrl / fonts.resolveAssetUrl so the bundled .woff2 are served.`, diff --git a/shared/font-system/src/types.ts b/shared/font-system/src/types.ts index 165f61fa8a..31b4495ca7 100644 --- a/shared/font-system/src/types.ts +++ b/shared/font-system/src/types.ts @@ -67,6 +67,30 @@ export interface FontLoadResult { status: FontLoadStatus; } +/** + * A specific physical font FACE the layout actually uses: family + weight + style. + * + * The gate must await faces, not families: `document.fonts.load('16px "Carlito"')` loads + * ONLY the regular (400/normal) face, so bold/italic text would otherwise measure against + * the regular face's advances (or a synthesized faux-bold) and reflow once the real face + * arrives. `weight`/`style` are normalized tokens (the bundled pack ships 400/700 × + * normal/italic; the run model is binary bold/italic, so these four cover it - numeric + * weights are a follow-up). The planner builds these from layout runs; the registry awaits + * each one with a weight/style-specific probe. + */ +export interface FontFaceRequest { + /** Physical (resolved) family, e.g. "Carlito". */ + family: string; + weight: '400' | '700'; + style: 'normal' | 'italic'; +} + +/** Result of awaiting one required face. */ +export interface FontFaceLoadResult { + request: FontFaceRequest; + status: FontLoadStatus; +} + /** * Aggregate outcome of one readiness pass: the per-family results plus their counts. * `loaded + fallbackUsed + failed + timedOut` equals the number of distinct required From f515bc34d7bbe0abfe9ed2f8c81305e27a46a874 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 11:01:05 -0300 Subject: [PATCH 2/4] fix(fonts): plan header/footer faces and fall back to family path on planner throw Two review follow-ups on the face-aware planner: - A font used only in a header/footer was never planned: #fontPlanBlocks held only body+notes, but incrementalLayout also measures header/footer blocks, so that face measured against fallback and reflowed on late load. Build the header/footer input before the gate and feed its blocks into the same planRequiredFontFaces input (planner dedups, so getBatch/getBlocksByRId overlap is harmless). - If face planning ever throws, the gate skipped loading entirely; degrade to the family path (which still awaits the resolved physical families) instead. Add a regression test. --- .../presentation-editor/PresentationEditor.ts | 36 ++++++++++++++++--- .../fonts/FontReadinessGate.test.ts | 25 +++++++++++++ .../fonts/FontReadinessGate.ts | 6 +++- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 76c9d85031..5db61b3d3a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -6710,14 +6710,22 @@ export class PresentationEditor extends EventEmitter { let extraMeasures: Measure[] | undefined; let resolveBlocks: FlowBlock[] = blocksForLayout; let resolveMeasures: Measure[] = previousMeasures; + // Build the header/footer layout input BEFORE the gate so its faces are planned too: + // a font used only in a header/footer is still measured (via incrementalLayout below), + // so it must load before measure or it reflows on late load. Reused unchanged for the + // incrementalLayout call and the per-rId header/footer pass. + const headerFooterInput = this.#buildHeaderFooterInput(); // Load-before-measure gate (T3): wait for the fonts this document needs so the first // measurement pass uses real metrics instead of a fallback that would reflow on load. // Bounded by a per-font timeout; resolves to the cached summary once fonts are stable; // never throws, so font readiness can never block layout. try { - // Stash the blocks this render will measure so the gate's planner extracts the - // exact used faces (footnote/endnote blocks are already part of blocksForLayout). - this.#fontPlanBlocks = blocksForLayout; + // Stash the blocks this render will measure so the gate's planner extracts the exact + // used faces: body + notes (blocksForLayout) plus the header/footer blocks. One + // planner input; planRequiredFontFaces dedups, so batch/by-rId overlap is harmless. + this.#fontPlanBlocks = headerFooterInput + ? [...blocksForLayout, ...this.#collectHeaderFooterFaceBlocks(headerFooterInput)] + : blocksForLayout; const fontSummary = (await this.#fontGate?.ensureReadyForMeasure()) ?? null; // Now that the gate has settled, the font report reflects real load status. Emit // the authoritative `fonts-changed` once the picture first resolves and whenever it @@ -6727,7 +6735,6 @@ export class PresentationEditor extends EventEmitter { /* font readiness must never break layout */ } - const headerFooterInput = this.#buildHeaderFooterInput(); try { const incrementalLayoutStart = perfNow(); const result = await incrementalLayout( @@ -7900,6 +7907,27 @@ export class PresentationEditor extends EventEmitter { }; } + /** + * Flatten a header/footer layout input into the FlowBlocks it will measure, so the font + * planner can include header/footer faces. getBatch variants and getBlocksByRId can cover + * the same content; planRequiredFontFaces dedups by face, so the overlap is harmless. + */ + #collectHeaderFooterFaceBlocks(input: { + headerBlocks?: Partial>; + footerBlocks?: Partial>; + headerBlocksByRId?: Map; + footerBlocksByRId?: Map; + }): FlowBlock[] { + const out: FlowBlock[] = []; + for (const batch of [input.headerBlocks, input.footerBlocks]) { + if (batch) for (const blocks of Object.values(batch)) if (blocks) out.push(...blocks); + } + for (const byRId of [input.headerBlocksByRId, input.footerBlocksByRId]) { + if (byRId) for (const blocks of byRId.values()) out.push(...blocks); + } + return out; + } + #buildHeaderFooterInput() { if (this.#isSemanticFlowMode()) { return null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts index fe01f7bad0..235bc440f3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts @@ -246,5 +246,30 @@ describe('FontReadinessGate', () => { fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'bold', style: 'normal' }] }); expect(requestReflow).toHaveBeenCalledTimes(1); }); + + it('falls back to the family path when face planning throws', async () => { + registry.statuses.set('Carlito', 'loaded'); + const gate = new FontReadinessGate({ + registry: registry.asRegistry(), + getDocumentFonts: () => ['Calibri'], + resolveFamilies: calibriToCarlito, + getRequiredFaces: () => { + throw new Error('planner blew up'); + }, + requestReflow, + invalidateCaches, + getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), + timeoutMs: 1000, + }); + + const summary = await gate.ensureReadyForMeasure(); + + // The face path bailed before awaiting any face, and the gate degraded to the family + // path - which still awaits the resolved physical family (Calibri -> Carlito) rather + // than skipping load and letting fallback metrics reach measurement. + expect(registry.faceAwaitCalls).toEqual([]); + expect(registry.awaitCalls).toEqual([['Carlito']]); + expect(summary.loaded).toBe(1); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index f5a60040e2..bc988a63f0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -171,7 +171,11 @@ export class FontReadinessGate { try { required = getRequiredFaces(); } catch { - return this.#lastSummary ?? emptySummary(); + // Face planning is pure traversal, so a throw here is a bug - but if it ever does, + // degrade to the family path (which still awaits the resolved physical families, + // e.g. Calibri -> Carlito) rather than skipping load and letting fallback metrics + // reach measurement. + return this.#ensureFamiliesReady(); } const keyed = required.map((r) => ({ request: r, key: faceKeyOf(r.family, r.weight, r.style) })); From fcc8085b0942ff04657e0fc07826ad8959f0d5b6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 11:50:30 -0300 Subject: [PATCH 3/4] fix(fonts): plan all measured text sources and make the load summary per-family Follow-ups from review of the face-aware planner: - Paginated footnotes are measured via layoutOptions.footnotes (not blocksForLayout), so footnote-only faces were unplanned and reflowed on late load. Flatten the footnote blocks into the same planner input (semantic mode already folds them in). - A drop cap is measured from attrs.dropCapDescriptor.run with its own often-distinct font; collect that run in the planner. - A field annotation with no explicit font is measured against 'Arial' by the measurer; mirror that default in the planner instead of skipping the run. - FontLoadSummary is documented as per-family and rides the public fonts-changed payload, but summarizeFaces counted per face (3 Carlito faces -> loaded: 3). Collapse to per-family, taking each family's worst face status. - Assert the gate forwards its configured timeoutMs to the registry (test gap). --- .../presentation-editor/PresentationEditor.ts | 18 +++++++---- .../fonts/FontReadinessGate.test.ts | 31 ++++++++++++++++++- .../fonts/FontReadinessGate.ts | 28 +++++++++++++---- .../fonts/font-load-planner.test.ts | 25 +++++++++++++++ .../fonts/font-load-planner.ts | 14 ++++++++- 5 files changed, 102 insertions(+), 14 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 5db61b3d3a..55b2ebdd70 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -6720,12 +6720,18 @@ export class PresentationEditor extends EventEmitter { // Bounded by a per-font timeout; resolves to the cached summary once fonts are stable; // never throws, so font readiness can never block layout. try { - // Stash the blocks this render will measure so the gate's planner extracts the exact - // used faces: body + notes (blocksForLayout) plus the header/footer blocks. One - // planner input; planRequiredFontFaces dedups, so batch/by-rId overlap is harmless. - this.#fontPlanBlocks = headerFooterInput - ? [...blocksForLayout, ...this.#collectHeaderFooterFaceBlocks(headerFooterInput)] - : blocksForLayout; + // Stash every text source this render measures so the gate's planner awaits the exact + // used faces: body + notes (blocksForLayout), header/footer blocks, and - in paginated + // mode - footnote blocks (measured via layoutOptions.footnotes, NOT in blocksForLayout; + // semantic mode already folds footnotes into blocksForLayout). One planner input; + // planRequiredFontFaces dedups, so any overlap is harmless. + this.#fontPlanBlocks = [ + ...blocksForLayout, + ...(headerFooterInput ? this.#collectHeaderFooterFaceBlocks(headerFooterInput) : []), + ...(!isSemanticFlow && footnotesLayoutInput?.blocksById + ? [...footnotesLayoutInput.blocksById.values()].flat() + : []), + ]; const fontSummary = (await this.#fontGate?.ensureReadyForMeasure()) ?? null; // Now that the gate has settled, the font report reflects real load status. Emit // the authoritative `fonts-changed` once the picture first resolves and whenever it diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts index 235bc440f3..3a60db74ee 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts @@ -40,12 +40,17 @@ class FakeRegistry { // Face-level slice for the face path. readonly faceStatuses = new Map(); readonly faceAwaitCalls: string[][] = []; + faceAwaitOptions: { timeoutMs?: number } | undefined; getFaceStatus(request: FontFaceRequest): FontLoadStatus { return this.faceStatuses.get(faceKey(request)) ?? 'unloaded'; } - async awaitFaceRequests(requests: Iterable): Promise { + async awaitFaceRequests( + requests: Iterable, + options?: { timeoutMs?: number }, + ): Promise { const unique = [...requests]; this.faceAwaitCalls.push(unique.map(faceKey)); + this.faceAwaitOptions = options; return unique.map((request) => ({ request, status: this.getFaceStatus(request) })); } asRegistry(): FontRegistry { @@ -223,9 +228,33 @@ describe('FontReadinessGate', () => { const gate = makeFaceGate(() => [BOLD]); const summary = await gate.ensureReadyForMeasure(); expect(registry.faceAwaitCalls).toEqual([['carlito|700|normal']]); + // The gate forwards its configured per-font budget, not the registry default. + expect(registry.faceAwaitOptions).toEqual({ timeoutMs: 1000 }); expect(summary.loaded).toBe(1); }); + it('summarizes per family, not per face (counts distinct physical families)', async () => { + const REGULAR: FontFaceRequest = { family: 'Carlito', weight: '400', style: 'normal' }; + registry.faceStatuses.set(faceKey(REGULAR), 'loaded'); + registry.faceStatuses.set(faceKey(BOLD), 'loaded'); + const gate = makeFaceGate(() => [REGULAR, BOLD]); + const summary = await gate.ensureReadyForMeasure(); + // Two Carlito faces, one Carlito family on the public summary. + expect(summary.loaded).toBe(1); + expect(summary.results).toEqual([{ family: 'Carlito', status: 'loaded' }]); + }); + + it('rolls a family up to its worst face status (failed bold not masked by loaded regular)', async () => { + const REGULAR: FontFaceRequest = { family: 'Carlito', weight: '400', style: 'normal' }; + registry.faceStatuses.set(faceKey(REGULAR), 'loaded'); + registry.faceStatuses.set(faceKey(BOLD), 'failed'); + const gate = makeFaceGate(() => [REGULAR, BOLD]); + const summary = await gate.ensureReadyForMeasure(); + expect(summary.loaded).toBe(0); + expect(summary.failed).toBe(1); + expect(summary.results).toEqual([{ family: 'Carlito', status: 'failed' }]); + }); + it('reflows once when the required bold face loads after a timed-out first paint', async () => { registry.faceStatuses.set(faceKey(BOLD), 'timed_out'); const gate = makeFaceGate(() => [BOLD]); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index bc988a63f0..35412ab84c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -8,6 +8,7 @@ import { type FontFaceRequest, type FontFaceLoadResult, type FontLoadSummary, + type FontLoadStatus, type FontResolutionRecord, } from '@superdoc/font-system'; @@ -392,14 +393,29 @@ function summarize(results: FontLoadResult[]): FontLoadSummary { } /** Summarize face results (counts are per-FACE; `results` keeps the physical family name). */ +// Status precedence for rolling per-face outcomes up to a family: a settled failure must +// never be masked by a loaded sibling. Mirrors FontRegistry.getStatus's rollup order. +const FACE_STATUS_PRIORITY: FontLoadStatus[] = ['failed', 'timed_out', 'fallback_used', 'loaded', 'loading', 'unloaded']; + function summarizeFaces(results: FontFaceLoadResult[]): FontLoadSummary { + // FontLoadSummary's counts are documented as distinct physical FAMILIES and ride the + // public `fonts-changed` payload, but the face path awaits per face. Collapse faces to + // their family, taking the family's worst status, so a Calibri doc using regular+bold+ + // italic reports one Carlito family (not three faces). + const worstByFamily = new Map(); + for (const { request, status } of results) { + const prev = worstByFamily.get(request.family); + if (prev === undefined || FACE_STATUS_PRIORITY.indexOf(status) < FACE_STATUS_PRIORITY.indexOf(prev)) { + worstByFamily.set(request.family, status); + } + } const summary = emptySummary(); - summary.results = results.map((r) => ({ family: r.request.family, status: r.status })); - for (const result of results) { - if (result.status === 'loaded') summary.loaded += 1; - else if (result.status === 'failed') summary.failed += 1; - else if (result.status === 'timed_out') summary.timedOut += 1; - else if (result.status === 'fallback_used') summary.fallbackUsed += 1; + for (const [family, status] of worstByFamily) { + summary.results.push({ family, status }); + if (status === 'loaded') summary.loaded += 1; + else if (status === 'failed') summary.failed += 1; + else if (status === 'timed_out') summary.timedOut += 1; + else if (status === 'fallback_used') summary.fallbackUsed += 1; } return summary; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.test.ts index ad25da172d..7cc082e4e2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.test.ts @@ -99,6 +99,31 @@ describe('planRequiredFontFaces', () => { ); }); + it('collects the drop-cap descriptor run font (measured separately, distinct face)', () => { + // A paragraph with an Arial body and a Cambria(->Caladea) drop cap whose text lives in + // attrs.dropCapDescriptor.run, not in `runs`. + const block = { + kind: 'paragraph', + id: 'p', + runs: [text('Arial')], + attrs: { dropCapDescriptor: { run: { text: 'A', fontFamily: 'Cambria', fontSize: 117 }, lines: 3 } }, + } as unknown as FlowBlock; + expect(keyset(planRequiredFontFaces([block]))).toEqual( + new Set(['Liberation Sans|400|normal', 'Caladea|400|normal']), + ); + }); + + it('plans Arial for a field annotation with no explicit font (matches the measurer default)', () => { + // FieldAnnotationRun.fontFamily is optional; the measurer measures a fontless pill + // against 'Arial' (-> Liberation Sans), so the planner must await that face. + const block = { + kind: 'paragraph', + id: 'p', + runs: [{ kind: 'fieldAnnotation', text: 'x', fontSize: 12 }], + } as unknown as FlowBlock; + expect(keyset(planRequiredFontFaces([block]))).toEqual(new Set(['Liberation Sans|400|normal'])); + }); + it('ignores runs with no fontFamily and empty input', () => { expect(planRequiredFontFaces([])).toEqual([]); expect(planRequiredFontFaces(null)).toEqual([]); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts index 7ab41631d2..0b6162f203 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts @@ -48,7 +48,16 @@ function collectRuns(out: Map, runs: Run[] | undefined) if (!runs) return; // Duck-typed on fontFamily so every font-bearing run kind is covered (text, // fieldAnnotation, dropCap, ...) - missing one would silently measure against fallback. - for (const run of runs) collect(out, run as unknown as FontBearing); + for (const run of runs) { + const bearing = run as unknown as FontBearing; + // A field annotation with no explicit font is measured against 'Arial' by the measurer + // (its buildFontString default), so plan that face rather than skip the fontless run. + if (run.kind === 'fieldAnnotation' && (typeof bearing.fontFamily !== 'string' || !bearing.fontFamily)) { + collect(out, { ...bearing, fontFamily: 'Arial' }); + } else { + collect(out, bearing); + } + } } function collectParagraph(out: Map, paragraph: ParagraphBlock | undefined): void { @@ -58,6 +67,9 @@ function collectParagraph(out: Map, paragraph: Paragrap // (attrs.wordLayout.marker.run, used by the measurer's buildFontString), which can be a // different family/weight/style than the item text - so it must be planned too. collect(out, paragraph.attrs?.wordLayout?.marker?.run as FontBearing | undefined); + // A drop cap is measured from attrs.dropCapDescriptor.run (measureDropCap) with its own, + // often distinct and large, font; the cap text is moved out of `runs`, so plan it here. + collect(out, paragraph.attrs?.dropCapDescriptor?.run as FontBearing | undefined); } function collectTable(out: Map, table: TableBlock): void { From 5ecc8149b058bc8b5c13558649dd844a5d39226e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 11:59:44 -0300 Subject: [PATCH 4/4] docs(fonts): correct two comments stale after the planner-completeness fix - Drop the summarizeFaces line claiming counts are per-face; it now collapses to per-family (the function's own comment already documents this). - Generalize the planRequiredFontFaces docstring: the caller passes every measured block (body, notes, header/footer, paginated footnotes), so the old "appended to blocks" wording no longer describes the paginated-footnote path. --- .../v1/core/presentation-editor/fonts/FontReadinessGate.ts | 1 - .../v1/core/presentation-editor/fonts/font-load-planner.ts | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index 35412ab84c..8275eb2687 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -392,7 +392,6 @@ function summarize(results: FontLoadResult[]): FontLoadSummary { return summary; } -/** Summarize face results (counts are per-FACE; `results` keeps the physical family name). */ // Status precedence for rolling per-face outcomes up to a family: a settled failure must // never be masked by a loaded sibling. Mirrors FontRegistry.getStatus's rollup order. const FACE_STATUS_PRIORITY: FontLoadStatus[] = ['failed', 'timed_out', 'fallback_used', 'loaded', 'loading', 'unloaded']; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts index 0b6162f203..26189c5ec8 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts @@ -110,9 +110,10 @@ function collectBlock(out: Map, block: FlowBlock): void } /** - * The deduped physical face requests the given layout blocks actually render. Footnote and - * endnote blocks are included by passing them in `blocks` (the caller appends them to the - * layout block list before measurement, so they are ordinary paragraphs here). + * The deduped physical face requests the given layout blocks actually render. The caller + * passes every block this render measures - body, notes, header/footer, and (in paginated + * mode) footnotes - so each measured face is planned; this function only walks what it is + * given. */ export function planRequiredFontFaces(blocks: readonly FlowBlock[] | null | undefined): FontFaceRequest[] { const out = new Map();