From baeb73ac488f5f2275f3e668821709fe25bf3d24 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sat, 8 Apr 2023 00:30:07 +0200 Subject: [PATCH] closes #328 --- src/builder/shadow.ts | 89 +++++++++++++----- src/handler/expand.ts | 20 ++++ src/text.ts | 26 ++++- ...ld-support-multiple-box-shadows-1-snap.png | Bin 0 -> 2746 bytes test/shadow.test.tsx | 18 ++++ 5 files changed, 126 insertions(+), 27 deletions(-) create mode 100644 test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-box-shadows-1-snap.png diff --git a/src/builder/shadow.ts b/src/builder/shadow.ts index b2d02bd9..f984e582 100644 --- a/src/builder/shadow.ts +++ b/src/builder/shadow.ts @@ -13,6 +13,15 @@ function shiftPath(path: string, dx: number, dy: number) { ) } +// The scale is used to make the filter area larger than the bounding box, +// because usually the given measured text bounding is larger than the path +// bounding. +// The text bounding box is measured via the font metrics, which is not the same +// as the actual content. For example, the text bounding box of "A" is larger +// than the actual "a" path but they have the same font metrics. +// This scale can be adjusted to prevent the filter from cutting off the text. +const SCALE = 1.1 + export function buildDropShadow( { id, width, height }: { id: string; width: number; height: number }, style: Record @@ -25,28 +34,64 @@ export function buildDropShadow( return '' } - // Expand the area for the filter to prevent it from cutting off. - const grow = (style.shadowRadius * style.shadowRadius) / 4 - - const left = Math.min(style.shadowOffset.width - grow, 0) - const right = Math.max(style.shadowOffset.width + grow + width, width) - const top = Math.min(style.shadowOffset.height - grow, 0) - const bottom = Math.max(style.shadowOffset.height + grow + height, height) - - return `` + const shadowCount = style.shadowColor.length + let effects = '' + let merge = '' + + // There could be multiple shadows, we need to get the maximum bounding box + // and use `feMerge` to merge them together. + let left = 0 + let right = width + let top = 0 + let bottom = height + for (let i = 0; i < shadowCount; i++) { + // Expand the area for the filter to prevent it from cutting off. + const grow = (style.shadowRadius[i] * style.shadowRadius[i]) / 4 + left = Math.min(style.shadowOffset[i].width - grow, left) + right = Math.max(style.shadowOffset[i].width + grow + width, right) + top = Math.min(style.shadowOffset[i].height - grow, top) + bottom = Math.max(style.shadowOffset[i].height + grow + height, bottom) + + effects += buildXMLString('feDropShadow', { + dx: style.shadowOffset[i].width, + dy: style.shadowOffset[i].height, + stdDeviation: + // According to the spec, we use the half of the blur radius as the standard + // deviation for the filter. + // > the image that would be generated by applying to the shadow a Gaussian + // > blur with a standard deviation equal to half the blur radius + // > https://www.w3.org/TR/css-backgrounds-3/#shadow-blur + style.shadowRadius[i] / 2, + 'flood-color': style.shadowColor[i], + 'flood-opacity': 1, + ...(shadowCount > 1 + ? { + in: 'SourceGraphic', + result: `satori_s-${id}-result-${i}`, + } + : {}), + }) + + if (shadowCount > 1) { + // Merge needs to be in reverse order. + merge = + buildXMLString('feMergeNode', { + in: `satori_s-${id}-result-${i}`, + }) + merge + } + } + + return buildXMLString( + 'filter', + { + id: `satori_s-${id}`, + x: ((left / width) * 100 * SCALE).toFixed(2) + '%', + y: ((top / height) * 100 * SCALE).toFixed(2) + '%', + width: (((right - left) / width) * 100 * SCALE).toFixed(2) + '%', + height: (((bottom - top) / height) * 100 * SCALE).toFixed(2) + '%', + }, + effects + (merge ? buildXMLString('feMerge', {}, merge) : '') + ) } export function boxShadow( diff --git a/src/handler/expand.ts b/src/handler/expand.ts index a64d7d0e..6c6529d4 100644 --- a/src/handler/expand.ts +++ b/src/handler/expand.ts @@ -166,6 +166,26 @@ function handleSpecialCase( return getStylesForProperty('background', value, true) } + if (name === 'textShadow') { + // Handle multiple text shadows if provided. + value = value.toString().trim() + if (value.includes(',')) { + const shadows = value.split(',') + const result = {} + for (const shadow of shadows) { + const styles = getStylesForProperty('textShadow', shadow, true) + for (const k in styles) { + if (!result[k]) { + result[k] = [styles[k]] + } else { + result[k].push(styles[k]) + } + } + } + return result + } + } + return } diff --git a/src/text.ts b/src/text.ts index e4cd6ac3..6b0d5d39 100644 --- a/src/text.ts +++ b/src/text.ts @@ -343,6 +343,10 @@ export default async function* buildTextNodes( return { width: maxWidth, height } } + // It's possible that the text's measured size is different from the container's + // size, because the container might have a fixed width or height or being + // expanded by its parent. + let measuredTextSize = { width: 0, height: 0 } textContainer.setMeasureFunc((containerWidth) => { const { width, height } = flow(containerWidth) @@ -363,9 +367,11 @@ export default async function* buildTextNodes( } } flow(r) + measuredTextSize = { width: r, height } return { width: r, height } } + measuredTextSize = { width, height } return { width, height } }) @@ -408,18 +414,28 @@ export default async function* buildTextNodes( let filter = '' if (parentStyle.textShadowOffset) { + let { textShadowColor, textShadowOffset, textShadowRadius } = + parentStyle as any + if (!Array.isArray(parentStyle.textShadowOffset)) { + textShadowColor = [textShadowColor] + textShadowOffset = [textShadowOffset] + textShadowRadius = [textShadowRadius] + } + filter = buildDropShadow( { - width: containerWidth, - height: containerHeight, + width: measuredTextSize.width, + height: measuredTextSize.height, id, }, { - shadowColor: parentStyle.textShadowColor, - shadowOffset: parentStyle.textShadowOffset, - shadowRadius: parentStyle.textShadowRadius, + shadowColor: textShadowColor, + shadowOffset: textShadowOffset, + shadowRadius: textShadowRadius, } ) + + filter = buildXMLString('defs', {}, filter) } let decorationShape = '' diff --git a/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-box-shadows-1-snap.png b/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-box-shadows-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..4931a5bd112fea4aedcac387d20576622a44bed8 GIT binary patch literal 2746 zcmd6pX*d)L7ssW@PSUlEFoa>0eJc_(29X#hRI-dMV;>PQuF;HL6qDV>*ix9Rld(;f zv6Csp#bmp7V=0Qf?)&}y{(d;;d^qR)p8s<`oaY~JWog38CC0_V!oq823Wc9mw||3! z{WJ%B%dll(;fgkc8rnY1-N=sg5$X}`mdxP?2)0WtumDM%QmKmA!c-_dT@xt%I0HKc z=dtA#5U?DxD!1a`6S{G!LLq)YQqzhJYn=->iO7;;HR>~Qzbz|-<%Abv!yW6{9$c7+ z;E267z1+h5`fRPar8e?4Kz0{0%p3|24e`hi^EB9ena#=b)tP6M2N~xf0@3&=QY6|f zFsOp}!mC2nTSM^lFJFq@zkiwLdUU{~4Gnn+Ss)OXEDLT9nv#N7;2635>O3Go+plwH zv0qf7)FKOu>`CcJba&9f9O=m5u6-16qcsK2J5OneS2;F!52w_6jY_Z2wB)3@_7kpV z>Oa#eKA6`e>VUbyWL{CxTOM-aO4Y!W#~LbW=R`lXMMY_7XsE!Oe$#&hQ zRD&^lP&5~BF#}H0Ai0Dgr4oz5!W?_8rXDYzkno`pvcuUTtrWK1*)HeXg|IEz0e<$~aWy zaiBa^z4nL_r|vmaLrm2g%GTsICn&{FzzphCiA6=yDs!Jk1Fp!*x-1Q3P1Jhwn-|8H z0d;kCqdCz(HXR8@7w-U$N2mg-$e~=_w{2M^w1}-VlkJWY^Q5mAl_9Vc@s%466jOlq zW!+B16_ohXwNG*GypcN~R7ydxIo$@w&H1@k73|`TzDo!7F$`p8y3Y&ClZq>p0w>!o zj|3`36fCXymn4k4CAj%WT32*+vq*}1hL?%r>YF8&os;=&IuViL<_SGaZ3>>OL$%Me zJbpaDPRw)c0I9X&JGoBrE2G6Gs1PAp`T21tOmAFF;0frh`Slb{Z3$k5?VlDFMKMsArx&4N4#(tvkNs{n*XcJ=8Vq#Zoay}PK7m-gb8q@MyX!snMP+ezCKx$(uc(wA1WwEY zwuI@U^Kn=1-h*RpUaN#C)%Z)MX2nZQBMXA%R)hE7)`cDA$KzC>?s37TG7(96;s=eQ3O?nAXn>mM zkn=-w5bi-Uo)_^$ajCCYVdbILH)Nd6a9b8wbR9QGzr+QG9w!fEXe{-8-|&v-Hnzp< zBqvE^ZTTh=cs0HJAVKJu;m-~}=f)l3f=&=Of@l3g!ay*&3P;_kaH@)TcjqvRE+dr&ZJVQbCKB zgi{$>wVC(LI5&Sn(o`THLaV6@y|2g!YsvE>O3FKL|2jz8!<4L3&hxC(2vXa%`-Mk%Yidxt1I+=pS@Sp z;A!%Lw$ppM?CEP_P;nC+A-yJ!EM5KJXmdo+FHVUY)$^h!rP9tJ zA^%U&{RjnRe%771nH}OKeh91YLgyqS93l`OPyc0mUAC#fiAZKVD!4K&5Sw5alWJ$z z15(@S{V`q;*=-uQaU;j-*Dqn#ctL*1IaT*x+YHdRP+%J9pP9?bV-!phPWi)~j@lo5 zY@4jOY%wYIi$G2_=oj;;1}3^yd9|PG^J44`++#N#x4hT0--q0@4J@wCX=N#3@Bwmj zZL^NaQ4#mWo(ebQ2g&#zp4q?Jw^ASH(Np&-eRPZf(%j-(N~@IxP#efyzQSw+;P*WfSgdzl z?Wp}!Z0I&Ot9sY4z<1%TW8#I6`~_5zrQ09m?N%oVZR&R4)$MXqxC}iweA@QrckxPe z1V8K&+Fb%&k_QYDSgcdcZoLgS(JTDp#iKXh)O-1eBR;Wf0~ul#i#zFq*=kM3Ko6RD zZy#25#L|U6O%V3GilXPZl8L|3nyMo&Cc!5$p2xVl7QJR|Mx9s6wmqM Vn^{b+fKyM(VrFa!tuk`P{s*Zp8vy_S literal 0 HcmV?d00001 diff --git a/test/shadow.test.tsx b/test/shadow.test.tsx index 88bc28b4..2adad156 100644 --- a/test/shadow.test.tsx +++ b/test/shadow.test.tsx @@ -194,5 +194,23 @@ describe('Shadow', () => { ) expect(toImage(svg, 100)).toMatchImageSnapshot() }) + + it('should support multiple box shadows', async () => { + const svg = await satori( +
+ Hello +
, + { width: 100, height: 100, fonts } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) }) })