Skip to content

Commit b22c04c

Browse files
authored
fix: Better font caching (#788)
1 parent ed7d78e commit b22c04c

2 files changed

Lines changed: 69 additions & 11 deletions

File tree

src/font.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,10 @@ const cachedParsedFont = new WeakMap<
429429
export 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

src/satori.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,52 @@ import { initHarfBuzz } from './harfbuzz.js'
1616
// We don't need to initialize the opentype instances every time.
1717
const fontCache = new WeakMap()
1818

19+
// Users often build the `fonts` array inline on every render while reusing
20+
// the same underlying font data buffers. Identify loaders by the identity of
21+
// each font's data buffer plus its metadata, so an equivalent inline array
22+
// still hits the cached FontLoader (and its shaping caches).
23+
const fontDataIds = new WeakMap<Buffer | ArrayBuffer, number>()
24+
let nextFontDataId = 0
25+
const MAX_FONT_LOADERS = 8
26+
const fontLoaderCache = new Map<string, FontLoader>()
27+
28+
function getFontLoaderKey(fonts: FontOptions[]): string {
29+
let key = ''
30+
for (const font of fonts) {
31+
let id = fontDataIds.get(font.data)
32+
if (id === undefined) {
33+
id = ++nextFontDataId
34+
fontDataIds.set(font.data, id)
35+
}
36+
key += `${id}:${font.name}:${font.weight ?? ''}:${font.style ?? ''}:${
37+
font.lang ?? ''
38+
};`
39+
}
40+
return key
41+
}
42+
43+
function getFontLoader(fonts: FontOptions[]): FontLoader {
44+
// Fast path: the exact same array instance.
45+
if (fontCache.has(fonts)) {
46+
return fontCache.get(fonts)
47+
}
48+
49+
const key = getFontLoaderKey(fonts)
50+
let loader = fontLoaderCache.get(key)
51+
52+
if (!loader) {
53+
loader = new FontLoader(fonts)
54+
if (fontLoaderCache.size >= MAX_FONT_LOADERS) {
55+
const oldestKey = fontLoaderCache.keys().next().value
56+
if (oldestKey !== undefined) fontLoaderCache.delete(oldestKey)
57+
}
58+
fontLoaderCache.set(key, loader)
59+
}
60+
61+
fontCache.set(fonts, loader)
62+
return loader
63+
}
64+
1965
export type SatoriOptions = (
2066
| {
2167
width: number
@@ -56,12 +102,7 @@ export default async function satori(
56102

57103
options.fonts = options.fonts || []
58104

59-
let font: FontLoader
60-
if (fontCache.has(options.fonts)) {
61-
font = fontCache.get(options.fonts)
62-
} else {
63-
fontCache.set(options.fonts, (font = new FontLoader(options.fonts)))
64-
}
105+
const font = getFontLoader(options.fonts)
65106

66107
const definedWidth = 'width' in options ? options.width : undefined
67108
const definedHeight = 'height' in options ? options.height : undefined

0 commit comments

Comments
 (0)