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 borderBottomRightRadiusSupported ShorthandSupported, i.e. 5px, 50% / 5px +cornerShape +Valuesround, 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 flexDirectioncolumn, row, row-reverse, column-reverse, default to row flexWrapwrap, 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() + }) +})