diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/lib/gradients | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/lib/gradients')
| -rw-r--r-- | apps/web/src/lib/gradients/canvas.ts | 923 | ||||
| -rw-r--r-- | apps/web/src/lib/gradients/index.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/gradients/parser.ts | 652 |
3 files changed, 1577 insertions, 0 deletions
diff --git a/apps/web/src/lib/gradients/canvas.ts b/apps/web/src/lib/gradients/canvas.ts new file mode 100644 index 00000000..e2287333 --- /dev/null +++ b/apps/web/src/lib/gradients/canvas.ts @@ -0,0 +1,923 @@ +import type { + GradientAst, + Color, + ColorStop, + GradientOrientation, +} from "./parser"; +import { parseGradient } from "./parser"; + +type BackgroundLayer = + | { type: "color"; value: string } + | { type: "gradient"; value: GradientAst }; + +type LinearPoints = { + x0: number; + y0: number; + x1: number; + y1: number; + length: number; +}; + +type RadialDimensions = { + cx: number; + cy: number; + rx: number; + ry: number; +}; + +type PositionKeyword = "left" | "center" | "right" | "top" | "bottom"; + +type Distance = + | { type: "%"; value: string } + | { type: "position-keyword"; value: string } + | { type: "calc"; value: string } + | { type: "px"; value: string } + | { type: "em"; value: string }; + +type Position = { type: "position"; value: { x?: Distance; y?: Distance } }; + +type Shape = { + type: "shape"; + value: "circle" | "ellipse"; + style?: Distance | { type: "extent-keyword"; value: string } | Position; + at?: Position; +}; + +type DefaultRadial = { type: "default-radial"; at: Position }; + +type ExtentKeyword = { type: "extent-keyword"; value: string; at?: Position }; + +type RadialOrientation = Shape | ExtentKeyword | DefaultRadial; + +const gradientLayerPattern = + /^(?:-(webkit|o|ms|moz)-)?(linear-gradient|repeating-linear-gradient|radial-gradient|repeating-radial-gradient)/i; + +export function drawCssBackground({ + ctx, + width, + height, + css, +}: { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + width: number; + height: number; + css: string; +}): void { + const layers = parseBackgroundLayers({ css }); + const layersInPaintOrder = layers.slice().reverse(); + + for (const layer of layersInPaintOrder) { + if (layer.type === "color") { + ctx.fillStyle = layer.value; + ctx.fillRect(0, 0, width, height); + continue; + } + + drawGradientLayer({ ctx, width, height, gradient: layer.value }); + } +} + +const parseBackgroundLayers = ({ + css, +}: { + css: string; +}): Array<BackgroundLayer> => { + const segments = splitCssLayers({ css }); + const layers: Array<BackgroundLayer> = []; + + for (const segment of segments) { + if (!segment) { + continue; + } + + if (gradientLayerPattern.test(segment)) { + try { + const parsed = parseGradient({ code: segment.trim() }); + for (const gradient of parsed) { + layers.push({ type: "gradient", value: gradient }); + } + continue; + } catch { + layers.push({ type: "color", value: segment }); + continue; + } + } + + layers.push({ type: "color", value: segment }); + } + + return layers; +}; + +const splitCssLayers = ({ css }: { css: string }): Array<string> => { + const layers: Array<string> = []; + let current = ""; + let depth = 0; + + for (const char of css) { + if (char === "(") { + depth += 1; + } + if (char === ")") { + depth = Math.max(0, depth - 1); + } + + if (char === "," && depth === 0) { + layers.push(current.trim()); + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) { + layers.push(current.trim()); + } + + return layers; +}; + +const drawGradientLayer = ({ + ctx, + width, + height, + gradient, +}: { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + width: number; + height: number; + gradient: GradientAst; +}): void => { + if (gradient.type.includes("linear")) { + drawLinearGradient({ ctx, width, height, gradient }); + return; + } + + drawRadialGradient({ ctx, width, height, gradient }); +}; + +const drawLinearGradient = ({ + ctx, + width, + height, + gradient, +}: { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + width: number; + height: number; + gradient: GradientAst; +}): void => { + const { x0, y0, x1, y1, length } = resolveLinearPoints({ + width, + height, + orientation: gradient.orientation, + }); + const canvasGradient = ctx.createLinearGradient(x0, y0, x1, y1); + const colorStops = normalizeColorStops({ + colorStops: gradient.colorStops, + gradientLength: length, + }); + + for (const stop of colorStops) { + canvasGradient.addColorStop(stop.offset, stop.color); + } + + ctx.fillStyle = canvasGradient; + ctx.fillRect(0, 0, width, height); +}; + +const drawRadialGradient = ({ + ctx, + width, + height, + gradient, +}: { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + width: number; + height: number; + gradient: GradientAst; +}): void => { + const { cx, cy, rx, ry } = resolveRadialDimensions({ + width, + height, + orientation: gradient.orientation, + }); + const gradientLength = Math.max(rx, ry); + const colorStops = normalizeColorStops({ + colorStops: gradient.colorStops, + gradientLength, + }); + + if (rx === ry || ry === 0) { + const canvasGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, rx); + for (const stop of colorStops) { + canvasGradient.addColorStop(stop.offset, stop.color); + } + ctx.fillStyle = canvasGradient; + ctx.fillRect(0, 0, width, height); + return; + } + + const scaleY = ry / rx; + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(1, scaleY); + const canvasGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, rx); + for (const stop of colorStops) { + canvasGradient.addColorStop(stop.offset, stop.color); + } + ctx.fillStyle = canvasGradient; + ctx.fillRect(-cx, -cy / scaleY, width, height / scaleY); + ctx.restore(); +}; + +const resolveLinearPoints = ({ + width, + height, + orientation, +}: { + width: number; + height: number; + orientation: GradientOrientation | undefined; +}): LinearPoints => { + const angle = resolveLinearAngle({ orientation }); + const radians = (angle * Math.PI) / 180; + const dx = Math.sin(radians); + const dy = -Math.cos(radians); + const centerX = width / 2; + const centerY = height / 2; + const halfLength = (Math.abs(width * dx) + Math.abs(height * dy)) / 2; + const x0 = centerX - dx * halfLength; + const y0 = centerY - dy * halfLength; + const x1 = centerX + dx * halfLength; + const y1 = centerY + dy * halfLength; + const length = Math.hypot(x1 - x0, y1 - y0); + + return { x0, y0, x1, y1, length }; +}; + +const resolveLinearAngle = ({ + orientation, +}: { + orientation: GradientOrientation | undefined; +}): number => { + if (!orientation) { + return 180; + } + + if (!Array.isArray(orientation)) { + if (orientation.type === "angular") { + return Number.parseFloat(orientation.value); + } + + if (orientation.type === "directional") { + return angleFromDirectional({ value: orientation.value }); + } + } + + return 180; +}; + +const angleFromDirectional = ({ value }: { value: string }): number => { + const normalized = value.toLowerCase().replace("to", "").trim(); + const parts = normalized.split(/\s+/).filter(Boolean); + let dx = 0; + let dy = 0; + + for (const part of parts) { + if (part === "left") { + dx = -1; + } + if (part === "right") { + dx = 1; + } + if (part === "top") { + dy = -1; + } + if (part === "bottom") { + dy = 1; + } + } + + if (dx === 0 && dy === 0) { + return 180; + } + + const angle = (Math.atan2(dx, -dy) * 180) / Math.PI; + return (angle + 360) % 360; +}; + +const resolveRadialDimensions = ({ + width, + height, + orientation, +}: { + width: number; + height: number; + orientation: GradientOrientation | undefined; +}): RadialDimensions => { + const centerFallback = { cx: width / 2, cy: height / 2 }; + const radial = Array.isArray(orientation) + ? (orientation[0] as RadialOrientation | undefined) + : undefined; + + if (!radial) { + const { rx, ry } = resolveRadialExtents({ + width, + height, + cx: centerFallback.cx, + cy: centerFallback.cy, + shape: "ellipse", + extent: "farthest-corner", + }); + return { ...centerFallback, rx, ry }; + } + + if (radial.type === "shape") { + const { cx, cy } = resolveRadialCenter({ + width, + height, + position: radial.at, + }); + const shape = radial.value; + + if (radial.style && radial.style.type === "position") { + const xDist = radial.style.value.x; + const yDist = radial.style.value.y; + const rx = xDist + ? resolveEllipseDimension({ distance: xDist, axisSize: width }) + : width / 2; + const ry = yDist + ? resolveEllipseDimension({ distance: yDist, axisSize: height }) + : height / 2; + return { cx, cy, rx, ry }; + } + + if (radial.style && radial.style.type !== "extent-keyword") { + const resolvedRadius = resolveDistanceInPixels({ + distance: radial.style, + axisSize: Math.max(width, height), + }); + if (shape === "circle") { + return { + cx, + cy, + rx: resolvedRadius ?? 0, + ry: resolvedRadius ?? 0, + }; + } + + const radius = resolvedRadius ?? 0; + return { cx, cy, rx: radius, ry: radius }; + } + + const extent = + radial.style && radial.style.type === "extent-keyword" + ? radial.style.value + : "farthest-corner"; + const { rx, ry } = resolveRadialExtents({ + width, + height, + cx, + cy, + shape, + extent, + }); + return { cx, cy, rx, ry }; + } + + if (radial.type === "extent-keyword") { + const { cx, cy } = resolveRadialCenter({ + width, + height, + position: radial.at, + }); + const { rx, ry } = resolveRadialExtents({ + width, + height, + cx, + cy, + shape: "ellipse", + extent: radial.value, + }); + return { cx, cy, rx, ry }; + } + + const { cx, cy } = resolveRadialCenter({ + width, + height, + position: radial.at, + }); + const { rx, ry } = resolveRadialExtents({ + width, + height, + cx, + cy, + shape: "ellipse", + extent: "farthest-corner", + }); + return { cx, cy, rx, ry }; +}; + +const resolveRadialCenter = ({ + width, + height, + position, +}: { + width: number; + height: number; + position?: Position; +}): { cx: number; cy: number } => { + if (!position) { + return { cx: width / 2, cy: height / 2 }; + } + + const normalized = normalizePositionKeywords({ position }); + const cx = resolvePositionValue({ + distance: normalized.value.x, + axisSize: width, + axis: "x", + }); + const cy = resolvePositionValue({ + distance: normalized.value.y, + axisSize: height, + axis: "y", + }); + + return { cx, cy }; +}; + +const normalizePositionKeywords = ({ + position, +}: { + position: Position; +}): Position => { + const xValue = position.value.x; + const yValue = position.value.y; + + if ( + xValue?.type === "position-keyword" && + yValue?.type === "position-keyword" + ) { + const xKeyword = xValue.value.toLowerCase() as PositionKeyword; + const yKeyword = yValue.value.toLowerCase() as PositionKeyword; + const xIsVertical = xKeyword === "top" || xKeyword === "bottom"; + const yIsHorizontal = yKeyword === "left" || yKeyword === "right"; + + if (xIsVertical && yIsHorizontal) { + return { + type: "position", + value: { + x: { type: "position-keyword", value: yKeyword }, + y: { type: "position-keyword", value: xKeyword }, + }, + }; + } + } + + return position; +}; + +const resolveRadialExtents = ({ + width, + height, + cx, + cy, + shape, + extent, +}: { + width: number; + height: number; + cx: number; + cy: number; + shape: "circle" | "ellipse"; + extent: string; +}): { rx: number; ry: number } => { + const left = cx; + const right = width - cx; + const top = cy; + const bottom = height - cy; + + if (shape === "circle") { + const distances = [ + Math.hypot(left, top), + Math.hypot(right, top), + Math.hypot(left, bottom), + Math.hypot(right, bottom), + ]; + if (extent === "closest-side") { + return { rx: Math.min(left, right, top, bottom), ry: 0 }; + } + if (extent === "farthest-side") { + return { rx: Math.max(left, right, top, bottom), ry: 0 }; + } + if (extent === "closest-corner") { + return { rx: Math.min(...distances), ry: 0 }; + } + return { rx: Math.max(...distances), ry: 0 }; + } + + if (extent === "closest-side") { + return { rx: Math.min(left, right), ry: Math.min(top, bottom) }; + } + if (extent === "farthest-side") { + return { rx: Math.max(left, right), ry: Math.max(top, bottom) }; + } + + const corners = [ + { dx: left, dy: top }, + { dx: right, dy: top }, + { dx: left, dy: bottom }, + { dx: right, dy: bottom }, + ]; + const sorted = corners + .slice() + .sort((a, b) => Math.hypot(a.dx, a.dy) - Math.hypot(b.dx, b.dy)); + const chosen = + extent === "closest-corner" ? sorted[0] : sorted[sorted.length - 1]; + return { rx: Math.abs(chosen.dx), ry: Math.abs(chosen.dy) }; +}; + +const resolvePositionValue = ({ + distance, + axisSize, + axis, +}: { + distance?: Distance; + axisSize: number; + axis: "x" | "y"; +}): number => { + if (!distance) { + return axisSize / 2; + } + + if (distance.type === "%") { + return (Number.parseFloat(distance.value) / 100) * axisSize; + } + + if (distance.type === "position-keyword") { + return keywordToPosition({ + value: distance.value, + axisSize, + axis, + }); + } + + if (distance.type === "px") { + return Number.parseFloat(distance.value); + } + + if (distance.type === "em") { + return Number.parseFloat(distance.value) * 16; + } + + return axisSize / 2; +}; + +const resolveDistanceInPixels = ({ + distance, + axisSize, +}: { + distance: Distance; + axisSize: number; +}): number | null => { + if (distance.type === "%") { + return (Number.parseFloat(distance.value) / 100) * axisSize; + } + + if (distance.type === "px") { + return Number.parseFloat(distance.value); + } + + if (distance.type === "em") { + return Number.parseFloat(distance.value) * 16; + } + + return null; +}; + +const resolveEllipseDimension = ({ + distance, + axisSize, +}: { + distance: Distance; + axisSize: number; +}): number => { + if (distance.type === "%") { + return (Number.parseFloat(distance.value) / 100) * axisSize; + } + + if (distance.type === "px") { + return Number.parseFloat(distance.value); + } + + if (distance.type === "em") { + return Number.parseFloat(distance.value) * 16; + } + + return axisSize / 2; +}; + +const keywordToPosition = ({ + value, + axisSize, + axis, +}: { + value: string; + axisSize: number; + axis: "x" | "y"; +}): number => { + const keyword = value.toLowerCase() as PositionKeyword; + + if (keyword === "center") { + return axisSize / 2; + } + + if (axis === "x") { + if (keyword === "left") { + return 0; + } + if (keyword === "right") { + return axisSize; + } + } + + if (axis === "y") { + if (keyword === "top") { + return 0; + } + if (keyword === "bottom") { + return axisSize; + } + } + + return axisSize / 2; +}; + +const isTransparent = ({ color }: { color: string }): boolean => { + const lower = color.toLowerCase().trim(); + if (lower === "transparent") { + return true; + } + const rgbaMatch = lower.match( + /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/, + ); + if (rgbaMatch && Number.parseFloat(rgbaMatch[1]) === 0) { + return true; + } + return false; +}; + +const parseColorToRgb = ({ + color, +}: { + color: string; +}): { r: number; g: number; b: number; a: number } | null => { + const lower = color.toLowerCase().trim(); + + const hexMatch = lower.match(/^#([0-9a-f]{3,8})$/); + if (hexMatch) { + const hex = hexMatch[1]; + if (hex.length === 3) { + return { + r: Number.parseInt(hex[0] + hex[0], 16), + g: Number.parseInt(hex[1] + hex[1], 16), + b: Number.parseInt(hex[2] + hex[2], 16), + a: 1, + }; + } + if (hex.length === 6) { + return { + r: Number.parseInt(hex.slice(0, 2), 16), + g: Number.parseInt(hex.slice(2, 4), 16), + b: Number.parseInt(hex.slice(4, 6), 16), + a: 1, + }; + } + if (hex.length === 8) { + return { + r: Number.parseInt(hex.slice(0, 2), 16), + g: Number.parseInt(hex.slice(2, 4), 16), + b: Number.parseInt(hex.slice(4, 6), 16), + a: Number.parseInt(hex.slice(6, 8), 16) / 255, + }; + } + } + + const rgbMatch = lower.match( + /^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/, + ); + if (rgbMatch) { + return { + r: Number.parseFloat(rgbMatch[1]), + g: Number.parseFloat(rgbMatch[2]), + b: Number.parseFloat(rgbMatch[3]), + a: 1, + }; + } + + const rgbaMatch = lower.match( + /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/, + ); + if (rgbaMatch) { + return { + r: Number.parseFloat(rgbaMatch[1]), + g: Number.parseFloat(rgbaMatch[2]), + b: Number.parseFloat(rgbaMatch[3]), + a: Number.parseFloat(rgbaMatch[4]), + }; + } + + return null; +}; + +const fixTransparentStops = ({ + stops, +}: { + stops: Array<{ color: string; offset: number | null }>; +}): Array<{ color: string; offset: number | null }> => { + if (stops.length === 0) { + return stops; + } + + const result = stops.map((stop) => ({ ...stop })); + + for (let i = 0; i < result.length; i++) { + if (!isTransparent({ color: result[i].color })) { + continue; + } + + let donorColor: { r: number; g: number; b: number } | null = null; + + for (let j = i - 1; j >= 0; j--) { + if (!isTransparent({ color: result[j].color })) { + const parsed = parseColorToRgb({ color: result[j].color }); + if (parsed) { + donorColor = parsed; + break; + } + } + } + + if (!donorColor) { + for (let j = i + 1; j < result.length; j++) { + if (!isTransparent({ color: result[j].color })) { + const parsed = parseColorToRgb({ color: result[j].color }); + if (parsed) { + donorColor = parsed; + break; + } + } + } + } + + if (donorColor) { + result[i].color = + `rgba(${donorColor.r},${donorColor.g},${donorColor.b},0)`; + } + } + + return result; +}; + +const normalizeColorStops = ({ + colorStops, + gradientLength, +}: { + colorStops: Array<ColorStop>; + gradientLength: number; +}): Array<{ color: string; offset: number }> => { + const mappedStops = colorStops.map((stop) => ({ + color: colorToString({ color: stop }), + offset: resolveStopOffset({ stop, gradientLength }), + })); + + const fixedStops = fixTransparentStops({ stops: mappedStops }); + const resolvedStops = fixedStops.map((stop) => ({ ...stop })); + const knownIndices = resolvedStops + .map((stop, index) => (stop.offset === null ? null : index)) + .filter((index): index is number => index !== null); + + if (knownIndices.length === 0) { + const step = resolvedStops.length > 1 ? 1 / (resolvedStops.length - 1) : 1; + for (let index = 0; index < resolvedStops.length; index += 1) { + resolvedStops[index].offset = step * index; + } + return clampStops({ stops: resolvedStops }); + } + + const firstKnown = knownIndices[0]; + if (resolvedStops[firstKnown].offset === null) { + resolvedStops[firstKnown].offset = 0; + } + + for (let index = 0; index < firstKnown; index += 1) { + const nextOffset = resolvedStops[firstKnown].offset ?? 0; + resolvedStops[index].offset = (nextOffset * index) / firstKnown; + } + + for (let i = 0; i < knownIndices.length - 1; i += 1) { + const startIndex = knownIndices[i]; + const endIndex = knownIndices[i + 1]; + const startOffset = resolvedStops[startIndex].offset ?? 0; + const endOffset = resolvedStops[endIndex].offset ?? startOffset; + const gap = endIndex - startIndex; + + if (gap <= 1) { + continue; + } + + const step = (endOffset - startOffset) / gap; + for (let index = 1; index < gap; index += 1) { + resolvedStops[startIndex + index].offset = startOffset + step * index; + } + } + + const lastKnown = knownIndices[knownIndices.length - 1]; + const lastOffset = resolvedStops[lastKnown].offset ?? 1; + if (lastKnown < resolvedStops.length - 1) { + const gap = resolvedStops.length - 1 - lastKnown; + const step = (1 - lastOffset) / gap; + for (let index = 1; index <= gap; index += 1) { + resolvedStops[lastKnown + index].offset = lastOffset + step * index; + } + } + + return clampStops({ stops: resolvedStops }); +}; + +const clampStops = ({ + stops, +}: { + stops: Array<{ color: string; offset: number | null }>; +}): Array<{ color: string; offset: number }> => { + return stops.map((stop) => ({ + color: stop.color, + offset: clamp01({ value: stop.offset ?? 0 }), + })); +}; + +const resolveStopOffset = ({ + stop, + gradientLength, +}: { + stop: ColorStop; + gradientLength: number; +}): number | null => { + if (!stop.length) { + return null; + } + + if (stop.length.type === "%") { + return Number.parseFloat(stop.length.value) / 100; + } + + if (stop.length.type === "px") { + return Number.parseFloat(stop.length.value) / gradientLength; + } + + if (stop.length.type === "em") { + return (Number.parseFloat(stop.length.value) * 16) / gradientLength; + } + + return null; +}; + +const clamp01 = ({ value }: { value: number }): number => { + if (value < 0) { + return 0; + } + if (value > 1) { + return 1; + } + return value; +}; + +const colorToString = ({ color }: { color: Color }): string => { + if (color.type === "hex") { + return `#${color.value}`; + } + + if (color.type === "literal") { + return color.value; + } + + if (color.type === "rgb") { + return `rgb(${color.value.join(",")})`; + } + + if (color.type === "rgba") { + return `rgba(${color.value.join(",")})`; + } + + if (color.type === "hsl") { + return `hsl(${color.value.join(",")})`; + } + + if (color.type === "hsla") { + return `hsla(${color.value.join(",")})`; + } + + return `var(${color.value})`; +}; diff --git a/apps/web/src/lib/gradients/index.ts b/apps/web/src/lib/gradients/index.ts new file mode 100644 index 00000000..490a8fbf --- /dev/null +++ b/apps/web/src/lib/gradients/index.ts @@ -0,0 +1,2 @@ +export * from "./parser"; +export * from "./canvas"; diff --git a/apps/web/src/lib/gradients/parser.ts b/apps/web/src/lib/gradients/parser.ts new file mode 100644 index 00000000..55440e3c --- /dev/null +++ b/apps/web/src/lib/gradients/parser.ts @@ -0,0 +1,652 @@ +/* + * Original source: https://github.com/rafaelcaricio/gradient-parser/blob/master/lib/parser.js + */ + +type GradientType = + | "linear-gradient" + | "repeating-linear-gradient" + | "radial-gradient" + | "repeating-radial-gradient"; + +type DirectionalOrientation = { type: "directional"; value: string }; +type AngularOrientation = { type: "angular"; value: string }; +type LinearOrientation = DirectionalOrientation | AngularOrientation; + +type Distance = + | { type: "%"; value: string } + | { type: "position-keyword"; value: string } + | { type: "calc"; value: string } + | { type: "px"; value: string } + | { type: "em"; value: string }; + +type PositionValue = { x?: Distance; y?: Distance }; +type Position = { type: "position"; value: PositionValue }; + +type ExtentKeyword = { type: "extent-keyword"; value: string }; + +type ShapeValue = "circle" | "ellipse"; +type Shape = { + type: "shape"; + value: ShapeValue; + style?: Distance | ExtentKeyword | Position; + at?: Position; +}; + +type DefaultRadial = { type: "default-radial"; at: Position }; + +type RadialOrientation = + | Shape + | (ExtentKeyword & { at?: Position }) + | DefaultRadial; + +export type GradientOrientation = LinearOrientation | Array<RadialOrientation>; + +export type Color = + | { type: "hex"; value: string } + | { type: "literal"; value: string } + | { type: "rgb"; value: Array<string> } + | { type: "rgba"; value: Array<string> } + | { type: "hsl"; value: [string, string, string] } + | { type: "hsla"; value: [string, string, string, string] } + | { type: "var"; value: string }; + +export type ColorStop = Color & { length?: Distance }; + +export type GradientAst = { + type: GradientType; + orientation: GradientOrientation | undefined; + colorStops: Array<ColorStop>; +}; + +type Tokens = { + linearGradient: RegExp; + repeatingLinearGradient: RegExp; + radialGradient: RegExp; + repeatingRadialGradient: RegExp; + sideOrCorner: RegExp; + extentKeywords: RegExp; + positionKeywords: RegExp; + pixelValue: RegExp; + percentageValue: RegExp; + emValue: RegExp; + angleValue: RegExp; + radianValue: RegExp; + startCall: RegExp; + endCall: RegExp; + comma: RegExp; + hexColor: RegExp; + literalColor: RegExp; + rgbColor: RegExp; + rgbaColor: RegExp; + varColor: RegExp; + calcValue: RegExp; + variableName: RegExp; + number: RegExp; + hslColor: RegExp; + hslaColor: RegExp; +}; + +const tokens: 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]+\.?)))%/, + emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/, + angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/, + radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/, + startCall: /^\(/, + endCall: /^\)/, + comma: /^,/, + hexColor: /^#([0-9a-fA-F]+)/, + literalColor: /^([a-zA-Z]+)/, + rgbColor: /^rgb/i, + rgbaColor: /^rgba/i, + varColor: /^var/i, + calcValue: /^calc/i, + variableName: /^(--[a-zA-Z0-9-,\s#]+)/, + number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/, + hslColor: /^hsl/i, + hslaColor: /^hsla/i, +}; + +let input = ""; + +const error = ({ message }: { message: string }): never => { + const err = new Error(`${input}: ${message}`); + (err as Error & { source?: string }).source = input; + throw err; +}; + +const getAst = (): Array<GradientAst> => { + const ast = matchListDefinitions(); + + if (input.length > 0) { + error({ message: "Invalid input not EOF" }); + } + + return ast; +}; + +const matchListDefinitions = (): Array<GradientAst> => + matchListing({ matcher: matchDefinition }); + +const matchDefinition = (): GradientAst | undefined => + matchGradient({ + gradientType: "linear-gradient", + pattern: tokens.linearGradient, + orientationMatcher: matchLinearOrientation, + }) || + matchGradient({ + gradientType: "repeating-linear-gradient", + pattern: tokens.repeatingLinearGradient, + orientationMatcher: matchLinearOrientation, + }) || + matchGradient({ + gradientType: "radial-gradient", + pattern: tokens.radialGradient, + orientationMatcher: matchListRadialOrientations, + }) || + matchGradient({ + gradientType: "repeating-radial-gradient", + pattern: tokens.repeatingRadialGradient, + orientationMatcher: matchListRadialOrientations, + }); + +const matchGradient = ({ + gradientType, + pattern, + orientationMatcher, +}: { + gradientType: GradientType; + pattern: RegExp; + orientationMatcher: () => GradientOrientation | undefined; +}): GradientAst | undefined => + matchCall({ + pattern, + callback: () => { + const orientation = orientationMatcher(); + if (orientation && !scan({ regexp: tokens.comma })) { + error({ message: "Missing comma before color stops" }); + } + + return { + type: gradientType, + orientation, + colorStops: matchListing({ matcher: matchColorStop }), + }; + }, + }); + +const matchCall = <T>({ + pattern, + callback, +}: { + pattern: RegExp; + callback: (captures: RegExpExecArray) => T; +}): T | undefined => { + const captures = scan({ regexp: pattern }); + + if (!captures) { + return undefined; + } + + if (!scan({ regexp: tokens.startCall })) { + error({ message: "Missing (" }); + } + + const result = callback(captures); + + if (!scan({ regexp: tokens.endCall })) { + error({ message: "Missing )" }); + } + + return result; +}; + +const matchLinearOrientation = (): LinearOrientation | undefined => { + const sideOrCorner = matchSideOrCorner(); + if (sideOrCorner) { + return sideOrCorner; + } + + const legacyDirection = match({ + type: "position-keyword", + pattern: tokens.positionKeywords, + captureIndex: 1, + }); + if (legacyDirection) { + return { + type: "directional", + value: legacyDirection.value, + }; + } + + return matchAngle(); +}; + +const matchSideOrCorner = (): DirectionalOrientation | undefined => + match({ type: "directional", pattern: tokens.sideOrCorner, captureIndex: 1 }); + +const matchAngle = (): AngularOrientation | undefined => + match({ type: "angular", pattern: tokens.angleValue, captureIndex: 1 }) || + match({ type: "angular", pattern: tokens.radianValue, captureIndex: 1 }); + +const matchListRadialOrientations = (): + | Array<RadialOrientation> + | undefined => { + const radialOrientation = matchRadialOrientation(); + if (!radialOrientation) { + return undefined; + } + + const radialOrientations: Array<RadialOrientation> = [radialOrientation]; + const lookaheadCache = input; + + if (!scan({ regexp: tokens.comma })) { + return radialOrientations; + } + + const nextRadial = matchRadialOrientation(); + if (!nextRadial) { + input = lookaheadCache; + return radialOrientations; + } + + radialOrientations.push(nextRadial); + return radialOrientations; +}; + +const matchRadialOrientation = (): RadialOrientation | undefined => { + const radialType = matchCircle() || matchEllipse(); + if (radialType) { + radialType.at = matchAtPosition(); + return radialType; + } + + const extent = matchExtentKeyword(); + if (extent) { + const positionAt = matchAtPosition(); + if (positionAt) { + return { ...extent, at: positionAt }; + } + return extent; + } + + const implicitEllipse = matchImplicitEllipse(); + if (implicitEllipse) { + return implicitEllipse; + } + + const atPosition = matchAtPosition(); + if (atPosition) { + return { type: "default-radial", at: atPosition }; + } + + const defaultPosition = matchPositioning(); + if (defaultPosition) { + return { type: "default-radial", at: defaultPosition }; + } + + return undefined; +}; + +const matchImplicitEllipse = (): Shape | undefined => { + const lookaheadCache = input; + + const width = matchDistance(); + if (!width) { + return undefined; + } + + const height = matchDistance(); + if (!height) { + input = lookaheadCache; + return undefined; + } + + const atPos = matchAtPosition(); + if (!atPos) { + input = lookaheadCache; + return undefined; + } + + return { + type: "shape", + value: "ellipse", + style: { type: "position", value: { x: width, y: height } }, + at: atPos, + }; +}; + +const matchCircle = (): Shape | undefined => { + const circle = match({ + type: "shape", + pattern: /^(circle)/i, + captureIndex: 0, + }) as Shape | undefined; + + if (!circle) { + return undefined; + } + + circle.style = matchLength() || matchExtentKeyword(); + circle.value = "circle"; + return circle; +}; + +const matchEllipse = (): Shape | undefined => { + const ellipse = match({ + type: "shape", + pattern: /^(ellipse)/i, + captureIndex: 0, + }) as Shape | undefined; + + if (!ellipse) { + return undefined; + } + + ellipse.style = matchPositioning() || matchDistance() || matchExtentKeyword(); + ellipse.value = "ellipse"; + return ellipse; +}; + +const matchExtentKeyword = (): ExtentKeyword | undefined => + match({ + type: "extent-keyword", + pattern: tokens.extentKeywords, + captureIndex: 1, + }); + +const matchAtPosition = (): Position | undefined => { + if (!match({ type: "position", pattern: /^at/, captureIndex: 0 })) { + return undefined; + } + + const positioning = matchPositioning(); + if (!positioning) { + error({ message: "Missing positioning value" }); + } + + return positioning; +}; + +const matchPositioning = (): Position | undefined => { + const location = matchCoordinates(); + + if (!location.x && !location.y) { + return undefined; + } + + return { + type: "position", + value: location, + }; +}; + +const matchCoordinates = (): PositionValue => ({ + x: matchDistance(), + y: matchDistance(), +}); + +const matchListing = <T>({ + matcher, +}: { + matcher: () => T | undefined; +}): Array<T> => { + const captures = matcher(); + const result: Array<T> = []; + + if (!captures) { + return result; + } + + result.push(captures); + while (scan({ regexp: tokens.comma })) { + const nextCapture = matcher() ?? error({ message: "One extra comma" }); + result.push(nextCapture); + } + + return result; +}; + +const matchColorStop = (): ColorStop => { + const color = matchColor() ?? error({ message: "Expected color definition" }); + const length = matchDistance(); + return { ...color, length }; +}; + +const matchColor = (): Color | undefined => + matchHexColor() || + matchHSLAColor() || + matchHSLColor() || + matchRGBAColor() || + matchRGBColor() || + matchVarColor() || + matchLiteralColor(); + +const matchLiteralColor = (): Color | undefined => + match({ type: "literal", pattern: tokens.literalColor, captureIndex: 0 }); + +const matchHexColor = (): Color | undefined => + match({ type: "hex", pattern: tokens.hexColor, captureIndex: 1 }); + +const matchRGBColor = (): Color | undefined => + matchCall({ + pattern: tokens.rgbColor, + callback: () => ({ + type: "rgb", + value: matchListing({ matcher: matchNumber }), + }), + }); + +const matchRGBAColor = (): Color | undefined => + matchCall({ + pattern: tokens.rgbaColor, + callback: () => ({ + type: "rgba", + value: matchListing({ matcher: matchNumber }), + }), + }); + +const matchVarColor = (): Color | undefined => + matchCall({ + pattern: tokens.varColor, + callback: () => ({ + type: "var", + value: matchVariableName(), + }), + }); + +const matchHSLColor = (): Color | undefined => + matchCall({ + pattern: tokens.hslColor, + callback: () => { + const lookahead = scan({ regexp: tokens.percentageValue }); + if (lookahead) { + error({ + message: + "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage", + }); + } + + const hue = matchNumber(); + scan({ regexp: tokens.comma }); + let captures = scan({ regexp: tokens.percentageValue }); + const sat = captures ? captures[1] : null; + scan({ regexp: tokens.comma }); + captures = scan({ regexp: tokens.percentageValue }); + const light = captures ? captures[1] : null; + const ensuredSat = + sat ?? + error({ + message: + "Expected percentage value for saturation and lightness in HSL", + }); + const ensuredLight = + light ?? + error({ + message: + "Expected percentage value for saturation and lightness in HSL", + }); + return { + type: "hsl", + value: [hue, ensuredSat, ensuredLight], + }; + }, + }); + +const matchHSLAColor = (): Color | undefined => + matchCall({ + pattern: tokens.hslaColor, + callback: () => { + const hue = matchNumber(); + scan({ regexp: tokens.comma }); + let captures = scan({ regexp: tokens.percentageValue }); + const sat = captures ? captures[1] : null; + scan({ regexp: tokens.comma }); + captures = scan({ regexp: tokens.percentageValue }); + const light = captures ? captures[1] : null; + scan({ regexp: tokens.comma }); + const alpha = matchNumber(); + const ensuredSat = + sat ?? + error({ + message: + "Expected percentage value for saturation and lightness in HSLA", + }); + const ensuredLight = + light ?? + error({ + message: + "Expected percentage value for saturation and lightness in HSLA", + }); + return { + type: "hsla", + value: [hue, ensuredSat, ensuredLight, alpha], + }; + }, + }); + +const matchVariableName = (): string => { + const captures = + scan({ regexp: tokens.variableName }) ?? + error({ message: "Expected CSS variable name" }); + return captures[1]; +}; + +const matchNumber = (): string => { + const captures = + scan({ regexp: tokens.number }) ?? error({ message: "Expected number" }); + return captures[1]; +}; + +const matchDistance = (): Distance | undefined => + match({ type: "%", pattern: tokens.percentageValue, captureIndex: 1 }) || + matchPositionKeyword() || + matchCalc() || + matchLength(); + +const matchPositionKeyword = (): Distance | undefined => + match({ + type: "position-keyword", + pattern: tokens.positionKeywords, + captureIndex: 1, + }); + +const matchCalc = (): Distance | undefined => + matchCall({ + pattern: tokens.calcValue, + callback: () => { + let openParenCount = 1; + let index = 0; + + while (openParenCount > 0 && index < input.length) { + const char = input.charAt(index); + if (char === "(") { + openParenCount++; + } else if (char === ")") { + openParenCount--; + } + index++; + } + + if (openParenCount > 0) { + error({ message: "Missing closing parenthesis in calc() expression" }); + } + + const calcContent = input.slice(0, index - 1); + consume({ size: index - 1 }); + + return { + type: "calc", + value: calcContent, + }; + }, + }); + +const matchLength = (): Distance | undefined => + match({ type: "px", pattern: tokens.pixelValue, captureIndex: 1 }) || + match({ type: "em", pattern: tokens.emValue, captureIndex: 1 }); + +const match = <TType extends string>({ + type, + pattern, + captureIndex, +}: { + type: TType; + pattern: RegExp; + captureIndex: number; +}): { type: TType; value: string } | undefined => { + const captures = scan({ regexp: pattern }); + if (!captures) { + return undefined; + } + + return { + type, + value: captures[captureIndex], + }; +}; + +const scan = ({ regexp }: { regexp: RegExp }): RegExpExecArray | null => { + const blankCaptures = /^[\n\r\t\s]+/.exec(input); + if (blankCaptures) { + consume({ size: blankCaptures[0].length }); + } + + const captures = regexp.exec(input); + if (captures) { + consume({ size: captures[0].length }); + } + + return captures; +}; + +const consume = ({ size }: { size: number }): void => { + input = input.slice(size); +}; + +export const parseGradient = ({ + code, +}: { + code: string; +}): Array<GradientAst> => { + input = code.toString().trim(); + if (input.endsWith(";")) { + input = input.slice(0, -1); + } + return getAst(); +}; + +export const GradientParser = { + parse: parseGradient, +}; |
