Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Support multiple text-shadow #436

Merged
merged 1 commit into from
Apr 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
})
})
})