sai 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sai/ansi'
4
+
5
+ module Sai
6
+ module Conversion
7
+ # RGB color conversion utilities
8
+ #
9
+ # @author {https://aaronmallen.me Aaron Allen}
10
+ # @since unreleased
11
+ #
12
+ # @api private
13
+ module RGB
14
+ class << self
15
+ # Get closest ANSI color for RGB values
16
+ #
17
+ # @author {https://aaronmallen.me Aaron Allen}
18
+ # @since unreleased
19
+ #
20
+ # @api private
21
+ #
22
+ # @param red [Float] the red component (0-1)
23
+ # @param green [Float] the green component (0-1)
24
+ # @param blue [Float] the blue component (0-1)
25
+ #
26
+ # @return [Symbol] the closest ANSI color name
27
+ # @rbs (Float red, Float green, Float blue) -> Symbol
28
+ def closest_ansi_color(red, green, blue)
29
+ return :black if dark?(red, green, blue)
30
+ return :white if grayscale?(red, green, blue)
31
+ return primary_color(red, green, blue) if primary?(red, green, blue)
32
+ return secondary_color(red, green, blue) if secondary?(red, green, blue)
33
+
34
+ :white
35
+ end
36
+
37
+ # Determine if a color is dark
38
+ #
39
+ # @author {https://aaronmallen.me Aaron Allen}
40
+ # @since unreleased
41
+ #
42
+ # @api private
43
+ #
44
+ # @param red [Float] the red component (0-1)
45
+ # @param green [Float] the green component (0-1)
46
+ # @param blue [Float] the blue component (0-1)
47
+ #
48
+ # @return [Boolean] true if color is dark
49
+ # @rbs (Float red, Float green, Float blue) -> bool
50
+ def dark?(red, green, blue)
51
+ [red, green, blue].max < 0.3
52
+ end
53
+
54
+ # Determine if a color is grayscale
55
+ #
56
+ # @author {https://aaronmallen.me Aaron Allen}
57
+ # @since unreleased
58
+ #
59
+ # @api private
60
+ #
61
+ # @param red [Float] the red component (0-1)
62
+ # @param green [Float] the green component (0-1)
63
+ # @param blue [Float] the blue component (0-1)
64
+ #
65
+ # @return [Boolean] true if color is grayscale
66
+ # @rbs (Float red, Float green, Float blue) -> bool
67
+ def grayscale?(red, green, blue)
68
+ red == green && green == blue
69
+ end
70
+
71
+ # Convert a color value to RGB components
72
+ #
73
+ # @author {https://aaronmallen.me Aaron Allen}
74
+ # @since unreleased
75
+ #
76
+ # @api private
77
+ #
78
+ # @param color [String, Array<Integer>] the color to convert
79
+ #
80
+ # @raise [ArgumentError] if the color format is invalid
81
+ # @return [Array<Integer>] the RGB components
82
+ # @rbs (Array[Integer] | String | Symbol color) -> Array[Integer]
83
+ def resolve(color)
84
+ case color
85
+ when Array then validate_rgb(color)
86
+ when /^#?([A-Fa-f0-9]{6})$/
87
+ hex_to_rgb(
88
+ Regexp.last_match(1) #: String
89
+ )
90
+ when String, Symbol then named_to_rgb(color.to_s.downcase)
91
+ else
92
+ raise ArgumentError, "Invalid color format: #{color}"
93
+ end
94
+ end
95
+
96
+ # Convert RGB values to 256-color cube index
97
+ #
98
+ # @author {https://aaronmallen.me Aaron Allen}
99
+ # @since unreleased
100
+ #
101
+ # @api private
102
+ #
103
+ # @param rgb [Array<Integer>] RGB values (0-255)
104
+ #
105
+ # @return [Integer] the color cube index
106
+ # @rbs (Array[Integer] rgb) -> Integer
107
+ def to_color_cube_index(rgb)
108
+ r, g, b = rgb.map { |c| ((c / 255.0) * 5).round } #: [Integer, Integer, Integer]
109
+ 16 + (r * 36) + (g * 6) + b
110
+ end
111
+
112
+ # Convert RGB values to grayscale index
113
+ #
114
+ # @author {https://aaronmallen.me Aaron Allen}
115
+ # @since unreleased
116
+ #
117
+ # @api private
118
+ #
119
+ # @param rgb [Array<Integer>] RGB values
120
+ #
121
+ # @return [Integer] the grayscale index
122
+ # @rbs (Array[Integer] rgb) -> Integer
123
+ def to_grayscale_index(rgb)
124
+ 232 + ((rgb[0] / 255.0) * 23).round
125
+ end
126
+
127
+ private
128
+
129
+ # Check if RGB values represent cyan
130
+ #
131
+ # @author {https://aaronmallen.me Aaron Allen}
132
+ # @since unreleased
133
+ #
134
+ # @api private
135
+ #
136
+ # @param red [Float] the red component (0-1)
137
+ # @param green [Float] the green component (0-1)
138
+ # @param blue [Float] the blue component (0-1)
139
+ #
140
+ # @return [Boolean] true if color is cyan
141
+ # @rbs (Float red, Float green, Float blue) -> bool
142
+ def cyan?(red, green, blue)
143
+ green > red && blue > red
144
+ end
145
+
146
+ # Convert a hex string to RGB values
147
+ #
148
+ # @author {https://aaronmallen.me Aaron Allen}
149
+ # @since unreleased
150
+ #
151
+ # @api private
152
+ #
153
+ # @param hex [String] the hex color code
154
+ #
155
+ # @return [Array<Integer>] the RGB components
156
+ # @rbs (String hex) -> Array[Integer]
157
+ def hex_to_rgb(hex)
158
+ hex = hex.delete_prefix('#') #: String
159
+ [
160
+ hex[0..1].to_i(16), # steep:ignore UnexpectedPositionalArgument
161
+ hex[2..3].to_i(16), # steep:ignore UnexpectedPositionalArgument
162
+ hex[4..5].to_i(16) # steep:ignore UnexpectedPositionalArgument
163
+ ]
164
+ end
165
+
166
+ # Check if RGB values represent magenta
167
+ #
168
+ # @author {https://aaronmallen.me Aaron Allen}
169
+ # @since unreleased
170
+ #
171
+ # @api private
172
+ #
173
+ # @param red [Float] the red component (0-1)
174
+ # @param green [Float] the green component (0-1)
175
+ # @param blue [Float] the blue component (0-1)
176
+ #
177
+ # @return [Boolean] true if color is magenta
178
+ # @rbs (Float red, Float green, Float blue) -> bool
179
+ def magenta?(red, green, blue)
180
+ red > green && blue > green
181
+ end
182
+
183
+ # Convert a named color to RGB values
184
+ #
185
+ # @author {https://aaronmallen.me Aaron Allen}
186
+ # @since unreleased
187
+ #
188
+ # @api private
189
+ #
190
+ # @param color_name [String] the color name
191
+ #
192
+ # @raise [ArgumentError] if the color name is unknown
193
+ # @return [Array<Integer>] the RGB components
194
+ # @rbs (String color_name) -> Array[Integer]
195
+ def named_to_rgb(color_name)
196
+ ANSI::COLOR_NAMES.fetch(color_name.to_sym) do
197
+ raise ArgumentError, "Unknown color name: #{color_name}"
198
+ end
199
+ end
200
+
201
+ # Determine if RGB values represent a primary color
202
+ #
203
+ # @author {https://aaronmallen.me Aaron Allen}
204
+ # @since unreleased
205
+ #
206
+ # @api private
207
+ #
208
+ # @param red [Float] the red component (0-1)
209
+ # @param green [Float] the green component (0-1)
210
+ # @param blue [Float] the blue component (0-1)
211
+ #
212
+ # @return [Boolean] true if color is primary
213
+ # @rbs (Float red, Float green, Float blue) -> bool
214
+ def primary?(red, green, blue)
215
+ max = [red, green, blue].max
216
+ mid = [red, green, blue].sort[1]
217
+ (max - mid) > 0.3
218
+ end
219
+
220
+ # Get the closest primary color
221
+ #
222
+ # @author {https://aaronmallen.me Aaron Allen}
223
+ # @since unreleased
224
+ #
225
+ # @api private
226
+ #
227
+ # @param red [Float] the red component (0-1)
228
+ # @param green [Float] the green component (0-1)
229
+ # @param blue [Float] the blue component (0-1)
230
+ #
231
+ # @return [Symbol] the primary color name
232
+ # @rbs (Float red, Float green, Float blue) -> Symbol
233
+ def primary_color(red, green, blue)
234
+ max = [red, green, blue].max
235
+ case max
236
+ when red then :red
237
+ when green then :green
238
+ else :blue
239
+ end
240
+ end
241
+
242
+ # Determine if RGB values represent a secondary color
243
+ #
244
+ # @author {https://aaronmallen.me Aaron Allen}
245
+ # @since unreleased
246
+ #
247
+ # @api private
248
+ #
249
+ # @param red [Float] the red component (0-1)
250
+ # @param green [Float] the green component (0-1)
251
+ # @param blue [Float] the blue component (0-1)
252
+ #
253
+ # @return [Boolean] true if color is secondary
254
+ # @rbs (Float red, Float green, Float blue) -> bool
255
+ def secondary?(red, green, blue)
256
+ return true if yellow?(red, green, blue)
257
+ return true if magenta?(red, green, blue)
258
+ return true if cyan?(red, green, blue)
259
+
260
+ false
261
+ end
262
+
263
+ # Get the closest secondary color
264
+ #
265
+ # @author {https://aaronmallen.me Aaron Allen}
266
+ # @since unreleased
267
+ #
268
+ # @api private
269
+ #
270
+ # @param red [Float] the red component (0-1)
271
+ # @param green [Float] the green component (0-1)
272
+ # @param blue [Float] the blue component (0-1)
273
+ #
274
+ # @return [Symbol] the secondary color name
275
+ # @rbs (Float red, Float green, Float blue) -> Symbol
276
+ def secondary_color(red, green, blue)
277
+ return :yellow if yellow?(red, green, blue)
278
+ return :magenta if magenta?(red, green, blue)
279
+ return :cyan if cyan?(red, green, blue)
280
+
281
+ :white
282
+ end
283
+
284
+ # Validate RGB values
285
+ #
286
+ # @author {https://aaronmallen.me Aaron Allen}
287
+ # @since unreleased
288
+ #
289
+ # @api private
290
+ #
291
+ # @param color [Array<Integer>] the RGB components to validate
292
+ # @return [Array<Integer>] the validated RGB components
293
+ # @raise [ArgumentError] if the RGB values are invalid
294
+ # @rbs (Array[Integer] color) -> Array[Integer]
295
+ def validate_rgb(color)
296
+ unless color.size == 3 && color.all? { |c| c.is_a?(Integer) && c.between?(0, 255) }
297
+ raise ArgumentError, "Invalid RGB values: #{color}"
298
+ end
299
+
300
+ color
301
+ end
302
+
303
+ # Check if RGB values represent yellow
304
+ #
305
+ # @author {https://aaronmallen.me Aaron Allen}
306
+ # @since unreleased
307
+ #
308
+ # @api private
309
+ #
310
+ # @param red [Float] the red component (0-1)
311
+ # @param green [Float] the green component (0-1)
312
+ # @param blue [Float] the blue component (0-1)
313
+ #
314
+ # @return [Boolean] true if color is yellow
315
+ # @rbs (Float red, Float green, Float blue) -> bool
316
+ def yellow?(red, green, blue)
317
+ red > blue && green > blue && (red - green).abs < 0.3
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end