Skip to content

Commit 1481902

Browse files
authored
feat: add ability to add text stroke (#645)
![af8e1b7b-0c8a-4722-833c-df1b38b0df26](https://github.com/user-attachments/assets/af7d0862-a31e-43f9-9415-acab1ea98dd5) This PR is adding ability to stroke to texts. fixes: #578 Added 2 properties (`WebkitTextStrokeWidth`, `WebkitTextStrokeColor`) and 1 shorthands (`WebkitTextStroke`). When stroke is enabled, `paint-order: stroke;` and `stroke-linejoin: round;` are automatically set to prevent the stroke from obscuring the text. I don't have a deep understanding of all the code so further improvements may be needed.
1 parent 11575c9 commit 1481902

9 files changed

+132
-0
lines changed

README.md

+12
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,18 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
270270
<tr><td><code>maskSize</code></td><td>Support two-value size i.e. `10px 20%`</td><td><a href="https://og-playground.vercel.app/?share=pVLfb9MwEP5XLEvLhpQ2P0a3LlpAAiYxJEATk_rSF8e-JNc6drAd2lD1f8duV8H6yoN19ved7j7ffTvKtQBa0HuBv5aKEOtGCeVuF-6EtIBN6wpymaXpxWV8BDcoXHuGCbS9ZKNHawnbExrun9AAd6iV57iWQ6dOLJPYqEcHnQ0UKAfmRK0G67AeP2oPqtD_NV17_Af-hoJc5_9Aixe1N2n6glaMrxujByV8jcHIq9a53hZJgh1rwE4HFWTbdsp1l_StdnqSzfJ5Pr-9e5tnt9mkruD6ZiYyccf4e9xKrEpTTbJpPs2in-V8FtVdueqbiBvdl16jD2O0KbM8TSNuS2uaKsItihLGLy3__KFmiyf8vnpIvz03s_rpzelHHbPrx6DJ6zRMIJOTJkRf8oqj4RIIc2SWXoQTk0oOEBNnmNfPjE96Veg4Gr-ffkvyvztaQLVG9_X_O5EkOWzID90QAzV4nANBRVrXyfNm52oCv98v1buluk-863ykMdV98IilxY4e_EWLMMOYHh1Ii7BTKqAaGlrUTFqIKXR6hc9jH-zrNoeXLxSM8NBVIGjhzAD7mDpW-YwWpNQbbaSg-z8">Example</a></td></tr>
271271
<tr><td><code>maskRepeat</code></td><td><code>repeat</code>, <code>repeat-x</code>, <code>repeat-y</code>, <code>no-repeat</code>, defaults to <code>repeat</code></td><td><a href="https://og-playground.vercel.app/?share=nVbpjqNIEn6VkqXVzMg1AhtjQ-3MStwGA-Ywl9U_hssJ5jSHAbf63TdxdfXUzh4_FhllHF8cGZkm4usirKJ48bb4LUrvX8qXl7ab8vj3r19n-uUliVOQdG8vP61Q9G8_vb4LhzTqkr_IorStc3-C0ksejx_SmWbTJg67tCqhLqzyvig_tH6eglLs4qKdVXHZxc2H6tq3XXqZmAoKyzn-v6ovUG6mj_jtBVt_Ejnfs92i6Hdp4IcZaKq-jKCPvsl_jvzOf0sLH8RIXYK_B34bbzevqU0fjQE9CKCi4KOaVsJZAFK0PvMaQ3lwYbPiSNqzgHJV00BFqmk34XaGiEbucHlxslDqMNtRAJpyJkUpM0NTFAcXzqco6ztPUSbggs-8BTgY_AMvwl9UU9Qz_lPvPP0-WaiEz6zinkKoPwLIs9_lkGcATGEO-o6jTUANn3iKeJJ2F-6CJ58PJp8_ICFzA7QeFZqSbqHwBOW1zSeow62UY6HeAxNPzgKZnk18E7jfU2LHzbFMulBY5ZHAgVhYtUGpbGMWTT3HuHuFtZ35wLFRzyRScQ-2EDNEQkuKeaJaDM0GmJSLrNcrzGYQr5uDyFBA20vZ-VqbBuf98BkWRqGZUhXtjeGYEvcIizC5DB9yQU7niRiPpwyXH9QkP8RJdqF9unrEDo56Luig_fXD9yf_3NlVr2GRw3zye5DS01nwtp4j3SNXJ8VU_IH_eD9ygfjifEVTf2-gIVvd5TUO8-CzYC3l8rNWJOo750J-cHBfRKqB6rMf4t2-1mDsPCiN5Bn_uhk15t3umJGT79h9JPCQJ_tP9oSM_YfcP-rGwFrAPViZIUAbiH2v97P-p81BsHcJDc-ZWvGSwfHWkRZUm-8UDuWsMsJM7ITXvl8YYxpVaWIcDFuQMp-lDYZXUyUzNVLX4KYTMBgGNxwFaEUoF84QWe6mXMYz13GVeU91ISF0sPF8oDNpbYtxHVWOmSxRzOXLyHCzEenHcHk5as7liJUdRl2SjhWUE3PZ2gdM43nDPrEUGwnJxHISvL6WuHksd8qjuK66bTDEQyKKHu5qIGNFxvAo5byyizvKZ1y7x28hf-CjZXAw2EGzWn_bP04b2lbtMx3Y0jpXaDNktVtXbPH7o8A3k8q5jhVNxsBWhe0F8jqqYqJnr17XAO9o1UZrAcDRo3tY7Zc5wGhikM4Wilz3YTH1akMOy1XMMGOVEZokexWWlo7HMrJ4mBzVqM7u8aSyN7SWb870cAKdUCqJ6c2z3xr6gV61QqrD22-O_pRosq3iHQHGzFRQ5Yisjo61Tseq0VByWxKmdVrHa9fRk122bBpiUHnDCTG_OR28OudwcW0E4qGozUaOdnHZRLesiUbXkbg1bku7lGdSrNTYBlHqgxtVdr9RjvZyd8xXd4BUYBvou7VvXLoxGkldoSrXdvmzRHnurQiLi9WfDPsgi_dJE5cB4pIZG6gaVm1qkrxQVSUqZU3d0bDMtxfdU3kSiVwJKVrPXy8HaWAJzNBDjq4entTprFOeWL32HNwX1u2dXVfSQJQeeyMecFNHn6ezUUmaO9md4uaKL3twybpTNuQ1QYGym8gitAjRu7DoFaQkWHI7miNzZ2UUsuEE2LlmjNrpp5XTh3mM-eXt_mi2O1kor9tBsfwbxgILhzkJ68PRPh25nlhRkxTTuWQq6dWVpp3BVPWq6ree34P7ivDPAljuwsQbB8fxjnTSn-lsx7sPMdwQe48zB3lI0rI02Vgz2kBmsColkI3M3QuwOSUEzZuCd99fNIykkP0YINFwL6LNUiQmKiAPoo5PqkVFGc1fEaQtxrW6psdO4C5IhRxC1916J3mrxeDerWKcRC_0etLjCiR7IexI2eG0w57VB4S0zd53hq0xZI7sNuqZ2ka4KhmHvBQke2sbO_gn013TtlADlAmPyZEcSqsdVrqFl8IbmAgqnY9pFukjifCFN1mcGPOEFPsIAi-V0rgqfdMZSlIkpEGa4ua3J44SQ4thMqQRr8ujjuxUKgCIq3Iaheg6fbOvF2qN895725IMC-eaTAIA_P77Lx_NvfDbTJw79NvLH3laxn7zK2j8KIUTwM9d9dLMPf71Jcj7-PWla_yyrf0G6n7545P9-3AwTyj1-LJaz8tn90Zcx_48VjRP4tcfSicOsrRTPmXwl6GhvYPlWOQfg4O2V9fnicZ8x0B92OyOLDWIKV2dnbz097B5XMGgMCKICnsK1_MHGk0VczNCzBp-2DG9IDeaKQ4iSwHlJEIsNSrXp49N4Ix9-PjUXGCjiyYcUyb8HhbhfcYpDPmIijDVruPguUYlCjBmho4KMzxUk6Yhpn2-zDDILNeqJ0g_LKDDWDI7v0_dKLMZpJWVyHM45NeqOazgikMfhgJt1KvVzuts66QiOBd5G8D9RuukjgQrFRliCZvOnMvyx0H8ezH_n-P808v_ONQ_Qf_laL99-1L-40v5GwLHXLguXhdVPQ-l7eLt6-I50C7eZpevi_eRd_E2D5GLKA56sHi7-Hkbvy7iorqmp6me5-VueHLQ0Tx5ckUQR4u3runjb6-Lzg8gIonzvBqqJo8W3_4J">Example</a></td></tr>
272272

273+
<tr>
274+
<td rowspan="2"><code>WebkitTextStroke</code>
275+
<td><code>WebkitTextStrokeWidth</code></td>
276+
<td>Supported</td>
277+
<td></td>
278+
</tr>
279+
<tr>
280+
<td><code>WebkitTextStrokeColor</code></td>
281+
<td>Supported</td>
282+
<td></td>
283+
</tr>
284+
273285
</tbody>
274286
</table>
275287

src/builder/text.ts

+8
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ export default function buildText(
132132
transform: matrix || undefined,
133133
'clip-path': clipPathId ? `url(#${clipPathId})` : undefined,
134134
style: style.filter ? `filter:${style.filter}` : undefined,
135+
'stroke-width': style.WebkitTextStrokeWidth
136+
? `${style.WebkitTextStrokeWidth}px`
137+
: undefined,
138+
stroke: style.WebkitTextStrokeWidth
139+
? style.WebkitTextStrokeColor
140+
: undefined,
141+
'stroke-linejoin': style.WebkitTextStrokeWidth ? 'round' : undefined,
142+
'paint-order': style.WebkitTextStrokeWidth ? 'stroke' : undefined,
135143
}
136144
return [
137145
(filter ? `${filter}<g filter="url(#satori_s-${id})">` : '') +

src/handler/expand.ts

+15
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,19 @@ function handleSpecialCase(
195195
return result
196196
}
197197

198+
if (name === 'WebkitTextStroke') {
199+
value = value.toString().trim()
200+
const values = value.split(' ')
201+
if (values.length !== 2) {
202+
throw new Error('Invalid `WebkitTextStroke` value.')
203+
}
204+
205+
return {
206+
WebkitTextStrokeWidth: purify(name, values[0]),
207+
WebkitTextStrokeColor: purify(name, values[1]),
208+
}
209+
}
210+
198211
return
199212
}
200213

@@ -267,6 +280,8 @@ type MainStyle = {
267280
}[]
268281
textShadowColor: string[]
269282
textShadowRadius: number[]
283+
WebkitTextStrokeWidth: number
284+
WebkitTextStrokeColor: string
270285
}
271286

272287
type OtherStyle = Exclude<Record<PropertyKey, string | number>, keyof MainStyle>

src/handler/inheritable.ts

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const list = new Set([
1414
'textShadowOffset',
1515
'textShadowColor',
1616
'textShadowRadius',
17+
'WebkitTextStrokeWidth',
18+
'WebkitTextStrokeColor',
1719
'textDecorationLine',
1820
'textDecorationStyle',
1921
'textDecorationColor',

src/text/index.ts

+12
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,18 @@ export default async function* buildTextNodes(
734734
mask: overflowMaskId ? `url(#${overflowMaskId})` : undefined,
735735

736736
style: cssFilter ? `filter:${cssFilter}` : undefined,
737+
'stroke-width': inheritedStyle.WebkitTextStrokeWidth
738+
? `${inheritedStyle.WebkitTextStrokeWidth}px`
739+
: undefined,
740+
stroke: inheritedStyle.WebkitTextStrokeWidth
741+
? inheritedStyle.WebkitTextStrokeColor
742+
: undefined,
743+
'stroke-linejoin': inheritedStyle.WebkitTextStrokeWidth
744+
? 'round'
745+
: undefined,
746+
'paint-order': inheritedStyle.WebkitTextStrokeWidth
747+
? 'stroke'
748+
: undefined,
737749
})
738750
: ''
739751

test/webkit-text-stroke.test.tsx

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { it, describe, expect } from 'vitest'
2+
3+
import { initFonts, toImage } from './utils.js'
4+
import satori from '../src/index.js'
5+
6+
describe('webkit-text-stroke', () => {
7+
let fonts
8+
initFonts((f) => (fonts = f))
9+
10+
it('should work basic text stroke', async () => {
11+
const svg = await satori(
12+
<div
13+
style={{
14+
width: 100,
15+
height: 100,
16+
fontSize: 30,
17+
background: '#ebebeb',
18+
color: '#ffffff',
19+
WebkitTextStroke: '4px #000000',
20+
}}
21+
>
22+
Hello, world
23+
</div>,
24+
{ width: 100, height: 100, fonts }
25+
)
26+
expect(toImage(svg, 100)).toMatchImageSnapshot()
27+
})
28+
29+
it('should work nested text stroke', async () => {
30+
const svg = await satori(
31+
<div
32+
style={{
33+
display: 'flex',
34+
flexWrap: 'wrap',
35+
width: 100,
36+
height: 100,
37+
fontSize: 30,
38+
background: '#ebebeb',
39+
color: '#ffffff',
40+
WebkitTextStroke: '4px #000000',
41+
}}
42+
>
43+
Hello, <span style={{ WebkitTextStrokeColor: '#ff0000' }}>world</span>
44+
</div>,
45+
{ width: 100, height: 100, fonts }
46+
)
47+
expect(toImage(svg, 100)).toMatchImageSnapshot()
48+
})
49+
50+
it('should work nested and complex text stroke', async () => {
51+
const svg = await satori(
52+
<div
53+
style={{
54+
display: 'flex',
55+
flexWrap: 'wrap',
56+
width: 100,
57+
height: 100,
58+
fontSize: 30,
59+
background: '#ebebeb',
60+
color: '#ffffff',
61+
WebkitTextStroke: '4px #000000',
62+
}}
63+
>
64+
Hello,
65+
<span style={{ WebkitTextStrokeColor: '#f00' }}>w</span>
66+
<span style={{ WebkitTextStrokeColor: '#ff0' }}>o</span>
67+
<span style={{ WebkitTextStrokeColor: '#0f0' }}>r</span>
68+
<span style={{ WebkitTextStrokeColor: '#0ff' }}>l</span>
69+
<span style={{ WebkitTextStrokeColor: '#00f' }}>d</span>
70+
<span
71+
style={{
72+
WebkitTextStrokeColor: '#f0f',
73+
WebkitTextStrokeWidth: '6px',
74+
}}
75+
>
76+
!
77+
</span>
78+
</div>,
79+
{ width: 100, height: 100, fonts }
80+
)
81+
expect(toImage(svg, 100)).toMatchImageSnapshot()
82+
})
83+
})

0 commit comments

Comments
 (0)