From 226d9a95bb2753d487a56cdfdbe4621636322bbe Mon Sep 17 00:00:00 2001 From: R4vager Date: Wed, 15 Apr 2026 13:54:36 -0400 Subject: [PATCH] core: extract shared parseColorRGBA and normalizeBorderRadius render utilities The Canvas2D and WebGPU renderers each carried their own near-identical implementations of CSS color parsing and border-radius clamping (packages/renderer-canvas/src/renderer.ts:112 parseColorRGB, packages/ renderer-webgpu/src/index.ts:1610 parseColor and :1586 normalizeBorderRadius). Any fix or additional color syntax would have to land twice and risk drift. Moves both helpers into a new packages/core/src/render-utils.ts module and routes both renderers through it. Channels are returned normalized to [0, 1] so WebGPU can consume them directly; renderer-canvas's single contrast callsite now feeds parseColorRGBA output straight into an updated luminance() that also expects [0, 1] inputs. Round-rect border-radius clamping is shared verbatim. Adds 20 vitest cases covering 3/6-digit hex, rgb/rgba parsing with whitespace and alpha, malformed-input fallback to opaque black, per-corner radius clamping, zero-sized boxes, and undefined/empty inputs. Fast suite goes from 2357 to 2377 passing; full workspace build stays green. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/src/__tests__/render-utils.test.ts | 84 +++++++++++++++++ packages/core/src/index.ts | 5 ++ packages/core/src/render-utils.ts | 89 +++++++++++++++++++ packages/renderer-canvas/src/renderer.ts | 40 ++------- packages/renderer-webgpu/src/index.ts | 50 +---------- 5 files changed, 186 insertions(+), 82 deletions(-) create mode 100644 packages/core/src/__tests__/render-utils.test.ts create mode 100644 packages/core/src/render-utils.ts diff --git a/packages/core/src/__tests__/render-utils.test.ts b/packages/core/src/__tests__/render-utils.test.ts new file mode 100644 index 00000000..a41a6450 --- /dev/null +++ b/packages/core/src/__tests__/render-utils.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { normalizeBorderRadius, parseColorRGBA } from '../render-utils.js' + +describe('parseColorRGBA', () => { + it('parses 6-digit hex', () => { + expect(parseColorRGBA('#3b82f6')).toEqual([0x3b / 255, 0x82 / 255, 0xf6 / 255, 1]) + }) + + it('parses 3-digit hex by doubling each nibble', () => { + expect(parseColorRGBA('#fff')).toEqual([1, 1, 1, 1]) + expect(parseColorRGBA('#000')).toEqual([0, 0, 0, 1]) + expect(parseColorRGBA('#f0a')).toEqual([1, 0, 170 / 255, 1]) + }) + + it('parses rgb() with implicit opaque alpha', () => { + expect(parseColorRGBA('rgb(59, 130, 246)')).toEqual([59 / 255, 130 / 255, 246 / 255, 1]) + }) + + it('parses rgba() with explicit decimal alpha', () => { + expect(parseColorRGBA('rgba(59, 130, 246, 0.4)')).toEqual([59 / 255, 130 / 255, 246 / 255, 0.4]) + }) + + it('parses rgba() with alpha = 0', () => { + expect(parseColorRGBA('rgba(255, 0, 0, 0)')).toEqual([1, 0, 0, 0]) + }) + + it('tolerates whitespace inside rgb/rgba functional notation', () => { + expect(parseColorRGBA('rgb( 10 , 20 , 30 )')).toEqual([10 / 255, 20 / 255, 30 / 255, 1]) + }) + + it('returns opaque black for malformed input so downstream fills never see NaN', () => { + expect(parseColorRGBA('not-a-color')).toEqual([0, 0, 0, 1]) + expect(parseColorRGBA('')).toEqual([0, 0, 0, 1]) + expect(parseColorRGBA('rgb(1, 2)')).toEqual([0, 0, 0, 1]) + }) + + it('hex channels round-trip cleanly back to 0-255 integers', () => { + const [r, g, b] = parseColorRGBA('#3b82f6') + expect([Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]).toEqual([0x3b, 0x82, 0xf6]) + }) +}) + +describe('normalizeBorderRadius', () => { + it('expands a uniform number into all four corners', () => { + expect(normalizeBorderRadius(8, 100, 100)).toEqual([8, 8, 8, 8]) + }) + + it('clamps a uniform number to half the smaller dimension', () => { + expect(normalizeBorderRadius(999, 40, 100)).toEqual([20, 20, 20, 20]) + expect(normalizeBorderRadius(999, 100, 30)).toEqual([15, 15, 15, 15]) + }) + + it('clamps negative uniform input to zero', () => { + expect(normalizeBorderRadius(-5, 100, 100)).toEqual([0, 0, 0, 0]) + }) + + it('expands per-corner object preserving tl/tr/br/bl order', () => { + expect(normalizeBorderRadius({ topLeft: 1, topRight: 2, bottomRight: 3, bottomLeft: 4 }, 100, 100)) + .toEqual([1, 2, 3, 4]) + }) + + it('treats missing corner fields as zero', () => { + expect(normalizeBorderRadius({ topLeft: 10 }, 100, 100)).toEqual([10, 0, 0, 0]) + }) + + it('clamps individual corners independently to the shared max', () => { + expect(normalizeBorderRadius({ topLeft: 100, topRight: 5, bottomRight: 100, bottomLeft: 2 }, 40, 80)) + .toEqual([20, 5, 20, 2]) + }) + + it('returns all zeros for undefined input', () => { + expect(normalizeBorderRadius(undefined, 100, 100)).toEqual([0, 0, 0, 0]) + }) + + it('returns all zeros for an empty per-corner object', () => { + expect(normalizeBorderRadius({}, 100, 100)).toEqual([0, 0, 0, 0]) + }) + + it('handles a zero-sized box without producing NaN', () => { + expect(normalizeBorderRadius(10, 0, 0)).toEqual([0, 0, 0, 0]) + expect(normalizeBorderRadius({ topLeft: 10, topRight: 10, bottomRight: 10, bottomLeft: 10 }, 0, 100)) + .toEqual([0, 0, 0, 0]) + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f79c7f57..c33b1afd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,11 @@ export { } from './layout-bounds.js' export { safePerformanceNowMs, readPerformanceNow } from './performance-now.js' +// Paint-backend helpers shared by @geometra/renderer-* packages so color parsing and border-radius +// clamping stay in lockstep across canvas, webgpu, and any future paint target. +export { parseColorRGBA, normalizeBorderRadius } from './render-utils.js' +export type { BorderRadiusInput } from './render-utils.js' + // Web fonts (browser) export { extractFontFamiliesFromCSSFont, diff --git a/packages/core/src/render-utils.ts b/packages/core/src/render-utils.ts new file mode 100644 index 00000000..d713b06b --- /dev/null +++ b/packages/core/src/render-utils.ts @@ -0,0 +1,89 @@ +/** + * Shared paint-backend helpers used by multiple `@geometra/renderer-*` packages so the same color + * parsing and border-radius clamping math does not drift between targets. `parseColorRGBA` accepts + * CSS `#rgb`/`#rrggbb` hex and `rgb()`/`rgba()` functional notation and returns channels in the + * normalized `[0, 1]` range expected by WebGPU shaders — canvas callers can scale back to 0–255 with + * `Math.round(c * 255)` at their single contrast callsite. `normalizeBorderRadius` produces the + * `[tl, tr, br, bl]` tuple clamped to half the smaller box dimension that both Canvas2D's `Path2D` + * round-rect and WebGPU's SDF box path consume. Keeping these in core means fixes to the parser + * (e.g. additional color syntax support) land once for every target. + */ + +/** Border-radius shape accepted by box elements — either a uniform number or per-corner overrides. */ +export type BorderRadiusInput = + | number + | { + topLeft?: number + topRight?: number + bottomLeft?: number + bottomRight?: number + } + | undefined + +/** + * Parse a CSS color string into normalized `[r, g, b, a]` channels in the `[0, 1]` range. + * + * Accepts `#rgb`, `#rrggbb`, `rgb(r, g, b)`, and `rgba(r, g, b, a)` notations. Malformed input + * falls back to opaque black `[0, 0, 0, 1]` so downstream shaders and Canvas2D fills never observe + * `NaN` channels. This matches the pre-existing behavior of the WebGPU renderer's local parser. + * + * @param color — CSS color string from element props (e.g. `backgroundColor`, gradient stop). + * @returns Four finite numbers in `[0, 1]`: red, green, blue, alpha. Alpha defaults to `1` when the + * input omits it (`rgb(…)` or hex). + */ +export function parseColorRGBA(color: string): [number, number, number, number] { + if (color.startsWith('#')) { + const hex = color.slice(1) + const full = hex.length === 3 + ? hex[0]! + hex[0]! + hex[1]! + hex[1]! + hex[2]! + hex[2]! + : hex + const r = parseInt(full.slice(0, 2), 16) / 255 + const g = parseInt(full.slice(2, 4), 16) / 255 + const b = parseInt(full.slice(4, 6), 16) / 255 + return [r, g, b, 1] + } + const m = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?\s*\)/) + if (m) { + return [ + Number(m[1]) / 255, + Number(m[2]) / 255, + Number(m[3]) / 255, + m[4] === undefined ? 1 : Number(m[4]), + ] + } + return [0, 0, 0, 1] +} + +/** + * Clamp a {@link BorderRadiusInput} into the `[tl, tr, br, bl]` tuple consumed by paint backends. + * + * Per-corner radii are clamped to `min(w / 2, h / 2)` so a single corner can never exceed half the + * smaller box dimension (matching CSS border-radius behavior for overlapping corners). Negative + * inputs are clamped to `0`. An `undefined` input or an object with no corner fields yields + * `[0, 0, 0, 0]`, letting callers skip the round-rect path when every corner is zero. + * + * @param r — Either a uniform radius number, a per-corner record, or `undefined`. + * @param w — Box width in paint pixels. + * @param h — Box height in paint pixels. + * @returns Tuple `[topLeft, topRight, bottomRight, bottomLeft]` in paint pixels. + */ +export function normalizeBorderRadius( + r: BorderRadiusInput, + w: number, + h: number, +): [number, number, number, number] { + const maxR = Math.min(w / 2, h / 2) + if (typeof r === 'number') { + const v = Math.min(Math.max(0, r), maxR) + return [v, v, v, v] + } + if (r && typeof r === 'object') { + return [ + Math.min(Math.max(0, r.topLeft ?? 0), maxR), + Math.min(Math.max(0, r.topRight ?? 0), maxR), + Math.min(Math.max(0, r.bottomRight ?? 0), maxR), + Math.min(Math.max(0, r.bottomLeft ?? 0), maxR), + ] + } + return [0, 0, 0, 0] +} diff --git a/packages/renderer-canvas/src/renderer.ts b/packages/renderer-canvas/src/renderer.ts index 2e579833..54f062f7 100644 --- a/packages/renderer-canvas/src/renderer.ts +++ b/packages/renderer-canvas/src/renderer.ts @@ -25,6 +25,8 @@ import { readPerformanceNow, finiteNumberOrZero, findInTextNodes, + parseColorRGBA, + normalizeBorderRadius, } from '@geometra/core' export interface CanvasRendererOptions { @@ -108,27 +110,9 @@ interface FailedImageEntry { nextRetryAt: number } -/** Parse a CSS color string into [r, g, b] (0-255). Supports #hex and rgba(). */ -function parseColorRGB(color: string): [number, number, number] { - if (color.startsWith('#')) { - const hex = color.slice(1) - const full = hex.length === 3 - ? hex[0]! + hex[0]! + hex[1]! + hex[1]! + hex[2]! + hex[2]! - : hex - return [ - parseInt(full.slice(0, 2), 16), - parseInt(full.slice(2, 4), 16), - parseInt(full.slice(4, 6), 16), - ] - } - const match = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) - if (match) return [parseInt(match[1]!), parseInt(match[2]!), parseInt(match[3]!)] - return [59, 130, 246] -} - -/** Compute relative luminance (0-1) from sRGB. */ +/** Compute relative luminance (0-1) from sRGB channels already in the [0, 1] range. */ function luminance(r: number, g: number, b: number): number { - const [rs, gs, bs] = [r / 255, g / 255, b / 255].map( + const [rs, gs, bs] = [r, g, b].map( c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4), ) return 0.2126 * rs! + 0.7152 * gs! + 0.0722 * bs! @@ -224,7 +208,7 @@ export class CanvasRenderer implements Renderer { if (options.selectedTextColor) { this.selectedTextColor = options.selectedTextColor } else { - const [r, g, b] = parseColorRGB(this.selectionColor) + const [r, g, b] = parseColorRGBA(this.selectionColor) this.selectedTextColor = luminance(r, g, b) > 0.4 ? '#000000' : '#ffffff' } @@ -884,19 +868,7 @@ export class CanvasRenderer implements Renderer { r: number | { topLeft?: number; topRight?: number; bottomLeft?: number; bottomRight?: number }, ): void { const { ctx } = this - const maxR = Math.min(w / 2, h / 2) - let tl: number - let tr: number - let br: number - let bl: number - if (typeof r === 'number') { - tl = tr = br = bl = Math.min(Math.max(0, r), maxR) - } else { - tl = Math.min(Math.max(0, r.topLeft ?? 0), maxR) - tr = Math.min(Math.max(0, r.topRight ?? 0), maxR) - br = Math.min(Math.max(0, r.bottomRight ?? 0), maxR) - bl = Math.min(Math.max(0, r.bottomLeft ?? 0), maxR) - } + const [tl, tr, br, bl] = normalizeBorderRadius(r, w, h) ctx.beginPath() ctx.moveTo(x + tl, y) ctx.lineTo(x + w - tr, y) diff --git a/packages/renderer-webgpu/src/index.ts b/packages/renderer-webgpu/src/index.ts index 321eab5c..6bf55fca 100644 --- a/packages/renderer-webgpu/src/index.ts +++ b/packages/renderer-webgpu/src/index.ts @@ -4,6 +4,8 @@ import { finiteNumberOrZero, focusedElement, layoutBoundsAreFinite, + normalizeBorderRadius, + parseColorRGBA as parseColor, type Renderer, type SelectionRange, type TextNodeInfo, @@ -1581,51 +1583,3 @@ function pushRectVertices( out.push(x1, y1, r, g, b, a) out.push(x0, y1, r, g, b, a) } - -/** - * Normalize `borderRadius` (number | object) into a 4-tuple [tl, tr, br, bl] in pixels, clamped - * to half of the smaller dimension. - */ -function normalizeBorderRadius( - r: number | { topLeft?: number; topRight?: number; bottomLeft?: number; bottomRight?: number } | undefined, - w: number, - h: number, -): [number, number, number, number] { - const maxR = Math.min(w / 2, h / 2) - if (typeof r === 'number') { - const v = Math.min(Math.max(0, r), maxR) - return [v, v, v, v] - } - if (r && typeof r === 'object') { - return [ - Math.min(Math.max(0, r.topLeft ?? 0), maxR), - Math.min(Math.max(0, r.topRight ?? 0), maxR), - Math.min(Math.max(0, r.bottomRight ?? 0), maxR), - Math.min(Math.max(0, r.bottomLeft ?? 0), maxR), - ] - } - return [0, 0, 0, 0] -} - -function parseColor(color: string): [number, number, number, number] { - if (color.startsWith('#')) { - const hex = color.slice(1) - const full = hex.length === 3 - ? hex[0]! + hex[0]! + hex[1]! + hex[1]! + hex[2]! + hex[2]! - : hex - const r = parseInt(full.slice(0, 2), 16) / 255 - const g = parseInt(full.slice(2, 4), 16) / 255 - const b = parseInt(full.slice(4, 6), 16) / 255 - return [r, g, b, 1] - } - const m = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?\s*\)/) - if (m) { - return [ - Number(m[1]) / 255, - Number(m[2]) / 255, - Number(m[3]) / 255, - m[4] === undefined ? 1 : Number(m[4]), - ] - } - return [0, 0, 0, 1] -}