From bd5cdc8eee63c63826bd294be7d93c8ab143d5be Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 7 Mar 2026 10:02:11 +0300 Subject: [PATCH] Centralize all color utilities in color.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/core/src/color.ts | 39 ++++++-- packages/core/src/index.ts | 14 ++- packages/core/src/kiwi/kiwi-convert.ts | 9 +- packages/core/src/render/export-jsx.ts | 15 +-- packages/core/src/svg-export.ts | 11 +-- packages/core/src/tools/analyze.ts | 10 +- src/components/HsvColorArea.vue | 15 +-- tests/engine/color.test.ts | 123 ++++++++++++++++++++++++- 8 files changed, 180 insertions(+), 56 deletions(-) diff --git a/packages/core/src/color.ts b/packages/core/src/color.ts index 2ec2cac13..dff4951cc 100644 --- a/packages/core/src/color.ts +++ b/packages/core/src/color.ts @@ -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 { + 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 +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d7b6c7ab..d5b6ddf03 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 54eaa661e..519463920 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -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 { - 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 { const bytes = Object.keys(hash) diff --git a/packages/core/src/render/export-jsx.ts b/packages/core/src/render/export-jsx.ts index 81d4e452c..3a5dd6f81 100644 --- a/packages/core/src/render/export-jsx.ts +++ b/packages/core/src/render/export-jsx.ts @@ -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> = { } 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}` diff --git a/packages/core/src/svg-export.ts b/packages/core/src/svg-export.ts index ca301fae7..0a6337a43 100644 --- a/packages/core/src/svg-export.ts +++ b/packages/core/src/svg-export.ts @@ -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 --- diff --git a/packages/core/src/tools/analyze.ts b/packages/core/src/tools/analyze.ts index 872a47af1..45e06d619 100644 --- a/packages/core/src/tools/analyze.ts +++ b/packages/core/src/tools/analyze.ts @@ -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 diff --git a/src/components/HsvColorArea.vue b/src/components/HsvColorArea.vue index 29f87159e..c9a3c6a0d 100644 --- a/src/components/HsvColorArea.vue +++ b/src/components/HsvColorArea.vue @@ -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) { diff --git a/tests/engine/color.test.ts b/tests/engine/color.test.ts index b4214f9fe..caa3a4606 100644 --- a/tests/engine/color.test.ts +++ b/tests/engine/color.test.ts @@ -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) + }) +})