Skip to content

Commit ed7d78e

Browse files
authored
fix: Improve cache and update benchmark (~12% faster) (#787)
1 parent b2127fe commit ed7d78e

2 files changed

Lines changed: 142 additions & 32 deletions

File tree

src/font.ts

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44
import opentype from '@shuding/opentype.js'
55
import { inflateSync } from 'fflate'
66
import { 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'
812
import { 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+
175187
type BandPoint = [number, number]
176188

177189
type 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

test/benchmark/index.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,75 @@ async function generateSVG() {
251251
)
252252
}
253253

254+
async function generateGradientTextSVG() {
255+
return await satori(
256+
{
257+
type: 'div',
258+
props: {
259+
style: {
260+
display: 'flex',
261+
height: '100%',
262+
width: '100%',
263+
alignItems: 'center',
264+
justifyContent: 'center',
265+
flexDirection: 'column',
266+
backgroundImage: 'linear-gradient(to bottom, #dbf4ff, #fff1f1)',
267+
fontSize: 60,
268+
letterSpacing: -2,
269+
fontWeight: 700,
270+
textAlign: 'center',
271+
},
272+
children: [
273+
{
274+
type: 'div',
275+
props: {
276+
style: {
277+
backgroundImage:
278+
'linear-gradient(90deg, rgb(0, 124, 240), rgb(0, 223, 216))',
279+
backgroundClip: 'text',
280+
'-webkit-background-clip': 'text',
281+
color: 'transparent',
282+
},
283+
children: 'Develop',
284+
},
285+
},
286+
{
287+
type: 'div',
288+
props: {
289+
style: {
290+
backgroundImage:
291+
'linear-gradient(90deg, rgb(121, 40, 202), rgb(255, 0, 128))',
292+
backgroundClip: 'text',
293+
'-webkit-background-clip': 'text',
294+
color: 'transparent',
295+
},
296+
children: 'Preview',
297+
},
298+
},
299+
{
300+
type: 'div',
301+
props: {
302+
style: {
303+
backgroundImage:
304+
'linear-gradient(90deg, rgb(255, 77, 77), rgb(249, 203, 40))',
305+
backgroundClip: 'text',
306+
'-webkit-background-clip': 'text',
307+
color: 'transparent',
308+
},
309+
children: 'Ship',
310+
},
311+
},
312+
],
313+
},
314+
},
315+
{
316+
width: 1200,
317+
height: 630,
318+
fonts,
319+
}
320+
)
321+
}
322+
254323
function generatePNGWithResvg(svg: string) {
255324
const resvg = new Resvg(svg, {
256325
fitTo: {
@@ -283,4 +352,16 @@ summary(() => {
283352
})
284353
})
285354

355+
summary(() => {
356+
bench('gradient text: satori', () => generateGradientTextSVG())
357+
bench('gradient text: satori + resvg', async () => {
358+
const svg = await generateGradientTextSVG()
359+
return generatePNGWithResvg(svg)
360+
})
361+
bench('gradient text: satori + sharp', async () => {
362+
const svg = await generateGradientTextSVG()
363+
return generatePNGWithSharp(svg)
364+
})
365+
})
366+
286367
await run()

0 commit comments

Comments
 (0)