Skip to content

Commit 8705f4c

Browse files
authored
1 parent e872906 commit 8705f4c

12 files changed

Lines changed: 598 additions & 0 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
198198
<tr><td><code>borderBottomRightRadius</code></td><td>Supported</td><td></td></tr>
199199
<tr><td>Shorthand</td><td>Supported, i.e. <code>5px</code>, <code>50% / 5px</code></td><td></td></tr>
200200

201+
<tr><td rowspan="4"><code>cornerShape</code></td></tr>
202+
<tr><td>Values</td><td><code>round</code>, <code>squircle</code>, <code>square</code>, <code>bevel</code>, <code>scoop</code>, <code>notch</code>, and <code>superellipse()</code></td><td></td></tr>
203+
<tr><td>Corner longhands (<code>cornerTopLeftShape</code>, <code>cornerTopRightShape</code>, ...)</td><td>Supported</td><td></td></tr>
204+
<tr><td>Side shorthands (<code>cornerTopShape</code>, <code>cornerRightShape</code>, ...)</td><td>Supported. Corner shapes apply when the corresponding <code>borderRadius</code> is nonzero.</td><td></td></tr>
205+
201206
<tr><td rowspan="11">Flex</td></tr>
202207
<tr><td><code>flexDirection</code></td><td><code>column</code>, <code>row</code>, <code>row-reverse</code>, <code>column-reverse</code>, default to <code>row</code></td><td></td></tr>
203208
<tr><td><code>flexWrap</code></td><td><code>wrap</code>, <code>nowrap</code>, <code>wrap-reverse</code>, default to <code>nowrap</code></td><td></td></tr>

src/builder/border-radius.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// https://developer.mozilla.org/en-US/docs/Web/CSS/border-radius
77

88
import { buildXMLString, lengthToNumber } from '../utils.js'
9+
import { parseCornerShapeValue } from '../parser/corner-shape.js'
910

