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.
@@ -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(lightness: new_lightness, chroma: new_chroma, hue: new_hue)
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
- format("oklch(%.4f %.4f %.2f)", @lightness.to_ratio, @chroma.value, @hue.value)
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 both full (6-digit) and short (3-digit) hex formats,
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 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)"
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
- RGB.new(red: r, green: g, blue: b)
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