Skip to content

Commit

Permalink
feat: add ability to add text stroke (#645)
Browse files Browse the repository at this point in the history
![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.
  • Loading branch information
AioiLight authored Nov 19, 2024
1 parent 11575c9 commit 1481902
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 0 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,18 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
<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>
<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>

<tr>
<td rowspan="2"><code>WebkitTextStroke</code>
<td><code>WebkitTextStrokeWidth</code></td>
<td>Supported</td>
<td></td>
</tr>
<tr>
<td><code>WebkitTextStrokeColor</code></td>
<td>Supported</td>
<td></td>
</tr>

</tbody>
</table>

Expand Down
8 changes: 8 additions & 0 deletions src/builder/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ export default function buildText(
transform: matrix || undefined,
'clip-path': clipPathId ? `url(#${clipPathId})` : undefined,
style: style.filter ? `filter:${style.filter}` : undefined,
'stroke-width': style.WebkitTextStrokeWidth
? `${style.WebkitTextStrokeWidth}px`
: undefined,
stroke: style.WebkitTextStrokeWidth
? style.WebkitTextStrokeColor
: undefined,
'stroke-linejoin': style.WebkitTextStrokeWidth ? 'round' : undefined,
'paint-order': style.WebkitTextStrokeWidth ? 'stroke' : undefined,
}
return [
(filter ? `${filter}<g filter="url(#satori_s-${id})">` : '') +
Expand Down
15 changes: 15 additions & 0 deletions src/handler/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,19 @@ function handleSpecialCase(
return result
}

if (name === 'WebkitTextStroke') {
value = value.toString().trim()
const values = value.split(' ')
if (values.length !== 2) {
throw new Error('Invalid `WebkitTextStroke` value.')
}

return {
WebkitTextStrokeWidth: purify(name, values[0]),
WebkitTextStrokeColor: purify(name, values[1]),
}
}

return
}

Expand Down Expand Up @@ -267,6 +280,8 @@ type MainStyle = {
}[]
textShadowColor: string[]
textShadowRadius: number[]
WebkitTextStrokeWidth: number
WebkitTextStrokeColor: string
}

type OtherStyle = Exclude<Record<PropertyKey, string | number>, keyof MainStyle>
Expand Down
2 changes: 2 additions & 0 deletions src/handler/inheritable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const list = new Set([
'textShadowOffset',
'textShadowColor',
'textShadowRadius',
'WebkitTextStrokeWidth',
'WebkitTextStrokeColor',
'textDecorationLine',
'textDecorationStyle',
'textDecorationColor',
Expand Down
12 changes: 12 additions & 0 deletions src/text/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,18 @@ export default async function* buildTextNodes(
mask: overflowMaskId ? `url(#${overflowMaskId})` : undefined,

style: cssFilter ? `filter:${cssFilter}` : undefined,
'stroke-width': inheritedStyle.WebkitTextStrokeWidth
? `${inheritedStyle.WebkitTextStrokeWidth}px`
: undefined,
stroke: inheritedStyle.WebkitTextStrokeWidth
? inheritedStyle.WebkitTextStrokeColor
: undefined,
'stroke-linejoin': inheritedStyle.WebkitTextStrokeWidth
? 'round'
: undefined,
'paint-order': inheritedStyle.WebkitTextStrokeWidth
? 'stroke'
: undefined,
})
: ''

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 83 additions & 0 deletions test/webkit-text-stroke.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { it, describe, expect } from 'vitest'

import { initFonts, toImage } from './utils.js'
import satori from '../src/index.js'

describe('webkit-text-stroke', () => {
let fonts
initFonts((f) => (fonts = f))

it('should work basic text stroke', async () => {
const svg = await satori(
<div
style={{
width: 100,
height: 100,
fontSize: 30,
background: '#ebebeb',
color: '#ffffff',
WebkitTextStroke: '4px #000000',
}}
>
Hello, world
</div>,
{ width: 100, height: 100, fonts }
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})

it('should work nested text stroke', async () => {
const svg = await satori(
<div
style={{
display: 'flex',
flexWrap: 'wrap',
width: 100,
height: 100,
fontSize: 30,
background: '#ebebeb',
color: '#ffffff',
WebkitTextStroke: '4px #000000',
}}
>
Hello, <span style={{ WebkitTextStrokeColor: '#ff0000' }}>world</span>
</div>,
{ width: 100, height: 100, fonts }
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})

it('should work nested and complex text stroke', async () => {
const svg = await satori(
<div
style={{
display: 'flex',
flexWrap: 'wrap',
width: 100,
height: 100,
fontSize: 30,
background: '#ebebeb',
color: '#ffffff',
WebkitTextStroke: '4px #000000',
}}
>
Hello,
<span style={{ WebkitTextStrokeColor: '#f00' }}>w</span>
<span style={{ WebkitTextStrokeColor: '#ff0' }}>o</span>
<span style={{ WebkitTextStrokeColor: '#0f0' }}>r</span>
<span style={{ WebkitTextStrokeColor: '#0ff' }}>l</span>
<span style={{ WebkitTextStrokeColor: '#00f' }}>d</span>
<span
style={{
WebkitTextStrokeColor: '#f0f',
WebkitTextStrokeWidth: '6px',
}}
>
!
</span>
</div>,
{ width: 100, height: 100, fonts }
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})
})

0 comments on commit 1481902

Please sign in to comment.