Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -6702,11 +6710,28 @@ 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 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
Expand All @@ -6716,7 +6741,6 @@ export class PresentationEditor extends EventEmitter {
/* font readiness must never break layout */
}

const headerFooterInput = this.#buildHeaderFooterInput();
try {
const incrementalLayoutStart = perfNow();
const result = await incrementalLayout(
Expand Down Expand Up @@ -7889,6 +7913,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<Record<string, FlowBlock[]>>;
footerBlocks?: Partial<Record<string, FlowBlock[]>>;
headerBlocksByRId?: Map<string, FlowBlock[]>;
footerBlocksByRId?: Map<string, FlowBlock[]>;
}): 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
Expand All @@ -28,6 +36,23 @@ 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<string, FontLoadStatus>();
readonly faceAwaitCalls: string[][] = [];
faceAwaitOptions: { timeoutMs?: number } | undefined;
getFaceStatus(request: FontFaceRequest): FontLoadStatus {
return this.faceStatuses.get(faceKey(request)) ?? 'unloaded';
}
async awaitFaceRequests(
requests: Iterable<FontFaceRequest>,
options?: { timeoutMs?: number },
): Promise<FontFaceLoadResult[]> {
const unique = [...requests];
this.faceAwaitCalls.push(unique.map(faceKey));
this.faceAwaitOptions = options;
return unique.map((request) => ({ request, status: this.getFaceStatus(request) }));
}
asRegistry(): FontRegistry {
return this as unknown as FontRegistry;
}
Expand Down Expand Up @@ -182,4 +207,98 @@ 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']]);
// 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]);
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);
});

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);
});
});
});
Loading
Loading