Merge pull request #237 from ryujh030820/fix/gradient-export-rendering

fix: fix gradient background export rendering
This commit is contained in:
Sid
2026-03-19 18:27:43 -07:00
committed by GitHub
3 changed files with 341 additions and 34 deletions
+47 -34
View File
@@ -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());
const parsedGradient = parseCssGradient(wallpaper);
if (parsedGradient) {
const gradient =
parsedGradient.type === "linear"
? (() => {
const points = getLinearGradientPoints(
resolveLinearGradientAngle(parsedGradient.descriptor),
this.config.width,
this.config.height,
);
let gradient: CanvasGradient;
return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1);
})()
: (() => {
const shape = getRadialGradientShape(
parsedGradient.descriptor,
this.config.width,
this.config.height,
);
if (type === "linear") {
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
parts.forEach((part, index) => {
if (part.startsWith("to ") || part.includes("deg")) return;
return bgCtx.createRadialGradient(
shape.cx,
shape.cy,
0,
shape.cx,
shape.cy,
shape.radius,
);
})();
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);
}
});
}
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;
+58
View File
@@ -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));
});
});
+236
View File
@@ -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<string, number> = {
"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<number | null>) {
for (let index = values.length - 1; index >= 0; index -= 1) {
if (values[index] !== null) {
return index;
}
}
return -1;
}