Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/core/src/__tests__/render-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/render-utils.ts
Original file line number Diff line number Diff line change
@@ -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]
}
40 changes: 6 additions & 34 deletions packages/renderer-canvas/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
readPerformanceNow,
finiteNumberOrZero,
findInTextNodes,
parseColorRGBA,
normalizeBorderRadius,
} from '@geometra/core'

export interface CanvasRendererOptions {
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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'
}

Expand Down Expand Up @@ -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)
Expand Down
50 changes: 2 additions & 48 deletions packages/renderer-webgpu/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
finiteNumberOrZero,
focusedElement,
layoutBoundsAreFinite,
normalizeBorderRadius,
parseColorRGBA as parseColor,
type Renderer,
type SelectionRange,
type TextNodeInfo,
Expand Down Expand Up @@ -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]
}