sai 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,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