Skip to content

Commit 9487cbb

Browse files
authored
1 parent 8705f4c commit 9487cbb

6 files changed

Lines changed: 595 additions & 1 deletion

File tree

.pnpm-store/v11/index.db

8 KB
Binary file not shown.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
299299

300300
<tr>
301301
<td colspan="2"><code>clipPath</code></td>
302-
<td>Supported</td>
302+
<td>Supports <code>circle()</code>, <code>ellipse()</code>, <code>inset()</code>, <code>polygon()</code>, <code>path()</code>, and <code>shape()</code>. <code>shape()</code> supports <code>move</code>, <code>line</code>, <code>hline</code>, <code>vline</code>, <code>curve</code>, <code>smooth</code>, <code>arc</code>, and <code>close</code> commands.</td>
303303
<td><a href="https://og-playground.vercel.app/?share=XVJNb9wgEP0rI6poW8lJnX6pstpe0h7aQ1UlrXLJBZvBZosZBDgbZ7X_PQMbZze5wPCGmXmPx1Z0pFA04osytzcOIKbZ4tftNscAA5p-SA2szuv6ZFXtwY1RaXiBKRO9lTOj2uLdgub4uwnYJUOOcx3ZaXRLVlrTu58Jx5hT6BKGJbWeYjJ6viAGXZ7_PN3K7n8faHLqgiwFzr_SWj9N5aorc48NvH93BF0_avlU1wXd7W7ctxws0l-KP8j_8FhypP4Y8lIp4_oGzg_YgSKzY6FDau2EC0WAzhr_R5Z39GTnntzrj_UJ1BU34Z3jKi_lVEGd4zerfXEmDlCoA_yLqKCdIdKIQBrSgLChYNUqgpWhx5igo9FLZzBW8Bvv0tk6AjrZWoww0wSJoAsoE4KerD2NianDNbYgvbemk9m8mGdwLbqstEyxXMHNL1F2CTTXTyFPkE6BYbP6wIV81dMGAzeGS_b0tJWZ7y95K6-6YHzi4WTzNU2hdNUylrbtZKyKZ8Wft2wQy112UQnyhZRotqL4IZrP7IfY-yWabI5Q2E69aLS0ESuBI63N39nnv5425cR98r_4MbaoRJPChLtKJNnyjQGtpfKMYvcA">Example</a></td>
304304
</tr>
305305

