summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/gradients
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/gradients')
-rw-r--r--apps/web/src/lib/gradients/canvas.ts923
-rw-r--r--apps/web/src/lib/gradients/index.ts2
-rw-r--r--apps/web/src/lib/gradients/parser.ts652
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,
+};