diff --git a/package.json b/package.json index e19783b9..1fd05d9b 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.16", "css-to-react-native": "^3.0.0", "emoji-regex": "^10.2.1", "escape-html": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64d70d8e..782d2203 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: css-box-shadow: specifier: 1.0.0-3 version: 1.0.0-3 + css-gradient-parser: + specifier: ^0.0.16 + version: 0.0.16 css-to-react-native: specifier: ^3.0.0 version: 3.0.0 @@ -4202,6 +4205,11 @@ packages: engines: {node: '>=4'} dev: false + /css-gradient-parser@0.0.16: + resolution: {integrity: sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==} + engines: {node: '>=16'} + dev: false + /css-to-react-native@3.0.0: resolution: {integrity: sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==} dependencies: diff --git a/src/builder/background-image.ts b/src/builder/background-image.ts index 69b8fe0f..9b61160a 100644 --- a/src/builder/background-image.ts +++ b/src/builder/background-image.ts @@ -1,9 +1,9 @@ import CssDimension from '../vendor/parse-css-dimension/index.js' -import { buildXMLString, lengthToNumber } from '../utils.js' -import cssColorParse from 'parse-css-color' +import { buildXMLString } from '../utils.js' -import gradient from '../vendor/gradient-parser/index.js' import { resolveImageData } from '../handler/image.js' +import { buildLinearGradient } from './gradient/linear.js' +import { buildRadialGradient } from './gradient/radial.js' interface Background { attachment?: string @@ -16,39 +16,6 @@ interface Background { repeat: string } -function resolveColorFromStop(stop) { - if (stop.type === 'literal') return stop.value - if (stop.type === 'hex') return `#${stop.value}` - if (stop.type === 'rgb') return `rgb(${stop.value.join(',')})` - if (stop.type === 'rgba') return `rgba(${stop.value.join(',')})` - return 'transparent' -} - -function resolveXYFromDirection(dir: string) { - let x1 = 0, - y1 = 0, - x2 = 0, - y2 = 0 - - if (dir.includes('top')) { - y1 = 1 - } else if (dir.includes('bottom')) { - y2 = 1 - } - - if (dir.includes('left')) { - x1 = 1 - } else if (dir.includes('right')) { - x2 = 1 - } - - if (!x1 && !x2 && !y1 && !y2) { - y1 = 1 - } - - return [x1, y1, x2, y2] -} - function toAbsoluteValue(v: string | number, base: number) { if (typeof v === 'string' && v.endsWith('%')) { return (base * parseFloat(v)) / 100 @@ -89,92 +56,6 @@ function parseLengthPairs( ).map((v, index) => toAbsoluteValue(v, [x, y][index])) } -function normalizeStops( - totalLength: number, - colorStops: any[], - from?: 'background' | 'mask' -) { - // Resolve the color stops based on the spec: - // https://drafts.csswg.org/css-images/#color-stop-syntax - const stops = [] - for (const stop of colorStops) { - const color = resolveColorFromStop(stop) - if (!stops.length) { - // First stop, ensure it's at the start. - stops.push({ - offset: 0, - color, - }) - - if (typeof stop.length === 'undefined') continue - if (stop.length.value === '0') continue - } - - // All offsets are relative values (0-1) in SVG. - const offset = - typeof stop.length === 'undefined' - ? undefined - : stop.length.type === '%' - ? stop.length.value / 100 - : stop.length.value / totalLength - - stops.push({ - offset, - color, - }) - } - if (!stops.length) { - stops.push({ - offset: 0, - color: 'transparent', - }) - } - // Last stop, ensure it's at the end. - const lastStop = stops[stops.length - 1] - if (lastStop.offset !== 1) { - if (typeof lastStop.offset === 'undefined') { - lastStop.offset = 1 - } else { - stops.push({ - offset: 1, - color: lastStop.color, - }) - } - } - - let previousStop = 0 - let nextStop = 1 - // Evenly distribute the missing stop offsets. - for (let i = 0; i < stops.length; i++) { - if (typeof stops[i].offset === 'undefined') { - // Find the next stop that has an offset. - if (nextStop < i) nextStop = i - while (typeof stops[nextStop].offset === 'undefined') nextStop++ - - stops[i].offset = - ((stops[nextStop].offset - stops[previousStop].offset) / - (nextStop - previousStop)) * - (i - previousStop) + - stops[previousStop].offset - } else { - previousStop = i - } - } - - if (from === 'mask') { - return stops.map((stop) => { - const color = cssColorParse(stop.color) - if (color.alpha === 0) { - return { ...stop, color: `rgba(0, 0, 0, 1)` } - } else { - return { ...stop, color: `rgba(255, 255, 255, ${color.alpha})` } - } - }) - } - - return stops -} - export default async function backgroundImage( { id, @@ -208,238 +89,25 @@ export default async function backgroundImage( }) if (image.startsWith('linear-gradient(')) { - const parsed = gradient.parse(image)[0] - const [imageWidth, imageHeight] = dimensions - - // Calculate the direction. - let x1, y1, x2, y2, length - - if (parsed.orientation.type === 'directional') { - ;[x1, y1, x2, y2] = resolveXYFromDirection(parsed.orientation.value) - - length = Math.sqrt( - Math.pow((x2 - x1) * imageWidth, 2) + - Math.pow((y2 - y1) * imageHeight, 2) - ) - } else if (parsed.orientation.type === 'angular') { - const EPS = 0.000001 - const r = imageWidth / imageHeight - - function calc(angle) { - angle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) - - if (Math.abs(angle - Math.PI / 2) < EPS) { - x1 = 0 - y1 = 0 - x2 = 1 - y2 = 0 - length = imageWidth - return - } else if (Math.abs(angle) < EPS) { - x1 = 0 - y1 = 1 - x2 = 0 - y2 = 0 - length = imageHeight - return - } - - // Assuming 0 <= angle < PI / 2. - if (angle >= Math.PI / 2 && angle < Math.PI) { - calc(Math.PI - angle) - y1 = 1 - y1 - y2 = 1 - y2 - return - } else if (angle >= Math.PI) { - calc(angle - Math.PI) - let tmp = x1 - x1 = x2 - x2 = tmp - tmp = y1 - y1 = y2 - y2 = tmp - return - } - - // Remap SVG distortion - const tan = Math.tan(angle) - const tanTexture = tan * r - const angleTexture = Math.atan(tanTexture) - const l = Math.sqrt(2) * Math.cos(Math.PI / 4 - angleTexture) - x1 = 0 - y1 = 1 - x2 = Math.sin(angleTexture) * l - y2 = 1 - Math.cos(angleTexture) * l - - // Get the angle between the distored gradient direction and diagonal. - const x = 1 - const y = 1 / tan - const cosA = Math.abs( - (x * r + y) / Math.sqrt(x * x + y * y) / Math.sqrt(r * r + 1) - ) - - // Get the distored gradient length. - const diagonal = Math.sqrt( - imageWidth * imageWidth + imageHeight * imageHeight - ) - length = diagonal * cosA - } - - calc((+parsed.orientation.value / 180) * Math.PI) - } - - const stops = normalizeStops(length, parsed.colorStops, from) - - const gradientId = `satori_bi${id}` - const patternId = `satori_pattern_${id}` - - const defs = buildXMLString( - 'pattern', - { - id: patternId, - x: offsets[0] / width, - y: offsets[1] / height, - width: repeatX ? imageWidth / width : '1', - height: repeatY ? imageHeight / height : '1', - patternUnits: 'objectBoundingBox', - }, - buildXMLString( - 'linearGradient', - { - id: gradientId, - x1, - y1, - x2, - y2, - }, - stops - .map((stop) => - buildXMLString('stop', { - offset: stop.offset * 100 + '%', - 'stop-color': stop.color, - }) - ) - .join('') - ) + - buildXMLString('rect', { - x: 0, - y: 0, - width: imageWidth, - height: imageHeight, - fill: `url(#${gradientId})`, - }) + return buildLinearGradient( + { id, width, height, repeatX, repeatY }, + image, + dimensions, + offsets, + inheritableStyle, + from ) - return [patternId, defs] } if (image.startsWith('radial-gradient(')) { - const parsed = gradient.parse(image)[0] - const orientation = parsed.orientation[0] - const [xDelta, yDelta] = dimensions - - let shape = 'circle' - let cx: number = xDelta / 2 - let cy: number = yDelta / 2 - - if (orientation.type === 'shape') { - shape = orientation.value - if (!orientation.at) { - // Defaults to center. - } else if (orientation.at.type === 'position') { - const pos = calcRadialGradient( - orientation.at.value.x, - orientation.at.value.y, - xDelta, - yDelta, - inheritableStyle.fontSize as number, - inheritableStyle - ) - cx = pos.x - cy = pos.y - } else { - throw new Error( - 'orientation.at.type not implemented: ' + orientation.at.type - ) - } - } else { - throw new Error('orientation.type not implemented: ' + orientation.type) - } - - const stops = normalizeStops(width, parsed.colorStops, from) - - const gradientId = `satori_radial_${id}` - const patternId = `satori_pattern_${id}` - const maskId = `satori_mask_${id}` - - // We currently only support `farthest-corner`: - // https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/radial-gradient()#values - const spread = calcRadius( - shape as Shape, - orientation.style, - inheritableStyle.fontSize as number, - { x: cx, y: cy }, - [xDelta, yDelta], - inheritableStyle + return buildRadialGradient( + { id, width, height, repeatX, repeatY }, + image, + dimensions, + offsets, + inheritableStyle, + from ) - - // TODO: check for repeat-x/repeat-y - const defs = buildXMLString( - 'pattern', - { - id: patternId, - x: offsets[0] / width, - y: offsets[1] / height, - width: repeatX ? xDelta / width : '1', - height: repeatY ? yDelta / height : '1', - patternUnits: 'objectBoundingBox', - }, - buildXMLString( - 'radialGradient', - { - id: gradientId, - }, - stops - .map((stop) => - buildXMLString('stop', { - offset: stop.offset, - 'stop-color': stop.color, - }) - ) - .join('') - ) + - buildXMLString( - 'mask', - { - id: maskId, - }, - buildXMLString('rect', { - x: 0, - y: 0, - width: xDelta, - height: yDelta, - fill: '#fff', - }) - ) + - buildXMLString('rect', { - x: 0, - y: 0, - width: xDelta, - height: yDelta, - fill: stops.at(-1).color, - }) + - buildXMLString(shape, { - cx: cx, - cy: cy, - width: xDelta, - height: yDelta, - ...spread, - fill: `url(#${gradientId})`, - mask: `url(#${maskId})`, - }) - ) - - const result = [patternId, defs] - return result } if (image.startsWith('url(')) { @@ -488,178 +156,3 @@ export default async function backgroundImage( throw new Error(`Invalid background image: "${image}"`) } - -type PositionKeyWord = 'center' | 'left' | 'right' | 'top' | 'bottom' -interface Position { - type: string - value: PositionKeyWord -} - -function calcRadialGradient( - cx: Position, - cy: Position, - xDelta: number, - yDelta: number, - baseFontSize: number, - style: Record -) { - const pos: { x: number; y: number } = { x: xDelta / 2, y: yDelta / 2 } - if (cx.type === 'position-keyword') { - Object.assign(pos, calcPos(cx.value, xDelta, yDelta, 'x')) - } else { - pos.x = lengthToNumber( - `${cx.value}${cx.type}`, - baseFontSize, - xDelta, - style, - true - ) - } - - if (cy.type === 'position-keyword') { - Object.assign(pos, calcPos(cy.value, xDelta, yDelta, 'y')) - } else { - pos.y = lengthToNumber( - `${cy.value}${cy.type}`, - baseFontSize, - yDelta, - style, - true - ) - } - - return pos -} - -function calcPos( - key: PositionKeyWord, - xDelta: number, - yDelta: number, - dir: 'x' | 'y' -) { - switch (key) { - case 'center': - return { [dir]: dir === 'x' ? xDelta / 2 : yDelta / 2 } - case 'left': - return { x: 0 } - case 'top': - return { y: 0 } - case 'right': - return { x: xDelta } - case 'bottom': - return { y: yDelta } - } -} - -type Shape = 'circle' | 'ellipse' -function calcRadius( - shape: Shape, - endingShape: Array<{ type: string; value: string }>, - baseFontSize: number, - centerAxis: { x: number; y: number }, - length: [number, number], - inheritableStyle: Record -) { - const [xDelta, yDelta] = length - const { x: cx, y: cy } = centerAxis - const spread: Record = {} - let fx = 0 - let fy = 0 - const isExtentKeyWord = endingShape.some((v) => v.type === 'extent-keyword') - - if (!isExtentKeyWord) { - if (endingShape.some((v) => v.value.startsWith('-'))) { - throw new Error( - 'disallow setting negative values to the size of the shape. Check https://w3c.github.io/csswg-drafts/css-images/#valdef-rg-size-length-0' - ) - } - if (shape === 'circle') { - return { - r: lengthToNumber( - `${endingShape[0].value}${endingShape[0].type}`, - baseFontSize, - xDelta, - inheritableStyle, - true - ), - } - } else { - return { - rx: lengthToNumber( - `${endingShape[0].value}${endingShape[0].type}`, - baseFontSize, - xDelta, - inheritableStyle, - true - ), - ry: lengthToNumber( - `${endingShape[1].value}${endingShape[1].type}`, - baseFontSize, - yDelta, - inheritableStyle, - true - ), - } - } - } - - switch (endingShape[0].value) { - case 'farthest-corner': - fx = Math.max(Math.abs(xDelta - cx), Math.abs(cx)) - fy = Math.max(Math.abs(yDelta - cy), Math.abs(cy)) - break - case 'closest-corner': - fx = Math.min(Math.abs(xDelta - cx), Math.abs(cx)) - fy = Math.min(Math.abs(yDelta - cy), Math.abs(cy)) - break - case 'farthest-side': - if (shape === 'circle') { - spread.r = Math.max( - Math.abs(xDelta - cx), - Math.abs(cx), - Math.abs(yDelta - cy), - Math.abs(cy) - ) - } else { - spread.rx = Math.max(Math.abs(xDelta - cx), Math.abs(cx)) - spread.ry = Math.max(Math.abs(yDelta - cy), Math.abs(cy)) - } - return spread - case 'closest-side': - if (shape === 'circle') { - spread.r = Math.min( - Math.abs(xDelta - cx), - Math.abs(cx), - Math.abs(yDelta - cy), - Math.abs(cy) - ) - } else { - spread.rx = Math.min(Math.abs(xDelta - cx), Math.abs(cx)) - spread.ry = Math.min(Math.abs(yDelta - cy), Math.abs(cy)) - } - - return spread - } - if (shape === 'circle') { - spread.r = Math.sqrt(fx * fx + fy * fy) - } else { - // Spec: https://drafts.csswg.org/css-images/#typedef-size - // Get the aspect ratio of the closest-side size. - const ratio = fy !== 0 ? fx / fy : 1 - - if (fx === 0) { - spread.rx = 0 - spread.ry = 0 - } else { - // fx^2/a^2 + fy^2/b^2 = 1 - // fx^2/(b*ratio)^2 + fy^2/b^2 = 1 - // (fx^2+fy^2*ratio^2) = (b*ratio)^2 - // b = sqrt(fx^2+fy^2*ratio^2)/ratio - - spread.ry = Math.sqrt(fx * fx + fy * fy * ratio * ratio) / ratio - spread.rx = spread.ry * ratio - } - } - - return spread -} diff --git a/src/builder/gradient/linear.ts b/src/builder/gradient/linear.ts new file mode 100644 index 00000000..23373f43 --- /dev/null +++ b/src/builder/gradient/linear.ts @@ -0,0 +1,177 @@ +import { parseLinearGradient } from 'css-gradient-parser' +import { normalizeStops } from './utils.js' +import { buildXMLString, calcDegree } from '../../utils.js' + +export function buildLinearGradient( + { + id, + width, + height, + repeatX, + repeatY, + }: { + id: string + width: number + height: number + repeatX: boolean + repeatY: boolean + }, + image: string, + dimensions: number[], + offsets: number[], + inheritableStyle: Record, + from?: 'background' | 'mask' +) { + const parsed = parseLinearGradient(image) + const [imageWidth, imageHeight] = dimensions + + // Calculate the direction. + let x1, y1, x2, y2, length + + if (parsed.orientation.type === 'directional') { + ;[x1, y1, x2, y2] = resolveXYFromDirection(parsed.orientation.value) + + length = Math.sqrt( + Math.pow((x2 - x1) * imageWidth, 2) + Math.pow((y2 - y1) * imageHeight, 2) + ) + } else if (parsed.orientation.type === 'angular') { + const EPS = 0.000001 + const r = imageWidth / imageHeight + + function calc(angle) { + angle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) + + if (Math.abs(angle - Math.PI / 2) < EPS) { + x1 = 0 + y1 = 0 + x2 = 1 + y2 = 0 + length = imageWidth + return + } else if (Math.abs(angle) < EPS) { + x1 = 0 + y1 = 1 + x2 = 0 + y2 = 0 + length = imageHeight + return + } + + // Assuming 0 <= angle < PI / 2. + if (angle >= Math.PI / 2 && angle < Math.PI) { + calc(Math.PI - angle) + y1 = 1 - y1 + y2 = 1 - y2 + return + } else if (angle >= Math.PI) { + calc(angle - Math.PI) + let tmp = x1 + x1 = x2 + x2 = tmp + tmp = y1 + y1 = y2 + y2 = tmp + return + } + + // Remap SVG distortion + const tan = Math.tan(angle) + const tanTexture = tan * r + const angleTexture = Math.atan(tanTexture) + const l = Math.sqrt(2) * Math.cos(Math.PI / 4 - angleTexture) + x1 = 0 + y1 = 1 + x2 = Math.sin(angleTexture) * l + y2 = 1 - Math.cos(angleTexture) * l + + // Get the angle between the distored gradient direction and diagonal. + const x = 1 + const y = 1 / tan + const cosA = Math.abs( + (x * r + y) / Math.sqrt(x * x + y * y) / Math.sqrt(r * r + 1) + ) + + // Get the distored gradient length. + const diagonal = Math.sqrt( + imageWidth * imageWidth + imageHeight * imageHeight + ) + length = diagonal * cosA + } + + calc( + (calcDegree( + `${parsed.orientation.value.value}${parsed.orientation.value.unit}` + ) / + 180) * + Math.PI + ) + } + + const stops = normalizeStops(length, parsed.stops, inheritableStyle, from) + + const gradientId = `satori_bi${id}` + const patternId = `satori_pattern_${id}` + + const defs = buildXMLString( + 'pattern', + { + id: patternId, + x: offsets[0] / width, + y: offsets[1] / height, + width: repeatX ? imageWidth / width : '1', + height: repeatY ? imageHeight / height : '1', + patternUnits: 'objectBoundingBox', + }, + buildXMLString( + 'linearGradient', + { + id: gradientId, + x1, + y1, + x2, + y2, + }, + stops + .map((stop) => + buildXMLString('stop', { + offset: (stop.offset ?? 0) * 100 + '%', + 'stop-color': stop.color, + }) + ) + .join('') + ) + + buildXMLString('rect', { + x: 0, + y: 0, + width: imageWidth, + height: imageHeight, + fill: `url(#${gradientId})`, + }) + ) + return [patternId, defs] +} + +function resolveXYFromDirection(dir: string) { + let x1 = 0, + y1 = 0, + x2 = 0, + y2 = 0 + + if (dir.includes('top')) { + y1 = 1 + } else if (dir.includes('bottom')) { + y2 = 1 + } + + if (dir.includes('left')) { + x1 = 1 + } else if (dir.includes('right')) { + x2 = 1 + } + + if (!x1 && !x2 && !y1 && !y2) { + y1 = 1 + } + + return [x1, y1, x2, y2] +} diff --git a/src/builder/gradient/radial.ts b/src/builder/gradient/radial.ts new file mode 100644 index 00000000..ea50d178 --- /dev/null +++ b/src/builder/gradient/radial.ts @@ -0,0 +1,324 @@ +import { + parseRadialGradient, + RadialResult, + RadialPropertyValue, +} from 'css-gradient-parser' +import { buildXMLString, lengthToNumber } from '../../utils.js' +import { normalizeStops } from './utils.js' + +export function buildRadialGradient( + { + id, + width, + height, + repeatX, + repeatY, + }: { + id: string + width: number + height: number + repeatX: boolean + repeatY: boolean + }, + image: string, + dimensions: number[], + offsets: number[], + inheritableStyle: Record, + from?: 'background' | 'mask' +) { + const { + shape, + stops: colorStops, + position, + size, + } = parseRadialGradient(image) + const [xDelta, yDelta] = dimensions + + let cx: number = xDelta / 2 + let cy: number = yDelta / 2 + + const pos = calcRadialGradient( + position.x, + position.y, + xDelta, + yDelta, + inheritableStyle.fontSize as number, + inheritableStyle + ) + cx = pos.x + cy = pos.y + + const stops = normalizeStops(width, colorStops, inheritableStyle, from) + + const gradientId = `satori_radial_${id}` + const patternId = `satori_pattern_${id}` + const maskId = `satori_mask_${id}` + + // https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/radial-gradient()#values + const spread = calcRadius( + shape as Shape, + size, + inheritableStyle.fontSize as number, + { x: cx, y: cy }, + [xDelta, yDelta], + inheritableStyle + ) + + // TODO: check for repeat-x/repeat-y + const defs = buildXMLString( + 'pattern', + { + id: patternId, + x: offsets[0] / width, + y: offsets[1] / height, + width: repeatX ? xDelta / width : '1', + height: repeatY ? yDelta / height : '1', + patternUnits: 'objectBoundingBox', + }, + buildXMLString( + 'radialGradient', + { + id: gradientId, + }, + stops + .map((stop) => + buildXMLString('stop', { + offset: stop.offset || 0, + 'stop-color': stop.color, + }) + ) + .join('') + ) + + buildXMLString( + 'mask', + { + id: maskId, + }, + buildXMLString('rect', { + x: 0, + y: 0, + width: xDelta, + height: yDelta, + fill: '#fff', + }) + ) + + buildXMLString('rect', { + x: 0, + y: 0, + width: xDelta, + height: yDelta, + fill: stops.at(-1)?.color || 'transparent', + }) + + buildXMLString(shape, { + cx: cx, + cy: cy, + width: xDelta, + height: yDelta, + ...spread, + fill: `url(#${gradientId})`, + mask: `url(#${maskId})`, + }) + ) + + const result = [patternId, defs] + return result +} + +interface Position { + type: 'keyword' | 'length' + value: string +} + +type PositionKeyWord = 'center' | 'left' | 'right' | 'top' | 'bottom' + +function calcRadialGradient( + cx: RadialPropertyValue, + cy: RadialPropertyValue, + xDelta: number, + yDelta: number, + baseFontSize: number, + style: Record +) { + const pos: { x: number; y: number } = { x: xDelta / 2, y: yDelta / 2 } + if (cx.type === 'keyword') { + Object.assign( + pos, + calcPos(cx.value as PositionKeyWord, xDelta, yDelta, 'x') + ) + } else { + pos.x = + lengthToNumber( + `${cx.value.value}${cx.value.unit}`, + baseFontSize, + xDelta, + style, + true + ) || xDelta / 2 + } + + if (cy.type === 'keyword') { + Object.assign( + pos, + calcPos(cy.value as PositionKeyWord, xDelta, yDelta, 'y') + ) + } else { + pos.y = + lengthToNumber( + `${cy.value.value}${cy.value.unit}`, + baseFontSize, + yDelta, + style, + true + ) || yDelta / 2 + } + + return pos +} + +function calcPos( + key: PositionKeyWord, + xDelta: number, + yDelta: number, + dir: 'x' | 'y' +) { + switch (key) { + case 'center': + return { [dir]: dir === 'x' ? xDelta / 2 : yDelta / 2 } + case 'left': + return { x: 0 } + case 'top': + return { y: 0 } + case 'right': + return { x: xDelta } + case 'bottom': + return { y: yDelta } + } +} + +type Shape = 'circle' | 'ellipse' +function calcRadius( + shape: Shape, + endingShape: RadialResult['size'], + baseFontSize: number, + centerAxis: { x: number; y: number }, + length: [number, number], + inheritableStyle: Record +) { + const [xDelta, yDelta] = length + const { x: cx, y: cy } = centerAxis + const spread: Record = {} + let fx = 0 + let fy = 0 + + if (isSizeAllLength(endingShape)) { + if (endingShape.some((v) => v.value.value.startsWith('-'))) { + throw new Error( + 'disallow setting negative values to the size of the shape. Check https://w3c.github.io/csswg-drafts/css-images/#valdef-rg-size-length-0' + ) + } + if (shape === 'circle') { + return { + r: Number( + lengthToNumber( + `${endingShape[0].value.value}${endingShape[0].value.unit}`, + baseFontSize, + xDelta, + inheritableStyle, + true + ) + ), + } + } else { + return { + rx: Number( + lengthToNumber( + `${endingShape[0].value.value}${endingShape[0].value.unit}`, + baseFontSize, + xDelta, + inheritableStyle, + true + ) + ), + ry: Number( + lengthToNumber( + `${endingShape[1].value.value}${endingShape[1].value.unit}`, + baseFontSize, + yDelta, + inheritableStyle, + true + ) + ), + } + } + } + + switch (endingShape[0].value) { + case 'farthest-corner': + fx = Math.max(Math.abs(xDelta - cx), Math.abs(cx)) + fy = Math.max(Math.abs(yDelta - cy), Math.abs(cy)) + break + case 'closest-corner': + fx = Math.min(Math.abs(xDelta - cx), Math.abs(cx)) + fy = Math.min(Math.abs(yDelta - cy), Math.abs(cy)) + break + case 'farthest-side': + if (shape === 'circle') { + spread.r = Math.max( + Math.abs(xDelta - cx), + Math.abs(cx), + Math.abs(yDelta - cy), + Math.abs(cy) + ) + } else { + spread.rx = Math.max(Math.abs(xDelta - cx), Math.abs(cx)) + spread.ry = Math.max(Math.abs(yDelta - cy), Math.abs(cy)) + } + return spread + case 'closest-side': + if (shape === 'circle') { + spread.r = Math.min( + Math.abs(xDelta - cx), + Math.abs(cx), + Math.abs(yDelta - cy), + Math.abs(cy) + ) + } else { + spread.rx = Math.min(Math.abs(xDelta - cx), Math.abs(cx)) + spread.ry = Math.min(Math.abs(yDelta - cy), Math.abs(cy)) + } + + return spread + } + if (shape === 'circle') { + spread.r = Math.sqrt(fx * fx + fy * fy) + } else { + // Spec: https://drafts.csswg.org/css-images/#typedef-size + // Get the aspect ratio of the closest-side size. + const ratio = fy !== 0 ? fx / fy : 1 + + if (fx === 0) { + spread.rx = 0 + spread.ry = 0 + } else { + // fx^2/a^2 + fy^2/b^2 = 1 + // fx^2/(b*ratio)^2 + fy^2/b^2 = 1 + // (fx^2+fy^2*ratio^2) = (b*ratio)^2 + // b = sqrt(fx^2+fy^2*ratio^2)/ratio + + spread.ry = Math.sqrt(fx * fx + fy * fy * ratio * ratio) / ratio + spread.rx = spread.ry * ratio + } + } + + return spread +} + +function isSizeAllLength(v: RadialPropertyValue[]): v is Array<{ + type: 'length' + value: { + unit: string + value: string + } +}> { + return !v.some((s) => s.type === 'keyword') +} diff --git a/src/builder/gradient/utils.ts b/src/builder/gradient/utils.ts new file mode 100644 index 00000000..0ae2ea1f --- /dev/null +++ b/src/builder/gradient/utils.ts @@ -0,0 +1,104 @@ +import { lengthToNumber } from '../../utils.js' +import cssColorParse from 'parse-css-color' +import type { ColorStop } from 'css-gradient-parser' + +interface Stop { + color: string + offset?: number +} + +export function normalizeStops( + totalLength: number, + colorStops: ColorStop[], + inheritedStyle: Record, + from?: 'background' | 'mask' +) { + // Resolve the color stops based on the spec: + // https://drafts.csswg.org/css-images/#color-stop-syntax + const stops: Stop[] = [] + for (const stop of colorStops) { + const { color } = stop + if (!stops.length) { + // First stop, ensure it's at the start. + stops.push({ + offset: 0, + color, + }) + + if (!stop.offset) continue + if (stop.offset.value === '0') continue + } + + // All offsets are relative values (0-1) in SVG. + const offset = + typeof stop.offset === 'undefined' + ? undefined + : stop.offset.unit === '%' + ? +stop.offset.value / 100 + : Number( + lengthToNumber( + `${stop.offset.value}${stop.offset.unit}`, + inheritedStyle.fontSize as number, + totalLength, + inheritedStyle, + true + ) + ) / totalLength + + stops.push({ + offset, + color, + }) + } + if (!stops.length) { + stops.push({ + offset: 0, + color: 'transparent', + }) + } + // Last stop, ensure it's at the end. + const lastStop = stops[stops.length - 1] + if (lastStop.offset !== 1) { + if (typeof lastStop.offset === 'undefined') { + lastStop.offset = 1 + } else { + stops.push({ + offset: 1, + color: lastStop.color, + }) + } + } + + let previousStop = 0 + let nextStop = 1 + // Evenly distribute the missing stop offsets. + for (let i = 0; i < stops.length; i++) { + if (typeof stops[i].offset === 'undefined') { + // Find the next stop that has an offset. + if (nextStop < i) nextStop = i + while (typeof stops[nextStop].offset === 'undefined') nextStop++ + + stops[i].offset = + ((stops[nextStop].offset - stops[previousStop].offset) / + (nextStop - previousStop)) * + (i - previousStop) + + stops[previousStop].offset + } else { + previousStop = i + } + } + + if (from === 'mask') { + return stops.map((stop) => { + const color = cssColorParse(stop.color) + if (!color) return stop + if (color.alpha === 0) { + return { ...stop, color: `rgba(0, 0, 0, 1)` } + } else { + return { ...stop, color: `rgba(255, 255, 255, ${color.alpha})` } + } + }) + } + + return stops +} diff --git a/src/utils.ts b/src/utils.ts index c672766f..55c410f6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -94,14 +94,7 @@ export function lengthToNumber( return parsed.value } } else if (parsed.type === 'angle') { - switch (parsed.unit) { - case 'deg': - return parsed.value - case 'rad': - return (parsed.value * 180) / Math.PI - default: - return parsed.value - } + return calcDegree(length) } else if (parsed.type === 'percentage') { if (percentage) { return (parsed.value / 100) * baseLength @@ -112,6 +105,21 @@ export function lengthToNumber( } } +export function calcDegree(deg: string) { + const parsed = new CssDimension(deg) + + switch (parsed.unit) { + case 'deg': + return parsed.value + case 'rad': + return (parsed.value * 180) / Math.PI + case 'turn': + return parsed.value * 360 + case 'grad': + return 0.9 * parsed.value + } +} + // Multiplies two 2d transform matrices. export function multiply(m1: number[], m2: number[]) { return [ diff --git a/src/vendor/gradient-parser/LICENSE b/src/vendor/gradient-parser/LICENSE deleted file mode 100644 index 7428dd18..00000000 --- a/src/vendor/gradient-parser/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014 Rafael CarĂ­cio - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/src/vendor/gradient-parser/index.js b/src/vendor/gradient-parser/index.js deleted file mode 100644 index 4ab417b7..00000000 --- a/src/vendor/gradient-parser/index.js +++ /dev/null @@ -1,408 +0,0 @@ -// Copyright (c) 2014 Rafael Caricio. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -var GradientParser = GradientParser || {} - -// https://w3c.github.io/csswg-drafts/css-images-3/#linear-gradients -// It may be omitted; if so, it defaults to to bottom. -const FALLBACK_LINEAR_ORIENTATION = { type: 'directional', value: 'bottom' } - -GradientParser.parse = (function () { - var tokens = { - linearGradient: /^(\-(webkit|o|ms|moz)\-)?(linear\-gradient)/i, - repeatingLinearGradient: - /^(\-(webkit|o|ms|moz)\-)?(repeating\-linear\-gradient)/i, - radialGradient: /^(\-(webkit|o|ms|moz)\-)?(radial\-gradient)/i, - repeatingRadialGradient: - /^(\-(webkit|o|ms|moz)\-)?(repeating\-radial\-gradient)/i, - sideOrCorner: - /^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i, - extentKeywords: - /^(closest\-side|closest\-corner|farthest\-side|farthest\-corner|contain|cover)/, - positionKeywords: /^(left|center|right|top|bottom)/i, - pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/, - percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))\%/, - emLikeValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))(r?em|vw|vh)/, - angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/, - zeroValue: /[0]/, - startCall: /^\(/, - endCall: /^\)/, - comma: /^,/, - hexColor: /^\#([0-9a-fA-F]+)/, - literalColor: /^([a-zA-Z]+)/, - rgbColor: /^rgb/i, - rgbaColor: /^rgba/i, - number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/, - } - - var input = '' - - function error(msg) { - var err = new Error(input + ': ' + msg) - err.source = input - throw err - } - - function getAST() { - var ast = matchListDefinitions() - - if (input.length > 0) { - error('Invalid input not EOF') - } - return ast - } - - function matchListDefinitions() { - return matchListing(matchDefinition) - } - - function matchDefinition() { - return ( - matchGradient( - 'linear-gradient', - tokens.linearGradient, - matchLinearOrientation, - FALLBACK_LINEAR_ORIENTATION - ) || - matchGradient( - 'repeating-linear-gradient', - tokens.repeatingLinearGradient, - matchLinearOrientation, - FALLBACK_LINEAR_ORIENTATION - ) || - matchGradient( - 'radial-gradient', - tokens.radialGradient, - matchListRadialOrientations - ) || - matchGradient( - 'repeating-radial-gradient', - tokens.repeatingRadialGradient, - matchListRadialOrientations - ) - ) - } - - function getDefaultRadialGradient(orientation = {}) { - const res = { ...orientation } - - Object.assign(res, { - style: (res.style || []).length > 0 - ? res.style - : [{ type: 'extent-keyword', value: 'farthest-corner' }], - at: { - type: 'position', - value: { - x: { - type: 'position-keyword', - value: 'center', - ...(res.at?.value?.x || {}) - }, - y: { - type: 'position-keyword', - value: 'center', - ...(res.at?.value?.y || {}) - } - } - }} - ) - - if (!orientation.value) { - Object.assign(res, { - type: 'shape', - value: res.style.some(v => ['%', 'extent-keyword'].includes(v.type)) - ? 'ellipse' - : 'circle' - }) - } - - return res - } - - function matchGradient( - gradientType, - pattern, - orientationMatcher, - fallbackOrientation - ) { - return matchCall(pattern, function (captures) { - var orientation = orientationMatcher() - if (orientation) { - if (!scan(tokens.comma)) { - error('Missing comma before color stops') - } - } else { - orientation = fallbackOrientation - } - - return { - type: gradientType, - orientation: gradientType.endsWith('radial-gradient') - ? orientation?.map(v => getDefaultRadialGradient(v)) ?? [getDefaultRadialGradient()] - : orientation, - colorStops: matchListing(matchColorStop), - } - }) - } - - function matchCall(pattern, callback) { - var captures = scan(pattern) - if (captures) { - if (!scan(tokens.startCall)) { - error('Missing (') - } - - var result = callback(captures) - - if (!scan(tokens.endCall)) { - error('Missing )') - } - - return result - } - } - - function matchLinearOrientation() { - return matchSideOrCorner() || matchAngle() || matchZero(); - } - - function matchSideOrCorner() { - return match('directional', tokens.sideOrCorner, 1) - } - - function matchAngle() { - return match('angular', tokens.angleValue, 1) - } - - // For zero value passed into linear-gradient first arg: - function matchZero() { - return match('directional', tokens.zeroValue, 0) - } - - function matchListRadialOrientations() { - var radialOrientations, - radialOrientation = matchRadialOrientation(), - lookaheadCache - - if (radialOrientation) { - radialOrientations = [] - radialOrientations.push(radialOrientation) - - lookaheadCache = input - if (scan(tokens.comma)) { - radialOrientation = matchRadialOrientation() - if (radialOrientation) { - radialOrientations.push(radialOrientation) - } else { - input = lookaheadCache - } - } - } - - return radialOrientations - } - - function matchRadialOrientation() { - const pre = preMatchRadialOrientation() - const at = matchAtPosition() - - if (!pre && !at) return - - return { ...pre, at } - } - - function preMatchRadialOrientation() { - let rgEndingShape = matchCircle() || matchEllipse() - let rgSize = matchExtentKeyword() || matchLength() || matchDistance() - const extra = match('%', tokens.percentageValue, 1) - - if (rgEndingShape) { - return { - ...rgEndingShape, - style: [rgSize, extra].filter(v => v) - } - } else if (rgSize){ - return { - style: [rgSize, extra].filter(v => v), - ...(matchCircle() || matchEllipse()) - } - } else { - // - } - } - - function matchCircle() { - return match('shape', /^(circle)/i, 0) - } - - function matchEllipse() { - return match('shape', /^(ellipse)/i, 0) - } - - function matchExtentKeyword() { - return match('extent-keyword', tokens.extentKeywords, 1) - } - - function matchAtPosition() { - if (match('position', /^at/, 0)) { - var positioning = matchPositioning() - - if (!positioning) { - error('Missing positioning value') - } - - return positioning - } - } - - function matchPositioning() { - var location = matchCoordinates() - - if (location.x || location.y) { - return { - type: 'position', - value: location, - } - } - } - - function matchCoordinates() { - return { - x: matchDistance(), - y: matchDistance(), - } - } - - function matchListing(matcher) { - var captures = matcher(), - result = [] - - if (captures) { - result.push(captures) - while (scan(tokens.comma)) { - captures = matcher() - if (captures) { - result.push(captures) - } else { - error('One extra comma') - } - } - } - - return result - } - - function matchColorStop() { - var color = matchColor() - - if (!color) { - error('Expected color definition') - } - - color.length = matchDistance() - return color - } - - function matchColor() { - return ( - matchHexColor() || - matchRGBAColor() || - matchRGBColor() || - matchLiteralColor() - ) - } - - function matchLiteralColor() { - return match('literal', tokens.literalColor, 0) - } - - function matchHexColor() { - return match('hex', tokens.hexColor, 1) - } - - function matchRGBColor() { - return matchCall(tokens.rgbColor, function () { - return { - type: 'rgb', - value: matchListing(matchNumber), - } - }) - } - - function matchRGBAColor() { - return matchCall(tokens.rgbaColor, function () { - return { - type: 'rgba', - value: matchListing(matchNumber), - } - }) - } - - function matchNumber() { - return scan(tokens.number)[1] - } - - function matchDistance() { - return ( - match('%', tokens.percentageValue, 1) || - matchPositionKeyword() || - matchLength() - ) - } - - function matchPositionKeyword() { - return match('position-keyword', tokens.positionKeywords, 1) - } - - function matchLength() { - return match('px', tokens.pixelValue, 1) || matchRelativeLength(tokens.emLikeValue, 1) - } - - function matchRelativeLength(pattern, captureIndex) { - var captures = scan(pattern) - if (captures) { - return { - type: captures[5], - value: captures[captureIndex], - } - } - } - - function match(type, pattern, captureIndex) { - var captures = scan(pattern) - if (captures) { - return { - type: type, - value: captures[captureIndex], - } - } - } - - function scan(regexp) { - var captures, blankCaptures - - blankCaptures = /^[\n\r\t\s]+/.exec(input) - if (blankCaptures) { - consume(blankCaptures[0].length) - } - - captures = regexp.exec(input) - if (captures) { - consume(captures[0].length) - } - - return captures - } - - function consume(size) { - input = input.substr(size) - } - - return function (code) { - input = code.toString() - return getAST() - } -})() - -export default GradientParser diff --git a/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-1-snap.png b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-1-snap.png new file mode 100644 index 00000000..7da3aecd Binary files /dev/null and b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-1-snap.png differ diff --git a/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-2-snap.png b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-2-snap.png new file mode 100644 index 00000000..7da3aecd Binary files /dev/null and b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-2-snap.png differ diff --git a/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-3-snap.png b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-3-snap.png new file mode 100644 index 00000000..7da3aecd Binary files /dev/null and b/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-gradient-linear-gradient-should-support-other-degree-unit-3-snap.png differ diff --git a/test/gradient.test.tsx b/test/gradient.test.tsx index 06a2ed34..ce981602 100644 --- a/test/gradient.test.tsx +++ b/test/gradient.test.tsx @@ -120,6 +120,35 @@ describe('Gradient', () => { ) expect(toImage(svg, 100)).toMatchImageSnapshot() }) + + it('should support other degree unit', async () => { + const svgs = await Promise.all( + [ + 'linear-gradient(0.5turn, red, blue)', + `linear-gradient(${Math.PI}rad, red, blue)`, + `linear-gradient(200grad, red, blue)`, + ].map((background) => + satori( +
, + { + width: 100, + height: 100, + fonts, + } + ) + ) + ) + + for (const svg of svgs) { + expect(toImage(svg, 100)).toMatchImageSnapshot() + } + }) }) describe('radial-gradient', () => {