diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index b928df27..28973bcf 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -38,6 +38,12 @@ import { type StyledRenderRect, } from "@/lib/compositeLayout"; import { renderAnnotations } from "./annotationRenderer"; +import { + getLinearGradientPoints, + getRadialGradientShape, + parseCssGradient, + resolveLinearGradientAngle, +} from "./gradientParser"; interface FrameRenderConfig { width: number; @@ -163,7 +169,9 @@ export class FrameRenderer { this.compositeCanvas = document.createElement("canvas"); this.compositeCanvas.width = this.config.width; this.compositeCanvas.height = this.config.height; - this.compositeCtx = this.compositeCanvas.getContext("2d", { willReadFrequently: false }); + this.compositeCtx = this.compositeCanvas.getContext("2d", { + willReadFrequently: false, + }); if (!this.compositeCtx) { throw new Error("Failed to get 2D context for composite canvas"); @@ -174,7 +182,9 @@ export class FrameRenderer { this.shadowCanvas = document.createElement("canvas"); this.shadowCanvas.width = this.config.width; this.shadowCanvas.height = this.config.height; - this.shadowCtx = this.shadowCanvas.getContext("2d", { willReadFrequently: false }); + this.shadowCtx = this.shadowCanvas.getContext("2d", { + willReadFrequently: false, + }); if (!this.shadowCtx) { throw new Error("Failed to get 2D context for shadow canvas"); @@ -255,40 +265,39 @@ export class FrameRenderer { wallpaper.startsWith("linear-gradient") || wallpaper.startsWith("radial-gradient") ) { - const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); - if (gradientMatch) { - const [, type, params] = gradientMatch; - const parts = params.split(",").map((s) => s.trim()); - - let gradient: CanvasGradient; - - if (type === "linear") { - gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height); - parts.forEach((part, index) => { - if (part.startsWith("to ") || part.includes("deg")) return; - - const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/); - if (colorMatch) { - const color = colorMatch[1]; - const position = index / (parts.length - 1); - gradient.addColorStop(position, color); - } - }); - } else { - const cx = this.config.width / 2; - const cy = this.config.height / 2; - const radius = Math.max(this.config.width, this.config.height) / 2; - gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius); - - parts.forEach((part, index) => { - const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/); - if (colorMatch) { - const color = colorMatch[1]; - const position = index / (parts.length - 1); - gradient.addColorStop(position, color); - } - }); - } + const parsedGradient = parseCssGradient(wallpaper); + if (parsedGradient) { + const gradient = + parsedGradient.type === "linear" + ? (() => { + const points = getLinearGradientPoints( + resolveLinearGradientAngle(parsedGradient.descriptor), + this.config.width, + this.config.height, + ); + + return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1); + })() + : (() => { + const shape = getRadialGradientShape( + parsedGradient.descriptor, + this.config.width, + this.config.height, + ); + + return bgCtx.createRadialGradient( + shape.cx, + shape.cy, + 0, + shape.cx, + shape.cy, + shape.radius, + ); + })(); + + parsedGradient.stops.forEach((stop) => { + gradient.addColorStop(stop.offset, stop.color); + }); bgCtx.fillStyle = gradient; bgCtx.fillRect(0, 0, this.config.width, this.config.height); @@ -690,7 +699,11 @@ export class FrameRenderer { } this.backgroundSprite = null; if (this.app) { - this.app.destroy(true, { children: true, texture: true, textureSource: true }); + this.app.destroy(true, { + children: true, + texture: true, + textureSource: true, + }); this.app = null; } this.cameraContainer = null; diff --git a/src/lib/exporter/gradientParser.test.ts b/src/lib/exporter/gradientParser.test.ts new file mode 100644 index 00000000..a2c0ef0d --- /dev/null +++ b/src/lib/exporter/gradientParser.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + getLinearGradientPoints, + getRadialGradientShape, + parseCssGradient, + resolveLinearGradientAngle, +} from "./gradientParser"; + +describe("parseCssGradient", () => { + it("parses rgba-based gradient presets without splitting inside color functions", () => { + const parsed = parseCssGradient( + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", + ); + + expect(parsed?.type).toBe("linear"); + expect(parsed?.descriptor).toBe("111.6deg"); + expect(parsed?.stops).toHaveLength(4); + expect(parsed?.stops.map((stop) => stop.color)).toEqual([ + "rgba(114,167,232,1)", + "rgba(253,129,82,1)", + "rgba(253,129,82,1)", + "rgba(249,202,86,1)", + ]); + expect(parsed?.stops[0]?.offset).toBeCloseTo(0.094); + expect(parsed?.stops[1]?.offset).toBeCloseTo(0.439); + expect(parsed?.stops[2]?.offset).toBeCloseTo(0.548); + expect(parsed?.stops[3]?.offset).toBeCloseTo(0.863); + }); + + it("fills missing stop positions for simple hex gradients", () => { + const parsed = parseCssGradient("linear-gradient(135deg, #FBC8B4, #2447B1)"); + + expect(parsed?.stops).toEqual([ + { color: "#FBC8B4", offset: 0 }, + { color: "#2447B1", offset: 1 }, + ]); + }); +}); + +describe("gradient geometry", () => { + it("maps linear directions to canvas endpoints", () => { + const angle = resolveLinearGradientAngle("to right"); + const points = getLinearGradientPoints(angle, 1920, 1080); + + expect(points.x0).toBeCloseTo(0); + expect(points.y0).toBeCloseTo(540); + expect(points.x1).toBeCloseTo(1920); + expect(points.y1).toBeCloseTo(540); + }); + + it("uses radial positions from the descriptor", () => { + const shape = getRadialGradientShape("circle farthest-corner at 10% 20%", 1000, 500); + + expect(shape.cx).toBe(100); + expect(shape.cy).toBe(100); + expect(shape.radius).toBeCloseTo(Math.hypot(900, 400)); + }); +}); diff --git a/src/lib/exporter/gradientParser.ts b/src/lib/exporter/gradientParser.ts new file mode 100644 index 00000000..c643edcb --- /dev/null +++ b/src/lib/exporter/gradientParser.ts @@ -0,0 +1,236 @@ +export interface ParsedGradientStop { + color: string; + offset: number; +} + +export interface ParsedGradient { + type: "linear" | "radial"; + descriptor: string | null; + stops: ParsedGradientStop[]; +} + +const COLOR_TOKEN_RE = /^(#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?)\([^)]*\)|[a-zA-Z-]+)/; + +export function parseCssGradient(input: string): ParsedGradient | null { + const gradientMatch = input.match(/^(linear|radial)-gradient\((.*)\)$/i); + if (!gradientMatch) { + return null; + } + + const type = gradientMatch[1].toLowerCase() as ParsedGradient["type"]; + const rawArgs = splitGradientArgs(gradientMatch[2]); + if (rawArgs.length === 0) { + return null; + } + + let descriptor: string | null = null; + let stopArgs = rawArgs; + + if (isGradientDescriptor(type, rawArgs[0])) { + descriptor = rawArgs[0]; + stopArgs = rawArgs.slice(1); + } + + const parsedStops = stopArgs + .map((part) => parseColorStop(part)) + .filter((stop): stop is { color: string; offset: number | null } => stop !== null); + + if (parsedStops.length === 0) { + return null; + } + + return { + type, + descriptor, + stops: normalizeStopOffsets(parsedStops), + }; +} + +export function getLinearGradientPoints(angleDeg: number, width: number, height: number) { + const radians = (angleDeg * Math.PI) / 180; + const vx = Math.sin(radians); + const vy = -Math.cos(radians); + const halfSpan = (Math.abs(vx) * width + Math.abs(vy) * height) / 2; + const cx = width / 2; + const cy = height / 2; + + return { + x0: cx - vx * halfSpan, + y0: cy - vy * halfSpan, + x1: cx + vx * halfSpan, + y1: cy + vy * halfSpan, + }; +} + +export function resolveLinearGradientAngle(descriptor: string | null): number { + if (!descriptor) { + return 180; + } + + const angleMatch = descriptor.match(/(-?\d*\.?\d+)deg/i); + if (angleMatch) { + return Number.parseFloat(angleMatch[1]); + } + + const normalized = descriptor.trim().toLowerCase().replace(/\s+/g, " "); + const directionMap: Record = { + "to top": 0, + "to top right": 45, + "to right": 90, + "to bottom right": 135, + "to bottom": 180, + "to bottom left": 225, + "to left": 270, + "to top left": 315, + }; + + return directionMap[normalized] ?? 180; +} + +export function getRadialGradientShape(descriptor: string | null, width: number, height: number) { + const atMatch = descriptor?.match(/at\s+(-?\d*\.?\d+)%\s+(-?\d*\.?\d+)%/i); + const cx = atMatch ? (Number.parseFloat(atMatch[1]) / 100) * width : width / 2; + const cy = atMatch ? (Number.parseFloat(atMatch[2]) / 100) * height : height / 2; + + const distances = [ + Math.hypot(cx, cy), + Math.hypot(width - cx, cy), + Math.hypot(cx, height - cy), + Math.hypot(width - cx, height - cy), + ]; + + return { + cx, + cy, + radius: Math.max(...distances), + }; +} + +function splitGradientArgs(input: string): string[] { + const parts: string[] = []; + let current = ""; + let depth = 0; + + for (const char of input) { + if (char === "(") { + depth += 1; + current += char; + continue; + } + + if (char === ")") { + depth = Math.max(0, depth - 1); + current += char; + continue; + } + + if (char === "," && depth === 0) { + const trimmed = current.trim(); + if (trimmed) { + parts.push(trimmed); + } + current = ""; + continue; + } + + current += char; + } + + const trimmed = current.trim(); + if (trimmed) { + parts.push(trimmed); + } + + return parts; +} + +function isGradientDescriptor(type: ParsedGradient["type"], part: string) { + if (type === "linear") { + return /^\s*to\s+/i.test(part) || /-?\d*\.?\d+deg/i.test(part); + } + + return /\b(circle|ellipse|closest|farthest)\b/i.test(part) || /\bat\b/i.test(part); +} + +function parseColorStop(part: string): { color: string; offset: number | null } | null { + const match = part.trim().match(COLOR_TOKEN_RE); + if (!match) { + return null; + } + + const color = match[1]; + const rest = part.slice(match[0].length); + const percentMatch = rest.match(/(-?\d*\.?\d+)%/); + const offset = percentMatch ? clamp(Number.parseFloat(percentMatch[1]) / 100, 0, 1) : null; + + return { color, offset }; +} + +function normalizeStopOffsets( + stops: Array<{ color: string; offset: number | null }>, +): ParsedGradientStop[] { + const explicitCount = stops.filter((stop) => stop.offset !== null).length; + if (explicitCount === 0) { + if (stops.length === 1) { + return [{ color: stops[0].color, offset: 0 }]; + } + + return stops.map((stop, index) => ({ + color: stop.color, + offset: index / (stops.length - 1), + })); + } + + const resolved = stops.map((stop) => stop.offset); + const firstExplicit = resolved.findIndex((offset) => offset !== null); + const lastExplicit = findLastDefinedIndex(resolved); + + for (let index = 0; index < firstExplicit; index += 1) { + const end = resolved[firstExplicit] ?? 0; + resolved[index] = firstExplicit === 0 ? end : (end * index) / firstExplicit; + } + + for (let index = lastExplicit + 1; index < resolved.length; index += 1) { + const start = resolved[lastExplicit] ?? 1; + const denominator = resolved.length - 1 - lastExplicit; + resolved[index] = + denominator <= 0 ? start : start + ((1 - start) * (index - lastExplicit)) / denominator; + } + + let runStart = firstExplicit; + while (runStart < lastExplicit) { + const nextExplicit = resolved.findIndex((offset, index) => index > runStart && offset !== null); + if (nextExplicit === -1) { + break; + } + + const start = resolved[runStart] ?? 0; + const end = resolved[nextExplicit] ?? start; + const gap = nextExplicit - runStart; + + for (let index = runStart + 1; index < nextExplicit; index += 1) { + resolved[index] = start + ((end - start) * (index - runStart)) / gap; + } + + runStart = nextExplicit; + } + + return stops.map((stop, index) => ({ + color: stop.color, + offset: clamp(resolved[index] ?? 0, 0, 1), + })); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function findLastDefinedIndex(values: Array) { + for (let index = values.length - 1; index >= 0; index -= 1) { + if (values[index] !== null) { + return index; + } + } + + return -1; +}