11import type { H3Event } from 'h3'
22import type { FontConfig , OgImageRenderEventContext , RuntimeFontConfig } from '../../types'
3+ import type { FontFormat } from './font-source'
34import { resolve } from '#og-image-virtual/public-assets.mjs'
45import { fontRequirements , getComponentFontMap } from '#og-image/font-requirements'
56import resolvedFonts from '#og-image/fonts'
67import availableFonts from '#og-image/fonts-available'
78import { logger } from '../../logger'
89import { fontArrayCache , fontCache } from './cache/lru'
10+ import { fontFormat , selectFontSource } from './font-source'
911import { renameSubsetFonts } from './font-subsets'
1012import { codepointsIntersectRanges , parseUnicodeRange } from './unicode-range'
1113
1214export { buildSubsetFamilyChain , renameSubsetFonts , resolveSubsetChain } from './font-subsets'
1315export { codepointsIntersectRanges , extractCodepoints , parseUnicodeRange } from './unicode-range'
1416
15- type FontFormat = 'ttf' | 'otf' | 'woff' | 'woff2'
16-
17- function fontFormat ( src : string ) : FontFormat {
18- if ( src . endsWith ( '.woff2' ) )
19- return 'woff2'
20- if ( src . endsWith ( '.woff' ) )
21- return 'woff'
22- if ( src . endsWith ( '.otf' ) )
23- return 'otf'
24- return 'ttf'
25- }
26-
2717export interface LoadFontsOptions {
2818 /**
2919 * Font formats the renderer can parse.
@@ -37,6 +27,13 @@ export interface LoadFontsOptions {
3727 fontFamilyOverride ?: string
3828 /** Codepoints present in the template — fonts whose unicodeRange doesn't intersect are skipped */
3929 codepoints ?: Set < number >
30+ /**
31+ * Prefer the static satoriSrc (full TTF/WOFF from fontless) over the primary src
32+ * (subset WOFF2 from @nuxt/fonts) when both are usable. Takumi uses this so non-Latin
33+ * glyphs (devanagari, CJK, etc.) render — @nuxt/fonts CSS often only ships latin
34+ * subsets for a family, but the fontless static file contains the full glyph set.
35+ */
36+ preferStatic ?: boolean
4037}
4138
4239async function loadFont ( event : H3Event , font : FontConfig , src : string ) : Promise < BufferSource | null > {
@@ -151,47 +148,52 @@ export async function loadAllFonts(event: H3Event, options: LoadFontsOptions): P
151148 }
152149 }
153150
154- // Filter out font subsets whose unicodeRange doesn't intersect the template's codepoints
155- if ( options . codepoints && options . codepoints . size > 0 ) {
156- for ( let i = fonts . length - 1 ; i >= 0 ; i -- ) {
157- const f = fonts [ i ] !
158- if ( ! f . unicodeRange )
159- continue
160- const ranges = parseUnicodeRange ( f . unicodeRange )
161- if ( ! ranges )
162- continue
163- if ( ! codepointsIntersectRanges ( options . codepoints , ranges ) )
164- fonts . splice ( i , 1 )
165- }
151+ // Resolve the effective src per font up-front: primary src when supported, else satoriSrc.
152+ // Takumi opts into preferStatic so the full static TTF is used instead of the subset WOFF2 —
153+ // @nuxt /fonts CSS may only include latin subsets, and using the subset would hide non-latin
154+ // glyphs (devanagari, CJK) that the full static file covers.
155+ const resolved : Array < { font : FontConfig , src : string , isStaticFallback : boolean } > = [ ]
156+ for ( const f of fonts ) {
157+ const selection = selectFontSource ( f , options . supportedFormats , options . preferStatic ?? false )
158+ if ( selection )
159+ resolved . push ( { font : f , ...selection } )
166160 }
167161
168- const results = await Promise . all (
169- fonts . map ( async ( f ) => {
170- let src = f . src
171- const srcFormat = fontFormat ( f . src )
172-
173- // If the primary src format isn't supported, try satoriSrc as alternative
174- if ( ! options . supportedFormats . has ( srcFormat ) ) {
175- if ( f . satoriSrc && options . supportedFormats . has ( fontFormat ( f . satoriSrc ) ) ) {
176- src = f . satoriSrc
177- }
178- else {
179- // No usable format available, skip this font
180- return null
181- }
182- }
162+ // Filter out font subsets whose unicodeRange doesn't intersect the template's codepoints.
163+ // Skip entries using the full static fallback — its glyph coverage isn't described by the
164+ // parent @font -face unicodeRange (which only applied to the WOFF2 subset).
165+ const filtered = options . codepoints && options . codepoints . size > 0
166+ ? resolved . filter ( ( { font : f , isStaticFallback } ) => {
167+ if ( isStaticFallback || ! f . unicodeRange )
168+ return true
169+ const ranges = parseUnicodeRange ( f . unicodeRange )
170+ if ( ! ranges )
171+ return true
172+ return codepointsIntersectRanges ( options . codepoints ! , ranges )
173+ } )
174+ : resolved
183175
176+ const results = await Promise . all (
177+ filtered . map ( async ( { font : f , src : initialSrc , isStaticFallback } ) => {
178+ let src = initialSrc
184179 let data = await loadFont ( event , f , src )
185- // When satoriSrc fails to load (e.g. 404), try original src if its format is supported
186- if ( ! data && src !== f . src && options . supportedFormats . has ( srcFormat ) ) {
180+ // Fall back to primary src if the static alternative fails to load (e.g. 404)
181+ if ( ! data && src !== f . src && options . supportedFormats . has ( fontFormat ( f . src ) ) ) {
187182 data = await loadFont ( event , f , f . src )
188- if ( data )
183+ if ( data ) {
189184 src = f . src
185+ isStaticFallback = false
186+ }
190187 }
191188 if ( ! data )
192189 return null
190+ // Reflect the src actually loaded so downstream dedupe (takumi) keys on the real binary.
191+ // Drop unicodeRange when using the full static fallback — the subset range doesn't apply
192+ // to the static TTF, and takumi's subset-chain logic would otherwise split the family.
193193 return {
194194 ...f ,
195+ src,
196+ ...( isStaticFallback ? { unicodeRange : undefined } : { } ) ,
195197 cacheKey : `${ f . family } -${ f . weight } -${ f . style } -${ src } ` ,
196198 data,
197199 } satisfies RuntimeFontConfig
0 commit comments