Centralize all color utilities in color.ts

- Add colorToHex8 (hex with alpha via culori formatHex8)
- Add colorToCSSCompact (no-space rgba for Tailwind shadow classes)
- Add normalizeColor (partial Color → full Color with defaults)
- Add colorDistance (Euclidean RGB via culori differenceEuclidean)
- Replace colorToCSS body with culori formatRgb
- Change rgba255ToColor to positional args (r, g, b, a)
- Remove duplicate formatColor from svg-export.ts and export-jsx.ts
- Remove duplicate colorDistance from tools/analyze.ts
- Remove duplicate convertColor from kiwi/kiwi-convert.ts
- Replace hand-rolled hex+alpha in HsvColorArea.vue with colorToHex8
- Replace hand-rolled rgba255→Color in HsvColorArea.vue with rgba255ToColor
- Expand color.test.ts from 16 to 34 tests
This commit is contained in:
Danila Poyarkov 2026-03-07 10:02:11 +03:00
parent 9784544f1f
commit bd5cdc8eee
8 changed files with 180 additions and 56 deletions

View file

@ -1,4 +1,4 @@
import { parse, formatHex, converter } from 'culori'
import { parse, formatHex, formatHex8, formatRgb, converter, differenceEuclidean } from 'culori'
import { BLACK } from './constants'
@ -18,10 +18,23 @@ export function parseColor(input: string): Color {
}
}
export function normalizeColor(color?: Partial<Color>): Color {
if (!color) return { ...BLACK }
return { r: color.r ?? 0, g: color.g ?? 0, b: color.b ?? 0, a: color.a ?? 1 }
}
export function colorToHex(color: Color): string {
return (formatHex({ mode: 'rgb', r: color.r, g: color.g, b: color.b }) ?? '#000000').toUpperCase()
}
export function colorToHex8(color: Color, alpha?: number): string {
const a = alpha ?? color.a
if (a >= 1) return colorToHex(color)
return (
formatHex8({ mode: 'rgb', r: color.r, g: color.g, b: color.b, alpha: a }) ?? '#000000FF'
).toUpperCase()
}
export function colorToHexRaw(color: Color): string {
return colorToHex(color).slice(1)
}
@ -36,13 +49,18 @@ export function colorToRgba255(color: Color) {
}
export function colorToCSS(color: Color): string {
const { r, g, b, a } = colorToRgba255(color)
if (a >= 1) return `rgb(${r}, ${g}, ${b})`
return `rgba(${r}, ${g}, ${b}, ${a})`
return formatRgb({ mode: 'rgb', r: color.r, g: color.g, b: color.b, alpha: color.a }) ?? 'rgb(0, 0, 0)'
}
export function rgba255ToColor(rgba: Color): Color {
return { r: rgba.r / 255, g: rgba.g / 255, b: rgba.b / 255, a: rgba.a }
export function colorToCSSCompact(color: Color): string {
const { r, g, b } = colorToRgba255(color)
const a = Number(color.a.toFixed(3))
if (a >= 1) return `rgb(${r},${g},${b})`
return `rgba(${r},${g},${b},${a})`
}
export function rgba255ToColor(r: number, g: number, b: number, a = 1): Color {
return { r: r / 255, g: g / 255, b: b / 255, a }
}
export function colorToFill(color: string | Color) {
@ -54,3 +72,12 @@ export function colorToFill(color: string | Color) {
visible: true
}
}
const euclideanRgb255 = differenceEuclidean('rgb')
export function colorDistance(c1: Color, c2: Color): number {
return euclideanRgb255(
{ mode: 'rgb', r: c1.r, g: c1.g, b: c1.b },
{ mode: 'rgb', r: c2.r, g: c2.g, b: c2.b }
) * 255
}

View file

@ -77,7 +77,19 @@ export {
styleToWeight,
weightToStyle
} from './fonts'
export { parseColor, colorToHex, colorToHexRaw, colorToRgba255, colorToCSS } from './color'
export {
parseColor,
normalizeColor,
colorToHex,
colorToHex8,
colorToHexRaw,
colorToRgba255,
colorToCSS,
colorToCSSCompact,
rgba255ToColor,
colorToFill,
colorDistance
} from './color'
export {
vectorNetworkToPath,
geometryBlobToPath,

View file

@ -1,4 +1,5 @@
import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '../constants'
import { normalizeColor } from '../color'
import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '../constants'
import { styleToWeight } from '../fonts'
import { decodeVectorNetworkBlob } from '../vector'
@ -9,7 +10,6 @@ import type {
FillType,
Stroke,
Effect,
Color,
BlendMode,
ImageScaleMode,
GradientTransform,
@ -39,10 +39,7 @@ export function guidToString(guid: GUID): string {
return `${guid.sessionID}:${guid.localID}`
}
function convertColor(color?: Partial<Color>): Color {
if (!color) return { ...BLACK }
return { r: color.r ?? 0, g: color.g ?? 0, b: color.b ?? 0, a: color.a ?? 1 }
}
const convertColor = normalizeColor
function imageHashToString(hash: Record<string, number>): string {
const bytes = Object.keys(hash)

View file

@ -1,4 +1,4 @@
import { colorToHex } from '../color'
import { colorToHex8, colorToCSSCompact } from '../color'
import { DEFAULT_FONT_FAMILY } from '../constants'
import {
pxToSpacing,
@ -48,15 +48,7 @@ const NODE_TYPE_TO_TW_TAG: Partial<Record<NodeType, string>> = {
}
function formatColor(color: Color, opacity = 1): string {
const hex = colorToHex(color)
if (opacity < 1)
return (
hex +
Math.round(opacity * 255)
.toString(16)
.padStart(2, '0')
)
return hex
return colorToHex8(color, opacity)
}
function solidFillColor(fills: Fill[]): string | null {
@ -81,8 +73,7 @@ function formatShadow(e: Effect): string | null {
function formatTailwindShadow(e: Effect): string | null {
if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null
const { r, g, b } = e.color
const color = `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${Number(e.color.a.toFixed(3))})`
const color = colorToCSSCompact(e.color)
const inset = e.type === 'INNER_SHADOW' ? 'inset_' : ''
const spread = e.spread !== 0 ? `_${e.spread}px` : ''
return `${inset}${e.offset.x}px_${e.offset.y}px_${e.radius}px${spread}_${color}`

View file

@ -1,4 +1,4 @@
import { colorToHex } from './color'
import { colorToHex, colorToHex8 } from './color'
import { computeContentBounds } from './render-image'
import { svg, renderSVGNode } from './svg-node'
@ -36,14 +36,7 @@ function round(n: number, decimals = 2): number {
}
function formatColor(color: Color, opacity = 1): string {
if (opacity < 1) {
const hex = colorToHex(color)
const alpha = Math.round(opacity * 255)
.toString(16)
.padStart(2, '0')
return hex + alpha
}
return colorToHex(color)
return colorToHex8(color, opacity)
}
// --- Path data ---

View file

@ -1,6 +1,6 @@
import { createTwoFilesPatch } from 'diff'
import { colorToHex, parseColor } from '../color'
import { colorDistance, colorToHex, parseColor } from '../color'
import { defineTool } from './schema'
@ -8,14 +8,6 @@ import type { Color } from '../types'
import type { SceneNode } from '../scene-graph'
import type { FigmaAPI } from '../figma-api'
// ─── Color analysis helpers ───────────────────────────────────
function colorDistance(c1: Color, c2: Color): number {
return Math.sqrt(
((c1.r - c2.r) * 255) ** 2 + ((c1.g - c2.g) * 255) ** 2 + ((c1.b - c2.b) * 255) ** 2
)
}
interface ColorEntry {
hex: string
color: Color

View file

@ -14,7 +14,7 @@ import {
type Color as RekaColor
} from 'reka-ui'
import { colorToHex } from '@open-pencil/core'
import { colorToHex8, rgba255ToColor } from '@open-pencil/core'
import type { Color } from '@/types'
@ -26,22 +26,13 @@ const emit = defineEmits<{
update: [color: Color]
}>()
const hexWithAlpha = computed(() => {
const hex = colorToHex(props.color)
if (props.color.a < 1) {
const aa = Math.round(props.color.a * 255)
.toString(16)
.padStart(2, '0')
return `${hex}${aa}`
}
return hex
})
const hexWithAlpha = computed(() => colorToHex8(props.color))
const rekaColor = computed(() => normalizeColor(hexWithAlpha.value))
function rekaToColor(c: RekaColor): Color {
const rgb = convertToRgb(c)
return { r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255, a: rgb.alpha }
return rgba255ToColor(rgb.r, rgb.g, rgb.b, rgb.alpha)
}
function onRekaColorUpdate(c: RekaColor) {

View file

@ -2,10 +2,16 @@ import { describe, test, expect } from 'bun:test'
import {
parseColor,
normalizeColor,
colorToHex,
colorToHex8,
colorToHexRaw,
colorToRgba255,
colorToCSS
colorToCSS,
colorToCSSCompact,
rgba255ToColor,
colorToFill,
colorDistance
} from '@open-pencil/core'
describe('parseColor', () => {
@ -74,6 +80,23 @@ describe('parseColor', () => {
})
})
describe('normalizeColor', () => {
test('undefined returns black', () => {
const c = normalizeColor()
expect(c).toEqual({ r: 0, g: 0, b: 0, a: 1 })
})
test('partial fills defaults', () => {
const c = normalizeColor({ r: 0.5 })
expect(c).toEqual({ r: 0.5, g: 0, b: 0, a: 1 })
})
test('full color passes through', () => {
const c = normalizeColor({ r: 0.1, g: 0.2, b: 0.3, a: 0.4 })
expect(c).toEqual({ r: 0.1, g: 0.2, b: 0.3, a: 0.4 })
})
})
describe('colorToHex', () => {
test('red', () => {
expect(colorToHex({ r: 1, g: 0, b: 0, a: 1 })).toBe('#FF0000')
@ -88,6 +111,32 @@ describe('colorToHex', () => {
})
})
describe('colorToHex8', () => {
test('opaque returns 6-char hex', () => {
expect(colorToHex8({ r: 1, g: 0, b: 0, a: 1 })).toBe('#FF0000')
})
test('semi-transparent returns 8-char hex', () => {
const hex = colorToHex8({ r: 1, g: 0, b: 0, a: 0.5 })
expect(hex).toMatch(/^#[0-9A-F]{8}$/)
expect(hex.slice(0, 7)).toBe('#FF0000')
})
test('alpha override', () => {
const hex = colorToHex8({ r: 1, g: 0, b: 0, a: 1 }, 0.5)
expect(hex).toMatch(/^#FF0000[0-9A-F]{2}$/)
})
test('alpha=0 returns 8-char hex', () => {
expect(colorToHex8({ r: 1, g: 0, b: 0, a: 0 })).toBe('#FF000000')
})
test('uppercase', () => {
const hex = colorToHex8({ r: 0.8, g: 0.6, b: 0.1, a: 0.5 })
expect(hex).toBe(hex.toUpperCase())
})
})
describe('colorToHexRaw', () => {
test('strips # prefix', () => {
expect(colorToHexRaw({ r: 1, g: 0, b: 0, a: 1 })).toBe('FF0000')
@ -115,3 +164,75 @@ describe('colorToCSS', () => {
expect(result).toBe('rgba(255, 0, 0, 0.5)')
})
})
describe('colorToCSSCompact', () => {
test('no spaces between values', () => {
const result = colorToCSSCompact({ r: 1, g: 0, b: 0, a: 0.5 })
expect(result).toBe('rgba(255,0,0,0.5)')
})
test('opaque omits alpha', () => {
const result = colorToCSSCompact({ r: 1, g: 0, b: 0, a: 1 })
expect(result).toBe('rgb(255,0,0)')
})
})
describe('rgba255ToColor', () => {
test('converts 255-scale to 0-1', () => {
const c = rgba255ToColor(255, 128, 0, 0.5)
expect(c.r).toBeCloseTo(1)
expect(c.g).toBeCloseTo(0.502, 2)
expect(c.b).toBe(0)
expect(c.a).toBe(0.5)
})
test('alpha defaults to 1', () => {
const c = rgba255ToColor(0, 0, 0)
expect(c.a).toBe(1)
})
})
describe('colorToFill', () => {
test('from Color object', () => {
const fill = colorToFill({ r: 1, g: 0, b: 0, a: 0.5 })
expect(fill.type).toBe('SOLID')
expect(fill.color.r).toBe(1)
expect(fill.opacity).toBe(0.5)
expect(fill.visible).toBe(true)
})
test('from hex string', () => {
const fill = colorToFill('#ff0000')
expect(fill.type).toBe('SOLID')
expect(fill.color.r).toBeCloseTo(1)
expect(fill.color.g).toBeCloseTo(0)
})
})
describe('colorDistance', () => {
test('identical colors have zero distance', () => {
const c = { r: 0.5, g: 0.3, b: 0.1, a: 1 }
expect(colorDistance(c, c)).toBe(0)
})
test('black to white is ~441', () => {
const d = colorDistance({ r: 0, g: 0, b: 0, a: 1 }, { r: 1, g: 1, b: 1, a: 1 })
expect(d).toBeCloseTo(441.67, 0)
})
test('similar reds are close', () => {
const d = colorDistance(
{ r: 1, g: 0, b: 0, a: 1 },
{ r: 0.95, g: 0.02, b: 0.02, a: 1 }
)
expect(d).toBeLessThan(15)
})
test('red to blue is far', () => {
const d = colorDistance(
{ r: 1, g: 0, b: 0, a: 1 },
{ r: 0, g: 0, b: 1, a: 1 }
)
expect(d).toBeGreaterThan(300)
})
})