44import opentype from '@shuding/opentype.js'
55import { inflateSync } from 'fflate'
66import { Locale , locales , isValidLocale } from './language.js'
7- import { shapeText , parseFontFeatureSettings } from './harfbuzz.js'
7+ import {
8+ shapeText ,
9+ parseFontFeatureSettings ,
10+ type ShapedGlyph ,
11+ } from './harfbuzz.js'
812import { segment } from './utils.js'
913
14+ const MAX_SHAPED_RUN_CACHE_ENTRIES = 256
15+
1016/**
1117 * Check if a character is whitespace (space, tab, etc.)
1218 */
@@ -172,6 +178,12 @@ export type FontEngine = {
172178 ) => { path : string ; boxes : GlyphBox [ ] }
173179}
174180
181+ type ShapedRun = [ text : string , font : opentype . Font , glyphs : ShapedGlyph [ ] ]
182+ type GetShapedRuns = (
183+ content : string ,
184+ fontFeatureSettings ?: string
185+ ) => ShapedRun [ ]
186+
175187type BandPoint = [ number , number ]
176188
177189type LineSegment = {
@@ -689,6 +701,38 @@ export default class FontLoader {
689701 return resolveFont ( s , false )
690702 }
691703
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 [ ] > ( )
707+ const getShapedRuns : GetShapedRuns = ( content , fontFeatureSettings ) => {
708+ const key = `${ fontFeatureSettings || '' } \0${ content } `
709+ const cached = shapedRunCache . get ( key )
710+
711+ if ( cached !== undefined ) {
712+ shapedRunCache . delete ( key )
713+ shapedRunCache . set ( key , cached )
714+ return cached
715+ }
716+
717+ const features = fontFeatureSettings
718+ ? parseFontFeatureSettings ( fontFeatureSettings )
719+ : { }
720+ const shapedRuns = splitByFont ( content , resolveFont ) . map (
721+ ( [ text , font ] ) : ShapedRun => [
722+ text ,
723+ font ,
724+ shapeText ( font , text , { features } ) ,
725+ ]
726+ )
727+
728+ if ( shapedRunCache . size >= MAX_SHAPED_RUN_CACHE_ENTRIES ) {
729+ const oldestKey = shapedRunCache . keys ( ) . next ( ) . value
730+ if ( oldestKey !== undefined ) shapedRunCache . delete ( oldestKey )
731+ }
732+ shapedRunCache . set ( key , shapedRuns )
733+ return shapedRuns
734+ }
735+
692736 const engine = {
693737 has : ( s : string ) => {
694738 if ( s === '\n' ) return true
@@ -726,7 +770,7 @@ export default class FontLoader {
726770 letterSpacing : number
727771 }
728772 ) => {
729- return this . measure ( resolveFont , s , style )
773+ return this . measure ( s , style , getShapedRuns )
730774 } ,
731775 getSVG : (
732776 s : string ,
@@ -738,7 +782,7 @@ export default class FontLoader {
738782 } ,
739783 band ?: SkipInkBand
740784 ) => {
741- return this . getSVG ( resolveFont , s , style , band )
785+ return this . getSVG ( s , style , getShapedRuns , band )
742786 } ,
743787 }
744788
@@ -803,7 +847,6 @@ export default class FontLoader {
803847 }
804848
805849 private measure (
806- resolveFont : ( word : string , fallback ?: boolean ) => opentype . Font ,
807850 content : string ,
808851 {
809852 fontSize,
@@ -813,29 +856,21 @@ export default class FontLoader {
813856 fontSize : number
814857 letterSpacing : number
815858 fontFeatureSettings ?: string
816- }
859+ } ,
860+ getShapedRuns : GetShapedRuns
817861 ) {
818- const features = fontFeatureSettings
819- ? parseFontFeatureSettings ( fontFeatureSettings )
820- : { }
821-
822- // Split content by font for proper font fallback
823- const segments = splitByFont ( content , resolveFont )
862+ const shapedRuns = getShapedRuns ( content , fontFeatureSettings )
824863
825864 let totalWidth = 0
826865 let glyphCount = 0
827- for ( const [ text , font ] of segments ) {
828- const shaped = shapeText ( font , text , {
829- features,
830- } )
831-
866+ for ( const [ , font , glyphs ] of shapedRuns ) {
832867 let segmentWidth = 0
833- for ( const glyph of shaped ) {
868+ for ( const glyph of glyphs ) {
834869 segmentWidth += glyph . ax
835870 }
836871
837872 totalWidth += ( segmentWidth / font . unitsPerEm ) * fontSize
838- glyphCount += shaped . length
873+ glyphCount += glyphs . length
839874 }
840875
841876 const spacingWidth = letterSpacing * Math . max ( 0 , glyphCount - 1 )
@@ -844,7 +879,6 @@ export default class FontLoader {
844879 }
845880
846881 private getSVG (
847- resolveFont : ( word : string , fallback ?: boolean ) => opentype . Font ,
848882 content : string ,
849883 {
850884 fontSize,
@@ -859,18 +893,17 @@ export default class FontLoader {
859893 letterSpacing : number
860894 fontFeatureSettings ?: string
861895 } ,
896+ getShapedRuns : GetShapedRuns ,
862897 band ?: SkipInkBand
863898 ) : { path : string ; boxes : GlyphBox [ ] } {
864899 if ( fontSize === 0 ) {
865900 return { path : '' , boxes : [ ] }
866901 }
867902
868- const features = fontFeatureSettings
869- ? parseFontFeatureSettings ( fontFeatureSettings )
870- : { }
871-
872- // Split content by font for proper font fallback
873- const segments = splitByFont ( content . replace ( / \n / g, '' ) , resolveFont )
903+ const shapedRuns = getShapedRuns (
904+ content . replace ( / \n / g, '' ) ,
905+ fontFeatureSettings
906+ )
874907
875908 const fullPath = new opentype . Path ( )
876909 const boxes : GlyphBox [ ] = [ ]
@@ -880,19 +913,15 @@ export default class FontLoader {
880913 let hasRenderedGlyph = false
881914
882915 // Process each font segment
883- for ( const [ text , font ] of segments ) {
916+ for ( const [ , font , glyphs ] of shapedRuns ) {
884917 const scale = fontSize / font . unitsPerEm
885918
886- const shaped = shapeText ( font , text , {
887- features,
888- } )
889-
890919 // DEBUG: Uncomment to trace glyph positions
891920 // console.log(`getSVG segment: "${text}", fontSize=${fontSize}, letterSpacing=${letterSpacing}`)
892921
893922 // Process shaped glyphs for this segment
894- for ( let i = 0 ; i < shaped . length ; i ++ ) {
895- const shapedGlyph = shaped [ i ]
923+ for ( let i = 0 ; i < glyphs . length ; i ++ ) {
924+ const shapedGlyph = glyphs [ i ]
896925
897926 if ( hasRenderedGlyph ) {
898927 cursorX += letterSpacing
0 commit comments