src/parser/shape-function.ts

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
import { calcDegree, lengthToNumber, splitEffects } from '../utils.js'
2+
3+
type Point = [number, number]
4+
5+
const horizontalKeywords: Record<string, number> = {
6+
left: 0,
7+
'x-start': 0,
8+
center: 0.5,
9+
right: 1,
10+
'x-end': 1,
11+
}
12+
13+
const verticalKeywords: Record<string, number> = {
14+
top: 0,
15+
'y-start': 0,
16+
center: 0.5,
17+
bottom: 1,
18+
'y-end': 1,
19+
}
20+
21+
function format(value: number) {
22+
return Math.round(value * 1000) / 1000
23+
}
24+
25+
export function parseShapeFunction(
26+
value: string,
27+
width: number,
28+
height: number,
29+
inheritedStyle: Record<string, string | number>
30+
) {
31+
const match = value.match(/^shape\((.*)\)$/s)
32+
if (!match) return null
33+
34+
const parts = splitEffects(match[1])
35+
const first = parts.shift()
36+
if (!first) throw new Error('Invalid `shape()` value.')
37+
38+
const startMatch = first.match(/^(?:(nonzero|evenodd)\s+)?from\s+(.+)$/)
39+
if (!startMatch) throw new Error('Invalid `shape()` starting point.')
40+
41+
const fillRule = startMatch[1] || 'nonzero'
42+
let current = resolvePosition(
43+
tokenize(startMatch[2]),
44+
width,
45+
height,
46+
inheritedStyle
47+
)
48+
let subpathStart = current
49+
let path = `M${format(current[0])},${format(current[1])}`
50+
51+
for (const part of parts) {
52+
const tokens = tokenize(part)
53+
const command = tokens.shift()
54+
55+
if (command === 'close') {
56+
if (tokens.length)
57+
throw new Error('Invalid `close` command in `shape()`.')
58+
path += ' Z'
59+
current = subpathStart
60+
continue
61+
}
62+
63+
if (command === 'move' || command === 'line') {
64+
const endpoint = resolveEndpoint(
65+
tokens,
66+
current,
67+
width,
68+
height,
69+
inheritedStyle
70+
)
71+
path += ` ${command === 'move' ? 'M' : 'L'}${format(
72+
endpoint[0]
73+
)},${format(endpoint[1])}`
74+
current = endpoint
75+
if (command === 'move') subpathStart = endpoint
76+
continue
77+
}
78+
79+
if (command === 'hline' || command === 'vline') {
80+
const axis = command === 'hline' ? 0 : 1
81+
const mode = tokens.shift()
82+
const token = tokens.shift()
83+
if ((mode !== 'to' && mode !== 'by') || !token || tokens.length) {
84+
throw new Error(`Invalid \`${command}\` command in \`shape()\`.`)
85+
}
86+
const base = axis === 0 ? width : height
87+
const keywordMap = axis === 0 ? horizontalKeywords : verticalKeywords
88+
const resolved =
89+
mode === 'to' && token in keywordMap
90+
? keywordMap[token] * base
91+
: resolveLength(token, base, inheritedStyle)
92+
current = [...current] as Point
93+
current[axis] = mode === 'by' ? current[axis] + resolved : resolved
94+
path += ` ${axis === 0 ? 'H' : 'V'}${format(current[axis])}`
95+
continue
96+
}
97+
98+
if (command === 'curve' || command === 'smooth') {
99+
const withIndex = tokens.indexOf('with')
100+
const endpointTokens =
101+
withIndex === -1 ? tokens : tokens.slice(0, withIndex)
102+
const controlTokens = withIndex === -1 ? [] : tokens.slice(withIndex + 1)
103+
const mode = endpointTokens[0]
104+
const endpoint = resolveEndpoint(
105+
endpointTokens,
106+
current,
107+
width,
108+
height,
109+
inheritedStyle
110+
)
111+
112+
if (command === 'curve' && withIndex === -1) {
113+
throw new Error('A `curve` command requires a control point.')
114+
}
115+
116+
const controls = splitAtSlash(controlTokens).map((control) =>
117+
resolveControlPoint(
118+
control,
119+
current,
120+
endpoint,
121+
width,
122+
height,
123+
inheritedStyle
124+
)
125+
)
126+
127+
if (command === 'curve') {
128+
if (controls.length === 1) {
129+
path += ` Q${pointString(controls[0])} ${pointString(endpoint)}`
130+
} else if (controls.length === 2) {
131+
path += ` C${pointString(controls[0])} ${pointString(
132+
controls[1]
133+
)} ${pointString(endpoint)}`
134+
} else {
135+
throw new Error(
136+
'A `curve` command accepts one or two control points.'
137+
)
138+
}
139+
} else if (!controls.length) {
140+
path += ` T${pointString(endpoint)}`
141+
} else if (controls.length === 1) {
142+
path += ` S${pointString(controls[0])} ${pointString(endpoint)}`
143+
} else {
144+
throw new Error('A `smooth` command accepts at most one control point.')
145+
}
146+
147+
current = endpoint
148+
continue
149+
}
150+
151+
if (command === 'arc') {
152+
const optionIndex = tokens.findIndex((token) =>
153+
['of', 'cw', 'ccw', 'large', 'small', 'rotate'].includes(token)
154+
)
155+
const endpointTokens =
156+
optionIndex === -1 ? tokens : tokens.slice(0, optionIndex)
157+
const options = optionIndex === -1 ? [] : tokens.slice(optionIndex)
158+
const endpoint = resolveEndpoint(
159+
endpointTokens,
160+
current,
161+
width,
162+
height,
163+
inheritedStyle
164+
)
165+
let rx = 0
166+
let ry = 0
167+
let rotation = 0
168+
let large = 0
169+
let sweep = 0
170+
171+
for (let index = 0; index < options.length; index++) {
172+
const option = options[index]
173+
if (option === 'of') {
174+
const firstRadius = options[++index]
175+
if (!firstRadius)
176+
throw new Error('Invalid `arc` radius in `shape()`.')
177+
const secondRadius = options[index + 1]
178+
const hasSecondRadius =
179+
secondRadius &&
180+
!['cw', 'ccw', 'large', 'small', 'rotate'].includes(secondRadius)
181+
if (hasSecondRadius) index++
182+
if (hasSecondRadius) {
183+
rx = resolveLength(firstRadius, width, inheritedStyle)
184+
ry = resolveLength(secondRadius, height, inheritedStyle)
185+
} else {
186+
const base =
187+
Math.sqrt(width * width + height * height) / Math.sqrt(2)
188+
rx = ry = resolveLength(firstRadius, base, inheritedStyle)
189+
}
190+
} else if (option === 'cw' || option === 'ccw') {
191+
sweep = option === 'cw' ? 1 : 0
192+
} else if (option === 'large' || option === 'small') {
193+
large = option === 'large' ? 1 : 0
194+
} else if (option === 'rotate') {
195+
const angle = options[++index]
196+
if (!angle) throw new Error('Invalid `arc` rotation in `shape()`.')
197+
const degree = calcDegree(angle)
198+
if (typeof degree === 'undefined') {
199+
throw new Error('Invalid `arc` rotation in `shape()`.')
200+
}
201+
rotation = degree
202+
} else {
203+
throw new Error('Invalid `arc` option in `shape()`.')
204+
}
205+
}
206+
207+
path += ` A${format(Math.abs(rx))},${format(Math.abs(ry))} ${format(
208+
rotation
209+
)} ${large} ${sweep} ${pointString(endpoint)}`
210+
current = endpoint
211+
continue
212+
}
213+
214+
throw new Error(`Unsupported \`${command}\` command in \`shape()\`.`)
215+
}
216+
217+
return {
218+
type: 'path',
219+
d: path,
220+
'fill-rule': fillRule,
221+
}
222+
}
223+
224+
function tokenize(value: string) {
225+
return value.trim().replace(/\//g, ' / ').split(/\s+/).filter(Boolean)
226+
}
227+
228+
function splitAtSlash(tokens: string[]) {
229+
if (!tokens.length) return []
230+
const slash = tokens.indexOf('/')
231+
return slash === -1
232+
? [tokens]
233+
: [tokens.slice(0, slash), tokens.slice(slash + 1)]
234+
}
235+
236+
function resolveEndpoint(
237+
tokens: string[],
238+
current: Point,
239+
width: number,
240+
height: number,
241+
inheritedStyle: Record<string, string | number>
242+
) {
243+
const mode = tokens[0]
244+
if (mode !== 'to' && mode !== 'by') {
245+
throw new Error('A `shape()` command must use `to` or `by`.')
246+
}
247+
const point = resolvePosition(tokens.slice(1), width, height, inheritedStyle)
248+
return mode === 'by'
249+
? ([current[0] + point[0], current[1] + point[1]] as Point)
250+
: point
251+
}
252+
253+
function resolveControlPoint(
254+
tokens: string[],
255+
start: Point,
256+
end: Point,
257+
width: number,
258+
height: number,
259+
inheritedStyle: Record<string, string | number>
260+
) {
261+
const fromIndex = tokens.indexOf('from')
262+
const pointTokens = fromIndex === -1 ? tokens : tokens.slice(0, fromIndex)
263+
const reference = fromIndex === -1 ? undefined : tokens[fromIndex + 1]
264+
if (fromIndex !== -1 && (fromIndex + 2 !== tokens.length || !reference)) {
265+
throw new Error('Invalid control point reference in `shape()`.')
266+
}
267+
const point = resolvePosition(
268+
pointTokens,
269+
width,
270+
height,
271+
inheritedStyle,
272+
true
273+
)
274+
275+
if (reference === 'start' || (!reference && !hasPositionKeyword(tokens))) {
276+
return [start[0] + point[0], start[1] + point[1]] as Point
277+
}
278+
if (reference === 'end') {
279+
return [end[0] + point[0], end[1] + point[1]] as Point
280+
}
281+
if (reference === 'origin') return point
282+
return point
283+
}
284+
285+
function resolvePosition(
286+
tokens: string[],
287+
width: number,
288+
height: number,
289+
inheritedStyle: Record<string, string | number>,
290+
isControlPoint = false
291+
): Point {
292+
if (!tokens.length || tokens.length > 4) {
293+
throw new Error('Invalid position in `shape()`.')
294+
}
295+
296+
let x: number | undefined
297+
let y: number | undefined
298+
const remaining = [...tokens]
299+
300+
for (let index = 0; index < remaining.length; index++) {
301+
const token = remaining[index]
302+
if (
303+
token === 'left' ||
304+
token === 'right' ||
305+
token === 'x-start' ||
306+
token === 'x-end'
307+
) {
308+
const next = remaining[index + 1]
309+
if (next && !isPositionKeyword(next)) {
310+
const offset = resolveLength(next, width, inheritedStyle)
311+
x = token === 'left' || token === 'x-start' ? offset : width - offset
312+
index++
313+
} else {
314+
x = horizontalKeywords[token] * width
315+
}
316+
} else if (
317+
token === 'top' ||
318+
token === 'bottom' ||
319+
token === 'y-start' ||
320+
token === 'y-end'
321+
) {
322+
const next = remaining[index + 1]
323+
if (next && !isPositionKeyword(next)) {
324+
const offset = resolveLength(next, height, inheritedStyle)
325+
y = token === 'top' || token === 'y-start' ? offset : height - offset
326+
index++
327+
} else {
328+
y = verticalKeywords[token] * height
329+
}
330+
} else if (token === 'center') {
331+
if (x === undefined) x = width / 2
332+
else if (y === undefined) y = height / 2
333+
else throw new Error('Invalid position in `shape()`.')
334+
} else if (x === undefined) {
335+
x = resolveLength(token, width, inheritedStyle)
336+
} else if (y === undefined) {
337+
y = resolveLength(token, height, inheritedStyle)
338+
} else {
339+
throw new Error('Invalid position in `shape()`.')
340+
}
341+
}
342+
343+
if (!isControlPoint && tokens.length === 1 && y === undefined) y = height / 2
344+
return [
345+
x ?? (isControlPoint ? 0 : width / 2),
346+
y ?? (isControlPoint ? 0 : height / 2),
347+
]
348+
}
349+
350+
function isPositionKeyword(value: string) {
351+
return value in horizontalKeywords || value in verticalKeywords
352+
}
353+
354+
function hasPositionKeyword(tokens: string[]) {
355+
return tokens.some(isPositionKeyword)
356+
}
357+
358+
function resolveLength(
359+
value: string,
360+
base: number,
361+
inheritedStyle: Record<string, string | number>
362+
) {
363+
const resolved = lengthToNumber(
364+
value,
365+
inheritedStyle.fontSize as number,
366+
base,
367+
inheritedStyle,
368+
true
369+
)
370+
if (typeof resolved === 'undefined') {
371+
throw new Error(`Invalid length \`${value}\` in \`shape()\`.`)
372+
}
373+
return resolved
374+
}
375+
376+
function pointString(point: Point) {
377+
return `${format(point[0])},${format(point[1])}`
378+
}

0 commit comments

Comments
 (0)