@@ -40,6 +40,9 @@ export interface FontFace {
4040 style : FontStyle ;
4141 /** 1 (ultra-condensed) - 9 (ultra-expanded), 5 is normal */
4242 stretch : number ;
43+ /** a variable font's ranges: of weights and stretches (its wght and wdth axes), whether it has italics (ital) and
44+ * obliques (negative slnt); null for other fonts */
45+ ranges : { weight : [ number , number ] | null , stretch : [ number , number ] | null , italic : boolean , oblique : boolean } | null ;
4346 /** the generic families it fits (serif, sans-serif, monospace, cursive, fantasy) */
4447 generic : Set < string > ;
4548 /** metrics in font units */
@@ -122,8 +125,20 @@ export function fontFace(data: Uint8Array, descriptors?: { family?: string, weig
122125 else if ( ( familyType === 2 && serifStyle >= 2 && serifStyle <= 10 ) || ( familyClass >= 1 && familyClass <= 7 && familyClass !== 6 ) ) generic . add ( 'serif' ) ;
123126 else if ( ( familyType === 2 && serifStyle >= 11 && serifStyle <= 15 ) || familyClass === 8 ) generic . add ( 'sans-serif' ) ;
124127
128+ // (a variable font's: by its axes)
129+ const axis = ( tag : string ) => font . axes . find ( a => a . tag === tag ) ;
130+ const wght = axis ( 'wght' ) , wdth = axis ( 'wdth' ) , ital = axis ( 'ital' ) , slnt = axis ( 'slnt' ) ;
131+ const classes = ( min : number , max : number ) : [ number , number ] | null => {
132+ const lo = STRETCH_PERCENT . findIndex ( p => p >= min ) , hi = STRETCH_PERCENT . filter ( p => p <= max ) . length - 1 ;
133+ return lo >= 0 && hi >= lo ? [ lo + 1 , hi + 1 ] : null ;
134+ } ;
135+ const ranges = font . axes . length ? {
136+ weight : wght ? [ wght . min , wght . max ] as [ number , number ] : null , stretch : wdth ? classes ( wdth . min , wdth . max ) : null ,
137+ italic : ! ! ital && ital . max >= 1 , oblique : ! ! slnt && slnt . min < 0 ,
138+ } : null ;
139+
125140 const face : FontFace = {
126- font, families : families . map ( name => name . toLowerCase ( ) ) , weight, style, stretch, generic,
141+ font, families : families . map ( name => name . toLowerCase ( ) ) , weight, style, stretch, ranges , generic,
127142 ascent, descent, xHeight,
128143 underlinePosition : post >= 0 ? font . i16 ( post + 8 ) : - Math . trunc ( units / 9 ) ,
129144 underlineThickness,
@@ -141,12 +156,26 @@ export interface FontQuery {
141156 weight : number ;
142157 style : FontStyle ;
143158 stretch : number ;
159+ /** font-variation-settings: axis values by tag */
160+ variations : Record < string , number > ;
161+ /** font-optical-sizing: whether a variable font's optical size is the font size (auto) */
162+ opticalSizing : boolean ;
144163}
145164
165+ // the percentages of the stretches 1-9 (CSS font-stretch keywords)
166+ const STRETCH_PERCENT = [ 50 , 62.5 , 75 , 87.5 , 100 , 112.5 , 125 , 150 , 200 ] ;
167+
146168const GENERIC = new Set ( [ 'serif' , 'sans-serif' , 'monospace' , 'cursive' , 'fantasy' ] ) ;
147169
148- // the face of a family that matches the query best (CSS Fonts' font matching: stretch, then style, then weight)
149- function bestMatch ( candidates : FontFace [ ] , query : FontQuery ) : FontFace {
170+ // the face of a family that matches the query best (CSS Fonts' font matching: stretch, then style, then weight; a
171+ // variable font's ranges match what they include, else by their nearest end)
172+ function bestMatch ( faces : FontFace [ ] , query : FontQuery ) : FontFace {
173+ const clamp = ( v : number , range : [ number , number ] | null | undefined , own : number ) : number => range ? Math . min ( Math . max ( v , range [ 0 ] ) , range [ 1 ] ) : own ;
174+ const candidates = faces . map ( f => ! f . ranges ? f : {
175+ ...f , face : f , weight : clamp ( query . weight , f . ranges . weight , f . weight ) , stretch : clamp ( query . stretch , f . ranges . stretch , f . stretch ) ,
176+ style : query . style === 'italic' && f . ranges . italic ? 'italic' : query . style === 'oblique' && f . ranges . oblique ? 'oblique' : f . style ,
177+ } ) ;
178+ const original = ( f : FontFace ) : FontFace => ( f as FontFace & { face ?: FontFace } ) . face ?? f ;
150179 const pick = ( values : number [ ] , wanted : number , below : boolean ) : number => {
151180 if ( values . includes ( wanted ) ) return wanted ;
152181 const lower = values . filter ( v => v < wanted ) . sort ( ( a , b ) => b - a ) , higher = values . filter ( v => v > wanted ) . sort ( ( a , b ) => a - b ) ;
@@ -166,7 +195,32 @@ function bestMatch(candidates: FontFace[], query: FontQuery): FontFace {
166195 const up = weights . filter ( v => v > w && v <= 500 ) . sort ( ( a , b ) => a - b ) [ 0 ] ;
167196 weight = up ?? pick ( weights . filter ( v => v < w || v > 500 ) , w , true ) ;
168197 } else weight = pick ( weights , w , w < 400 ) ;
169- return list . find ( f => f . weight === weight ) as FontFace ;
198+ return original ( list . find ( f => f . weight === weight ) as FontFace ) ;
199+ }
200+
201+ const variedFaces = new WeakMap < FontFace , Map < string , FontFace > > ( ) ;
202+
203+ /**
204+ * A face at a span's instance of its variable font (like usvg's): wght by the weight, wdth by the stretch, ital for
205+ * italic, slnt -12 for oblique, opsz by the font size (with optical sizing), then font-variation-settings; the face
206+ * itself for other fonts
207+ */
208+ export function variedFace ( face : FontFace , query : FontQuery , fontSize : number ) : FontFace {
209+ if ( ! face . font . axes . length ) return face ;
210+ const values : Record < string , number > = { wght : query . weight } ;
211+ if ( query . stretch !== 5 ) values . wdth = STRETCH_PERCENT [ query . stretch - 1 ] ;
212+ if ( query . style === 'italic' ) values . ital = 1 ;
213+ if ( query . style === 'oblique' ) values . slnt = - 12 ;
214+ if ( query . opticalSizing ) values . opsz = fontSize ;
215+ const font = face . font . withVariations ( { ...values , ...query . variations } ) ;
216+ if ( font === face . font ) return face ;
217+ let byCoords = variedFaces . get ( face ) ;
218+ if ( ! byCoords ) variedFaces . set ( face , byCoords = new Map < string , FontFace > ( ) ) ;
219+ const key = font . coords . join ( ',' ) ;
220+ let varied = byCoords . get ( key ) ;
221+ // (the ascent and descent moved like the font's, by MVAR)
222+ if ( ! varied ) byCoords . set ( key , varied = { ...face , font, ascent : face . ascent + font . ascender - face . font . ascender , descent : face . descent + font . descender - face . font . descender } ) ;
223+ return varied ;
170224}
171225
172226/**
@@ -256,6 +310,20 @@ function colorKind(face: FontFace, glyph: number, fontSize: number): ColorKind |
256310 return null ;
257311}
258312
313+ /**
314+ * font-variation-settings: normal, or quoted 4-character tags with a number each (comma separated; invalid ones are
315+ * left out, the last of a tag counts)
316+ */
317+ export function parseVariationSettings ( value : string ) : Record < string , number > {
318+ const out : Record < string , number > = { } ;
319+ if ( value . trim ( ) . toLowerCase ( ) === 'normal' ) return out ;
320+ for ( const part of value . split ( ',' ) ) {
321+ const m = / ^ \s * ( [ " ' ] ) ( .{ 4 } ) \1\s + ( [ - + ] ? (?: \d + \. ? \d * | \. \d + ) (?: e [ - + ] ? \d + ) ? ) \s * $ / i. exec ( part ) ;
322+ if ( m ) out [ m [ 2 ] ] = Number ( m [ 3 ] ) ;
323+ }
324+ return out ;
325+ }
326+
259327/** the generic and named families of a font-family value, lowercase */
260328export function parseFontFamilies ( value : string ) : string [ ] {
261329 const families : string [ ] = [ ] ;
@@ -418,7 +486,10 @@ function shapeChunk<S>(chunk: ChunkInput<S>, fonts: FontFace[], primary: Map<Spa
418486 if ( base ) {
419487 const grapheme = cps . slice ( start , end ) ;
420488 let face : FontFace = base ;
421- if ( ! grapheme . every ( c => isDefaultIgnorableCodepoint ( c ) || base . font . glyphIndex ( c ) ) ) face = fallbackFont ( fonts , base , grapheme , new Set ( [ base ] ) ) ?? base ;
489+ if ( ! grapheme . every ( c => isDefaultIgnorableCodepoint ( c ) || base . font . glyphIndex ( c ) ) ) {
490+ const other = fallbackFont ( fonts , base , grapheme , new Set ( [ base ] ) ) ;
491+ face = other && span ? variedFace ( other , span . font , span . fontSize ) : base ;
492+ }
422493 faceOf . fill ( face , start , end ) ;
423494 if ( face !== base ) fallback . fill ( face , start , end ) ;
424495 }
@@ -902,7 +973,7 @@ export function layOutText<S>(text: TextInput<S>, fonts: FontFace[]): TextOutput
902973 const primary = new Map < SpanInput < S > , FontFace > ( ) ;
903974 for ( const chunk of text . chunks ) for ( const span of chunk . spans ) {
904975 const face = matchFont ( fonts , span . font ) ;
905- if ( face ) primary . set ( span , face ) ;
976+ if ( face ) primary . set ( span , variedFace ( face , span . font , span . fontSize ) ) ;
906977 }
907978
908979 const spans : SpanOutput < S > [ ] = [ ] ;
0 commit comments