@@ -429,6 +429,10 @@ const cachedParsedFont = new WeakMap<
429429export default class FontLoader {
430430 defaultFont : opentype . Font
431431 fonts = new Map < string , [ opentype . Font , Weight ?, FontStyle ?] [ ] > ( )
432+ // Shaping results are cached on the loader (which persists across renders
433+ // for the same font configuration) so repeated renders don't re-shape
434+ // identical text runs with HarfBuzz.
435+ private shapedRunCache = new Map < string , ShapedRun [ ] > ( )
432436 constructor ( fontOptions : FontOptions [ ] ) {
433437 this . addFonts ( fontOptions )
434438 }
@@ -472,6 +476,11 @@ export default class FontLoader {
472476 }
473477
474478 public addFonts ( fontOptions : FontOptions [ ] ) {
479+ // Adding fonts can change how text resolves across the font list, so
480+ // previously shaped runs may no longer be correct.
481+ if ( fontOptions . length && this . fonts . size ) {
482+ this . shapedRunCache . clear ( )
483+ }
475484 for ( const fontOption of fontOptions ) {
476485 const { name, data, lang } = fontOption
477486 if ( lang && ! isValidLocale ( lang ) ) {
@@ -701,11 +710,18 @@ export default class FontLoader {
701710 return resolveFont ( s , false )
702711 }
703712
704- // Text is shaped during both measurement and SVG generation. Keep the
705- // result on this render-local engine so the second pass can reuse it.
706- const shapedRunCache = new Map < string , ShapedRun [ ] > ( )
713+ // Text is shaped during both measurement and SVG generation, and the same
714+ // text is typically shaped again on subsequent renders. Cache the result
715+ // on the loader (keyed by the resolved font configuration) so both the
716+ // second pass of this render and future renders can reuse it.
717+ // Shaped glyphs are in font units, so the key only needs the inputs that
718+ // affect font resolution and shaping — not the font size.
719+ const shapedRunCache = this . shapedRunCache
720+ const engineKey = `${ fontFamily . join ( ',' ) } |${ fontWeight } |${ fontStyle } |${
721+ locale || ''
722+ } `
707723 const getShapedRuns : GetShapedRuns = ( content , fontFeatureSettings ) => {
708- const key = `${ fontFeatureSettings || '' } \0${ content } `
724+ const key = `${ engineKey } \0 ${ fontFeatureSettings || '' } \0${ content } `
709725 const cached = shapedRunCache . get ( key )
710726
711727 if ( cached !== undefined ) {
@@ -905,13 +921,14 @@ export default class FontLoader {
905921 fontFeatureSettings
906922 )
907923
908- const fullPath = new opentype . Path ( )
909924 const boxes : GlyphBox [ ] = [ ]
910925
911926 let cursorX = left
912927 const cursorY = top
913928 let hasRenderedGlyph = false
914929
930+ const fullPath = new opentype . Path ( )
931+
915932 // Process each font segment
916933 for ( const [ , font , glyphs ] of shapedRuns ) {
917934 const scale = fontSize / font . unitsPerEm
0 commit comments