Skip to content

Commit dc07b70

Browse files
fix: Consistent text positioning when embedFont is false (#723)
## Problem When using `embedFont: false`, consecutive `<text>` elements have inconsistent `x` positions, causing uneven spacing between words. This is especially noticeable in multi-line text where gaps can accumulate to over 1px. | Content | x | width | Expected x | Gap | |---------|---|-------|------------|-----| | `" "` (space) | 96.29 | 3.96 | - | - | | `"behavior"` | 100.56 | 61.02 | 100.25 | +0.31px | ## Root Cause Two issues in `src/text/index.ts`: 1. **Line 570**: `leftOffset = Math.round(leftOffset)` rounds x positions to integers while widths remain fractional 2. **Line 344**: `measureGrapheme()` on multi-character strings uses `getAdvanceWidth()` (includes kerning), but `currentWidth` accumulates using `measureText()` (sum of individual graphemes) ## Changes 1. Only round `leftOffset` when `embedFont: true` (paths benefit from pixel alignment) 2. Use `measureText()` for multi-character strings when `embedFont: false` to match position accumulation ## Test Added `test/embed-font.test.tsx` that verifies consecutive text elements have consistent x positions (gap < 0.01px).
1 parent 54a749b commit dc07b70

5 files changed

+84
-1
lines changed

src/text/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,12 @@ export default async function* buildTextNodes(
341341
if (isImage(_text)) {
342342
_width = fontSize
343343
_isImage = true
344+
} else if (!embedFont && _text.length > 1) {
345+
// When embedFont is false, use measureText for multi-character strings
346+
// to ensure consistency with how currentWidth is accumulated (sum of
347+
// grapheme widths). measureGrapheme uses getAdvanceWidth which includes
348+
// kerning, causing position mismatches between consecutive <text> elements.
349+
_width = measureText(_text)
344350
} else {
345351
_width = measureGrapheme(_text)
346352
}
@@ -585,7 +591,12 @@ export default async function* buildTextNodes(
585591
}
586592
}
587593

588-
leftOffset = Math.round(leftOffset)
594+
// Only round for embedded fonts (paths benefit from pixel alignment).
595+
// For non-embedded fonts (<text> elements), keep fractional positions
596+
// to maintain consistent spacing between consecutive elements.
597+
if (embedFont) {
598+
leftOffset = Math.round(leftOffset)
599+
}
589600
}
590601

591602
const baselineOfLine = baselines[line]
-18 Bytes
Loading
Loading

test/embed-font.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { it, describe, expect } from 'vitest'
2+
3+
import { initFonts } from './utils.js'
4+
import satori from '../src/index.js'
5+
6+
describe('embedFont: false', () => {
7+
let fonts
8+
initFonts((f) => (fonts = f))
9+
10+
it('should have consistent x positions for multi-line text', async () => {
11+
// This test verifies that when embedFont is false, consecutive text elements
12+
// have consistent x positions (x[n] should equal x[n-1] + width[n-1]).
13+
//
14+
// Regression test for: src/text/index.ts rounding leftOffset to integers
15+
// and using inconsistent width measurements (measureGrapheme vs measureText).
16+
const svg = await satori(
17+
<div
18+
style={{
19+
display: 'flex',
20+
fontFamily: 'Roboto',
21+
fontSize: 16,
22+
width: 200,
23+
}}
24+
>
25+
Hello world this is a test of text wrapping behavior
26+
</div>,
27+
{
28+
width: 200,
29+
height: 100,
30+
fonts,
31+
embedFont: false,
32+
}
33+
)
34+
35+
// Parse all <text> elements from the SVG
36+
const textElementRegex =
37+
/<text[^>]*\bx="([^"]+)"[^>]*\bwidth="([^"]+)"[^>]*>([^<]*)<\/text>/g
38+
const textElements: { x: number; width: number; content: string }[] = []
39+
40+
let match
41+
while ((match = textElementRegex.exec(svg)) !== null) {
42+
textElements.push({
43+
x: parseFloat(match[1]),
44+
width: parseFloat(match[2]),
45+
content: match[3],
46+
})
47+
}
48+
49+
expect(textElements.length).toBeGreaterThan(1)
50+
51+
// Check consecutive elements on the same line
52+
// The x position of each element should equal the previous element's x + width
53+
let maxGap = 0
54+
55+
for (let i = 1; i < textElements.length; i += 1) {
56+
const prev = textElements[i - 1]
57+
const curr = textElements[i]
58+
59+
const expectedX = prev.x + prev.width
60+
const gap = Math.abs(curr.x - expectedX)
61+
62+
// Only check elements that appear to be on the same line (small gap)
63+
// Large gaps indicate line breaks
64+
if (gap < 50) {
65+
maxGap = Math.max(maxGap, gap)
66+
}
67+
}
68+
69+
// The gap between consecutive elements should be negligible (< 0.01px)
70+
expect(maxGap).toBeLessThan(0.01)
71+
})
72+
})

0 commit comments

Comments
 (0)