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 00000000..4931a5bd Binary files /dev/null and b/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-shadow-box-shadow-should-support-multiple-box-shadows-1-snap.png differ 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() + }) }) })