Skip to content

Commit

Permalink
fix: Support multiple text-shadow (#436)
Browse files Browse the repository at this point in the history
Closes #328.
  • Loading branch information
shuding authored Apr 7, 2023
1 parent 078a9a4 commit 8d5c7a6
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 27 deletions.
89 changes: 67 additions & 22 deletions src/builder/shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>
Expand All @@ -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 `<defs><filter id="satori_s-${id}" x="${(left / width) * 100}%" y="${
(top / height) * 100
}%" width="${((right - left) / width) * 100}%" height="${
((bottom - top) / height) * 100
}%"><feDropShadow dx="${style.shadowOffset.width}" dy="${
style.shadowOffset.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 / 2
}" flood-color="${style.shadowColor}" flood-opacity="1"/></filter></defs>`
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(
Expand Down
20 changes: 20 additions & 0 deletions src/handler/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
26 changes: 21 additions & 5 deletions src/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 }
})

Expand Down Expand Up @@ -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 = ''
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions test/shadow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,5 +194,23 @@ describe('Shadow', () => {
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})

it('should support multiple box shadows', async () => {
const svg = await satori(
<div
style={{
background: 'white',
width: 100,
height: 100,
fontSize: 40,
textShadow: '2px 2px red, 4px 4px blue',
}}
>
Hello
</div>,
{ width: 100, height: 100, fonts }
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})
})
})

1 comment on commit 8d5c7a6

@vercel
Copy link

@vercel vercel bot commented on 8d5c7a6 Apr 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.