Skip to content

Commit c47e1a9

Browse files
authored
fix: text decoration line (#531)
### Description 1. Default to the current color when `textDecorationColor` is unset. 2. Resolve incorrect positioning of decoration line. 3. Address inaccurate line-through positioning. <img width="1438" alt="image" src="https://github.com/vercel/satori/assets/22126563/71e401e1-f03d-45e4-a5ad-c202487ad605"> <img width="912" alt="image" src="https://github.com/vercel/satori/assets/22126563/fe010957-ce7c-401d-bf02-e59e1a03a2a9"> Closes: #530
1 parent 5c37104 commit c47e1a9

8 files changed

+181
-26
lines changed

src/builder/text-decoration.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default function buildDecoration(
2121
textDecorationStyle,
2222
textDecorationLine,
2323
fontSize,
24+
color,
2425
} = style
2526
if (!textDecorationLine || textDecorationLine === 'none') return ''
2627

@@ -30,7 +31,7 @@ export default function buildDecoration(
3031

3132
const y =
3233
textDecorationLine === 'line-through'
33-
? top + ascender * 0.5
34+
? top + ascender * 0.7
3435
: textDecorationLine === 'underline'
3536
? top + ascender * 1.1
3637
: top
@@ -47,7 +48,7 @@ export default function buildDecoration(
4748
y1: y,
4849
x2: left + width,
4950
y2: y,
50-
stroke: textDecorationColor,
51+
stroke: textDecorationColor || color,
5152
'stroke-width': height,
5253
'stroke-dasharray': dasharray,
5354
'stroke-linecap': textDecorationStyle === 'dotted' ? 'round' : 'square',

src/text.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -541,9 +541,16 @@ export default async function* buildTextNodes(
541541
}
542542
}
543543

544+
const baselineOfLine = baselines[line]
545+
const baselineOfWord = engine.baseline(text)
546+
const heightOfWord = engine.height(text)
547+
const baselineDelta = baselineOfLine - baselineOfWord
548+
544549
if (!decorationLines[line]) {
545550
decorationLines[line] = [
546551
leftOffset,
552+
top + topOffset + baselineDelta,
553+
baselineOfWord,
547554
extendedWidth ? containerWidth : lineWidths[line],
548555
]
549556
}
@@ -599,15 +606,15 @@ export default async function* buildTextNodes(
599606

600607
text = subset + _blockEllipsis
601608
skippedLine = line
602-
decorationLines[line][1] = resolvedWidth
609+
decorationLines[line][2] = resolvedWidth
603610
isLastDisplayedBeforeEllipsis = true
604611
} else if (nextLayout && nextLayout.line !== line) {
605612
if (textAlign === 'center') {
606613
const { subset, resolvedWidth } = calcEllipsis(leftOffset, text)
607614

608615
text = subset + _blockEllipsis
609616
skippedLine = line
610-
decorationLines[line][1] = resolvedWidth
617+
decorationLines[line][2] = resolvedWidth
611618
isLastDisplayedBeforeEllipsis = true
612619
} else {
613620
const nextLineText = texts[i + 1]
@@ -619,18 +626,13 @@ export default async function* buildTextNodes(
619626

620627
text = text + subset + _blockEllipsis
621628
skippedLine = line
622-
decorationLines[line][1] = resolvedWidth
629+
decorationLines[line][2] = resolvedWidth
623630
isLastDisplayedBeforeEllipsis = true
624631
}
625632
}
626633
}
627634
}
628635

629-
const baselineOfLine = baselines[line]
630-
const baselineOfWord = engine.baseline(text)
631-
const heightOfWord = engine.height(text)
632-
const baselineDelta = baselineOfLine - baselineOfWord
633-
634636
if (image) {
635637
// For images, we remove the baseline offset.
636638
topOffset += 0
@@ -703,22 +705,19 @@ export default async function* buildTextNodes(
703705

704706
// Get the decoration shape.
705707
if (parentStyle.textDecorationLine) {
706-
// If it's the last word in the current line.
707-
if (line !== nextLayout?.line || skippedLine === line) {
708-
const deco = decorationLines[line]
709-
if (deco && !deco[2]) {
710-
decorationShape += buildDecoration(
711-
{
712-
left: left + deco[0],
713-
top: top + heightOfWord * +line,
714-
width: deco[1],
715-
ascender: engine.baseline(text),
716-
clipPathId,
717-
},
718-
parentStyle
719-
)
720-
deco[2] = 1
721-
}
708+
const deco = decorationLines[line]
709+
if (deco && !deco[4]) {
710+
decorationShape += buildDecoration(
711+
{
712+
left: left + deco[0],
713+
top: deco[1],
714+
width: deco[3],
715+
ascender: deco[2],
716+
clipPathId,
717+
},
718+
parentStyle
719+
)
720+
deco[4] = 1
722721
}
723722
}
724723

test/text-decoration.test.tsx

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { it, describe, expect } from 'vitest'
2+
3+
import { initFonts, loadDynamicAsset, toImage } from './utils.js'
4+
import satori from '../src/index.js'
5+
6+
describe('Text Decoration', () => {
7+
let fonts
8+
initFonts((f) => (fonts = f))
9+
10+
it('Should work correctly when `text-decoration-line: line-through` and `text-align: right`', async () => {
11+
const svg = await satori(
12+
<div
13+
style={{
14+
height: '100%',
15+
width: '100%',
16+
display: 'flex',
17+
flexDirection: 'column',
18+
alignItems: 'center',
19+
justifyContent: 'center',
20+
backgroundColor: '#fff',
21+
fontSize: 20,
22+
fontWeight: 600,
23+
}}
24+
>
25+
<div
26+
style={{
27+
maxWidth: '190px',
28+
backgroundColor: '#91a8d0',
29+
textDecorationLine: 'line-through',
30+
color: 'white',
31+
textAlign: 'center',
32+
}}
33+
>
34+
你好! It doesn’t 안녕! exist, it never has. I’m nostalgic for a place
35+
that never existed.
36+
</div>
37+
</div>,
38+
{
39+
width: 200,
40+
height: 200,
41+
fonts,
42+
loadAdditionalAsset: (languageCode: string, segment: string) => {
43+
return loadDynamicAsset(languageCode, segment) as any
44+
},
45+
}
46+
)
47+
expect(toImage(svg, 200)).toMatchImageSnapshot()
48+
})
49+
50+
it('Should work correctly when `text-decoration-line: underline` and `text-align: right`', async () => {
51+
const svg = await satori(
52+
<div
53+
style={{
54+
height: '100%',
55+
width: '100%',
56+
display: 'flex',
57+
flexDirection: 'column',
58+
alignItems: 'center',
59+
justifyContent: 'center',
60+
backgroundColor: '#fff',
61+
fontSize: 20,
62+
fontWeight: 600,
63+
}}
64+
>
65+
<div
66+
style={{
67+
maxWidth: '190px',
68+
backgroundColor: '#91a8d0',
69+
textDecorationLine: 'underline',
70+
color: 'white',
71+
textAlign: 'right',
72+
}}
73+
>
74+
你好! It doesn’t 안녕! exist, it never has. I’m nostalgic for a place
75+
that never existed.
76+
</div>
77+
</div>,
78+
{
79+
width: 200,
80+
height: 200,
81+
fonts,
82+
loadAdditionalAsset: (languageCode: string, segment: string) => {
83+
return loadDynamicAsset(languageCode, segment) as any
84+
},
85+
}
86+
)
87+
expect(toImage(svg, 200)).toMatchImageSnapshot()
88+
})
89+
90+
it('Should work correctly when `text-decoration-style: dotted`', async () => {
91+
const svg = await satori(
92+
<div
93+
style={{
94+
height: '100%',
95+
width: '100%',
96+
display: 'flex',
97+
flexDirection: 'column',
98+
alignItems: 'center',
99+
justifyContent: 'center',
100+
backgroundColor: '#fff',
101+
fontSize: 20,
102+
fontWeight: 600,
103+
}}
104+
>
105+
<div
106+
style={{
107+
maxWidth: '190px',
108+
backgroundColor: '#91a8d0',
109+
textDecorationLine: 'underline',
110+
textDecorationStyle: 'dotted',
111+
color: 'white',
112+
}}
113+
>
114+
It doesn’t exist, it never has. I’m nostalgic for a place that never
115+
existed.
116+
</div>
117+
</div>,
118+
{ width: 200, height: 200, fonts }
119+
)
120+
expect(toImage(svg, 200)).toMatchImageSnapshot()
121+
})
122+
123+
it('Should work correctly when `text-decoration-style: dashed`', async () => {
124+
const svg = await satori(
125+
<div
126+
style={{
127+
height: '100%',
128+
width: '100%',
129+
display: 'flex',
130+
flexDirection: 'column',
131+
alignItems: 'center',
132+
justifyContent: 'center',
133+
backgroundColor: '#fff',
134+
fontSize: 20,
135+
fontWeight: 600,
136+
}}
137+
>
138+
<div
139+
style={{
140+
maxWidth: '190px',
141+
backgroundColor: '#91a8d0',
142+
textDecorationLine: 'underline',
143+
textDecorationStyle: 'dashed',
144+
color: 'white',
145+
}}
146+
>
147+
It doesn’t exist, it never has. I’m nostalgic for a place that never
148+
existed.
149+
</div>
150+
</div>,
151+
{ width: 200, height: 200, fonts }
152+
)
153+
expect(toImage(svg, 200)).toMatchImageSnapshot()
154+
})
155+
})

0 commit comments

Comments
 (0)