Skip to content

Commit 589d9fa

Browse files
authored
fix: Performance improvement (#707)
- Improve caching: generating images with the same font buffer will re-use the parsed font file; - Improve caching: avoid re-computing paths of the same glyph in one pass (this is highly inspired by @NiteshSingh17's work in #679). ``` clk: ~4.15 GHz cpu: Apple M4 Pro runtime: node 22.13.1 (arm64-darwin) benchmark avg (min … max) p75 / p99 (min … top 1%) ------------------------------------------- ------------------------------- satori 3.88 ms/iter 4.07 ms █ (3.40 ms … 7.24 ms) 6.00 ms █▅ ▆ ▃ ( 3.63 mb … 11.83 mb) 6.76 mb ███████▄▄▂▂▂▂▁▁▂▁▂▁▁▂ satori + resvg 31.76 ms/iter 32.07 ms █ █ █ (31.31 ms … 32.50 ms) 32.21 ms █▅▅█▅ ▅ ▅▅ ▅▅ ▅▅█▅ ( 6.78 mb … 6.82 mb) 6.79 mb █████▁█▁██▁▁▁██▁▁████ satori + sharp 14.30 ms/iter 14.36 ms █ ▅█ (13.95 ms … 15.21 ms) 15.11 ms ███▆██ ( 6.80 mb … 6.92 mb) 6.83 mb █▁████████▄▁▁▄▁▁▄▁▁▁▄ summary satori 3.69x faster than satori + sharp 8.19x faster than satori + resvg ``` Before this change: ``` satori 4.06 ms/iter 4.29 ms █ (3.47 ms … 7.41 ms) 6.48 ms ▅█▅▆▆ ▃ ( 3.57 mb … 11.78 mb) 6.60 mb ███████▄▆▄▂▁▂▁▂▁▂▂▁▁▂ ``` It's a ~7% change in P99.
1 parent 1a338ca commit 589d9fa

File tree

1 file changed

+80
-26
lines changed

1 file changed

+80
-26
lines changed

src/font.ts

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ function compareFont(
8787
return -1
8888
}
8989

90+
const cachedParsedFont = new WeakMap<
91+
Buffer | ArrayBuffer,
92+
opentype.Font | null | undefined
93+
>()
94+
9095
export default class FontLoader {
9196
defaultFont: opentype.Font
9297
fonts = new Map<string, [opentype.Font, Weight?, FontStyle?][]>()
@@ -143,30 +148,37 @@ export default class FontLoader {
143148
)
144149
}
145150
const _lang = lang ?? SUFFIX_WHEN_LANG_NOT_SET
146-
const font = opentype.parse(
147-
// Buffer to ArrayBuffer.
148-
'buffer' in data
149-
? data.buffer.slice(
150-
data.byteOffset,
151-
data.byteOffset + data.byteLength
152-
)
153-
: data,
154-
// @ts-ignore
155-
{ lowMemory: true }
156-
)
151+
let font
157152

158-
// Modify the `charToGlyphIndex` method, so we can know which char is
159-
// being mapped to which glyph.
160-
const originalCharToGlyphIndex = font.charToGlyphIndex
161-
font.charToGlyphIndex = (char) => {
162-
const index = originalCharToGlyphIndex.call(font, char)
163-
if (index === 0) {
164-
// The current requested char is missing a glyph.
165-
if ((font as any)._trackBrokenChars) {
166-
;(font as any)._trackBrokenChars.push(char)
153+
if (cachedParsedFont.has(data)) {
154+
font = cachedParsedFont.get(data)
155+
} else {
156+
font = opentype.parse(
157+
// Buffer to ArrayBuffer.
158+
'buffer' in data
159+
? data.buffer.slice(
160+
data.byteOffset,
161+
data.byteOffset + data.byteLength
162+
)
163+
: data,
164+
// @ts-ignore
165+
{ lowMemory: true }
166+
)
167+
// Modify the `charToGlyphIndex` method, so we can know which char is
168+
// being mapped to which glyph.
169+
const originalCharToGlyphIndex = font.charToGlyphIndex
170+
font.charToGlyphIndex = (char) => {
171+
const index = originalCharToGlyphIndex.call(font, char)
172+
if (index === 0) {
173+
// The current requested char is missing a glyph.
174+
if ((font as any)._trackBrokenChars) {
175+
;(font as any)._trackBrokenChars.push(char)
176+
}
167177
}
178+
return index
168179
}
169-
return index
180+
181+
cachedParsedFont.set(data, font)
170182
}
171183

172184
// We use the first font as the default font fallback.
@@ -494,11 +506,53 @@ export default class FontLoader {
494506
if (fontSize === 0) {
495507
return ''
496508
}
497-
return font
498-
.getPath(content.replace(/\n/g, ''), left, top, fontSize, {
499-
letterSpacing: letterSpacing / fontSize,
500-
})
501-
.toPathData(1)
509+
510+
const fullPath = new opentype.Path()
511+
512+
const options = {
513+
letterSpacing: letterSpacing / fontSize,
514+
}
515+
516+
const cachedPath = new WeakMap<
517+
opentype.Glyph,
518+
[number, number, opentype.Path]
519+
>()
520+
521+
font.forEachGlyph(
522+
content.replace(/\n/g, ''),
523+
left,
524+
top,
525+
fontSize,
526+
options,
527+
function (glyph, gX, gY, gFontSize) {
528+
let glyphPath: opentype.Path
529+
if (!cachedPath.has(glyph)) {
530+
glyphPath = glyph.getPath(gX, gY, gFontSize, options)
531+
cachedPath.set(glyph, [gX, gY, glyphPath])
532+
} else {
533+
const [_x, _y, _glyphPath] = cachedPath.get(glyph)
534+
glyphPath = new opentype.Path()
535+
glyphPath.commands = _glyphPath.commands.map((command) => {
536+
const movedCommand = { ...command }
537+
for (let k in movedCommand) {
538+
if (typeof movedCommand[k] === 'number') {
539+
if (k === 'x' || k === 'x1' || k === 'x2') {
540+
movedCommand[k] += gX - _x
541+
}
542+
if (k === 'y' || k === 'y1' || k === 'y2') {
543+
movedCommand[k] += gY - _y
544+
}
545+
}
546+
}
547+
return movedCommand
548+
})
549+
}
550+
551+
fullPath.extend(glyphPath)
552+
}
553+
)
554+
555+
return fullPath.toPathData(1)
502556
} finally {
503557
unpatch()
504558
}

0 commit comments

Comments
 (0)