unmagic-color 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Color
5
+ # `OKLCH` (Lightness, Chroma, Hue) color representation.
6
+ #
7
+ # ## Understanding OKLCH
8
+ #
9
+ # OKLCH is a modern color space designed to match how humans actually perceive colors.
10
+ # Unlike {RGB} or even {HSL}, OKLCH ensures that colors with the same lightness value
11
+ # *look* equally bright to our eyes, regardless of their hue.
12
+ #
13
+ # ## The Problem with RGB and HSL
14
+ #
15
+ # In {RGB} and {HSL}, pure yellow and pure blue can have the same "lightness" value,
16
+ # but yellow looks much brighter to our eyes. This makes it hard to create
17
+ # consistent-looking color palettes.
18
+ #
19
+ # OKLCH solves this by being "perceptually uniform" - if you change lightness
20
+ # by `0.1`, it looks like the same amount of change whether you're working with
21
+ # red, green, blue, or any other hue.
22
+ #
23
+ # ## The Three Components
24
+ #
25
+ # 1. **Lightness** (`0.0-1.0`): How bright the color appears
26
+ # - `0.0` = Black
27
+ # - `0.5` = Medium brightness
28
+ # - `1.0` = White
29
+ #
30
+ # Unlike {HSL}, this matches *perceived* brightness consistently across all hues.
31
+ #
32
+ # 2. **Chroma** (`0.0-0.5`): How colorful/saturated it is
33
+ # - `0.0` = Gray (no color)
34
+ # - `0.15` = Moderate color (good for UI)
35
+ # - `0.3+` = Very vivid (use sparingly)
36
+ #
37
+ # Think of it like saturation, but more accurate to perception.
38
+ #
39
+ # 3. **Hue** (`0-360°`): The color itself (same as {HSL})
40
+ # - `0°/360°` = Red
41
+ # - `120°` = Green
42
+ # - `240°` = Blue
43
+ #
44
+ # ## Why Use OKLCH?
45
+ #
46
+ # - Creating accessible color palettes (ensure consistent contrast)
47
+ # - Generating color scales that look evenly spaced
48
+ # - Interpolating between colors smoothly
49
+ # - Matching colors that "feel" equally bright
50
+ #
51
+ # ## When to Use Each Color Space
52
+ #
53
+ # - **{RGB}**: When working with screens/displays directly
54
+ # - **{HSL}**: When you need intuitive color manipulation
55
+ # - **OKLCH**: When you need perceptually accurate colors (design systems, accessibility)
56
+ #
57
+ # ## Examples
58
+ #
59
+ # # Parse OKLCH colors
60
+ # color = Unmagic::Color::OKLCH.parse("oklch(0.65 0.15 240)") # Medium blue
61
+ #
62
+ # # Create directly
63
+ # accessible = Unmagic::Color::OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
64
+ #
65
+ # # Access components
66
+ # color.lightness #=> 0.65 (ratio form)
67
+ # color.chroma.value #=> 0.15
68
+ # color.hue.value #=> 240
69
+ #
70
+ # # Create perceptually uniform variations
71
+ # lighter = color.lighten(0.05) # Looks 5% brighter
72
+ # less_colorful = color.desaturate(0.03)
73
+ #
74
+ # # Generate consistent colors
75
+ # Unmagic::Color::OKLCH.derive("user@example.com".hash) # Perceptually balanced color
76
+ class OKLCH < Color
77
+ # Error raised when parsing OKLCH color strings fails
78
+ class ParseError < Color::Error; end
79
+
80
+ attr_reader :chroma, :hue
81
+
82
+ # Create a new OKLCH color.
83
+ #
84
+ # @param lightness [Float] Lightness as a ratio (0.0-1.0), clamped to range
85
+ # @param chroma [Float] Chroma intensity (0.0-0.5), clamped to range
86
+ # @param hue [Numeric] Hue in degrees (0-360), wraps around if outside range
87
+ #
88
+ # @example Create a medium blue
89
+ # OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
90
+ #
91
+ # @example Create a vibrant red
92
+ # OKLCH.new(lightness: 0.60, chroma: 0.25, hue: 30)
93
+ def initialize(lightness:, chroma:, hue:)
94
+ super()
95
+ @lightness = Color::Lightness.new(lightness * 100) # Convert 0-1 to percentage
96
+ @chroma = Color::Chroma.new(value: chroma)
97
+ @hue = Color::Hue.new(value: hue)
98
+ end
99
+
100
+ # Get the lightness as a ratio (0.0-1.0).
101
+ #
102
+ # This overrides the attr_reader to return the ratio form, which is the
103
+ # standard way to work with OKLCH lightness.
104
+ #
105
+ # @return [Float] Lightness from 0.0 (black) to 1.0 (white)
106
+ def lightness = @lightness.to_ratio
107
+
108
+ # Get the lightness as a percentage (0.0-100.0).
109
+ #
110
+ # Helper method for when you need the percentage form instead of ratio.
111
+ #
112
+ # @return [Float] Lightness from 0.0 to 100.0
113
+ def lightness_percentage = @lightness.value
114
+
115
+ class << self
116
+ # Parse an OKLCH color from a string.
117
+ #
118
+ # Accepts formats:
119
+ # - CSS format: "oklch(0.65 0.15 240)"
120
+ # - Raw values: "0.65 0.15 240"
121
+ # - Space-separated values
122
+ #
123
+ # @param input [String] The OKLCH color string to parse
124
+ # @return [OKLCH] The parsed OKLCH color
125
+ # @raise [ParseError] If the input format is invalid or values are out of range
126
+ #
127
+ # @example Parse CSS format
128
+ # OKLCH.parse("oklch(0.65 0.15 240)")
129
+ #
130
+ # @example Parse without function wrapper
131
+ # OKLCH.parse("0.58 0.12 180")
132
+ def parse(input)
133
+ raise ParseError, "Input must be a string" unless input.is_a?(::String)
134
+
135
+ # Remove oklch() wrapper if present
136
+ clean = input.gsub(/^oklch\s*\(\s*|\s*\)$/, "").strip
137
+
138
+ # Split values
139
+ parts = clean.split(/\s+/)
140
+ unless parts.length == 3
141
+ raise ParseError, "Expected 3 OKLCH values, got #{parts.length}"
142
+ end
143
+
144
+ # Check if all values are numeric
145
+ parts.each_with_index do |v, i|
146
+ unless v.match?(/\A\d+(\.\d+)?\z/)
147
+ component = ["lightness", "chroma", "hue"][i]
148
+ raise ParseError, "Invalid #{component} value: #{v.inspect} (must be a number)"
149
+ end
150
+ end
151
+
152
+ # Convert to floats
153
+ l = parts[0].to_f
154
+ c = parts[1].to_f
155
+ h = parts[2].to_f
156
+
157
+ # Validate ranges
158
+ if l < 0 || l > 1
159
+ raise ParseError, "Lightness must be between 0 and 1, got #{l}"
160
+ end
161
+
162
+ if c < 0 || c > 0.5
163
+ raise ParseError, "Chroma must be between 0 and 0.5, got #{c}"
164
+ end
165
+
166
+ if h < 0 || h >= 360
167
+ raise ParseError, "Hue must be between 0 and 360, got #{h}"
168
+ end
169
+
170
+ new(lightness: l, chroma: c, hue: h)
171
+ end
172
+
173
+ # Generate a deterministic OKLCH color from an integer seed.
174
+ #
175
+ # Creates perceptually balanced, visually distinct colors. This is particularly
176
+ # effective in OKLCH because the perceptual uniformity ensures all generated
177
+ # colors have consistent perceived brightness and saturation.
178
+ #
179
+ # The hue distribution uses a golden-angle approach to spread colors evenly
180
+ # and avoid clustering similar hues together.
181
+ #
182
+ # @param seed [Integer] The seed value (typically from a hash function)
183
+ # @param lightness [Float] Fixed lightness value (0.0-1.0, default 0.58)
184
+ # @param chroma_range [Range] Range for chroma variation (default 0.10..0.18)
185
+ # @param hue_spread [Integer] Modulo for hue distribution (default 997)
186
+ # @param hue_base [Float] Multiplier for hue calculation (default 137.508 - golden angle)
187
+ # @return [OKLCH] A deterministic, perceptually balanced color
188
+ # @raise [ArgumentError] If seed is not an integer
189
+ #
190
+ # @example Generate avatar color
191
+ # OKLCH.derive("user@example.com".hash)
192
+ #
193
+ # @example Generate lighter UI colors
194
+ # OKLCH.derive(12345, lightness: 0.75)
195
+ #
196
+ # @example Generate more saturated colors
197
+ # OKLCH.derive(12345, chroma_range: (0.15..0.25))
198
+ def derive(seed, lightness: 0.58, chroma_range: (0.10..0.18), hue_spread: 997, hue_base: 137.508)
199
+ raise ArgumentError, "Seed must be an integer" unless seed.is_a?(Integer)
200
+
201
+ h32 = seed & 0xFFFFFFFF # Ensure 32-bit
202
+
203
+ # Hue: golden-angle style distribution to avoid clusters
204
+ h = (hue_base * (h32 % hue_spread)) % 360
205
+
206
+ # Chroma: map a byte into a safe text-friendly range
207
+ c = chroma_range.begin + ((h32 >> 8) & 0xFF) / 255.0 * (chroma_range.end - chroma_range.begin)
208
+
209
+ new(lightness: lightness, chroma: c, hue: h)
210
+ end
211
+ end
212
+
213
+ # Convert to OKLCH color space.
214
+ #
215
+ # Since this is already an OKLCH color, returns self.
216
+ #
217
+ # @return [OKLCH] self
218
+ def to_oklch
219
+ self
220
+ end
221
+
222
+ # Convert to RGB color space.
223
+ #
224
+ # @return [RGB] The color in RGB color space (approximation)
225
+ # @note This is currently a simplified approximation. A proper OKLCH to sRGB
226
+ # conversion requires more complex color science calculations.
227
+ def to_rgb
228
+ # For now, convert via approximation - would need proper OKLCH->sRGB conversion
229
+ # This is a simplified placeholder that approximates RGB from OKLCH
230
+ require_relative "rgb"
231
+
232
+ # Simple approximation: use lightness and chroma to estimate RGB
233
+ base = (@lightness.to_ratio * 255).round
234
+ saturation = (@chroma * 255).value
235
+
236
+ # Convert hue to RGB ratios (very simplified)
237
+ h_rad = (@hue * Math::PI / 180).value
238
+ r_offset = (Math.cos(h_rad) * saturation).round
239
+ g_offset = (Math.cos(h_rad + 2 * Math::PI / 3) * saturation).round
240
+ b_offset = (Math.cos(h_rad + 4 * Math::PI / 3) * saturation).round
241
+
242
+ r = (base + r_offset).clamp(0, 255)
243
+ g = (base + g_offset).clamp(0, 255)
244
+ b = (base + b_offset).clamp(0, 255)
245
+
246
+ Unmagic::Color::RGB.new(red: r, green: g, blue: b)
247
+ end
248
+
249
+ # Calculate the relative luminance.
250
+ #
251
+ # In OKLCH, the lightness value directly represents perceptual luminance,
252
+ # so we can use it as-is.
253
+ #
254
+ # @return [Float] Luminance from 0.0 (black) to 1.0 (white)
255
+ def luminance
256
+ # OKLCH lightness is perceptually uniform, so we can use it directly
257
+ @lightness.to_ratio # Return 0-1 range
258
+ end
259
+
260
+ # Create a lighter version by increasing lightness.
261
+ #
262
+ # In OKLCH, lightness changes are perceptually uniform, so adding 0.05
263
+ # will look like the same brightness increase regardless of the hue.
264
+ #
265
+ # @param amount [Float] How much to increase lightness (default 0.03)
266
+ # @return [OKLCH] A lighter version of this color
267
+ #
268
+ # @example Make a color perceptually 5% brighter
269
+ # color = OKLCH.new(lightness: 0.60, chroma: 0.15, hue: 240)
270
+ # lighter = color.lighten(0.05)
271
+ def lighten(amount = 0.03)
272
+ current_lightness = @lightness.to_ratio
273
+ new_lightness = clamp01(current_lightness + amount)
274
+ self.class.new(lightness: new_lightness, chroma: @chroma.value, hue: @hue.value)
275
+ end
276
+
277
+ # Create a darker version by decreasing lightness.
278
+ #
279
+ # @param amount [Float] How much to decrease lightness (default 0.03)
280
+ # @return [OKLCH] A darker version of this color
281
+ #
282
+ # @example Make a color perceptually 5% darker
283
+ # color = OKLCH.new(lightness: 0.70, chroma: 0.15, hue: 120)
284
+ # darker = color.darken(0.05)
285
+ def darken(amount = 0.03)
286
+ current_lightness = @lightness.to_ratio
287
+ new_lightness = clamp01(current_lightness - amount)
288
+ self.class.new(lightness: new_lightness, chroma: @chroma.value, hue: @hue.value)
289
+ end
290
+
291
+ # Create a more saturated version by increasing chroma.
292
+ #
293
+ # @param amount [Float] How much to increase chroma (default 0.02)
294
+ # @return [OKLCH] A more saturated version of this color
295
+ #
296
+ # @example Make a color more vivid
297
+ # muted = OKLCH.new(lightness: 0.65, chroma: 0.10, hue: 180)
298
+ # vivid = muted.saturate(0.05)
299
+ def saturate(amount = 0.02)
300
+ new_chroma = [@chroma.value + amount, 0.4].min
301
+ self.class.new(lightness: @lightness.to_ratio, chroma: new_chroma, hue: @hue.value)
302
+ end
303
+
304
+ # Create a less saturated version by decreasing chroma.
305
+ #
306
+ # @param amount [Float] How much to decrease chroma (default 0.02)
307
+ # @return [OKLCH] A less saturated version of this color
308
+ #
309
+ # @example Make a color more muted
310
+ # vivid = OKLCH.new(lightness: 0.65, chroma: 0.20, hue: 30)
311
+ # muted = vivid.desaturate(0.10)
312
+ def desaturate(amount = 0.02)
313
+ new_chroma = [@chroma.value - amount, 0.0].max
314
+ self.class.new(lightness: @lightness.to_ratio, chroma: new_chroma, hue: @hue.value)
315
+ end
316
+
317
+ # Rotate the hue by a specified amount.
318
+ #
319
+ # @param amount [Numeric] Degrees to rotate the hue (default 10)
320
+ # @return [OKLCH] A color with the hue rotated
321
+ #
322
+ # @example Shift to an analogous color
323
+ # blue = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
324
+ # blue_green = blue.rotate(30)
325
+ def rotate(amount = 10)
326
+ new_hue = (@hue.value + amount) % 360
327
+ self.class.new(lightness: @lightness.to_ratio, chroma: @chroma.value, hue: new_hue)
328
+ end
329
+
330
+ # Blend this color with another color in OKLCH space.
331
+ #
332
+ # Blending in OKLCH produces perceptually smooth color transitions. Uses
333
+ # shortest-arc hue interpolation to avoid going the long way around the color wheel.
334
+ #
335
+ # @param other [Color] The color to blend with (automatically converted to OKLCH)
336
+ # @param amount [Float] How much of the other color to mix in (0.0-1.0)
337
+ # @return [OKLCH] A new OKLCH color that is a blend of the two
338
+ #
339
+ # @example Create a perceptually smooth gradient
340
+ # blue = OKLCH.new(lightness: 0.60, chroma: 0.15, hue: 240)
341
+ # red = OKLCH.new(lightness: 0.60, chroma: 0.15, hue: 30)
342
+ # purple = blue.blend(red, 0.5)
343
+ def blend(other, amount = 0.5)
344
+ amount = amount.to_f.clamp(0, 1)
345
+ other_oklch = other.respond_to?(:to_oklch) ? other.to_oklch : other
346
+
347
+ # Blend in OKLCH space with shortest-arc hue interpolation
348
+ dh = (((other_oklch.hue.value - @hue.value + 540) % 360) - 180)
349
+ new_hue = (@hue.value + dh * amount) % 360
350
+ new_lightness = lightness + (other_oklch.lightness - lightness) * amount
351
+ new_chroma = @chroma.value + (other_oklch.chroma.value - @chroma.value) * amount
352
+
353
+ self.class.new(lightness: new_lightness, chroma: new_chroma, hue: new_hue)
354
+ end
355
+
356
+ # Convert to CSS oklch() function format.
357
+ #
358
+ # @return [String] CSS string like "oklch(0.6500 0.1500 240.00)"
359
+ #
360
+ # @example
361
+ # color = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
362
+ # color.to_css_oklch
363
+ # # => "oklch(0.6500 0.1500 240.00)"
364
+ def to_css_oklch
365
+ format("oklch(%.4f %.4f %.2f)", @lightness.to_ratio, @chroma.value, @hue.value)
366
+ end
367
+
368
+ # Convert to CSS custom properties (variables).
369
+ #
370
+ # Outputs the color as CSS variables for lightness, chroma, and hue that can
371
+ # be manipulated or mixed at runtime in CSS.
372
+ #
373
+ # @return [String] CSS variables like "--ul:0.6500;--uc:0.1500;--uh:240.00;"
374
+ #
375
+ # @example
376
+ # color = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
377
+ # color.to_css_vars
378
+ # # => "--ul:0.6500;--uc:0.1500;--uh:240.00;"
379
+ def to_css_vars
380
+ format("--ul:%.4f;--uc:%.4f;--uh:%.2f;", @lightness.to_ratio, @chroma.value, @hue.value)
381
+ end
382
+
383
+ # Create a CSS color-mix() expression.
384
+ #
385
+ # Generates a CSS color-mix expression that blends this color with a background
386
+ # color (typically a CSS variable).
387
+ #
388
+ # @param bg_css [String] The background color CSS (default "var(--bg)")
389
+ # @param a_pct [Integer] Percentage of this color (default 72)
390
+ # @param bg_pct [Integer] Percentage of background color (default 28)
391
+ # @return [String] CSS color-mix expression
392
+ #
393
+ # @example Mix with background
394
+ # color = Unmagic::Color::OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
395
+ # color.to_css_color_mix
396
+ # # => "color-mix(in oklch, oklch(0.6500 0.1500 240.00) 72%, var(--bg) 28%)"
397
+ #
398
+ # @example Custom background and percentages
399
+ # color = Unmagic::Color::OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
400
+ # color.to_css_color_mix("#FFFFFF", a_pct: 50, bg_pct: 50)
401
+ # # => "color-mix(in oklch, oklch(0.6500 0.1500 240.00) 50%, #FFFFFF 50%)"
402
+ def to_css_color_mix(bg_css = "var(--bg)", a_pct: 72, bg_pct: 28)
403
+ "color-mix(in oklch, #{to_css_oklch} #{a_pct}%, #{bg_css} #{bg_pct}%)"
404
+ end
405
+
406
+ # Check if two OKLCH colors are equal.
407
+ #
408
+ # @param other [Object] The object to compare with
409
+ # @return [Boolean] true if both colors have the same OKLCH values
410
+ def ==(other)
411
+ other.is_a?(Unmagic::Color::OKLCH) &&
412
+ lightness == other.lightness &&
413
+ chroma == other.chroma &&
414
+ hue == other.hue
415
+ end
416
+
417
+ # Convert to string representation.
418
+ #
419
+ # Returns the CSS oklch() function format.
420
+ #
421
+ # @return [String] CSS string like "oklch(0.6500 0.1500 240.00)"
422
+ def to_s
423
+ to_css_oklch
424
+ end
425
+
426
+ private
427
+
428
+ def clamp01(x)
429
+ x.clamp(0.0, 1.0)
430
+ end
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Color
5
+ class RGB
6
+ # Hexadecimal color parsing utilities.
7
+ #
8
+ # Hex colors are a compact way to write {RGB} values using hexadecimal (base-16)
9
+ # notation. Each pair of hex digits represents one color component (`0-255`).
10
+ #
11
+ # ## Understanding Hexadecimal
12
+ #
13
+ # Hexadecimal uses 16 digits: `0-9` and `A-F`
14
+ #
15
+ # - `0 = 0`, `9 = 9`, `A = 10`, `B = 11`, ... `F = 15`
16
+ # - Two hex digits can represent `0-255` (`16 × 16 = 256` values)
17
+ # - `FF = 255`, `00 = 0`, `80 = 128`, etc.
18
+ #
19
+ # ## Hex Color Format
20
+ #
21
+ # - **Full format**: `#RRGGBB` (6 digits, 2 per component)
22
+ # - **Short format**: `#RGB` (3 digits, each digit is doubled)
23
+ # - **Hash optional**: Can be written with or without the `#` prefix
24
+ #
25
+ # ## Examples
26
+ #
27
+ # - `#FF0000` = Red (`255, 0, 0`)
28
+ # - `#00FF00` = Green (`0, 255, 0`)
29
+ # - `#0000FF` = Blue (`0, 0, 255`)
30
+ # - `#F00` = `#FF0000` (short form)
31
+ # - `#ABC` = `#AABBCC` (short form expanded)
32
+ class Hex
33
+ # Error raised when parsing hex color strings fails
34
+ class ParseError < Color::Error; end
35
+
36
+ class << self
37
+ # Check if a string is a valid hex color.
38
+ #
39
+ # @param value [String] The string to validate
40
+ # @return [Boolean] true if valid hex color, false otherwise
41
+ #
42
+ # @example
43
+ # Unmagic::Color::RGB::Hex.valid?("#FF5733")
44
+ # # => true
45
+ #
46
+ # Unmagic::Color::RGB::Hex.valid?("F73")
47
+ # # => true
48
+ #
49
+ # Unmagic::Color::RGB::Hex.valid?("GGGGGG")
50
+ # # => false
51
+ def valid?(value)
52
+ parse(value)
53
+ true
54
+ rescue ParseError
55
+ false
56
+ end
57
+
58
+ # Parse a hexadecimal color string.
59
+ #
60
+ # Accepts both full (6-digit) and short (3-digit) hex formats,
61
+ # with or without the # prefix.
62
+ #
63
+ # @param input [String] The hex color string to parse
64
+ # @return [RGB] The parsed RGB color
65
+ # @raise [ParseError] If the input is not a valid hex color
66
+ #
67
+ # @example Full format with hash
68
+ # Unmagic::Color::RGB::Hex.parse("#FF8800")
69
+ #
70
+ # @example Short format without hash
71
+ # Unmagic::Color::RGB::Hex.parse("F80")
72
+ def parse(input)
73
+ raise ParseError, "Input must be a string" unless input.is_a?(::String)
74
+
75
+ # Clean up the input
76
+ hex = input.strip.gsub(/^#/, "")
77
+
78
+ # Check for valid length (3 or 6 characters)
79
+ unless hex.length == 3 || hex.length == 6
80
+ raise ParseError, "Invalid number of characters (got #{hex.length}, expected 3 or 6)"
81
+ end
82
+
83
+ # Check if all characters are valid hex digits
84
+ unless hex.match?(/\A[0-9A-Fa-f]+\z/)
85
+ invalid_chars = hex.chars.reject { |c| c.match?(/[0-9A-Fa-f]/) }
86
+ raise ParseError, "Invalid hex characters: #{invalid_chars.join(", ")}"
87
+ end
88
+
89
+ # Handle 3-character hex codes
90
+ if hex.length == 3
91
+ hex = hex.chars.map { |c| c * 2 }.join
92
+ end
93
+
94
+ r = hex[0..1].to_i(16)
95
+ g = hex[2..3].to_i(16)
96
+ b = hex[4..5].to_i(16)
97
+
98
+ RGB.new(red: r, green: g, blue: b)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Color
5
+ class RGB < Color
6
+ # Named colors support for RGB colors.
7
+ #
8
+ # Provides access to standard named colors (like "red", "blue", "goldenrod")
9
+ # and converts them to RGB color instances.
10
+ #
11
+ # @example Parse a named color
12
+ # Unmagic::Color::RGB::Named.parse("goldenrod")
13
+ # #=> RGB instance for #daa520
14
+ #
15
+ # @example Case-insensitive and whitespace-tolerant
16
+ # Unmagic::Color::RGB::Named.parse("Golden Rod")
17
+ # #=> RGB instance for #daa520
18
+ # Unmagic::Color::RGB::Named.parse("GOLDENROD")
19
+ # #=> RGB instance for #daa520
20
+ #
21
+ # @example Check if a name is valid
22
+ # Unmagic::Color::RGB::Named.valid?("goldenrod")
23
+ # #=> true
24
+ # Unmagic::Color::RGB::Named.valid?("notacolor")
25
+ # #=> false
26
+ class Named
27
+ # Error raised when a color name is not found
28
+ class ParseError < Color::Error; end
29
+
30
+ class << self
31
+ # Parse a named color and return its RGB representation.
32
+ #
33
+ # @param name [String] The color name to parse (case-insensitive)
34
+ # @return [RGB] The RGB color instance
35
+ # @raise [ParseError] If the color name is not recognized
36
+ #
37
+ # @example
38
+ # Unmagic::Color::RGB::Named.parse("goldenrod")
39
+ # #=> RGB instance for #daa520
40
+ def parse(name)
41
+ normalized_name = normalize_name(name)
42
+ hex_value = data[normalized_name]
43
+
44
+ raise ParseError, "Unknown color name: #{name.inspect}" unless hex_value
45
+
46
+ Hex.parse(hex_value)
47
+ end
48
+
49
+ # Check if a color name is valid.
50
+ #
51
+ # @param name [String] The color name to check
52
+ # @return [Boolean] true if the name exists
53
+ #
54
+ # @example
55
+ # Unmagic::Color::RGB::Named.valid?("goldenrod")
56
+ # #=> true
57
+ def valid?(name)
58
+ normalized_name = normalize_name(name)
59
+ data.key?(normalized_name)
60
+ end
61
+
62
+ # Get all available color names.
63
+ #
64
+ # @return [Array<String>] Array of all color names
65
+ #
66
+ # @example
67
+ # Unmagic::Color::RGB::Named.all.take(5)
68
+ # #=> ["black", "silver", "gray", "white", "maroon"]
69
+ def all
70
+ data.keys
71
+ end
72
+
73
+ private
74
+
75
+ # Normalize a color name for lookup.
76
+ # Converts to lowercase and removes all whitespace.
77
+ #
78
+ # @param name [String] The color name to normalize
79
+ # @return [String] The normalized name
80
+ def normalize_name(name)
81
+ name.to_s.downcase.gsub(/\s+/, "")
82
+ end
83
+
84
+ # Load color data from the rgb.txt file.
85
+ # Uses memoization to only load the file once.
86
+ #
87
+ # @return [Hash] Hash of color names to hex values
88
+ def data
89
+ @data ||= load_data
90
+ end
91
+
92
+ # Load and parse the rgb.txt file.
93
+ #
94
+ # @return [Hash] Hash of color names to hex values
95
+ def load_data
96
+ data_file = File.join(__dir__, "..", "..", "..", "..", "data", "rgb.txt")
97
+ colors = {}
98
+
99
+ File.readlines(data_file).each do |line|
100
+ name, hex = line.strip.split("\t")
101
+ next if name.nil? || hex.nil?
102
+
103
+ colors[name] = hex
104
+ end
105
+
106
+ colors
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end