Skip to content

Commit f24be21

Browse files
committed
feat: Add Phase 2 Color Utilities (colorUtils.ts)
- Create comprehensive colorUtils.ts with 147 CSS color names - Add color format conversion (hex, RGB, RGBA, named colors) - Add color manipulation (darkenColor, normalizeColor) - Add Grid3-specific color formatting (ensureAlphaChannel) - Consolidate with existing styleHelpers.ts - Remove duplicate darkenColor function from styleHelpers - Add 36 comprehensive unit tests for color utilities - Update exports in src/processors/index.ts - All tests pass: 52 new tests (36 color + 16 gridset helpers) - No regressions in existing functionality
1 parent 26627c8 commit f24be21

5 files changed

Lines changed: 1256 additions & 37 deletions

File tree

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
/**
2+
* Grid3 Color Utilities
3+
*
4+
* Comprehensive color handling for Grid3 format, including:
5+
* - CSS color name lookup (147 named colors)
6+
* - Color format conversion (hex, RGB, RGBA, named colors)
7+
* - Color manipulation (darkening, normalization)
8+
* - Grid3-specific color formatting (8-digit ARGB hex)
9+
*/
10+
11+
/**
12+
* CSS color names to RGB values
13+
* Supports 147 standard CSS color names
14+
*/
15+
const CSS_COLORS: Record<string, [number, number, number]> = {
16+
aliceblue: [240, 248, 255],
17+
antiquewhite: [250, 235, 215],
18+
aqua: [0, 255, 255],
19+
aquamarine: [127, 255, 212],
20+
azure: [240, 255, 255],
21+
beige: [245, 245, 220],
22+
bisque: [255, 228, 196],
23+
black: [0, 0, 0],
24+
blanchedalmond: [255, 235, 205],
25+
blue: [0, 0, 255],
26+
blueviolet: [138, 43, 226],
27+
brown: [165, 42, 42],
28+
burlywood: [222, 184, 135],
29+
cadetblue: [95, 158, 160],
30+
chartreuse: [127, 255, 0],
31+
chocolate: [210, 105, 30],
32+
coral: [255, 127, 80],
33+
cornflowerblue: [100, 149, 237],
34+
cornsilk: [255, 248, 220],
35+
crimson: [220, 20, 60],
36+
cyan: [0, 255, 255],
37+
darkblue: [0, 0, 139],
38+
darkcyan: [0, 139, 139],
39+
darkgoldenrod: [184, 134, 11],
40+
darkgray: [169, 169, 169],
41+
darkgreen: [0, 100, 0],
42+
darkgrey: [169, 169, 169],
43+
darkkhaki: [189, 183, 107],
44+
darkmagenta: [139, 0, 139],
45+
darkolivegreen: [85, 107, 47],
46+
darkorange: [255, 140, 0],
47+
darkorchid: [153, 50, 204],
48+
darkred: [139, 0, 0],
49+
darksalmon: [233, 150, 122],
50+
darkseagreen: [143, 188, 143],
51+
darkslateblue: [72, 61, 139],
52+
darkslategray: [47, 79, 79],
53+
darkslategrey: [47, 79, 79],
54+
darkturquoise: [0, 206, 209],
55+
darkviolet: [148, 0, 211],
56+
deeppink: [255, 20, 147],
57+
deepskyblue: [0, 191, 255],
58+
dimgray: [105, 105, 105],
59+
dimgrey: [105, 105, 105],
60+
dodgerblue: [30, 144, 255],
61+
firebrick: [178, 34, 34],
62+
floralwhite: [255, 250, 240],
63+
forestgreen: [34, 139, 34],
64+
fuchsia: [255, 0, 255],
65+
gainsboro: [220, 220, 220],
66+
ghostwhite: [248, 248, 255],
67+
gold: [255, 215, 0],
68+
goldenrod: [218, 165, 32],
69+
gray: [128, 128, 128],
70+
grey: [128, 128, 128],
71+
green: [0, 128, 0],
72+
greenyellow: [173, 255, 47],
73+
honeydew: [240, 255, 240],
74+
hotpink: [255, 105, 180],
75+
indianred: [205, 92, 92],
76+
indigo: [75, 0, 130],
77+
ivory: [255, 255, 240],
78+
khaki: [240, 230, 140],
79+
lavender: [230, 230, 250],
80+
lavenderblush: [255, 240, 245],
81+
lawngreen: [124, 252, 0],
82+
lemonchiffon: [255, 250, 205],
83+
lightblue: [173, 216, 230],
84+
lightcoral: [240, 128, 128],
85+
lightcyan: [224, 255, 255],
86+
lightgoldenrodyellow: [250, 250, 210],
87+
lightgray: [211, 211, 211],
88+
lightgreen: [144, 238, 144],
89+
lightgrey: [211, 211, 211],
90+
lightpink: [255, 182, 193],
91+
lightsalmon: [255, 160, 122],
92+
lightseagreen: [32, 178, 170],
93+
lightskyblue: [135, 206, 250],
94+
lightslategray: [119, 136, 153],
95+
lightslategrey: [119, 136, 153],
96+
lightsteelblue: [176, 196, 222],
97+
lightyellow: [255, 255, 224],
98+
lime: [0, 255, 0],
99+
limegreen: [50, 205, 50],
100+
linen: [250, 240, 230],
101+
magenta: [255, 0, 255],
102+
maroon: [128, 0, 0],
103+
mediumaquamarine: [102, 205, 170],
104+
mediumblue: [0, 0, 205],
105+
mediumorchid: [186, 85, 211],
106+
mediumpurple: [147, 112, 219],
107+
mediumseagreen: [60, 179, 113],
108+
mediumslateblue: [123, 104, 238],
109+
mediumspringgreen: [0, 250, 154],
110+
mediumturquoise: [72, 209, 204],
111+
mediumvioletred: [199, 21, 133],
112+
midnightblue: [25, 25, 112],
113+
mintcream: [245, 255, 250],
114+
mistyrose: [255, 228, 225],
115+
moccasin: [255, 228, 181],
116+
navajowhite: [255, 222, 173],
117+
navy: [0, 0, 128],
118+
oldlace: [253, 245, 230],
119+
olive: [128, 128, 0],
120+
olivedrab: [107, 142, 35],
121+
orange: [255, 165, 0],
122+
orangered: [255, 69, 0],
123+
orchid: [218, 112, 214],
124+
palegoldenrod: [238, 232, 170],
125+
palegreen: [152, 251, 152],
126+
paleturquoise: [175, 238, 238],
127+
palevioletred: [219, 112, 147],
128+
papayawhip: [255, 239, 213],
129+
peachpuff: [255, 218, 185],
130+
peru: [205, 133, 63],
131+
pink: [255, 192, 203],
132+
plum: [221, 160, 221],
133+
powderblue: [176, 224, 230],
134+
purple: [128, 0, 128],
135+
rebeccapurple: [102, 51, 153],
136+
red: [255, 0, 0],
137+
rosybrown: [188, 143, 143],
138+
royalblue: [65, 105, 225],
139+
saddlebrown: [139, 69, 19],
140+
salmon: [250, 128, 114],
141+
sandybrown: [244, 164, 96],
142+
seagreen: [46, 139, 87],
143+
seashell: [255, 245, 238],
144+
sienna: [160, 82, 45],
145+
silver: [192, 192, 192],
146+
skyblue: [135, 206, 235],
147+
slateblue: [106, 90, 205],
148+
slategray: [112, 128, 144],
149+
slategrey: [112, 128, 144],
150+
snow: [255, 250, 250],
151+
springgreen: [0, 255, 127],
152+
steelblue: [70, 130, 180],
153+
tan: [210, 180, 140],
154+
teal: [0, 128, 128],
155+
thistle: [216, 191, 216],
156+
tomato: [255, 99, 71],
157+
turquoise: [64, 224, 208],
158+
violet: [238, 130, 238],
159+
wheat: [245, 222, 179],
160+
white: [255, 255, 255],
161+
whitesmoke: [245, 245, 245],
162+
yellow: [255, 255, 0],
163+
yellowgreen: [154, 205, 50],
164+
};
165+
166+
/**
167+
* Get RGB values for a CSS color name
168+
* @param name - CSS color name (case-insensitive)
169+
* @returns RGB tuple [r, g, b] or undefined if not found
170+
*/
171+
export function getNamedColor(name: string): [number, number, number] | undefined {
172+
return CSS_COLORS[name.toLowerCase()];
173+
}
174+
175+
/**
176+
* Convert RGBA values to hex format
177+
* @param r - Red channel (0-255)
178+
* @param g - Green channel (0-255)
179+
* @param b - Blue channel (0-255)
180+
* @param a - Alpha channel (0-1)
181+
* @returns Hex color string in format #RRGGBBAA
182+
*/
183+
export function rgbaToHex(r: number, g: number, b: number, a: number): string {
184+
const red = channelToHex(r);
185+
const green = channelToHex(g);
186+
const blue = channelToHex(b);
187+
const alpha = channelToHex(Math.round(a * 255));
188+
return `#${red}${green}${blue}${alpha}`;
189+
}
190+
191+
/**
192+
* Convert a single color channel value to hex
193+
* @param value - Channel value (0-255)
194+
* @returns Two-digit hex string
195+
*/
196+
export function channelToHex(value: number): string {
197+
const clamped = Math.max(0, Math.min(255, Math.round(value)));
198+
return clamped.toString(16).padStart(2, '0').toUpperCase();
199+
}
200+
201+
/**
202+
* Clamp RGB channel value to valid range
203+
* @param value - Channel value
204+
* @returns Clamped value (0-255)
205+
*/
206+
export function clampColorChannel(value: number): number {
207+
if (Number.isNaN(value)) {
208+
return 0;
209+
}
210+
return Math.max(0, Math.min(255, value));
211+
}
212+
213+
/**
214+
* Clamp alpha value to valid range
215+
* @param value - Alpha value
216+
* @returns Clamped value (0-1)
217+
*/
218+
export function clampAlpha(value: number): number {
219+
if (Number.isNaN(value)) {
220+
return 1;
221+
}
222+
return Math.max(0, Math.min(1, value));
223+
}
224+
225+
/**
226+
* Convert any color format to hex
227+
* Supports: hex (#RGB, #RRGGBB, #RRGGBBAA), RGB/RGBA, and CSS color names
228+
* @param value - Color string in any supported format
229+
* @returns Hex color string (#RRGGBBAA) or undefined if invalid
230+
*/
231+
export function toHexColor(value: string): string | undefined {
232+
// Try hex format
233+
const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
234+
if (hexMatch) {
235+
const hex = hexMatch[1];
236+
if (hex.length === 3 || hex.length === 4) {
237+
return `#${hex
238+
.split('')
239+
.map((char) => char + char)
240+
.join('')}`;
241+
}
242+
return `#${hex}`;
243+
}
244+
245+
// Try RGB/RGBA format
246+
const rgbMatch = value.match(/^rgba?\((.+)\)$/i);
247+
if (rgbMatch) {
248+
const parts = rgbMatch[1]
249+
.split(',')
250+
.map((part) => part.trim())
251+
.filter(Boolean);
252+
if (parts.length === 3 || parts.length === 4) {
253+
const [r, g, b, a] = parts;
254+
const red = clampColorChannel(parseFloat(r));
255+
const green = clampColorChannel(parseFloat(g));
256+
const blue = clampColorChannel(parseFloat(b));
257+
const alpha = parts.length === 4 ? clampAlpha(parseFloat(a)) : 1;
258+
return rgbaToHex(red, green, blue, alpha);
259+
}
260+
}
261+
262+
// Try CSS color name
263+
const rgb = getNamedColor(value);
264+
if (rgb) {
265+
return rgbaToHex(rgb[0], rgb[1], rgb[2], 1);
266+
}
267+
268+
return undefined;
269+
}
270+
271+
/**
272+
* Darken a hex color by a specified amount
273+
* @param hex - Hex color string
274+
* @param amount - Amount to darken (0-255)
275+
* @returns Darkened hex color
276+
*/
277+
export function darkenColor(hex: string, amount: number): string {
278+
const normalized = ensureAlphaChannel(hex).substring(1); // strip #
279+
const rgb = normalized.substring(0, 6);
280+
const alpha = normalized.substring(6) || 'FF';
281+
const r = parseInt(rgb.substring(0, 2), 16);
282+
const g = parseInt(rgb.substring(2, 4), 16);
283+
const b = parseInt(rgb.substring(4, 6), 16);
284+
const clamp = (val: number) => Math.max(0, Math.min(255, val));
285+
const newR = clamp(r - amount);
286+
const newG = clamp(g - amount);
287+
const newB = clamp(b - amount);
288+
return `#${channelToHex(newR)}${channelToHex(newG)}${channelToHex(newB)}${alpha.toUpperCase()}`;
289+
}
290+
291+
/**
292+
* Normalize any color format to Grid3's 8-digit hex format
293+
* @param input - Color string in any supported format
294+
* @param fallback - Fallback color if input is invalid (default: white)
295+
* @returns Normalized color in format #AARRGGBBFF
296+
*/
297+
export function normalizeColor(input: string, fallback: string = '#FFFFFFFF'): string {
298+
const trimmed = input.trim();
299+
if (!trimmed) {
300+
return fallback;
301+
}
302+
303+
const hex = toHexColor(trimmed);
304+
if (hex) {
305+
return ensureAlphaChannel(hex).toUpperCase();
306+
}
307+
308+
return fallback;
309+
}
310+
311+
/**
312+
* Ensure a color has an alpha channel (Grid3 format requires 8-digit ARGB)
313+
* @param color - Color string (hex format)
314+
* @returns Color with alpha channel in format #AARRGGBBFF
315+
*/
316+
export function ensureAlphaChannel(color: string | undefined): string {
317+
if (!color) return '#FFFFFFFF';
318+
// If already 8 digits (with alpha), return as is
319+
if (color.match(/^#[0-9A-Fa-f]{8}$/)) return color;
320+
// If 6 digits (no alpha), add FF for fully opaque
321+
if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + 'FF';
322+
// If 3 digits (shorthand), expand to 8
323+
if (color.match(/^#[0-9A-Fa-f]{3}$/)) {
324+
const r = color[1];
325+
const g = color[2];
326+
const b = color[3];
327+
return `#${r}${r}${g}${g}${b}${b}FF`;
328+
}
329+
// Invalid or unknown format, return white
330+
return '#FFFFFFFF';
331+
}
332+

src/processors/gridset/styleHelpers.ts

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { XMLBuilder } from 'fast-xml-parser';
9+
import { ensureAlphaChannel, darkenColor } from './colorUtils';
910

1011
/**
1112
* Grid3 Style object structure
@@ -129,26 +130,10 @@ export const CATEGORY_STYLES: Record<string, Grid3Style> = {
129130
};
130131

131132
/**
132-
* Ensure a color has an alpha channel (Grid3 format requires 8-digit ARGB)
133-
* @param color - Color string (hex format)
134-
* @returns Color with alpha channel in format #AARRGGBBFF
133+
* Re-export ensureAlphaChannel from colorUtils for backward compatibility
134+
* @deprecated Use ensureAlphaChannel from colorUtils instead
135135
*/
136-
export function ensureAlphaChannel(color: string | undefined): string {
137-
if (!color) return '#FFFFFFFF';
138-
// If already 8 digits (with alpha), return as is
139-
if (color.match(/^#[0-9A-Fa-f]{8}$/)) return color;
140-
// If 6 digits (no alpha), add FF for fully opaque
141-
if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + 'FF';
142-
// If 3 digits (shorthand), expand to 8
143-
if (color.match(/^#[0-9A-Fa-f]{3}$/)) {
144-
const r = color[1];
145-
const g = color[2];
146-
const b = color[3];
147-
return `#${r}${r}${g}${g}${b}${b}FF`;
148-
}
149-
// Invalid or unknown format, return white
150-
return '#FFFFFFFF';
151-
}
136+
export { ensureAlphaChannel } from './colorUtils';
152137

153138
/**
154139
* Create a Grid3 style XML string with default and category styles
@@ -211,19 +196,4 @@ export function createCategoryStyle(
211196
};
212197
}
213198

214-
/**
215-
* Darken a hex color by a given amount
216-
* @param hexColor - Hex color string
217-
* @param amount - Amount to darken (0-255)
218-
* @returns Darkened hex color
219-
*/
220-
function darkenColor(hexColor: string, amount: number): string {
221-
const normalized = ensureAlphaChannel(hexColor);
222-
const hex = normalized.slice(1, 7); // Extract RGB part (skip # and alpha)
223-
const num = parseInt(hex, 16);
224-
const clamp = (value: number): number => Math.max(0, Math.min(255, value));
225-
const r = clamp(((num >> 16) & 0xff) - amount);
226-
const g = clamp(((num >> 8) & 0xff) - amount);
227-
const b = clamp((num & 0xff) - amount);
228-
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
229-
}
199+

0 commit comments

Comments
 (0)