1011
// Getting the intersection of a 45deg ray with the elliptical arc x^2/rx^2 + y^2/ry^2 = 1.
1112
// Reference:
@@ -66,6 +67,159 @@ function resolveRadius(
6667
const radiusZeroOrNull = (_radius?: [number, number]) =>
6768
_radius && _radius[0] !== 0 && _radius[1] !== 0
6869

70+
function resolveCornerShape(value: unknown) {
71+
if (typeof value !== 'string') return 1
72+
return parseCornerShapeValue(value)
73+
}
74+
75+
type Point = [number, number]
76+
77+
function cornerPoints(
78+
start: Point,
79+
end: Point,
80+
outer: Point,
81+
center: Point,
82+
shape: number
83+
) {
84+
if (shape === Infinity) return [start, outer, end]
85+
if (shape === -Infinity) return [start, center, end]
86+
if (shape === 0) {
87+
return [
88+
start,
89+
[(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as Point,
90+
end,
91+
]
92+
}
93+
94+
const curveCenter = shape < 0 ? outer : center
95+
const exponent = Math.pow(2, 1 - Math.abs(shape))
96+
if (exponent === 0)
97+
return shape > 0 ? [start, outer, end] : [start, center, end]
98+
const points: Point[] = []
99+
const segments = 16
100+
101+
for (let i = 0; i <= segments; i++) {
102+
const angle = (Math.PI * i) / segments / 2
103+
const x = Math.pow(Math.sin(angle), exponent)
104+
const y = Math.pow(Math.cos(angle), exponent)
105+
points.push([
106+
curveCenter[0] +
107+
(end[0] - curveCenter[0]) * x +
108+
(start[0] - curveCenter[0]) * y,
109+
curveCenter[1] +
110+
(end[1] - curveCenter[1]) * x +
111+
(start[1] - curveCenter[1]) * y,
112+
])
113+
}
114+
115+
return points
116+
}
117+
118+
function pointString(point: Point) {
119+
return `${Math.round(point[0] * 1000) / 1000},${
120+
Math.round(point[1] * 1000) / 1000
121+
}`
122+
}
123+
124+
function oppositeRadiiScale(
125+
first: [number, number],
126+
second: [number, number],
127+
width: number,
128+
height: number
129+
) {
130+
return Math.min(
131+
1,
132+
width / (first[0] + second[0] || width),
133+
height / (first[1] + second[1] || height)
134+
)
135+
}
136+
137+
function shapedRadiusPath(
138+
left: number,
139+
top: number,
140+
width: number,
141+
height: number,
142+
radii: [number, number][],
143+
shapes: number[],
144+
partialSides?: boolean[]
145+
) {
146+
if (partialSides?.every(Boolean)) partialSides = undefined
147+
148+
const right = left + width
149+
const bottom = top + height
150+
const [topLeft, topRight, bottomRight, bottomLeft] = radii
151+
const corners = [
152+
cornerPoints(
153+
[left, top + topLeft[1]],
154+
[left + topLeft[0], top],
155+
[left, top],
156+
[left + topLeft[0], top + topLeft[1]],
157+
shapes[0]
158+
),
159+
cornerPoints(
160+
[right - topRight[0], top],
161+
[right, top + topRight[1]],
162+
[right, top],
163+
[right - topRight[0], top + topRight[1]],
164+
shapes[1]
165+
),
166+
cornerPoints(
167+
[right, bottom - bottomRight[1]],
168+
[right - bottomRight[0], bottom],
169+
[right, bottom],
170+
[right - bottomRight[0], bottom - bottomRight[1]],
171+
shapes[2]
172+
),
173+
cornerPoints(
174+
[left + bottomLeft[0], bottom],
175+
[left, bottom - bottomLeft[1]],
176+
[left, bottom],
177+
[left + bottomLeft[0], bottom - bottomLeft[1]],
178+
shapes[3]
179+
),
180+
]
181+
182+
if (partialSides) {
183+
let start = partialSides.indexOf(true)
184+
if (start === -1) throw new Error('Invalid `partialSides`.')
185+
while (partialSides[(start + 3) % 4]) start = (start + 3) % 4
186+
187+
const firstCorner = corners[start]
188+
const firstMiddle = Math.floor(firstCorner.length / 2)
189+
let path = `M${pointString(firstCorner[firstMiddle])}`
190+
let side = start
191+
192+
do {
193+
const currentCorner = corners[side]
194+
const currentMiddle = Math.floor(currentCorner.length / 2)
195+
for (let i = currentMiddle + 1; i < currentCorner.length; i++) {
196+
path += ` L${pointString(currentCorner[i])}`
197+
}
198+
199+
const nextCorner = corners[(side + 1) % 4]
200+
path += ` L${pointString(nextCorner[0])}`
201+
const nextMiddle = Math.floor(nextCorner.length / 2)
202+
for (let i = 1; i <= nextMiddle; i++) {
203+
path += ` L${pointString(nextCorner[i])}`
204+
}
205+
206+
side = (side + 1) % 4
207+
} while (partialSides[side] && side !== start)
208+
209+
return path
210+
}
211+
212+
let path = `M${pointString(corners[0][corners[0].length - 1])}`
213+
for (let side = 0; side < 4; side++) {
214+
const nextCorner = corners[(side + 1) % 4]
215+
path += ` L${pointString(nextCorner[0])}`
216+
for (let i = 1; i < nextCorner.length; i++) {
217+
path += ` L${pointString(nextCorner[i])}`
218+
}
219+
}
220+
return path + ' Z'
221+
}
222+
69223
export function getBorderRadiusClipPath(
70224
{
71225
id,
@@ -126,6 +280,12 @@ export default function radius(
126280
borderBottomRightRadius,
127281
fontSize,
128282
} = style
283+
const cornerShapes = [
284+
resolveCornerShape(style.cornerTopLeftShape),
285+
resolveCornerShape(style.cornerTopRightShape),
286+
resolveCornerShape(style.cornerBottomRightShape),
287+
resolveCornerShape(style.cornerBottomLeftShape),
288+
]
129289

130290
let singleAbsValueTopLeftCorner
131291
let singleAbsValueTopRightCorner
@@ -216,6 +376,51 @@ export default function radius(
216376
makeSmaller(borderBottomRightRadius)
217377
}
218378

379+
if (cornerShapes.some((shape) => shape < 0)) {
380+
const scale = Math.min(
381+
oppositeRadiiScale(
382+
borderTopLeftRadius,
383+
borderBottomRightRadius,
384+
width,
385+
height
386+
),
387+
oppositeRadiiScale(
388+
borderTopRightRadius,
389+
borderBottomLeftRadius,
390+
width,
391+
height
392+
)
393+
)
394+
if (scale < 1) {
395+
for (const corner of [
396+
borderTopLeftRadius,
397+
borderTopRightRadius,
398+
borderBottomRightRadius,
399+
borderBottomLeftRadius,
400+
]) {
401+
corner[0] *= scale
402+
corner[1] *= scale
403+
}
404+
}
405+
}
406+
407+
if (cornerShapes.some((shape) => shape !== 1)) {
408+
return shapedRadiusPath(
409+
left,
410+
top,
411+
width,
412+
height,
413+
[
414+
borderTopLeftRadius,
415+
borderTopRightRadius,
416+
borderBottomRightRadius,
417+
borderBottomLeftRadius,
418+
],
419+
cornerShapes,
420+
partialSides
421+
)
422+
}
423+
219424
type Arc = [[number, number], [number, number]]
220425
const p: Arc[] = []
221426
p[0] = [borderTopRightRadius, borderTopRightRadius]

src/handler/expand.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import parseTransformOrigin, {
1414
} from '../transform-origin.js'
1515
import { isString, lengthToNumber, v, splitEffects } from '../utils.js'
1616
import { MaskProperty, parseMask } from '../parser/mask.js'
17+
import { splitCornerShapeValues } from '../parser/corner-shape.js'
1718
import { FontWeight, FontStyle } from '../font.js'
1819
import {
1920
extractCustomProperties,
@@ -100,6 +101,52 @@ function handleSpecialCase(
100101
return vv
101102
}
102103

104+
if (name === 'cornerShape') {
105+
if (typeof value !== 'string') {
106+
throw new Error('Invalid `cornerShape` value: "' + value + '".')
107+
}
108+
const values = splitCornerShapeValues(value)
109+
const topLeft = values[0]
110+
const topRight = values[1] || topLeft
111+
const bottomRight = values[2] || topLeft
112+
const bottomLeft = values[3] || topRight
113+
114+
return {
115+
cornerTopLeftShape: topLeft,
116+
cornerTopRightShape: topRight,
117+
cornerBottomRightShape: bottomRight,
118+
cornerBottomLeftShape: bottomLeft,
119+
}
120+
}
121+
122+
if (/^corner(TopLeft|TopRight|BottomRight|BottomLeft)Shape$/.test(name)) {
123+
if (
124+
typeof value !== 'string' ||
125+
splitCornerShapeValues(value, 1).length !== 1
126+
) {
127+
throw new Error('Invalid `' + name + '` value: "' + value + '".')
128+
}
129+
return { [name]: value }
130+
}
131+
132+
const cornerSide = name.match(/^corner(Top|Right|Bottom|Left)Shape$/)
133+
if (cornerSide) {
134+
if (typeof value !== 'string') {
135+
throw new Error('Invalid `' + name + '` value: "' + value + '".')
136+
}
137+
const values = splitCornerShapeValues(value, 2)
138+
const first = values[0]
139+
const second = values[1] || first
140+
const properties = {
141+
Top: ['cornerTopLeftShape', 'cornerTopRightShape'],
142+
Right: ['cornerTopRightShape', 'cornerBottomRightShape'],
143+
Bottom: ['cornerBottomLeftShape', 'cornerBottomRightShape'],
144+
Left: ['cornerTopLeftShape', 'cornerBottomLeftShape'],
145+
}[cornerSide[1]]
146+
147+
return { [properties[0]]: first, [properties[1]]: second }
148+
}
149+
103150
if (/^border(Top|Right|Bottom|Left)?$/.test(name)) {
104151
const resolved = getStylesForProperty('border', value, true)
105152

src/parser/corner-shape.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export const cornerShapeKeywords = {
2+
round: 1,
3+
squircle: 2,
4+
square: Infinity,
5+
bevel: 0,
6+
scoop: -1,
7+
notch: -Infinity,
8+
} as const
9+
10+
export function parseCornerShapeValue(value: string) {
11+
const normalized = value.trim().toLowerCase()
12+
const keyword = cornerShapeKeywords[normalized]
13+
if (typeof keyword !== 'undefined') return keyword
14+
15+
const match = normalized.match(
16+
/^superellipse\(\s*(-?infinity|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?)\s*\)$/
17+
)
18+
if (!match) throw new Error('Invalid corner shape value: "' + value + '".')
19+
if (match[1] === 'infinity') return Infinity
20+
if (match[1] === '-infinity') return -Infinity
21+
22+
const shape = Number(match[1])
23+
if (!Number.isFinite(shape)) {
24+
throw new Error('Invalid corner shape value: "' + value + '".')
25+
}
26+
return shape
27+
}
28+
29+
export function splitCornerShapeValues(value: string, maxValues = 4) {
30+
const values = value.match(/superellipse\([^)]*\)|[^\s]+/gi) || []
31+
32+
if (values.length < 1 || values.length > maxValues) {
33+
throw new Error('Invalid corner shape value: "' + value + '".')
34+
}
35+
36+
for (const shape of values) parseCornerShapeValue(shape)
37+
return values
38+
}
Loading
Loading
696 Bytes
Loading
Loading

test/assets/Geist-Bold.ttf

126 KB
Binary file not shown.

test/assets/Geist-Regular.ttf

123 KB
Binary file not shown.

0 commit comments

Comments
 (0)