unmagic-color 0.1.0 → 0.2.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -0
- data/README.md +201 -41
- data/data/css.jsonc +150 -0
- data/data/css.txt +148 -0
- data/data/x11.jsonc +660 -0
- data/data/x11.txt +753 -0
- data/lib/unmagic/color/console/banner.rb +55 -0
- data/lib/unmagic/color/console/card.rb +165 -0
- data/lib/unmagic/color/console/help.rb +70 -0
- data/lib/unmagic/color/console/highlighter.rb +114 -0
- data/lib/unmagic/color/gradient/base.rb +252 -0
- data/lib/unmagic/color/gradient/bitmap.rb +91 -0
- data/lib/unmagic/color/gradient/stop.rb +48 -0
- data/lib/unmagic/color/gradient.rb +154 -0
- data/lib/unmagic/color/harmony.rb +293 -0
- data/lib/unmagic/color/hsl/gradient/linear.rb +152 -0
- data/lib/unmagic/color/hsl.rb +145 -21
- data/lib/unmagic/color/oklch/gradient/linear.rb +151 -0
- data/lib/unmagic/color/oklch.rb +124 -12
- data/lib/unmagic/color/rgb/ansi.rb +227 -0
- data/lib/unmagic/color/rgb/gradient/linear.rb +165 -0
- data/lib/unmagic/color/rgb/hex.rb +20 -8
- data/lib/unmagic/color/rgb/named.rb +213 -43
- data/lib/unmagic/color/rgb.rb +325 -22
- data/lib/unmagic/color/units/degrees.rb +233 -0
- data/lib/unmagic/color/units/direction.rb +206 -0
- data/lib/unmagic/color/util/percentage.rb +150 -22
- data/lib/unmagic/color/version.rb +8 -0
- data/lib/unmagic/color.rb +95 -0
- metadata +23 -3
- data/data/rgb.txt +0 -164
data/lib/unmagic/color/oklch.rb
CHANGED
|
@@ -77,24 +77,29 @@ module Unmagic
|
|
|
77
77
|
# Error raised when parsing OKLCH color strings fails
|
|
78
78
|
class ParseError < Color::Error; end
|
|
79
79
|
|
|
80
|
-
attr_reader :chroma, :hue
|
|
80
|
+
attr_reader :chroma, :hue, :alpha
|
|
81
81
|
|
|
82
82
|
# Create a new OKLCH color.
|
|
83
83
|
#
|
|
84
84
|
# @param lightness [Float] Lightness as a ratio (0.0-1.0), clamped to range
|
|
85
85
|
# @param chroma [Float] Chroma intensity (0.0-0.5), clamped to range
|
|
86
86
|
# @param hue [Numeric] Hue in degrees (0-360), wraps around if outside range
|
|
87
|
+
# @param alpha [Numeric, Color::Alpha, nil] Alpha channel (0-100%), defaults to 100 (fully opaque)
|
|
87
88
|
#
|
|
88
89
|
# @example Create a medium blue
|
|
89
90
|
# OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
90
91
|
#
|
|
92
|
+
# @example Create a semi-transparent red
|
|
93
|
+
# OKLCH.new(lightness: 0.60, chroma: 0.25, hue: 30, alpha: 50)
|
|
94
|
+
#
|
|
91
95
|
# @example Create a vibrant red
|
|
92
96
|
# OKLCH.new(lightness: 0.60, chroma: 0.25, hue: 30)
|
|
93
|
-
def initialize(lightness:, chroma:, hue:)
|
|
97
|
+
def initialize(lightness:, chroma:, hue:, alpha: nil)
|
|
94
98
|
super()
|
|
95
|
-
@lightness = Color::Lightness.new(lightness * 100) # Convert 0-1 to percentage
|
|
99
|
+
@lightness = Color::Lightness.new(value: lightness * 100) # Convert 0-1 to percentage
|
|
96
100
|
@chroma = Color::Chroma.new(value: chroma)
|
|
97
101
|
@hue = Color::Hue.new(value: hue)
|
|
102
|
+
@alpha = Color::Alpha.build(alpha) || Color::Alpha::DEFAULT
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
# Get the lightness as a ratio (0.0-1.0).
|
|
@@ -116,9 +121,9 @@ module Unmagic
|
|
|
116
121
|
# Parse an OKLCH color from a string.
|
|
117
122
|
#
|
|
118
123
|
# Accepts formats:
|
|
119
|
-
# - CSS format: "oklch(0.65 0.15 240)"
|
|
124
|
+
# - CSS format: "oklch(0.65 0.15 240)" or "oklch(0.65 0.15 240 / 0.5)"
|
|
120
125
|
# - Raw values: "0.65 0.15 240"
|
|
121
|
-
# - Space-separated values
|
|
126
|
+
# - Space-separated values with optional alpha after slash
|
|
122
127
|
#
|
|
123
128
|
# @param input [String] The OKLCH color string to parse
|
|
124
129
|
# @return [OKLCH] The parsed OKLCH color
|
|
@@ -127,6 +132,9 @@ module Unmagic
|
|
|
127
132
|
# @example Parse CSS format
|
|
128
133
|
# OKLCH.parse("oklch(0.65 0.15 240)")
|
|
129
134
|
#
|
|
135
|
+
# @example Parse with alpha
|
|
136
|
+
# OKLCH.parse("oklch(0.65 0.15 240 / 0.5)")
|
|
137
|
+
#
|
|
130
138
|
# @example Parse without function wrapper
|
|
131
139
|
# OKLCH.parse("0.58 0.12 180")
|
|
132
140
|
def parse(input)
|
|
@@ -135,6 +143,16 @@ module Unmagic
|
|
|
135
143
|
# Remove oklch() wrapper if present
|
|
136
144
|
clean = input.gsub(/^oklch\s*\(\s*|\s*\)$/, "").strip
|
|
137
145
|
|
|
146
|
+
# Check for alpha with slash separator
|
|
147
|
+
alpha = nil
|
|
148
|
+
if clean.include?("/")
|
|
149
|
+
parts = clean.split("/").map(&:strip)
|
|
150
|
+
raise ParseError, "Invalid format with /: expected 'L C H / alpha'" unless parts.length == 2
|
|
151
|
+
|
|
152
|
+
clean = parts[0]
|
|
153
|
+
alpha = Color::Alpha.parse(parts[1])
|
|
154
|
+
end
|
|
155
|
+
|
|
138
156
|
# Split values
|
|
139
157
|
parts = clean.split(/\s+/)
|
|
140
158
|
unless parts.length == 3
|
|
@@ -167,7 +185,36 @@ module Unmagic
|
|
|
167
185
|
raise ParseError, "Hue must be between 0 and 360, got #{h}"
|
|
168
186
|
end
|
|
169
187
|
|
|
170
|
-
new(lightness: l, chroma: c, hue: h)
|
|
188
|
+
new(lightness: l, chroma: c, hue: h, alpha: alpha)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Build an OKLCH color from a string, positional values, or keyword arguments.
|
|
192
|
+
#
|
|
193
|
+
# @param args [String, Numeric] Either a color string or 3 component values
|
|
194
|
+
# @option kwargs [Numeric] :lightness Lightness (0-1)
|
|
195
|
+
# @option kwargs [Numeric] :chroma Chroma (0-0.5)
|
|
196
|
+
# @option kwargs [Numeric] :hue Hue in degrees (0-360)
|
|
197
|
+
# @return [OKLCH] The constructed OKLCH color
|
|
198
|
+
#
|
|
199
|
+
# @example From string
|
|
200
|
+
# OKLCH.build("oklch(0.65 0.15 240)")
|
|
201
|
+
#
|
|
202
|
+
# @example From positional values
|
|
203
|
+
# OKLCH.build(0.65, 0.15, 240)
|
|
204
|
+
#
|
|
205
|
+
# @example From keyword arguments
|
|
206
|
+
# OKLCH.build(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
207
|
+
def build(*args, **kwargs)
|
|
208
|
+
if kwargs.any?
|
|
209
|
+
new(**kwargs)
|
|
210
|
+
elsif args.length == 1
|
|
211
|
+
parse(args[0])
|
|
212
|
+
elsif args.length == 3
|
|
213
|
+
values = args.map { |v| v.is_a?(::String) ? v.to_f : v }
|
|
214
|
+
new(lightness: values[0], chroma: values[1], hue: values[2])
|
|
215
|
+
else
|
|
216
|
+
raise ArgumentError, "Expected 1 or 3 arguments, got #{args.length}"
|
|
217
|
+
end
|
|
171
218
|
end
|
|
172
219
|
|
|
173
220
|
# Generate a deterministic OKLCH color from an integer seed.
|
|
@@ -219,6 +266,15 @@ module Unmagic
|
|
|
219
266
|
self
|
|
220
267
|
end
|
|
221
268
|
|
|
269
|
+
# Convert to HSL color space.
|
|
270
|
+
#
|
|
271
|
+
# Converts via RGB as an intermediate step.
|
|
272
|
+
#
|
|
273
|
+
# @return [HSL] The color in HSL color space
|
|
274
|
+
def to_hsl
|
|
275
|
+
to_rgb.to_hsl
|
|
276
|
+
end
|
|
277
|
+
|
|
222
278
|
# Convert to RGB color space.
|
|
223
279
|
#
|
|
224
280
|
# @return [RGB] The color in RGB color space (approximation)
|
|
@@ -243,7 +299,16 @@ module Unmagic
|
|
|
243
299
|
g = (base + g_offset).clamp(0, 255)
|
|
244
300
|
b = (base + b_offset).clamp(0, 255)
|
|
245
301
|
|
|
246
|
-
Unmagic::Color::RGB.new(red: r, green: g, blue: b)
|
|
302
|
+
Unmagic::Color::RGB.new(red: r, green: g, blue: b, alpha: @alpha)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Convert to hex string.
|
|
306
|
+
#
|
|
307
|
+
# Converts via RGB as an intermediate step.
|
|
308
|
+
#
|
|
309
|
+
# @return [String] The color as a hex string (e.g., "#ff5733")
|
|
310
|
+
def to_hex
|
|
311
|
+
to_rgb.to_hex
|
|
247
312
|
end
|
|
248
313
|
|
|
249
314
|
# Calculate the relative luminance.
|
|
@@ -271,7 +336,7 @@ module Unmagic
|
|
|
271
336
|
def lighten(amount = 0.03)
|
|
272
337
|
current_lightness = @lightness.to_ratio
|
|
273
338
|
new_lightness = clamp01(current_lightness + amount)
|
|
274
|
-
self.class.new(lightness: new_lightness, chroma: @chroma.value, hue: @hue.value)
|
|
339
|
+
self.class.new(lightness: new_lightness, chroma: @chroma.value, hue: @hue.value, alpha: @alpha.value)
|
|
275
340
|
end
|
|
276
341
|
|
|
277
342
|
# Create a darker version by decreasing lightness.
|
|
@@ -350,19 +415,33 @@ module Unmagic
|
|
|
350
415
|
new_lightness = lightness + (other_oklch.lightness - lightness) * amount
|
|
351
416
|
new_chroma = @chroma.value + (other_oklch.chroma.value - @chroma.value) * amount
|
|
352
417
|
|
|
353
|
-
self.class.new(
|
|
418
|
+
self.class.new(
|
|
419
|
+
lightness: new_lightness,
|
|
420
|
+
chroma: new_chroma,
|
|
421
|
+
hue: new_hue,
|
|
422
|
+
alpha: @alpha.value * (1 - amount) + other_oklch.alpha.value * amount,
|
|
423
|
+
)
|
|
354
424
|
end
|
|
355
425
|
|
|
356
426
|
# Convert to CSS oklch() function format.
|
|
357
427
|
#
|
|
358
|
-
# @return [String] CSS string like "oklch(0.6500 0.1500 240.00)"
|
|
428
|
+
# @return [String] CSS string like "oklch(0.6500 0.1500 240.00)" or "oklch(0.6500 0.1500 240.00 / 0.5)"
|
|
359
429
|
#
|
|
360
|
-
# @example
|
|
430
|
+
# @example Fully opaque
|
|
361
431
|
# color = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
362
432
|
# color.to_css_oklch
|
|
363
433
|
# # => "oklch(0.6500 0.1500 240.00)"
|
|
434
|
+
#
|
|
435
|
+
# @example Semi-transparent
|
|
436
|
+
# color = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240, alpha: 50)
|
|
437
|
+
# color.to_css_oklch
|
|
438
|
+
# # => "oklch(0.6500 0.1500 240.00 / 0.5)"
|
|
364
439
|
def to_css_oklch
|
|
365
|
-
|
|
440
|
+
if @alpha.value < 100
|
|
441
|
+
format("oklch(%.4f %.4f %.2f / %s)", @lightness.to_ratio, @chroma.value, @hue.value, @alpha.to_css)
|
|
442
|
+
else
|
|
443
|
+
format("oklch(%.4f %.4f %.2f)", @lightness.to_ratio, @chroma.value, @hue.value)
|
|
444
|
+
end
|
|
366
445
|
end
|
|
367
446
|
|
|
368
447
|
# Convert to CSS custom properties (variables).
|
|
@@ -423,6 +502,39 @@ module Unmagic
|
|
|
423
502
|
to_css_oklch
|
|
424
503
|
end
|
|
425
504
|
|
|
505
|
+
# Convert to ANSI SGR color code.
|
|
506
|
+
#
|
|
507
|
+
# Converts to RGB first, then generates the ANSI code.
|
|
508
|
+
#
|
|
509
|
+
# @param layer [Symbol] Whether to generate foreground (:foreground) or background (:background) code
|
|
510
|
+
# @param mode [Symbol] Color format mode (:truecolor, :palette256, :palette16)
|
|
511
|
+
# @return [String] ANSI SGR code like "31" or "38;2;255;0;0"
|
|
512
|
+
#
|
|
513
|
+
# @example
|
|
514
|
+
# color = OKLCH.new(lightness: 0.60, chroma: 0.25, hue: 30)
|
|
515
|
+
# color.to_ansi
|
|
516
|
+
# # => "38;2;..." (true color format)
|
|
517
|
+
def to_ansi(layer: :foreground, mode: :truecolor)
|
|
518
|
+
to_rgb.to_ansi(layer: layer, mode: mode)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Pretty print support with colored swatch in class name.
|
|
522
|
+
#
|
|
523
|
+
# Outputs standard Ruby object format with a colored block character
|
|
524
|
+
# embedded in the class name area. Note: @lightness is shown via its
|
|
525
|
+
# inspect method since it's a Lightness percentage object.
|
|
526
|
+
#
|
|
527
|
+
# @param pp [PrettyPrint] The pretty printer instance
|
|
528
|
+
#
|
|
529
|
+
# @example
|
|
530
|
+
# oklch = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 30)
|
|
531
|
+
# pp oklch
|
|
532
|
+
# # Outputs: #<Unmagic::Color::OKLCH[█] @lightness=... @chroma=0.15 @hue=30>
|
|
533
|
+
# # (with colored █ block)
|
|
534
|
+
def pretty_print(pp)
|
|
535
|
+
pp.text("#<#{self.class.name}[\x1b[#{to_ansi(mode: :truecolor)}m█\x1b[0m] @lightness=#{@lightness.inspect} @chroma=#{@chroma.value.round(2)} @hue=#{@hue.value.round}>")
|
|
536
|
+
end
|
|
537
|
+
|
|
426
538
|
private
|
|
427
539
|
|
|
428
540
|
def clamp01(x)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
class RGB
|
|
6
|
+
# ANSI SGR (Select Graphic Rendition) color code parsing.
|
|
7
|
+
#
|
|
8
|
+
# Parses ANSI color codes used in terminal output. Handles standard 3/4-bit colors,
|
|
9
|
+
# 256-color palette, and 24-bit true color formats.
|
|
10
|
+
#
|
|
11
|
+
# ## Supported Formats
|
|
12
|
+
#
|
|
13
|
+
# Standard 3/4-bit colors (foreground and background):
|
|
14
|
+
#
|
|
15
|
+
# Unmagic::Color::RGB::ANSI.parse("31") # Red foreground
|
|
16
|
+
# Unmagic::Color::RGB::ANSI.parse("41") # Red background
|
|
17
|
+
# Unmagic::Color::RGB::ANSI.parse("91") # Bright red foreground
|
|
18
|
+
# Unmagic::Color::RGB::ANSI.parse("101") # Bright red background
|
|
19
|
+
#
|
|
20
|
+
# 256-color palette:
|
|
21
|
+
#
|
|
22
|
+
# Unmagic::Color::RGB::ANSI.parse("38;5;196") # Red foreground (256-color)
|
|
23
|
+
# Unmagic::Color::RGB::ANSI.parse("48;5;196") # Red background (256-color)
|
|
24
|
+
#
|
|
25
|
+
# 24-bit true color:
|
|
26
|
+
#
|
|
27
|
+
# Unmagic::Color::RGB::ANSI.parse("38;2;255;0;0") # Red foreground (true color)
|
|
28
|
+
# Unmagic::Color::RGB::ANSI.parse("48;2;255;0;0") # Red background (true color)
|
|
29
|
+
#
|
|
30
|
+
# @example Parse standard ANSI color
|
|
31
|
+
# Unmagic::Color::RGB::ANSI.parse("31")
|
|
32
|
+
# #=> RGB instance for red
|
|
33
|
+
#
|
|
34
|
+
# @example Parse 24-bit true color
|
|
35
|
+
# Unmagic::Color::RGB::ANSI.parse("38;2;100;150;200")
|
|
36
|
+
# #=> RGB instance for RGB(100, 150, 200)
|
|
37
|
+
class ANSI
|
|
38
|
+
# Error raised when parsing ANSI color codes fails
|
|
39
|
+
class ParseError < Color::Error; end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Check if a string or integer is a valid ANSI color code.
|
|
43
|
+
#
|
|
44
|
+
# @param value [String, Integer] The value to validate
|
|
45
|
+
# @return [Boolean] true if valid ANSI code, false otherwise
|
|
46
|
+
#
|
|
47
|
+
# @example Check string
|
|
48
|
+
# Unmagic::Color::RGB::ANSI.valid?("31")
|
|
49
|
+
# #=> true
|
|
50
|
+
#
|
|
51
|
+
# @example Check integer
|
|
52
|
+
# Unmagic::Color::RGB::ANSI.valid?(31)
|
|
53
|
+
# #=> true
|
|
54
|
+
#
|
|
55
|
+
# @example Invalid input
|
|
56
|
+
# Unmagic::Color::RGB::ANSI.valid?("invalid")
|
|
57
|
+
# #=> false
|
|
58
|
+
def valid?(value)
|
|
59
|
+
parse(value)
|
|
60
|
+
true
|
|
61
|
+
rescue ParseError
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Parse an ANSI SGR color code.
|
|
66
|
+
#
|
|
67
|
+
# Accepts SGR parameters only (not full escape sequences).
|
|
68
|
+
# Handles foreground and background colors in all formats.
|
|
69
|
+
# Integers are automatically converted to strings.
|
|
70
|
+
#
|
|
71
|
+
# @param input [String, Integer] The ANSI code to parse
|
|
72
|
+
# @return [RGB] The parsed RGB color
|
|
73
|
+
# @raise [ParseError] If the input is not a valid ANSI color code
|
|
74
|
+
#
|
|
75
|
+
# @example Parse standard ANSI color with string
|
|
76
|
+
# Unmagic::Color::RGB::ANSI.parse("31")
|
|
77
|
+
# #=> Unmagic::Color::RGB instance for red
|
|
78
|
+
#
|
|
79
|
+
# @example Parse standard ANSI color with integer
|
|
80
|
+
# Unmagic::Color::RGB::ANSI.parse(31)
|
|
81
|
+
# #=> Unmagic::Color::RGB instance for red
|
|
82
|
+
#
|
|
83
|
+
# @example Parse 256-color palette
|
|
84
|
+
# Unmagic::Color::RGB::ANSI.parse("38;5;196")
|
|
85
|
+
# #=> Unmagic::Color::RGB instance for bright red
|
|
86
|
+
#
|
|
87
|
+
# @example Parse 24-bit true color
|
|
88
|
+
# color = Unmagic::Color::RGB::ANSI.parse("38;2;100;150;200")
|
|
89
|
+
# color.to_hex
|
|
90
|
+
# #=> "#6496c8"
|
|
91
|
+
def parse(input)
|
|
92
|
+
raise ParseError, "Input must be a string or integer" unless input.is_a?(::String) || input.is_a?(::Integer)
|
|
93
|
+
|
|
94
|
+
# Convert integers to strings
|
|
95
|
+
input = input.to_s if input.is_a?(::Integer)
|
|
96
|
+
|
|
97
|
+
# Strip and validate format
|
|
98
|
+
clean = input.strip
|
|
99
|
+
raise ParseError, "Can't parse empty string" if clean.empty?
|
|
100
|
+
|
|
101
|
+
# Must be numeric with optional semicolons
|
|
102
|
+
unless clean.match?(/\A\d+(?:;\d+)*\z/)
|
|
103
|
+
raise ParseError, "Invalid ANSI format: #{input.inspect} (must be numeric with optional semicolons)"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Split on semicolons
|
|
107
|
+
parts = clean.split(";").map(&:to_i)
|
|
108
|
+
|
|
109
|
+
parse_sgr_params(parts)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Parse SGR parameters and return RGB color
|
|
115
|
+
def parse_sgr_params(parts)
|
|
116
|
+
first = parts[0]
|
|
117
|
+
|
|
118
|
+
# Check for extended color formats (38 or 48 prefix)
|
|
119
|
+
if first == 38 || first == 48
|
|
120
|
+
parse_extended_color(parts)
|
|
121
|
+
# Check for standard 3/4-bit colors
|
|
122
|
+
elsif standard_color?(first)
|
|
123
|
+
parse_standard_color(first)
|
|
124
|
+
else
|
|
125
|
+
raise ParseError, "Unknown ANSI color code: #{parts.join(";")}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Parse extended color formats (256-color or true color)
|
|
130
|
+
def parse_extended_color(parts)
|
|
131
|
+
raise ParseError, "Extended color format requires at least 3 parameters" if parts.length < 3
|
|
132
|
+
|
|
133
|
+
color_type = parts[1]
|
|
134
|
+
|
|
135
|
+
case color_type
|
|
136
|
+
when 5
|
|
137
|
+
# 256-color palette: 38;5;N or 48;5;N
|
|
138
|
+
parse_256_color(parts)
|
|
139
|
+
when 2
|
|
140
|
+
# 24-bit true color: 38;2;R;G;B or 48;2;R;G;B
|
|
141
|
+
parse_true_color(parts)
|
|
142
|
+
else
|
|
143
|
+
raise ParseError, "Unknown extended color type: #{color_type} (expected 2 for true color or 5 for 256-color)"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Parse 256-color palette code
|
|
148
|
+
def parse_256_color(parts)
|
|
149
|
+
raise ParseError, "256-color format requires 3 parameters (e.g., 38;5;N)" unless parts.length == 3
|
|
150
|
+
|
|
151
|
+
color_256_to_rgb(parts[2])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Parse 24-bit true color code
|
|
155
|
+
def parse_true_color(parts)
|
|
156
|
+
raise ParseError, "True color format requires 5 parameters (e.g., 38;2;R;G;B)" unless parts.length == 5
|
|
157
|
+
|
|
158
|
+
RGB.new(red: parts[2], green: parts[3], blue: parts[4])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Check if code is a standard 3/4-bit color
|
|
162
|
+
def standard_color?(code)
|
|
163
|
+
# Foreground: 30-37 (normal), 90-97 (bright)
|
|
164
|
+
# Background: 40-47 (normal), 100-107 (bright)
|
|
165
|
+
(30..37).cover?(code) || (40..47).cover?(code) ||
|
|
166
|
+
(90..97).cover?(code) || (100..107).cover?(code)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Parse standard 3/4-bit color code
|
|
170
|
+
def parse_standard_color(code)
|
|
171
|
+
# Extract color index (0-7)
|
|
172
|
+
index = if (30..37).cover?(code)
|
|
173
|
+
code - 30
|
|
174
|
+
elsif (40..47).cover?(code)
|
|
175
|
+
code - 40
|
|
176
|
+
elsif (90..97).cover?(code)
|
|
177
|
+
code - 90
|
|
178
|
+
elsif (100..107).cover?(code)
|
|
179
|
+
code - 100
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
standard_color_to_rgb(index)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Convert standard color index to RGB
|
|
186
|
+
# Using bright ANSI colors for consistency
|
|
187
|
+
def standard_color_to_rgb(index)
|
|
188
|
+
case index
|
|
189
|
+
when 0 then RGB.new(red: 0, green: 0, blue: 0) # black
|
|
190
|
+
when 1 then RGB.new(red: 255, green: 0, blue: 0) # red
|
|
191
|
+
when 2 then RGB.new(red: 0, green: 255, blue: 0) # green
|
|
192
|
+
when 3 then RGB.new(red: 255, green: 255, blue: 0) # yellow
|
|
193
|
+
when 4 then RGB.new(red: 0, green: 0, blue: 255) # blue
|
|
194
|
+
when 5 then RGB.new(red: 255, green: 0, blue: 255) # magenta
|
|
195
|
+
when 6 then RGB.new(red: 0, green: 255, blue: 255) # cyan
|
|
196
|
+
when 7 then RGB.new(red: 255, green: 255, blue: 255) # white
|
|
197
|
+
else
|
|
198
|
+
raise ParseError, "Invalid color index: #{index}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Convert 256-color palette index to RGB
|
|
203
|
+
def color_256_to_rgb(index)
|
|
204
|
+
case index
|
|
205
|
+
when 0..15
|
|
206
|
+
# Standard colors (use same as 3/4-bit)
|
|
207
|
+
standard_color_to_rgb(index % 8)
|
|
208
|
+
when 16..231
|
|
209
|
+
# 6x6x6 RGB cube
|
|
210
|
+
index -= 16
|
|
211
|
+
r = (index / 36) * 51
|
|
212
|
+
g = ((index % 36) / 6) * 51
|
|
213
|
+
b = (index % 6) * 51
|
|
214
|
+
RGB.new(red: r, green: g, blue: b)
|
|
215
|
+
when 232..255
|
|
216
|
+
# Grayscale ramp
|
|
217
|
+
gray = 8 + (index - 232) * 10
|
|
218
|
+
RGB.new(red: gray, green: gray, blue: gray)
|
|
219
|
+
else
|
|
220
|
+
raise ParseError, "Invalid 256-color index: #{index}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
class RGB
|
|
6
|
+
# Gradient generation in RGB color space.
|
|
7
|
+
module Gradient
|
|
8
|
+
# Linear gradient interpolation in RGB color space.
|
|
9
|
+
#
|
|
10
|
+
# Creates smooth color transitions by interpolating RGB components linearly
|
|
11
|
+
# between color stops. Each color stop has a position (0.0-1.0) that defines
|
|
12
|
+
# where the color appears in the gradient.
|
|
13
|
+
#
|
|
14
|
+
# ## RGB Interpolation
|
|
15
|
+
#
|
|
16
|
+
# RGB gradients interpolate the red, green, and blue components separately.
|
|
17
|
+
# This can produce different visual results compared to HSL or OKLCH gradients,
|
|
18
|
+
# especially when transitioning through complementary colors.
|
|
19
|
+
#
|
|
20
|
+
# ## Examples
|
|
21
|
+
#
|
|
22
|
+
# # Simple horizontal gradient
|
|
23
|
+
# gradient = Unmagic::Color::RGB::Gradient::Linear.build(
|
|
24
|
+
# ["#FF0000", "#0000FF"],
|
|
25
|
+
# direction: "to right"
|
|
26
|
+
# )
|
|
27
|
+
# bitmap = gradient.rasterize(width: 10)
|
|
28
|
+
# bitmap.pixels[0].map(&:to_hex)
|
|
29
|
+
# #=> ["#ff0000", "#e60019", ..., "#0000ff"]
|
|
30
|
+
#
|
|
31
|
+
# # Gradient with intermediate stops
|
|
32
|
+
# gradient = Unmagic::Color::RGB::Gradient::Linear.build(
|
|
33
|
+
# [
|
|
34
|
+
# ["#FF0000", 0.0], # Red at start
|
|
35
|
+
# ["#00FF00", 0.5], # Green at middle
|
|
36
|
+
# ["#0000FF", 1.0] # Blue at end
|
|
37
|
+
# ],
|
|
38
|
+
# direction: "to bottom"
|
|
39
|
+
# )
|
|
40
|
+
# bitmap = gradient.rasterize(width: 1, height: 20)
|
|
41
|
+
#
|
|
42
|
+
# # Angled gradient
|
|
43
|
+
# gradient = Unmagic::Color::RGB::Gradient::Linear.build(
|
|
44
|
+
# ["#FF0000", "#0000FF"],
|
|
45
|
+
# direction: "45deg"
|
|
46
|
+
# )
|
|
47
|
+
# bitmap = gradient.rasterize(width: 100, height: 100)
|
|
48
|
+
#
|
|
49
|
+
# # Use Stop objects directly
|
|
50
|
+
# stops = [
|
|
51
|
+
# Unmagic::Color::Gradient::Stop.new(
|
|
52
|
+
# color: Unmagic::Color::RGB.parse("#FF0000"),
|
|
53
|
+
# position: 0.0
|
|
54
|
+
# ),
|
|
55
|
+
# Unmagic::Color::Gradient::Stop.new(
|
|
56
|
+
# color: Unmagic::Color::RGB.parse("#0000FF"),
|
|
57
|
+
# position: 1.0
|
|
58
|
+
# )
|
|
59
|
+
# ]
|
|
60
|
+
# direction = Unmagic::Color::Units::Degrees::Direction::LEFT_TO_RIGHT
|
|
61
|
+
# gradient = Unmagic::Color::RGB::Gradient::Linear.new(stops, direction: direction)
|
|
62
|
+
class Linear < Unmagic::Color::Gradient::Base
|
|
63
|
+
class << self
|
|
64
|
+
# Get the RGB color class.
|
|
65
|
+
#
|
|
66
|
+
# @return [Class] Unmagic::Color::RGB
|
|
67
|
+
def color_class
|
|
68
|
+
Unmagic::Color::RGB
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Rasterize the gradient to a bitmap.
|
|
73
|
+
#
|
|
74
|
+
# Generates a bitmap containing the gradient with support for angled directions.
|
|
75
|
+
# The direction is determined by the gradient's direction parameter.
|
|
76
|
+
#
|
|
77
|
+
# @param width [Integer] Width of the bitmap (default 1)
|
|
78
|
+
# @param height [Integer] Height of the bitmap (default 1)
|
|
79
|
+
# @return [Bitmap] A bitmap with the specified dimensions
|
|
80
|
+
#
|
|
81
|
+
# @raise [Error] If width or height is less than 1
|
|
82
|
+
def rasterize(width: 1, height: 1)
|
|
83
|
+
raise self.class::Error, "width must be at least 1" if width < 1
|
|
84
|
+
raise self.class::Error, "height must be at least 1" if height < 1
|
|
85
|
+
|
|
86
|
+
# Get the angle from the direction's "to" component
|
|
87
|
+
degrees = @direction.to.value
|
|
88
|
+
|
|
89
|
+
# Generate pixels row by row
|
|
90
|
+
pixels = Array.new(height) do |y|
|
|
91
|
+
Array.new(width) do |x|
|
|
92
|
+
position = calculate_position(x, y, width, height, degrees)
|
|
93
|
+
color_at_position(position)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Unmagic::Color::Gradient::Bitmap.new(
|
|
98
|
+
width: width,
|
|
99
|
+
height: height,
|
|
100
|
+
pixels: pixels,
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def validate_color_types(stops)
|
|
107
|
+
stops.each_with_index do |stop, i|
|
|
108
|
+
unless stop.color.is_a?(Unmagic::Color::RGB)
|
|
109
|
+
raise self.class::Error, "stops[#{i}].color must be an RGB color"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Calculate the position (0-1) of a pixel in the gradient.
|
|
115
|
+
#
|
|
116
|
+
# @param x [Integer] X coordinate
|
|
117
|
+
# @param y [Integer] Y coordinate
|
|
118
|
+
# @param width [Integer] Bitmap width
|
|
119
|
+
# @param height [Integer] Bitmap height
|
|
120
|
+
# @param degrees [Float] Gradient angle in degrees
|
|
121
|
+
# @return [Float] Position along gradient (0.0 to 1.0)
|
|
122
|
+
def calculate_position(x, y, width, height, degrees)
|
|
123
|
+
# Normalize coordinates to 0-1 range
|
|
124
|
+
nx = width > 1 ? x / (width - 1).to_f : 0.5
|
|
125
|
+
ny = height > 1 ? y / (height - 1).to_f : 0.5
|
|
126
|
+
|
|
127
|
+
# Calculate position based on angle
|
|
128
|
+
angle_rad = degrees * Math::PI / 180.0
|
|
129
|
+
|
|
130
|
+
# The gradient line goes in the direction of the angle
|
|
131
|
+
# 0° = to top (upward)
|
|
132
|
+
# 90° = to right
|
|
133
|
+
# 180° = to bottom (downward)
|
|
134
|
+
# 270° = to left
|
|
135
|
+
dx = Math.sin(angle_rad)
|
|
136
|
+
dy = Math.cos(angle_rad)
|
|
137
|
+
|
|
138
|
+
# Calculate position by projecting pixel onto gradient direction
|
|
139
|
+
# We want position 0 at the start and position 1 at the end
|
|
140
|
+
position = (nx - 0.5) * dx + (0.5 - ny) * dy + 0.5
|
|
141
|
+
|
|
142
|
+
# Clamp to valid range
|
|
143
|
+
position.clamp(0.0, 1.0)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get the color at a specific position in the gradient.
|
|
147
|
+
#
|
|
148
|
+
# @param position [Float] Position along gradient (0.0 to 1.0)
|
|
149
|
+
# @return [Color] The interpolated color at this position
|
|
150
|
+
def color_at_position(position)
|
|
151
|
+
start_stop, end_stop = find_bracket_stops(position)
|
|
152
|
+
|
|
153
|
+
if start_stop.position == end_stop.position
|
|
154
|
+
start_stop.color
|
|
155
|
+
else
|
|
156
|
+
segment_length = end_stop.position - start_stop.position
|
|
157
|
+
blend_amount = (position - start_stop.position) / segment_length
|
|
158
|
+
start_stop.color.blend(end_stop.color, blend_amount)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -57,8 +57,9 @@ module Unmagic
|
|
|
57
57
|
|
|
58
58
|
# Parse a hexadecimal color string.
|
|
59
59
|
#
|
|
60
|
-
# Accepts
|
|
61
|
-
# with or without the # prefix.
|
|
60
|
+
# Accepts full (6-digit) and short (3-digit) hex formats,
|
|
61
|
+
# with or without the # prefix. Also supports 8-digit (with alpha)
|
|
62
|
+
# and 4-digit short format with alpha.
|
|
62
63
|
#
|
|
63
64
|
# @param input [String] The hex color string to parse
|
|
64
65
|
# @return [RGB] The parsed RGB color
|
|
@@ -69,15 +70,18 @@ module Unmagic
|
|
|
69
70
|
#
|
|
70
71
|
# @example Short format without hash
|
|
71
72
|
# Unmagic::Color::RGB::Hex.parse("F80")
|
|
73
|
+
#
|
|
74
|
+
# @example With alpha
|
|
75
|
+
# Unmagic::Color::RGB::Hex.parse("#FF880080")
|
|
72
76
|
def parse(input)
|
|
73
77
|
raise ParseError, "Input must be a string" unless input.is_a?(::String)
|
|
74
78
|
|
|
75
79
|
# Clean up the input
|
|
76
80
|
hex = input.strip.gsub(/^#/, "")
|
|
77
81
|
|
|
78
|
-
# Check for valid length (3
|
|
79
|
-
unless
|
|
80
|
-
raise ParseError, "Invalid number of characters (got #{hex.length}, expected 3 or
|
|
82
|
+
# Check for valid length (3, 4, 6, or 8 characters)
|
|
83
|
+
unless [3, 4, 6, 8].include?(hex.length)
|
|
84
|
+
raise ParseError, "Invalid number of characters (got #{hex.length}, expected 3, 4, 6, or 8)"
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
# Check if all characters are valid hex digits
|
|
@@ -86,8 +90,8 @@ module Unmagic
|
|
|
86
90
|
raise ParseError, "Invalid hex characters: #{invalid_chars.join(", ")}"
|
|
87
91
|
end
|
|
88
92
|
|
|
89
|
-
# Handle 3-character hex codes
|
|
90
|
-
if hex.length == 3
|
|
93
|
+
# Handle 3 or 4-character hex codes (expand each digit)
|
|
94
|
+
if hex.length == 3 || hex.length == 4
|
|
91
95
|
hex = hex.chars.map { |c| c * 2 }.join
|
|
92
96
|
end
|
|
93
97
|
|
|
@@ -95,7 +99,15 @@ module Unmagic
|
|
|
95
99
|
g = hex[2..3].to_i(16)
|
|
96
100
|
b = hex[4..5].to_i(16)
|
|
97
101
|
|
|
98
|
-
|
|
102
|
+
# Parse alpha if present (8 characters total after expansion)
|
|
103
|
+
if hex.length == 8
|
|
104
|
+
a = hex[6..7].to_i(16)
|
|
105
|
+
# Convert 0-255 alpha to 0-100 percentage
|
|
106
|
+
alpha_percent = (a / 255.0 * 100).round(2)
|
|
107
|
+
RGB.build(red: r, green: g, blue: b, alpha: alpha_percent)
|
|
108
|
+
else
|
|
109
|
+
RGB.build(red: r, green: g, blue: b)
|
|
110
|
+
end
|
|
99
111
|
end
|
|
100
112
|
end
|
|
101
113
|
end
|