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
2830export 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 */
7787export 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. */
267375type FontSetLikeArg = Parameters < typeof getFontRegistryFor > [ 0 ] ;
268376type 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+
282403function emptySummary ( ) : FontLoadSummary {
283404 return { loaded : 0 , failed : 0 , timedOut : 0 , fallbackUsed : 0 , results : [ ] } ;
284405}
0 commit comments