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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ # Utility classes for color manipulation
5
+ module Util
6
+ # Represents a percentage value with validation and formatting capabilities.
7
+ # Handles both direct percentage values and ratio calculations.
8
+ #
9
+ # @example Direct percentage value
10
+ # percentage = Percentage.new(75.5)
11
+ # percentage.to_s
12
+ # #=> "75.5%"
13
+ # percentage.value
14
+ # #=> 75.5
15
+ #
16
+ # @example Calculated from ratio
17
+ # percentage = Percentage.new(50, 100)
18
+ # percentage.to_s
19
+ # #=> "50.0%"
20
+ #
21
+ # @example Progress tracking
22
+ # percentage = Percentage.new(current_item, total_items)
23
+ # percentage.to_s
24
+ # #=> "25.0%"
25
+ class Percentage
26
+ include Comparable
27
+
28
+ attr_reader :value
29
+
30
+ # Create a new percentage
31
+ #
32
+ # @param args [Array<Numeric>] Either a single percentage value (0-100) or numerator and denominator
33
+ def initialize(*args)
34
+ case args.length
35
+ when 1
36
+ @value = args[0].to_f
37
+ when 2
38
+ numerator, denominator = args
39
+ @value = if denominator.to_f.zero?
40
+ 0.0
41
+ else
42
+ (numerator.to_f / denominator.to_f * 100.0)
43
+ end
44
+ else
45
+ raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 1..2)"
46
+ end
47
+
48
+ # Clamp to valid percentage range
49
+ @value = @value.clamp(0.0, 100.0)
50
+ end
51
+
52
+ # Format as percentage string with configurable decimal places
53
+ #
54
+ # @param decimal_places [Integer] Number of decimal places to display
55
+ # @return [String] Formatted percentage string
56
+ def to_s(decimal_places: 1)
57
+ "#{@value.round(decimal_places)}%"
58
+ end
59
+
60
+ # Get the raw percentage value (0.0 to 100.0)
61
+ def to_f
62
+ @value
63
+ end
64
+
65
+ # Get the percentage as a ratio (0.0 to 1.0)
66
+ def to_ratio
67
+ @value / 100.0
68
+ end
69
+
70
+ # Comparison operator for Comparable
71
+ #
72
+ # @param other [Percentage, Numeric] Value to compare with
73
+ # @return [Integer, nil] -1, 0, 1, or nil
74
+ def <=>(other)
75
+ case other
76
+ when Percentage
77
+ @value <=> other.value
78
+ when Numeric
79
+ @value <=> other.to_f
80
+ end
81
+ end
82
+
83
+ # Equality comparison
84
+ #
85
+ # @param other [Object] Value to compare with
86
+ # @return [Boolean] true if values are equal
87
+ def ==(other)
88
+ case other
89
+ when Percentage
90
+ @value == other.value
91
+ when Numeric
92
+ @value == other.to_f
93
+ else
94
+ false
95
+ end
96
+ end
97
+
98
+ # Add percentages (clamped to 100%)
99
+ #
100
+ # @param other [Percentage, Numeric] Value to add
101
+ # @return [Percentage] New percentage with sum of values
102
+ def +(other)
103
+ case other
104
+ when Percentage
105
+ Percentage.new([value + other.value, 100.0].min)
106
+ when Numeric
107
+ Percentage.new([value + other.to_f, 100.0].min)
108
+ else
109
+ raise TypeError, "can't add #{other.class} to Percentage"
110
+ end
111
+ end
112
+
113
+ # Subtract percentages (clamped to 0%)
114
+ #
115
+ # @param other [Percentage, Numeric] Value to subtract
116
+ # @return [Percentage] New percentage with difference of values
117
+ def -(other)
118
+ case other
119
+ when Percentage
120
+ Percentage.new([value - other.value, 0.0].max)
121
+ when Numeric
122
+ Percentage.new([value - other.to_f, 0.0].max)
123
+ else
124
+ raise TypeError, "can't subtract #{other.class} from Percentage"
125
+ end
126
+ end
127
+
128
+ # Absolute value
129
+ #
130
+ # @return [Percentage] New percentage with absolute value
131
+ def abs
132
+ self.class.new(@value.abs)
133
+ end
134
+
135
+ # Check if percentage is zero
136
+ #
137
+ # @return [Boolean] true if percentage is 0.0
138
+ def zero?
139
+ @value.zero?
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @private
4
+ module Unmagic
5
+ # Base class for working with colors in different color spaces.
6
+ #
7
+ # ## Understanding Colors
8
+ #
9
+ # A color is simply a way to describe what we see. Just like you can describe
10
+ # a location using different coordinate systems (street address, latitude/longitude, etc.),
11
+ # you can describe colors using different "color spaces."
12
+ #
13
+ # This library supports three main color spaces:
14
+ #
15
+ # - {RGB}: Describes colors as a mix of Red, Green, and Blue light (like your screen)
16
+ # - {HSL}: Describes colors using Hue (color wheel position), Saturation (intensity),
17
+ # and Lightness (brightness)
18
+ # - {OKLCH}: A modern color space that matches how humans perceive color differences
19
+ #
20
+ # ## Basic Usage
21
+ #
22
+ # Parse a color from a string:
23
+ #
24
+ # color = Unmagic::Color.parse("#FF5733")
25
+ # color = Unmagic::Color["rgb(255, 87, 51)"]
26
+ # color = Unmagic::Color.parse("hsl(9, 100%, 60%)")
27
+ #
28
+ # Convert between color spaces:
29
+ #
30
+ # rgb = Unmagic::Color.parse("#FF5733")
31
+ # hsl = rgb.to_hsl
32
+ # oklch = rgb.to_oklch
33
+ #
34
+ # Manipulate colors:
35
+ #
36
+ # lighter = color.lighten(0.2)
37
+ # darker = color.darken(0.1)
38
+ # mixed = color.blend(other_color, 0.5)
39
+ class Color
40
+ # @private
41
+ class Error < StandardError; end
42
+ # @private
43
+ class ParseError < Error; end
44
+
45
+ require_relative "color/rgb"
46
+ require_relative "color/rgb/hex"
47
+ require_relative "color/rgb/named"
48
+ require_relative "color/hsl"
49
+ require_relative "color/oklch"
50
+ require_relative "color/string/hash_function"
51
+ require_relative "color/util/percentage"
52
+
53
+ class << self
54
+ # Parse a color string into the appropriate color space object.
55
+ #
56
+ # This method automatically detects the format and returns the correct color type.
57
+ # Supported formats include hex colors, RGB, HSL, OKLCH, and named colors.
58
+ #
59
+ # @param input [String, Color] The color string to parse, or an existing Color object
60
+ # @return [RGB, HSL, OKLCH] A color object in the appropriate color space
61
+ # @raise [ParseError] If the input is nil, empty, or in an unrecognized format
62
+ #
63
+ # @example Parse a hex color
64
+ # Unmagic::Color.parse("#FF5733")
65
+ #
66
+ # @example Parse an RGB color
67
+ # Unmagic::Color.parse("rgb(255, 87, 51)")
68
+ #
69
+ # @example Parse an HSL color
70
+ # Unmagic::Color.parse("hsl(9, 100%, 60%)")
71
+ #
72
+ # @example Parse an OKLCH color
73
+ # Unmagic::Color.parse("oklch(0.65 0.15 30)")
74
+ #
75
+ # @example Parse a named color
76
+ # Unmagic::Color.parse("goldenrod")
77
+ #
78
+ # @example Pass through an existing color
79
+ # color = Unmagic::Color.parse("#FF5733")
80
+ # Unmagic::Color.parse(color)
81
+ def parse(input)
82
+ return input if input.is_a?(self)
83
+ raise ParseError, "Can't pass nil as a color" if input.nil?
84
+
85
+ input = input.strip
86
+ raise ParseError, "Can't parse empty string" if input == ""
87
+
88
+ # Try hex or RGB format
89
+ if input.start_with?("#") || input.match?(/\A[0-9A-Fa-f]{3,6}\z/) || input.start_with?("rgb")
90
+ RGB.parse(input)
91
+ elsif input.start_with?("hsl")
92
+ HSL.parse(input)
93
+ elsif input.start_with?("oklch")
94
+ OKLCH.parse(input)
95
+ elsif RGB::Named.valid?(input)
96
+ RGB::Named.parse(input)
97
+ else
98
+ raise ParseError, "Unknown color #{input.inspect}"
99
+ end
100
+ end
101
+
102
+ # Parse a color string using bracket notation.
103
+ #
104
+ # This is a convenient alias for {parse}.
105
+ #
106
+ # @param value [String, Color] The color string to parse
107
+ # @return [RGB, HSL, OKLCH] A color object in the appropriate color space
108
+ # @raise [ParseError] If the input is invalid
109
+ #
110
+ # @example
111
+ # Unmagic::Color["#FF5733"]
112
+ # Unmagic::Color["hsl(9, 100%, 60%)"]
113
+ def [](value)
114
+ parse(value)
115
+ end
116
+ end
117
+
118
+ # Base unit for RGB components (0-255)
119
+ Component = Data.define(:value) do
120
+ include Comparable
121
+
122
+ # @param value [Numeric] Component value (0-255)
123
+ def initialize(value:)
124
+ super(value: value.to_i.clamp(0, 255))
125
+ end
126
+
127
+ def to_i = value
128
+ def to_f = value.to_f
129
+
130
+ # @param other [Component, Numeric] Value to compare
131
+ # @return [Integer, nil] Comparison result
132
+ def <=>(other)
133
+ case other
134
+ when Component, Numeric
135
+ value <=> other.to_f
136
+ end
137
+ end
138
+
139
+ # @param other [Numeric] Multiplier
140
+ # @return [Component] New component
141
+ def *(other)
142
+ self.class.new(value: value * other.to_f)
143
+ end
144
+
145
+ # @param other [Numeric] Divisor
146
+ # @return [Component] New component
147
+ def /(other)
148
+ self.class.new(value: value / other.to_f)
149
+ end
150
+
151
+ # @param other [Numeric] Value to add
152
+ # @return [Component] New component
153
+ def +(other)
154
+ self.class.new(value: value + other.to_f)
155
+ end
156
+
157
+ # @param other [Numeric] Value to subtract
158
+ # @return [Component] New component
159
+ def -(other)
160
+ self.class.new(value: value - other.to_f)
161
+ end
162
+
163
+ # @return [Component] Absolute value
164
+ def abs
165
+ self.class.new(value: value.abs)
166
+ end
167
+ end
168
+
169
+ # Type alias for red component
170
+ Red = Component
171
+ # Type alias for green component
172
+ Green = Component
173
+ # Type alias for blue component
174
+ Blue = Component
175
+
176
+ # Angular unit for hue (0-360 degrees, wrapping)
177
+ Hue = Data.define(:value) do
178
+ include Comparable
179
+
180
+ # @param value [Numeric] Hue value in degrees
181
+ def initialize(value:)
182
+ super(value: value.to_f % 360)
183
+ end
184
+
185
+ def to_f = value
186
+ def degrees = value
187
+
188
+ # @param other [Hue, Numeric] Value to compare
189
+ # @return [Integer, nil] Comparison result
190
+ def <=>(other)
191
+ case other
192
+ when Hue, Numeric
193
+ value <=> other.to_f
194
+ end
195
+ end
196
+
197
+ # @param other [Numeric] Multiplier
198
+ # @return [Hue] New hue
199
+ def *(other)
200
+ self.class.new(value: value * other.to_f)
201
+ end
202
+
203
+ # @param other [Numeric] Divisor
204
+ # @return [Hue] New hue
205
+ def /(other)
206
+ self.class.new(value: value / other.to_f)
207
+ end
208
+
209
+ # @param other [Numeric] Value to add
210
+ # @return [Hue] New hue
211
+ def +(other)
212
+ self.class.new(value: value + other.to_f)
213
+ end
214
+
215
+ # @param other [Numeric] Value to subtract
216
+ # @return [Hue] New hue
217
+ def -(other)
218
+ self.class.new(value: value - other.to_f)
219
+ end
220
+
221
+ # @return [Hue] Absolute value
222
+ def abs
223
+ self.class.new(value: value.abs)
224
+ end
225
+ end
226
+
227
+ # OKLCH chroma unit (0-0.5)
228
+ Chroma = Data.define(:value) do
229
+ include Comparable
230
+
231
+ # @param value [Numeric] Chroma value (0-0.5)
232
+ def initialize(value:)
233
+ super(value: value.to_f.clamp(0, 0.5))
234
+ end
235
+
236
+ # @return [Float] Chroma value
237
+ def to_f = value
238
+
239
+ # @param other [Chroma, Numeric] Value to compare
240
+ # @return [Integer, nil] Comparison result
241
+ def <=>(other)
242
+ case other
243
+ when Chroma, Numeric
244
+ value <=> other.to_f
245
+ end
246
+ end
247
+
248
+ # @param other [Numeric] Multiplier
249
+ # @return [Chroma] New chroma
250
+ def *(other)
251
+ self.class.new(value: value * other.to_f)
252
+ end
253
+
254
+ # @param other [Numeric] Divisor
255
+ # @return [Chroma] New chroma
256
+ def /(other)
257
+ self.class.new(value: value / other.to_f)
258
+ end
259
+
260
+ # @param other [Numeric] Value to add
261
+ # @return [Chroma] New chroma
262
+ def +(other)
263
+ self.class.new(value: value + other.to_f)
264
+ end
265
+
266
+ # @param other [Numeric] Value to subtract
267
+ # @return [Chroma] New chroma
268
+ def -(other)
269
+ self.class.new(value: value - other.to_f)
270
+ end
271
+
272
+ # @return [Chroma] Absolute value
273
+ def abs
274
+ self.class.new(value: value.abs)
275
+ end
276
+ end
277
+
278
+ # Percentage-based units
279
+ # Saturation percentage (0-100%)
280
+ class Saturation < Unmagic::Util::Percentage; end
281
+ # Lightness percentage (0-100%)
282
+ class Lightness < Unmagic::Util::Percentage; end
283
+
284
+ # Convert this color to RGB color space.
285
+ #
286
+ # RGB represents colors as a combination of Red, Green, and Blue light,
287
+ # with each component ranging from 0-255.
288
+ #
289
+ # @return [RGB] The color in RGB color space
290
+ def to_rgb
291
+ raise NotImplementedError
292
+ end
293
+
294
+ # Convert this color to HSL color space.
295
+ #
296
+ # HSL represents colors using Hue (0-360°), Saturation (0-100%),
297
+ # and Lightness (0-100%).
298
+ #
299
+ # @return [HSL] The color in HSL color space
300
+ def to_hsl
301
+ raise NotImplementedError
302
+ end
303
+
304
+ # Convert this color to OKLCH color space.
305
+ #
306
+ # OKLCH is a perceptually uniform color space that better matches
307
+ # how humans perceive color differences.
308
+ #
309
+ # @return [OKLCH] The color in OKLCH color space
310
+ def to_oklch
311
+ raise NotImplementedError
312
+ end
313
+
314
+ # Calculate the perceptual luminance of this color.
315
+ #
316
+ # Luminance represents how bright the color appears to the human eye,
317
+ # accounting for the fact that we perceive green as brighter than red,
318
+ # and red as brighter than blue.
319
+ #
320
+ # @return [Float] The luminance value from 0.0 (black) to 1.0 (white)
321
+ def luminance
322
+ raise NotImplementedError
323
+ end
324
+
325
+ # Blend this color with another color.
326
+ #
327
+ # Creates a new color by mixing this color with another. The amount
328
+ # parameter controls how much of the other color to mix in.
329
+ #
330
+ # @param other [Color] The color to blend with
331
+ # @param amount [Float] How much of the other color to use (0.0 to 1.0)
332
+ # @return [Color] A new color that is a blend of the two colors
333
+ #
334
+ # @example Mix two colors equally
335
+ # red = Unmagic::Color.parse("#FF0000")
336
+ # blue = Unmagic::Color.parse("#0000FF")
337
+ # red.blend(blue, 0.5)
338
+ #
339
+ # @example Add a hint of another color
340
+ # base = Unmagic::Color.parse("#336699")
341
+ # base.blend(Unmagic::Color.parse("#FF0000"), 0.1)
342
+ def blend(other, amount = 0.5)
343
+ raise NotImplementedError
344
+ end
345
+
346
+ # Create a lighter version of this color.
347
+ #
348
+ # Returns a new color that is lighter than the original. The exact
349
+ # implementation depends on the color space.
350
+ #
351
+ # @param amount [Float] How much to lighten (0.0 to 1.0)
352
+ # @return [Color] A new, lighter color
353
+ #
354
+ # @example Make a color 20% lighter
355
+ # dark = Unmagic::Color.parse("#336699")
356
+ # dark.lighten(0.2)
357
+ def lighten(amount = 0.1)
358
+ raise NotImplementedError
359
+ end
360
+
361
+ # Create a darker version of this color.
362
+ #
363
+ # Returns a new color that is darker than the original. The exact
364
+ # implementation depends on the color space.
365
+ #
366
+ # @param amount [Float] How much to darken (0.0 to 1.0)
367
+ # @return [Color] A new, darker color
368
+ #
369
+ # @example Make a color 10% darker
370
+ # bright = Unmagic::Color.parse("#FF9966")
371
+ # bright.darken(0.1)
372
+ def darken(amount = 0.1)
373
+ raise NotImplementedError
374
+ end
375
+
376
+ # Check if this is a light color.
377
+ #
378
+ # A color is considered light if its luminance is greater than 0.5.
379
+ # This is useful for determining whether to use dark or light text
380
+ # on a colored background.
381
+ #
382
+ # @return [Boolean] true if the color is light, false otherwise
383
+ #
384
+ # @example Choose text color based on background
385
+ # bg = Unmagic::Color.parse("#FFFF00") # Yellow
386
+ # text_color = bg.light? ? "#000000" : "#FFFFFF"
387
+ # # => "#000000"
388
+ def light?
389
+ luminance > 0.5
390
+ end
391
+
392
+ # Check if this is a dark color.
393
+ #
394
+ # A color is considered dark if its luminance is 0.5 or less.
395
+ # This is the opposite of {#light?}.
396
+ #
397
+ # @return [Boolean] true if the color is dark, false otherwise
398
+ def dark?
399
+ !light?
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unmagic/color"
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: unmagic-color
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Keith Pitt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Parse, convert, and manipulate colors with support for RGB, Hex, HSL
13
+ formats, contrast calculations, and color blending
14
+ email:
15
+ - keith@unreasonable-magic.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - data/rgb.txt
22
+ - lib/unmagic/color.rb
23
+ - lib/unmagic/color/hsl.rb
24
+ - lib/unmagic/color/oklch.rb
25
+ - lib/unmagic/color/rgb.rb
26
+ - lib/unmagic/color/rgb/hex.rb
27
+ - lib/unmagic/color/rgb/named.rb
28
+ - lib/unmagic/color/string/hash_function.rb
29
+ - lib/unmagic/color/util/percentage.rb
30
+ - lib/unmagic_color.rb
31
+ homepage: https://github.com/unreasonable-magic/unmagic-color
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ homepage_uri: https://github.com/unreasonable-magic/unmagic-color
36
+ source_code_uri: https://github.com/unreasonable-magic/unmagic-color
37
+ changelog_uri: https://github.com/unreasonable-magic/unmagic-color/CHANGELOG.md
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '3.0'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.6.9
53
+ specification_version: 4
54
+ summary: Comprehensive color manipulation library
55
+ test_files: []