Skip to content

Commit 89f3651

Browse files
committed
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.
1 parent 9554c1a commit 89f3651

9 files changed

Lines changed: 707 additions & 21 deletions

File tree

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ import { measureBlock } from '@superdoc/measuring-dom';
180180
import { resolvePhysicalFamilies, type FontResolutionRecord, type FontLoadSummary } from '@superdoc/font-system';
181181
import { installBundledSubstitutes } from '@superdoc/font-system/bundled';
182182
import { FontReadinessGate } from './fonts/FontReadinessGate';
183+
import { planRequiredFontFaces } from './fonts/font-load-planner';
183184
import type { FontsChangedPayload } from '../types/EditorEvents';
184185
import type {
185186
ColumnLayout,
@@ -530,6 +531,8 @@ export class PresentationEditor extends EventEmitter {
530531
#selectionSync = new SelectionSyncCoordinator();
531532
/** Load-before-measure gate: awaits required fonts before measurement, reflows on late load. */
532533
#fontGate: FontReadinessGate | null = null;
534+
/** Layout blocks for the current render, stashed so the gate's planner reads the live set. */
535+
#fontPlanBlocks: FlowBlock[] | null = null;
533536
/** Dedup key for `fonts-changed`: epoch + per-face load status. Null until the first emit. */
534537
#lastFontsChangedKey: string | null = null;
535538
/** Last emitted `fonts-changed` payload, so a late relay subscriber can replay it. */
@@ -959,8 +962,13 @@ export class PresentationEditor extends EventEmitter {
959962
this.#pendingDocChange = true;
960963
this.#scheduleRerender();
961964
},
962-
// Wait on the resolved PHYSICAL families (Calibri -> Carlito), so the gate holds
963-
// measurement until the substitute that measure + paint will use has loaded.
965+
// Face-aware required set: the exact physical faces (family + weight + style) the
966+
// rendered document uses, from the planner walking the current layout blocks. The
967+
// gate awaits these - so bold/italic load before measure and declared-but-unused
968+
// fonts are not fetched. Reads the blocks stashed just before each gate await.
969+
getRequiredFaces: () => planRequiredFontFaces(this.#fontPlanBlocks),
970+
// Fallback family path (used only if getRequiredFaces is unavailable): wait on the
971+
// resolved PHYSICAL families (Calibri -> Carlito).
964972
resolveFamilies: resolvePhysicalFamilies,
965973
// Register the bundled substitute pack (Carlito) into the document's registry the
966974
// first time it resolves, so the substitute is available with no manual setup.
@@ -6707,6 +6715,9 @@ export class PresentationEditor extends EventEmitter {
67076715
// Bounded by a per-font timeout; resolves to the cached summary once fonts are stable;
67086716
// never throws, so font readiness can never block layout.
67096717
try {
6718+
// Stash the blocks this render will measure so the gate's planner extracts the
6719+
// exact used faces (footnote/endnote blocks are already part of blocksForLayout).
6720+
this.#fontPlanBlocks = blocksForLayout;
67106721
const fontSummary = (await this.#fontGate?.ensureReadyForMeasure()) ?? null;
67116722
// Now that the gate has settled, the font report reflects real load status. Emit
67126723
// the authoritative `fonts-changed` once the picture first resolves and whenever it

packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
2-
import type { FontLoadResult, FontLoadStatus, FontRegistry } from '@superdoc/font-system';
2+
import type {
3+
FontFaceLoadResult,
4+
FontFaceRequest,
5+
FontLoadResult,
6+
FontLoadStatus,
7+
FontRegistry,
8+
} from '@superdoc/font-system';
39
import { FontReadinessGate, type FontEnvironment } from './FontReadinessGate';
410

11+
const faceKey = (r: FontFaceRequest) => `${r.family.toLowerCase()}|${r.weight}|${r.style}`;
12+
513
/** Minimal FontFace constructor stand-in for the environment (unused when a registry is injected). */
614
class FakeFontFace {
715
constructor(public readonly family: string) {}
@@ -28,6 +36,18 @@ class FakeRegistry {
2836
this.awaitCalls.push(unique);
2937
return unique.map((family) => ({ family, status: this.getStatus(family) }));
3038
}
39+
40+
// Face-level slice for the face path.
41+
readonly faceStatuses = new Map<string, FontLoadStatus>();
42+
readonly faceAwaitCalls: string[][] = [];
43+
getFaceStatus(request: FontFaceRequest): FontLoadStatus {
44+
return this.faceStatuses.get(faceKey(request)) ?? 'unloaded';
45+
}
46+
async awaitFaceRequests(requests: Iterable<FontFaceRequest>): Promise<FontFaceLoadResult[]> {
47+
const unique = [...requests];
48+
this.faceAwaitCalls.push(unique.map(faceKey));
49+
return unique.map((request) => ({ request, status: this.getFaceStatus(request) }));
50+
}
3151
asRegistry(): FontRegistry {
3252
return this as unknown as FontRegistry;
3353
}
@@ -182,4 +202,49 @@ describe('FontReadinessGate', () => {
182202

183203
await expect(gate.ensureReadyForMeasure()).resolves.toMatchObject({ loaded: 0 });
184204
});
205+
206+
describe('face-aware path (getRequiredFaces)', () => {
207+
const BOLD: FontFaceRequest = { family: 'Carlito', weight: '700', style: 'normal' };
208+
209+
function makeFaceGate(getRequiredFaces: () => FontFaceRequest[]) {
210+
return new FontReadinessGate({
211+
registry: registry.asRegistry(),
212+
getDocumentFonts: () => [],
213+
getRequiredFaces,
214+
requestReflow,
215+
invalidateCaches,
216+
getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }),
217+
timeoutMs: 1000,
218+
});
219+
}
220+
221+
it('awaits the exact required faces (family + weight + style), not families', async () => {
222+
registry.faceStatuses.set(faceKey(BOLD), 'loaded');
223+
const gate = makeFaceGate(() => [BOLD]);
224+
const summary = await gate.ensureReadyForMeasure();
225+
expect(registry.faceAwaitCalls).toEqual([['carlito|700|normal']]);
226+
expect(summary.loaded).toBe(1);
227+
});
228+
229+
it('reflows once when the required bold face loads after a timed-out first paint', async () => {
230+
registry.faceStatuses.set(faceKey(BOLD), 'timed_out');
231+
const gate = makeFaceGate(() => [BOLD]);
232+
await gate.ensureReadyForMeasure();
233+
expect(requestReflow).not.toHaveBeenCalled();
234+
235+
// A REGULAR Carlito face finishing must NOT reflow - it is not a required face.
236+
fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'normal', style: 'normal' }] });
237+
expect(requestReflow).not.toHaveBeenCalled();
238+
239+
// The required BOLD face finishing DOES reflow, exactly once.
240+
registry.faceStatuses.set(faceKey(BOLD), 'loaded');
241+
fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'bold', style: 'normal' }] });
242+
expect(requestReflow).toHaveBeenCalledTimes(1);
243+
expect(invalidateCaches).toHaveBeenCalledTimes(1);
244+
245+
// A second loadingdone for the same face does not reflow again (no loop).
246+
fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'bold', style: 'normal' }] });
247+
expect(requestReflow).toHaveBeenCalledTimes(1);
248+
});
249+
});
185250
});

packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts

Lines changed: 133 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
DEFAULT_FONT_LOAD_TIMEOUT_MS,
66
type FontRegistry,
77
type FontLoadResult,
8+
type FontFaceRequest,
9+
type FontFaceLoadResult,
810
type FontLoadSummary,
911
type FontResolutionRecord,
1012
} from '@superdoc/font-system';
@@ -26,8 +28,16 @@ export interface FontEnvironment {
2628
}
2729

2830
export interface FontReadinessGateOptions {
29-
/** Logical font families the current document uses. Cheap to call per render. */
31+
/** Logical font families the current document DECLARES (fontTable). Used for diagnostics. */
3032
getDocumentFonts: () => string[];
33+
/**
34+
* The exact physical FACES (family + weight + style) the current document RENDERS, from
35+
* the planner walking layout input. When provided, the gate awaits these faces instead of
36+
* declared families - so bold/italic load before measure and declared-but-unused fonts are
37+
* not fetched. Falls back to the {@link getDocumentFonts} + {@link resolveFamilies} family
38+
* path when omitted (tests / non-layout callers).
39+
*/
40+
getRequiredFaces?: () => FontFaceRequest[];
3141
/** Trigger a re-measure + re-layout + repaint (PresentationEditor's immediate render). */
3242
requestReflow: () => void;
3343
/**
@@ -76,6 +86,7 @@ export interface FontReadinessGateOptions {
7686
*/
7787
export class FontReadinessGate {
7888
readonly #getDocumentFonts: () => string[];
89+
readonly #getRequiredFaces: (() => FontFaceRequest[]) | null;
7990
readonly #resolveFamilies: (families: string[]) => string[];
8091
readonly #requestReflow: () => void;
8192
readonly #getFontEnvironment: () => FontEnvironment | null;
@@ -90,13 +101,18 @@ export class FontReadinessGate {
90101
#fontConfigVersion = 0;
91102
#requiredSignature = '';
92103
#requiredFamilies = new Set<string>();
93-
/** Families observed available, so the late-load handler fires at most once per face. */
104+
/** Required face keys (family|weight|style) for the face path's late-load matching. */
105+
#requiredFaceKeys = new Set<string>();
106+
/** Families observed available, so the family-path late-load handler fires once per face. */
94107
readonly #seenAvailable = new Set<string>();
108+
/** Face keys observed available, so the face-path late-load handler fires once per face. */
109+
readonly #seenAvailableFaces = new Set<string>();
95110
#lastSummary: FontLoadSummary | null = null;
96111
#loadingDoneHandler: ((event: FontFaceSetLoadEvent) => void) | null = null;
97112

98113
constructor(options: FontReadinessGateOptions) {
99114
this.#getDocumentFonts = options.getDocumentFonts;
115+
this.#getRequiredFaces = options.getRequiredFaces ?? null;
100116
this.#resolveFamilies = options.resolveFamilies ?? ((families) => families);
101117
this.#requestReflow = options.requestReflow;
102118
this.#getFontEnvironment = options.getFontEnvironment ?? defaultFontEnvironment;
@@ -143,6 +159,55 @@ export class FontReadinessGate {
143159
* must not break layout.
144160
*/
145161
async ensureReadyForMeasure(): Promise<FontLoadSummary> {
162+
if (this.#getRequiredFaces) return this.#ensureFacesReady(this.#getRequiredFaces);
163+
return this.#ensureFamiliesReady();
164+
}
165+
166+
/** Face-aware path: await the exact physical faces the rendered document uses. */
167+
async #ensureFacesReady(getRequiredFaces: () => FontFaceRequest[]): Promise<FontLoadSummary> {
168+
const registry = this.#resolveContext().registry;
169+
170+
let required: FontFaceRequest[];
171+
try {
172+
required = getRequiredFaces();
173+
} catch {
174+
return this.#lastSummary ?? emptySummary();
175+
}
176+
177+
const keyed = required.map((r) => ({ request: r, key: faceKeyOf(r.family, r.weight, r.style) }));
178+
const signature = keyed
179+
.map((k) => k.key)
180+
.sort()
181+
.join('|');
182+
const unchangedAndLoaded =
183+
signature === this.#requiredSignature && keyed.every((k) => registry.getFaceStatus(k.request) === 'loaded');
184+
if (unchangedAndLoaded && this.#lastSummary) {
185+
return this.#lastSummary;
186+
}
187+
188+
this.#requiredSignature = signature;
189+
this.#requiredFaceKeys = new Set(keyed.map((k) => k.key));
190+
this.#requiredFamilies = new Set();
191+
this.#ensureSubscribed();
192+
193+
let results: FontFaceLoadResult[] = [];
194+
try {
195+
results = required.length ? await registry.awaitFaceRequests(required, { timeoutMs: this.#timeoutMs }) : [];
196+
} catch {
197+
results = [];
198+
}
199+
200+
for (const result of results) {
201+
if (result.status === 'loaded') {
202+
this.#seenAvailableFaces.add(faceKeyOf(result.request.family, result.request.weight, result.request.style));
203+
}
204+
}
205+
this.#lastSummary = summarizeFaces(results);
206+
return this.#lastSummary;
207+
}
208+
209+
/** Legacy family path: await declared families (tests / non-layout callers). */
210+
async #ensureFamiliesReady(): Promise<FontLoadSummary> {
146211
const registry = this.#resolveContext().registry;
147212

148213
let required: string[];
@@ -161,6 +226,7 @@ export class FontReadinessGate {
161226

162227
this.#requiredSignature = signature;
163228
this.#requiredFamilies = new Set(required);
229+
this.#requiredFaceKeys = new Set();
164230
this.#ensureSubscribed();
165231

166232
let results: FontLoadResult[] = [];
@@ -185,6 +251,7 @@ export class FontReadinessGate {
185251
this.#fontConfigVersion += 1;
186252
bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust
187253
this.#seenAvailable.clear();
254+
this.#seenAvailableFaces.clear();
188255
this.#requiredSignature = '';
189256
this.#invalidateCaches();
190257
this.#requestReflow();
@@ -233,20 +300,40 @@ export class FontReadinessGate {
233300
}
234301

235302
#onLoadingDone(event: FontFaceSetLoadEvent): void {
236-
// A required face that the last measure could not use just finished loading -> that
237-
// paint used a fallback, so invalidate and reflow. We key off the faces the event
303+
// A required face/family that the last measure could not use just finished loading ->
304+
// that paint used a fallback, so invalidate and reflow. We key off the faces the event
238305
// actually reports as loaded (reliable), NOT FontFaceSet.check() (which lies for
239-
// unregistered bare families). The seen-set fires this once per face; never a loop.
240-
const loadedKeys = new Set((event?.fontfaces ?? []).map((face) => normalizeFamilyKey(face.family)));
241-
if (loadedKeys.size === 0) return;
306+
// unregistered bare families). The seen-set fires this at most once per face.
307+
const faces = event?.fontfaces ?? [];
308+
if (faces.length === 0) return;
242309
let changed = false;
243-
for (const family of this.#requiredFamilies) {
244-
if (this.#seenAvailable.has(family)) continue;
245-
if (loadedKeys.has(normalizeFamilyKey(family))) {
246-
this.#seenAvailable.add(family);
247-
changed = true;
310+
311+
if (this.#requiredFaceKeys.size > 0) {
312+
// Face path: reflow only when a loaded face matches a REQUIRED face key (family +
313+
// weight + style). "Liberation Sans bold loaded and it was required" - not merely
314+
// "Liberation Sans (regular) loaded".
315+
const loadedFaceKeys = new Set(
316+
faces.map((face) => faceKeyOf(face.family, normalizeWeightToken(face.weight), normalizeStyleToken(face.style))),
317+
);
318+
for (const key of this.#requiredFaceKeys) {
319+
if (this.#seenAvailableFaces.has(key)) continue;
320+
if (loadedFaceKeys.has(key)) {
321+
this.#seenAvailableFaces.add(key);
322+
changed = true;
323+
}
324+
}
325+
} else {
326+
// Legacy family path.
327+
const loadedFamilies = new Set(faces.map((face) => normalizeFamilyKey(face.family)));
328+
for (const family of this.#requiredFamilies) {
329+
if (this.#seenAvailable.has(family)) continue;
330+
if (loadedFamilies.has(normalizeFamilyKey(family))) {
331+
this.#seenAvailable.add(family);
332+
changed = true;
333+
}
248334
}
249335
}
336+
250337
if (!changed) return;
251338
this.#fontConfigVersion += 1;
252339
bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust
@@ -263,6 +350,27 @@ function normalizeFamilyKey(family: string): string {
263350
.toLowerCase();
264351
}
265352

353+
/** Canonical weight token for face matching: bold/>=600 -> '700', else '400'. */
354+
function normalizeWeightToken(weight: string | undefined): '400' | '700' {
355+
if (!weight) return '400';
356+
const w = weight.trim().toLowerCase();
357+
if (w === 'bold' || w === 'bolder') return '700';
358+
const n = Number(w);
359+
return Number.isFinite(n) && n >= 600 ? '700' : '400';
360+
}
361+
362+
/** Canonical style token for face matching: italic/oblique -> 'italic', else 'normal'. */
363+
function normalizeStyleToken(style: string | undefined): 'normal' | 'italic' {
364+
if (!style) return 'normal';
365+
const s = style.trim().toLowerCase();
366+
return s.startsWith('italic') || s.startsWith('oblique') ? 'italic' : 'normal';
367+
}
368+
369+
/** Face key matching the registry's: normalized family + weight + style. */
370+
function faceKeyOf(family: string, weight: '400' | '700', style: 'normal' | 'italic'): string {
371+
return `${normalizeFamilyKey(family)}|${weight}|${style}`;
372+
}
373+
266374
/** The font-system registry accepts a structural font set + face ctor; the DOM types satisfy them. */
267375
type FontSetLikeArg = Parameters<typeof getFontRegistryFor>[0];
268376
type FontFaceCtorArg = Parameters<typeof getFontRegistryFor>[1];
@@ -279,6 +387,19 @@ function summarize(results: FontLoadResult[]): FontLoadSummary {
279387
return summary;
280388
}
281389

390+
/** Summarize face results (counts are per-FACE; `results` keeps the physical family name). */
391+
function summarizeFaces(results: FontFaceLoadResult[]): FontLoadSummary {
392+
const summary = emptySummary();
393+
summary.results = results.map((r) => ({ family: r.request.family, status: r.status }));
394+
for (const result of results) {
395+
if (result.status === 'loaded') summary.loaded += 1;
396+
else if (result.status === 'failed') summary.failed += 1;
397+
else if (result.status === 'timed_out') summary.timedOut += 1;
398+
else if (result.status === 'fallback_used') summary.fallbackUsed += 1;
399+
}
400+
return summary;
401+
}
402+
282403
function emptySummary(): FontLoadSummary {
283404
return { loaded: 0, failed: 0, timedOut: 0, fallbackUsed: 0, results: [] };
284405
}

0 commit comments

Comments
 (0)