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:
parent
9784544f1f
commit
bd5cdc8eee
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue