Skip to content
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
<tr><td><code>borderBottomRightRadius</code></td><td>Supported</td><td></td></tr>
<tr><td>Shorthand</td><td>Supported, i.e. <code>5px</code>, <code>50% / 5px</code></td><td></td></tr>

<tr><td rowspan="4"><code>cornerShape</code></td></tr>
<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>
<tr><td>Corner longhands (<code>cornerTopLeftShape</code>, <code>cornerTopRightShape</code>, ...)</td><td>Supported</td><td></td></tr>
<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>

<tr><td rowspan="11">Flex</td></tr>
<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>
<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>
Expand Down
205 changes: 205 additions & 0 deletions src/builder/border-radius.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
47 changes: 47 additions & 0 deletions src/handler/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
38 changes: 38 additions & 0 deletions src/parser/corner-shape.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/assets/Geist-Bold.ttf
Binary file not shown.
Binary file added test/assets/Geist-Regular.ttf
Binary file not shown.
Binary file added test/assets/GeistMono-Regular.ttf
Binary file not shown.
Loading
Loading