From 14819024d4d9a38509656329877f200a753d504a Mon Sep 17 00:00:00 2001 From: AioiLight Date: Tue, 19 Nov 2024 09:23:33 +0900 Subject: [PATCH] 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. --- README.md | 12 +++ src/builder/text.ts | 8 ++ src/handler/expand.ts | 15 ++++ src/handler/inheritable.ts | 2 + src/text/index.ts | 12 +++ ...e-should-work-basic-text-stroke-1-snap.png | Bin 0 -> 2333 bytes ...-nested-and-complex-text-stroke-1-snap.png | Bin 0 -> 3115 bytes ...-should-work-nested-text-stroke-1-snap.png | Bin 0 -> 2424 bytes test/webkit-text-stroke.test.tsx | 83 ++++++++++++++++++ 9 files changed, 132 insertions(+) create mode 100644 test/__image_snapshots__/webkit-text-stroke-test-tsx-test-webkit-text-stroke-test-tsx-webkit-text-stroke-should-work-basic-text-stroke-1-snap.png create mode 100644 test/__image_snapshots__/webkit-text-stroke-test-tsx-test-webkit-text-stroke-test-tsx-webkit-text-stroke-should-work-nested-and-complex-text-stroke-1-snap.png create mode 100644 test/__image_snapshots__/webkit-text-stroke-test-tsx-test-webkit-text-stroke-test-tsx-webkit-text-stroke-should-work-nested-text-stroke-1-snap.png create mode 100644 test/webkit-text-stroke.test.tsx diff --git a/README.md b/README.md index 1340126a..b96a313a 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,18 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na maskSizeSupport two-value size i.e. `10px 20%`Example maskRepeatrepeat, repeat-x, repeat-y, no-repeat, defaults to repeatExample + +WebkitTextStroke +WebkitTextStrokeWidth +Supported + + + +WebkitTextStrokeColor +Supported + + + diff --git a/src/builder/text.ts b/src/builder/text.ts index 26eddbae..6ab354b8 100644 --- a/src/builder/text.ts +++ b/src/builder/text.ts @@ -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}` : '') + diff --git a/src/handler/expand.ts b/src/handler/expand.ts index 92fbc775..a1f39ba0 100644 --- a/src/handler/expand.ts +++ b/src/handler/expand.ts @@ -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 } @@ -267,6 +280,8 @@ type MainStyle = { }[] textShadowColor: string[] textShadowRadius: number[] + WebkitTextStrokeWidth: number + WebkitTextStrokeColor: string } type OtherStyle = Exclude, keyof MainStyle> diff --git a/src/handler/inheritable.ts b/src/handler/inheritable.ts index 07996e72..fefa84ae 100644 --- a/src/handler/inheritable.ts +++ b/src/handler/inheritable.ts @@ -14,6 +14,8 @@ const list = new Set([ 'textShadowOffset', 'textShadowColor', 'textShadowRadius', + 'WebkitTextStrokeWidth', + 'WebkitTextStrokeColor', 'textDecorationLine', 'textDecorationStyle', 'textDecorationColor', diff --git a/src/text/index.ts b/src/text/index.ts index 7149a84b..d40242bb 100644 --- a/src/text/index.ts +++ b/src/text/index.ts @@ -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, }) : '' diff --git a/test/__image_snapshots__/webkit-text-stroke-test-tsx-test-webkit-text-stroke-test-tsx-webkit-text-stroke-should-work-basic-text-stroke-1-snap.png b/test/__image_snapshots__/webkit-text-stroke-test-tsx-test-webkit-text-stroke-test-tsx-webkit-text-stroke-should-work-basic-text-stroke-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..40a5755157036fc32c8d9b4329aae0212185a878 GIT binary patch literal 2333 zcmcImX*84#8=h=UvOHrQ`;2`rV$5qFW-ttclqE@qQW;wzTQoy6B*qw91~0OUjL4EC zN|B*LLZNI~D@!rG_351VobSi?`}=V(=Un%X>)hwM?sKO&JJ|~IOY?(3AVI7h+I7GB z{|PYPeh&SZ?FIsY=dfs$+g0WoQ?FnUC)Rhl3zRJ+C?{>KscCQ}Fvu2@%qK4GR$F>X zP}88QYAI0No$T~fvUOv3(0uVxQ`==#qZft#tL%;F;NZwCoD(~Nou7q`RO?FkU34-z zNS)+w15rN45}(`MTpXQz5K?QA@asALM03=}hvliZ%cG5izM2dLi<3uu9#&oZ`2&8c z=Heo|aJ2e(WzFW|&;b57!U@n_ecut)baRmlUu62332;;5IXjMH2;|i-e_zI(xwrT{ zQ;CHb+S_ZNtZ3P*yL>g%#P{|0 zSmUEoHB@7Bp#Z9`oOvvD($c~NnmJ(bkz6v-gfOoBY~pDWpzhn?oB10 zRae7(P^eW;-h=^!z51l2|5iRF!4U%D2REFL-f;19q=3=EH zQL6ia7IX?_mtW!;JR{2(A^_sb677u~8iU&D08y5JGt0m%m|ut*>T1iU=`ErbDi3*I z6IvtJqO~uzD+=>VugchAFHy-rtRpJ$t#L@acWd~3YIK9OAtE2=?ZqAJRxQw|Wr*eJ zt~PG+qNJFvSL};(O!e{jnr<)gl!GZ@NXC8<2 za2(ar;OBENRZt|`3O@F=SYV{ax}+Da;#u)>Li}w5ljgxU|y2HT7v`y zbgb`G8DnTK|Gg0{3>fYmM_j}xVJfuctQ2wU>;Skl+eNNMdHtDW_r;!KFYI6rBuPU! zLE83g%-r_MTh>_GDBp1{Q>HXYM~Q04nX&xLss<*_{mTY_dxRJ zE1K|u{QC-`NoqG&xlhW#uF(TxRQ1h{2HXZgA6BXVtVt4~QA|zApq;kJwTX(|!aeiH z{<6o%kPc^6vo$MzSs4&ry~2aX;sbL(g__?hL_|L-i5xI|IkqwrbOzYz;p=jwc2)_J z4=EP6flEqfo?L&2*L}oRA;hQ#xfj2(yqzFMz1AIZxXwa>Xx_-_oa8(F8 zDI&}mzgabR^@E@r0S+ij%#U~1nt{)ipNHarwtE`QKWVgOzD2XOpy-C zJs4LtSGI@D{z7aVJGO@FhY3>t@pYbK&;cOnK8~T$h~N|TkBErqPw@6oMZUKe4XQoN zT9Ub8zK`3dUn%dMfZ}M|+uPL<3a`Y!v9dE%6phLcsgr(Y6bxL{44HfaMdeFLOWG&( zhLM~CCN3EC|{k->N+4TB>7!pMQS};pQqaPQk~$}EbTX+c+Prt$4q*o z1a$wt@MV6TsgXTyIq038cAC}VQZE80db$CM`XhRYPTh~47`|GFQ`BPre0eo$oKa2@ zwfm=QR;V{D`*4KWg<9d4pv~7PDuR@#(qH;#swf{ecJo=PaXS`y0iPrD2?wv{h5biI7 zQdg|gBf9kBS-2q_pnBl$x7;h`6bt$&BYwqF!Pkhz+jP^n&qk0 zz6T_^)jq?(0?Uq7+xhv!0~^#h%|`gR@^jh@l1a`hsgmYGC+QKZj@^bOAN{ZEQ(^fo qJTKA|?aqmz^vl3>ss9J`Ls(Jvb=P&OP163`1HxjQ(Dhcnr2hbxcR1nz literal 0 HcmV?d00001 diff --git a/test/__image_snapshots__/webkit-text-stroke-test-tsx-test-webkit-text-stroke-test-tsx-webkit-text-stroke-should-work-nested-and-complex-text-stroke-1-snap.png b/test/__image_snapshots__/webkit-text-stroke-test-tsx-test-webkit-text-stroke-test-tsx-webkit-text-stroke-should-work-nested-and-complex-text-stroke-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..0b3dc04c9a6eb9a5f629be9208b3ec2690ae36fd GIT binary patch literal 3115 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 { + let fonts + initFonts((f) => (fonts = f)) + + it('should work basic text stroke', async () => { + const svg = await satori( +
+ Hello, world +
, + { width: 100, height: 100, fonts } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + + it('should work nested text stroke', async () => { + const svg = await satori( +
+ Hello, world +
, + { width: 100, height: 100, fonts } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + + it('should work nested and complex text stroke', async () => { + const svg = await satori( +
+ Hello, + w + o + r + l + d + + ! + +
, + { width: 100, height: 100, fonts } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) +})