diff --git a/README.md b/README.md
index 7f0e9ad1..e2cc4322 100644
--- a/README.md
+++ b/README.md
@@ -198,6 +198,11 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
borderBottomRightRadius | Supported | |
| Shorthand | Supported, i.e. 5px, 50% / 5px | |
+cornerShape |
+| Values | round, squircle, square, bevel, scoop, notch, and superellipse() | |
+Corner longhands (cornerTopLeftShape, cornerTopRightShape, ...) | Supported | |
+Side shorthands (cornerTopShape, cornerRightShape, ...) | Supported. Corner shapes apply when the corresponding borderRadius is nonzero. | |
+
| Flex |
flexDirection | column, row, row-reverse, column-reverse, default to row | |
flexWrap | wrap, nowrap, wrap-reverse, default to nowrap | |
diff --git a/src/builder/border-radius.ts b/src/builder/border-radius.ts
index dc2b9923..0c23e080 100644
--- a/src/builder/border-radius.ts
+++ b/src/builder/border-radius.ts
@@ -6,6 +6,7 @@
// https://developer.mozilla.org/en-US/docs/Web/CSS/border-radius
import { buildXMLString, lengthToNumber } from '../utils.js'
+import { parseCornerShapeValue } from '../parser/corner-shape.js'
// Getting the intersection of a 45deg ray with the elliptical arc x^2/rx^2 + y^2/ry^2 = 1.
// Reference:
@@ -66,6 +67,159 @@ function resolveRadius(
const radiusZeroOrNull = (_radius?: [number, number]) =>
_radius && _radius[0] !== 0 && _radius[1] !== 0
+function resolveCornerShape(value: unknown) {
+ if (typeof value !== 'string') return 1
+ return parseCornerShapeValue(value)
+}
+
+type Point = [number, number]
+
+function cornerPoints(
+ start: Point,
+ end: Point,
+ outer: Point,
+ center: Point,
+ shape: number
+) {
+ if (shape === Infinity) return [start, outer, end]
+ if (shape === -Infinity) return [start, center, end]
+ if (shape === 0) {
+ return [
+ start,
+ [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as Point,
+ end,
+ ]
+ }
+
+ const curveCenter = shape < 0 ? outer : center
+ const exponent = Math.pow(2, 1 - Math.abs(shape))
+ if (exponent === 0)
+ return shape > 0 ? [start, outer, end] : [start, center, end]
+ const points: Point[] = []
+ const segments = 16
+
+ for (let i = 0; i <= segments; i++) {
+ const angle = (Math.PI * i) / segments / 2
+ const x = Math.pow(Math.sin(angle), exponent)
+ const y = Math.pow(Math.cos(angle), exponent)
+ points.push([
+ curveCenter[0] +
+ (end[0] - curveCenter[0]) * x +
+ (start[0] - curveCenter[0]) * y,
+ curveCenter[1] +
+ (end[1] - curveCenter[1]) * x +
+ (start[1] - curveCenter[1]) * y,
+ ])
+ }
+
+ return points
+}
+
+function pointString(point: Point) {
+ return `${Math.round(point[0] * 1000) / 1000},${
+ Math.round(point[1] * 1000) / 1000
+ }`
+}
+
+function oppositeRadiiScale(
+ first: [number, number],
+ second: [number, number],
+ width: number,
+ height: number
+) {
+ return Math.min(
+ 1,
+ width / (first[0] + second[0] || width),
+ height / (first[1] + second[1] || height)
+ )
+}
+
+function shapedRadiusPath(
+ left: number,
+ top: number,
+ width: number,
+ height: number,
+ radii: [number, number][],
+ shapes: number[],
+ partialSides?: boolean[]
+) {
+ if (partialSides?.every(Boolean)) partialSides = undefined
+
+ const right = left + width
+ const bottom = top + height
+ const [topLeft, topRight, bottomRight, bottomLeft] = radii
+ const corners = [
+ cornerPoints(
+ [left, top + topLeft[1]],
+ [left + topLeft[0], top],
+ [left, top],
+ [left + topLeft[0], top + topLeft[1]],
+ shapes[0]
+ ),
+ cornerPoints(
+ [right - topRight[0], top],
+ [right, top + topRight[1]],
+ [right, top],
+ [right - topRight[0], top + topRight[1]],
+ shapes[1]
+ ),
+ cornerPoints(
+ [right, bottom - bottomRight[1]],
+ [right - bottomRight[0], bottom],
+ [right, bottom],
+ [right - bottomRight[0], bottom - bottomRight[1]],
+ shapes[2]
+ ),
+ cornerPoints(
+ [left + bottomLeft[0], bottom],
+ [left, bottom - bottomLeft[1]],
+ [left, bottom],
+ [left + bottomLeft[0], bottom - bottomLeft[1]],
+ shapes[3]
+ ),
+ ]
+
+ if (partialSides) {
+ let start = partialSides.indexOf(true)
+ if (start === -1) throw new Error('Invalid `partialSides`.')
+ while (partialSides[(start + 3) % 4]) start = (start + 3) % 4
+
+ const firstCorner = corners[start]
+ const firstMiddle = Math.floor(firstCorner.length / 2)
+ let path = `M${pointString(firstCorner[firstMiddle])}`
+ let side = start
+
+ do {
+ const currentCorner = corners[side]
+ const currentMiddle = Math.floor(currentCorner.length / 2)
+ for (let i = currentMiddle + 1; i < currentCorner.length; i++) {
+ path += ` L${pointString(currentCorner[i])}`
+ }
+
+ const nextCorner = corners[(side + 1) % 4]
+ path += ` L${pointString(nextCorner[0])}`
+ const nextMiddle = Math.floor(nextCorner.length / 2)
+ for (let i = 1; i <= nextMiddle; i++) {
+ path += ` L${pointString(nextCorner[i])}`
+ }
+
+ side = (side + 1) % 4
+ } while (partialSides[side] && side !== start)
+
+ return path
+ }
+
+ let path = `M${pointString(corners[0][corners[0].length - 1])}`
+ for (let side = 0; side < 4; side++) {
+ const nextCorner = corners[(side + 1) % 4]
+ path += ` L${pointString(nextCorner[0])}`
+ for (let i = 1; i < nextCorner.length; i++) {
+ path += ` L${pointString(nextCorner[i])}`
+ }
+ }
+ return path + ' Z'
+}
+
export function getBorderRadiusClipPath(
{
id,
@@ -126,6 +280,12 @@ export default function radius(
borderBottomRightRadius,
fontSize,
} = style
+ const cornerShapes = [
+ resolveCornerShape(style.cornerTopLeftShape),
+ resolveCornerShape(style.cornerTopRightShape),
+ resolveCornerShape(style.cornerBottomRightShape),
+ resolveCornerShape(style.cornerBottomLeftShape),
+ ]
let singleAbsValueTopLeftCorner
let singleAbsValueTopRightCorner
@@ -216,6 +376,51 @@ export default function radius(
makeSmaller(borderBottomRightRadius)
}
+ if (cornerShapes.some((shape) => shape < 0)) {
+ const scale = Math.min(
+ oppositeRadiiScale(
+ borderTopLeftRadius,
+ borderBottomRightRadius,
+ width,
+ height
+ ),
+ oppositeRadiiScale(
+ borderTopRightRadius,
+ borderBottomLeftRadius,
+ width,
+ height
+ )
+ )
+ if (scale < 1) {
+ for (const corner of [
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderBottomRightRadius,
+ borderBottomLeftRadius,
+ ]) {
+ corner[0] *= scale
+ corner[1] *= scale
+ }
+ }
+ }
+
+ if (cornerShapes.some((shape) => shape !== 1)) {
+ return shapedRadiusPath(
+ left,
+ top,
+ width,
+ height,
+ [
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderBottomRightRadius,
+ borderBottomLeftRadius,
+ ],
+ cornerShapes,
+ partialSides
+ )
+ }
+
type Arc = [[number, number], [number, number]]
const p: Arc[] = []
p[0] = [borderTopRightRadius, borderTopRightRadius]
diff --git a/src/handler/expand.ts b/src/handler/expand.ts
index cd3b42a3..8746076d 100644
--- a/src/handler/expand.ts
+++ b/src/handler/expand.ts
@@ -14,6 +14,7 @@ import parseTransformOrigin, {
} from '../transform-origin.js'
import { isString, lengthToNumber, v, splitEffects } from '../utils.js'
import { MaskProperty, parseMask } from '../parser/mask.js'
+import { splitCornerShapeValues } from '../parser/corner-shape.js'
import { FontWeight, FontStyle } from '../font.js'
import {
extractCustomProperties,
@@ -100,6 +101,52 @@ function handleSpecialCase(
return vv
}
+ if (name === 'cornerShape') {
+ if (typeof value !== 'string') {
+ throw new Error('Invalid `cornerShape` value: "' + value + '".')
+ }
+ const values = splitCornerShapeValues(value)
+ const topLeft = values[0]
+ const topRight = values[1] || topLeft
+ const bottomRight = values[2] || topLeft
+ const bottomLeft = values[3] || topRight
+
+ return {
+ cornerTopLeftShape: topLeft,
+ cornerTopRightShape: topRight,
+ cornerBottomRightShape: bottomRight,
+ cornerBottomLeftShape: bottomLeft,
+ }
+ }
+
+ if (/^corner(TopLeft|TopRight|BottomRight|BottomLeft)Shape$/.test(name)) {
+ if (
+ typeof value !== 'string' ||
+ splitCornerShapeValues(value, 1).length !== 1
+ ) {
+ throw new Error('Invalid `' + name + '` value: "' + value + '".')
+ }
+ return { [name]: value }
+ }
+
+ const cornerSide = name.match(/^corner(Top|Right|Bottom|Left)Shape$/)
+ if (cornerSide) {
+ if (typeof value !== 'string') {
+ throw new Error('Invalid `' + name + '` value: "' + value + '".')
+ }
+ const values = splitCornerShapeValues(value, 2)
+ const first = values[0]
+ const second = values[1] || first
+ const properties = {
+ Top: ['cornerTopLeftShape', 'cornerTopRightShape'],
+ Right: ['cornerTopRightShape', 'cornerBottomRightShape'],
+ Bottom: ['cornerBottomLeftShape', 'cornerBottomRightShape'],
+ Left: ['cornerTopLeftShape', 'cornerBottomLeftShape'],
+ }[cornerSide[1]]
+
+ return { [properties[0]]: first, [properties[1]]: second }
+ }
+
if (/^border(Top|Right|Bottom|Left)?$/.test(name)) {
const resolved = getStylesForProperty('border', value, true)
diff --git a/src/parser/corner-shape.ts b/src/parser/corner-shape.ts
new file mode 100644
index 00000000..383dd0e2
--- /dev/null
+++ b/src/parser/corner-shape.ts
@@ -0,0 +1,38 @@
+export const cornerShapeKeywords = {
+ round: 1,
+ squircle: 2,
+ square: Infinity,
+ bevel: 0,
+ scoop: -1,
+ notch: -Infinity,
+} as const
+
+export function parseCornerShapeValue(value: string) {
+ const normalized = value.trim().toLowerCase()
+ const keyword = cornerShapeKeywords[normalized]
+ if (typeof keyword !== 'undefined') return keyword
+
+ const match = normalized.match(
+ /^superellipse\(\s*(-?infinity|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?)\s*\)$/
+ )
+ if (!match) throw new Error('Invalid corner shape value: "' + value + '".')
+ if (match[1] === 'infinity') return Infinity
+ if (match[1] === '-infinity') return -Infinity
+
+ const shape = Number(match[1])
+ if (!Number.isFinite(shape)) {
+ throw new Error('Invalid corner shape value: "' + value + '".')
+ }
+ return shape
+}
+
+export function splitCornerShapeValues(value: string, maxValues = 4) {
+ const values = value.match(/superellipse\([^)]*\)|[^\s]+/gi) || []
+
+ if (values.length < 1 || values.length > maxValues) {
+ throw new Error('Invalid corner shape value: "' + value + '".')
+ }
+
+ for (const shape of values) parseCornerShapeValue(shape)
+ return values
+}
diff --git a/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-applies-shaped-corners-to-fills-and-directional-borders-1-snap.png b/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-applies-shaped-corners-to-fills-and-directional-borders-1-snap.png
new file mode 100644
index 00000000..d49d3a7e
Binary files /dev/null and b/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-applies-shaped-corners-to-fills-and-directional-borders-1-snap.png differ
diff --git a/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-compares-the-corner-shape-values-1-snap.png b/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-compares-the-corner-shape-values-1-snap.png
new file mode 100644
index 00000000..6d8f046b
Binary files /dev/null and b/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-compares-the-corner-shape-values-1-snap.png differ
diff --git a/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-renders-a-bevel-contour-1-snap.png b/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-renders-a-bevel-contour-1-snap.png
new file mode 100644
index 00000000..bc807943
Binary files /dev/null and b/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-renders-a-bevel-contour-1-snap.png differ
diff --git a/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-renders-a-uniform-border-around-a-shaped-contour-1-snap.png b/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-renders-a-uniform-border-around-a-shaped-contour-1-snap.png
new file mode 100644
index 00000000..881c4542
Binary files /dev/null and b/test/__image_snapshots__/corner-shape-test-tsx-test-corner-shape-test-tsx-corner-shape-renders-a-uniform-border-around-a-shaped-contour-1-snap.png differ
diff --git a/test/assets/Geist-Bold.ttf b/test/assets/Geist-Bold.ttf
new file mode 100644
index 00000000..5b071f45
Binary files /dev/null and b/test/assets/Geist-Bold.ttf differ
diff --git a/test/assets/Geist-Regular.ttf b/test/assets/Geist-Regular.ttf
new file mode 100644
index 00000000..71cb8160
Binary files /dev/null and b/test/assets/Geist-Regular.ttf differ
diff --git a/test/assets/GeistMono-Regular.ttf b/test/assets/GeistMono-Regular.ttf
new file mode 100644
index 00000000..643e6ba0
Binary files /dev/null and b/test/assets/GeistMono-Regular.ttf differ
diff --git a/test/corner-shape.test.tsx b/test/corner-shape.test.tsx
new file mode 100644
index 00000000..f8bf88be
--- /dev/null
+++ b/test/corner-shape.test.tsx
@@ -0,0 +1,303 @@
+import { beforeAll, describe, expect, it } from 'vitest'
+import { join } from 'node:path'
+import { readFile } from 'node:fs/promises'
+
+import expand from '../src/handler/expand.js'
+import {
+ parseCornerShapeValue,
+ splitCornerShapeValues,
+} from '../src/parser/corner-shape.js'
+import satori from '../src/index.js'
+import { toImage } from './utils.js'
+
+type CornerShapeValue =
+ | 'round'
+ | 'squircle'
+ | 'square'
+ | 'bevel'
+ | 'scoop'
+ | 'notch'
+ | `superellipse(${string})`
+
+declare module 'react' {
+ interface CSSProperties {
+ cornerShape?: CornerShapeValue | string
+ cornerTopShape?: CornerShapeValue | string
+ cornerRightShape?: CornerShapeValue | string
+ cornerBottomShape?: CornerShapeValue | string
+ cornerLeftShape?: CornerShapeValue | string
+ cornerTopLeftShape?: CornerShapeValue | string
+ cornerTopRightShape?: CornerShapeValue | string
+ cornerBottomRightShape?: CornerShapeValue | string
+ cornerBottomLeftShape?: CornerShapeValue | string
+ }
+}
+
+const inheritedStyle = {
+ color: 'black',
+ fontSize: 16,
+ opacity: 1,
+}
+
+let comparisonFonts
+
+beforeAll(async () => {
+ comparisonFonts = [
+ {
+ name: 'Geist',
+ data: await readFile(
+ join(process.cwd(), 'test/assets/Geist-Regular.ttf')
+ ),
+ weight: 400,
+ style: 'normal',
+ },
+ {
+ name: 'Geist',
+ data: await readFile(join(process.cwd(), 'test/assets/Geist-Bold.ttf')),
+ weight: 700,
+ style: 'normal',
+ },
+ {
+ name: 'Geist Mono',
+ data: await readFile(
+ join(process.cwd(), 'test/assets/GeistMono-Regular.ttf')
+ ),
+ weight: 400,
+ style: 'normal',
+ },
+ ]
+})
+
+describe('corner-shape', () => {
+ it('parses keywords and superellipse functions', () => {
+ expect(parseCornerShapeValue('round')).toBe(1)
+ expect(parseCornerShapeValue('squircle')).toBe(2)
+ expect(parseCornerShapeValue('square')).toBe(Infinity)
+ expect(parseCornerShapeValue('bevel')).toBe(0)
+ expect(parseCornerShapeValue('scoop')).toBe(-1)
+ expect(parseCornerShapeValue('notch')).toBe(-Infinity)
+ expect(parseCornerShapeValue('superellipse(-1.5)')).toBe(-1.5)
+ expect(splitCornerShapeValues('scoop superellipse( -1.5 )')).toEqual([
+ 'scoop',
+ 'superellipse( -1.5 )',
+ ])
+ expect(() => parseCornerShapeValue('rounded')).toThrow(
+ 'Invalid corner shape value'
+ )
+ })
+
+ it('expands shorthand and side values like border-radius', () => {
+ expect(
+ expand(
+ {
+ cornerShape: 'scoop square squircle',
+ cornerLeftShape: 'bevel notch',
+ },
+ inheritedStyle
+ )
+ ).toMatchObject({
+ cornerTopLeftShape: 'bevel',
+ cornerTopRightShape: 'square',
+ cornerBottomRightShape: 'squircle',
+ cornerBottomLeftShape: 'notch',
+ })
+ })
+
+ it('renders a bevel contour', async () => {
+ const svg = await satori(
+ ,
+ { width: 100, height: 100, fonts: [] }
+ )
+
+ expect(toImage(svg, 100)).toMatchImageSnapshot()
+ })
+
+ it('applies shaped corners to fills and directional borders', async () => {
+ const svg = await satori(
+ ,
+ { width: 100, height: 100, fonts: [] }
+ )
+
+ expect(toImage(svg, 100)).toMatchImageSnapshot()
+ })
+
+ it('renders a uniform border around a shaped contour', async () => {
+ const svg = await satori(
+ ,
+ { width: 100, height: 100, fonts: [] }
+ )
+
+ expect(toImage(svg, 100)).toMatchImageSnapshot()
+ })
+
+ it('compares the corner shape values', async () => {
+ const specimens = [
+ { label: 'Default', value: undefined, color: '#3155ff' },
+ { label: 'Squircle', value: 'squircle', color: '#ff5b45' },
+ { label: 'Bevel', value: 'bevel', color: '#c6ef46' },
+ { label: 'Scoop', value: 'scoop', color: '#ffcc32' },
+ { label: 'Notch', value: 'notch', color: '#9e7bff' },
+ { label: 'Square', value: 'square', color: '#49d6cf' },
+ ]
+ const svg = await satori(
+
+
+
+
+
+ corner-shape
+
+ support in Satori
+
+
+ 6 contours from CSS Borders Level 4
+
+
+
+
+
+ {specimens.map((specimen, index) => (
+
+
+
+
+ {specimen.value || 'round'}
+
+
+
+ {specimen.value
+ ? `corner-shape: ${specimen.value};`
+ : 'corner-shape: round;'}
+
+
border-radius: 0 6em 0 6em;
+
+
+
+ ))}
+
+
,
+ { width: 1200, height: 630, fonts: comparisonFonts, pointScaleFactor: 2 }
+ )
+
+ expect(toImage(svg, 1200)).toMatchImageSnapshot()
+ })
+})