abachrome-float 0.1.6

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.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +3 -0
  3. data/.rubocop.yml +10 -0
  4. data/CHANGELOG.md +21 -0
  5. data/CLA.md +45 -0
  6. data/CODE-OF-CONDUCT.md +9 -0
  7. data/LICENSE +19 -0
  8. data/README.md +315 -0
  9. data/Rakefile +15 -0
  10. data/SECURITY.md +94 -0
  11. data/abachrome-float.gemspec +36 -0
  12. data/demos/ncurses/plasma.rb +124 -0
  13. data/devenv.lock +171 -0
  14. data/devenv.nix +52 -0
  15. data/devenv.yaml +8 -0
  16. data/lib/abachrome/color.rb +197 -0
  17. data/lib/abachrome/color_mixins/blend.rb +100 -0
  18. data/lib/abachrome/color_mixins/lighten.rb +90 -0
  19. data/lib/abachrome/color_mixins/spectral_mix.rb +70 -0
  20. data/lib/abachrome/color_mixins/to_colorspace.rb +107 -0
  21. data/lib/abachrome/color_mixins/to_grayscale.rb +87 -0
  22. data/lib/abachrome/color_mixins/to_lrgb.rb +121 -0
  23. data/lib/abachrome/color_mixins/to_oklab.rb +117 -0
  24. data/lib/abachrome/color_mixins/to_oklch.rb +110 -0
  25. data/lib/abachrome/color_mixins/to_srgb.rb +142 -0
  26. data/lib/abachrome/color_models/cmyk.rb +159 -0
  27. data/lib/abachrome/color_models/hsv.rb +49 -0
  28. data/lib/abachrome/color_models/lms.rb +38 -0
  29. data/lib/abachrome/color_models/oklab.rb +37 -0
  30. data/lib/abachrome/color_models/oklch.rb +91 -0
  31. data/lib/abachrome/color_models/rgb.rb +58 -0
  32. data/lib/abachrome/color_models/xyz.rb +31 -0
  33. data/lib/abachrome/color_models/yiq.rb +37 -0
  34. data/lib/abachrome/color_space.rb +199 -0
  35. data/lib/abachrome/converter.rb +117 -0
  36. data/lib/abachrome/converters/base.rb +128 -0
  37. data/lib/abachrome/converters/cmyk_to_srgb.rb +42 -0
  38. data/lib/abachrome/converters/lms_to_lrgb.rb +40 -0
  39. data/lib/abachrome/converters/lms_to_srgb.rb +27 -0
  40. data/lib/abachrome/converters/lms_to_xyz.rb +34 -0
  41. data/lib/abachrome/converters/lrgb_to_lms.rb +3 -0
  42. data/lib/abachrome/converters/lrgb_to_oklab.rb +57 -0
  43. data/lib/abachrome/converters/lrgb_to_srgb.rb +59 -0
  44. data/lib/abachrome/converters/lrgb_to_xyz.rb +33 -0
  45. data/lib/abachrome/converters/oklab_to_lms.rb +44 -0
  46. data/lib/abachrome/converters/oklab_to_lrgb.rb +71 -0
  47. data/lib/abachrome/converters/oklab_to_oklch.rb +56 -0
  48. data/lib/abachrome/converters/oklab_to_srgb.rb +46 -0
  49. data/lib/abachrome/converters/oklch_to_lrgb.rb +79 -0
  50. data/lib/abachrome/converters/oklch_to_oklab.rb +52 -0
  51. data/lib/abachrome/converters/oklch_to_srgb.rb +46 -0
  52. data/lib/abachrome/converters/oklch_to_xyz.rb +70 -0
  53. data/lib/abachrome/converters/srgb_to_cmyk.rb +64 -0
  54. data/lib/abachrome/converters/srgb_to_lrgb.rb +55 -0
  55. data/lib/abachrome/converters/srgb_to_oklab.rb +45 -0
  56. data/lib/abachrome/converters/srgb_to_oklch.rb +47 -0
  57. data/lib/abachrome/converters/srgb_to_yiq.rb +49 -0
  58. data/lib/abachrome/converters/xyz_to_lms.rb +34 -0
  59. data/lib/abachrome/converters/xyz_to_oklab.rb +42 -0
  60. data/lib/abachrome/converters/yiq_to_srgb.rb +47 -0
  61. data/lib/abachrome/gamut/base.rb +74 -0
  62. data/lib/abachrome/gamut/p3.rb +27 -0
  63. data/lib/abachrome/gamut/rec2020.rb +25 -0
  64. data/lib/abachrome/gamut/srgb.rb +49 -0
  65. data/lib/abachrome/illuminants/base.rb +35 -0
  66. data/lib/abachrome/illuminants/d50.rb +33 -0
  67. data/lib/abachrome/illuminants/d55.rb +29 -0
  68. data/lib/abachrome/illuminants/d65.rb +37 -0
  69. data/lib/abachrome/illuminants/d75.rb +29 -0
  70. data/lib/abachrome/named/css.rb +157 -0
  71. data/lib/abachrome/named/tailwind.rb +301 -0
  72. data/lib/abachrome/outputs/css.rb +119 -0
  73. data/lib/abachrome/palette.rb +244 -0
  74. data/lib/abachrome/palette_mixins/interpolate.rb +53 -0
  75. data/lib/abachrome/palette_mixins/resample.rb +61 -0
  76. data/lib/abachrome/palette_mixins/stretch_luminance.rb +72 -0
  77. data/lib/abachrome/parsers/css.rb +452 -0
  78. data/lib/abachrome/parsers/hex.rb +52 -0
  79. data/lib/abachrome/parsers/tailwind.rb +45 -0
  80. data/lib/abachrome/spectral.rb +276 -0
  81. data/lib/abachrome/to_abcd.rb +23 -0
  82. data/lib/abachrome/version.rb +7 -0
  83. data/lib/abachrome.rb +242 -0
  84. data/logo.png +0 -0
  85. data/logo.webp +0 -0
  86. data/security/assesments/2025-10-12-SECURITY_ASSESSMENT.md +53 -0
  87. data/security/vex.json +21 -0
  88. metadata +146 -0
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ class Palette
5
+ attr_reader :colors
6
+
7
+ # Initializes a new color palette with the given colors.
8
+ # Automatically converts non-Color objects to Color objects by parsing them as hex values.
9
+ #
10
+ # @param colors [Array] An array of colors to include in the palette. Each element can be
11
+ # either a Color object or a string-convertible object representing a hex color code.
12
+ # @return [Abachrome::Palette] A new palette instance containing the provided colors.
13
+ def initialize(colors = [])
14
+ @colors = colors.map { |c| c.is_a?(Color) ? c : Color.from_hex(c.to_s) }
15
+ end
16
+
17
+ # Adds a color to the palette.
18
+ # Accepts either an Abachrome::Color object or any object that can be
19
+ # converted to a string and parsed as a hex color code.
20
+ #
21
+ # @param color [Abachrome::Color, String, #to_s] The color to add to the palette.
22
+ # If not already an Abachrome::Color object, it will be converted using Color.from_hex
23
+ # @return [Abachrome::Palette] self, enabling method chaining
24
+ def add(color)
25
+ color = Color.from_hex(color.to_s) unless color.is_a?(Color)
26
+ @colors << color
27
+ self
28
+ end
29
+
30
+ alias << add
31
+
32
+ # Removes the specified color from the palette.
33
+ #
34
+ # @param color [Abachrome::Color, Object] The color to be removed from the palette
35
+ # @return [Abachrome::Palette] Returns self for method chaining
36
+ def remove(color)
37
+ @colors.delete(color)
38
+ self
39
+ end
40
+
41
+ # Clears all colors from the palette.
42
+ #
43
+ # This method removes all stored colors in the palette. It provides a way to
44
+ # reset the palette to an empty state while maintaining the same palette object.
45
+ #
46
+ # @return [Abachrome::Palette] Returns self for method chaining
47
+ def clear
48
+ @colors.clear
49
+ self
50
+ end
51
+
52
+ # Returns the number of colors in the palette.
53
+ #
54
+ # @return [Integer] the number of colors in the palette
55
+ def size
56
+ @colors.size
57
+ end
58
+
59
+ # Returns whether the palette has no colors.
60
+ #
61
+ # @return [Boolean] true if the palette contains no colors, false otherwise
62
+ def empty?
63
+ @colors.empty?
64
+ end
65
+
66
+ # Yields each color in the palette to the given block.
67
+ #
68
+ # @param block [Proc] The block to be executed for each color in the palette.
69
+ # @yield [Abachrome::Color] Each color in the palette.
70
+ # @return [Enumerator] Returns an Enumerator if no block is given.
71
+ # @see Enumerable#each
72
+ def each(&block)
73
+ @colors.each(&block)
74
+ end
75
+
76
+ # Calls the given block once for each color in the palette, passing the color and its index as parameters.
77
+ #
78
+ # @example
79
+ # palette.each_with_index { |color, index| puts "Color #{index}: #{color}" }
80
+ #
81
+ # @param block [Proc] The block to be called for each color
82
+ # @yield [color, index] Yields a color and its index
83
+ # @yieldparam color [Abachrome::Color] The color at the current position
84
+ # @yieldparam index [Integer] The index of the current color
85
+ # @return [Enumerator] If no block is given, returns an Enumerator
86
+ # @return [Array<Abachrome::Color>] Returns the array of colors if a block is given
87
+ def each_with_index(&block)
88
+ @colors.each_with_index(&block)
89
+ end
90
+
91
+ # Maps the palette by applying a block to each color.
92
+ #
93
+ # @param block [Proc] A block that takes a color and returns a new color.
94
+ # @return [Abachrome::Palette] A new palette with the mapped colors.
95
+ # @example
96
+ # # Convert all colors in palette to grayscale
97
+ # palette.map { |color| color.grayscale }
98
+ def map(&block)
99
+ self.class.new(@colors.map(&block))
100
+ end
101
+
102
+ # Returns a duplicate of the internal colors array.
103
+ #
104
+ # @return [Array<Abachrome::Color>] A duplicate of the palette's color array
105
+ def to_a
106
+ @colors.dup
107
+ end
108
+
109
+ # Access a color in the palette at the specified index.
110
+ #
111
+ # @param index [Integer] The index of the color to retrieve from the palette
112
+ # @return [Abachrome::Color, nil] The color at the specified index, or nil if the index is out of bounds
113
+ def [](index)
114
+ @colors[index]
115
+ end
116
+
117
+ # Slices the palette to create a new palette with a subset of colors.
118
+ #
119
+ # @param start [Integer] The starting index (or range) from which to start the slice.
120
+ # @param length [Integer, nil] The number of colors to include in the slice. If nil and start is an Integer,
121
+ # returns a new palette containing the single color at that index. If start is a Range, length is ignored.
122
+ # @return [Abachrome::Palette] A new palette containing the selected colors.
123
+ def slice(start, length = nil)
124
+ new_colors = length ? @colors[start, length] : @colors[start]
125
+ self.class.new(new_colors)
126
+ end
127
+
128
+ # Returns the first color in the palette.
129
+ #
130
+ # @return [Abachrome::Color, nil] The first color in the palette, or nil if the palette is empty.
131
+ def first
132
+ @colors.first
133
+ end
134
+
135
+ # Returns the last color in the palette.
136
+ #
137
+ # @return [Abachrome::Color, nil] The last color in the palette or nil if palette is empty.
138
+ def last
139
+ @colors.last
140
+ end
141
+
142
+ # Returns a new palette with colors sorted by lightness.
143
+ # This method creates a new palette instance containing the same colors as the current
144
+ # palette but sorted in ascending order based on their lightness values.
145
+ #
146
+ # @return [Abachrome::Palette] a new palette with the same colors sorted by lightness
147
+ def sort_by_lightness
148
+ self.class.new(@colors.sort_by(&:lightness))
149
+ end
150
+
151
+ # Returns a new palette with colors sorted by saturation from lowest to highest.
152
+ # Saturation is determined by the second coordinate (a*) in the OKLAB color space.
153
+ # Lower values represent less saturated colors, higher values represent more saturated colors.
154
+ #
155
+ # @return [Abachrome::Palette] A new palette instance with the same colors sorted by saturation
156
+ def sort_by_saturation
157
+ self.class.new(@colors.sort_by { |c| c.to_oklab.coordinates[1] })
158
+ end
159
+
160
+ # Blends all colors in the palette together sequentially at the specified amount.
161
+ # This method takes each color in the palette and blends it with the accumulated result
162
+ # of previous blends. It starts with the first color and progressively blends each subsequent
163
+ # color at the specified blend amount.
164
+ #
165
+ # @param amount [Float] The blend amount to use between each color pair, between 0.0 and 1.0.
166
+ # Defaults to 0.5 (equal blend).
167
+ # @return [Abachrome::Color, nil] The final blended color result, or nil if the palette is empty.
168
+ def blend_all(amount = 0.5)
169
+ return nil if empty?
170
+
171
+ result = first
172
+ @colors[1..].each do |color|
173
+ result = result.blend(color, amount)
174
+ end
175
+ result
176
+ end
177
+
178
+ # Calculates the average color of the palette by finding the centroid in OKLAB space.
179
+ # This method converts each color in the palette to OKLAB coordinates,
180
+ # calculates the arithmetic mean of these coordinates, and creates a new
181
+ # color from the average values. Alpha values are also averaged.
182
+ #
183
+ # @return [Abachrome::Color, nil] The average color of all colors in the palette,
184
+ # or nil if the palette is empty
185
+ def average
186
+ return nil if empty?
187
+
188
+ oklab_coords = @colors.map(&:to_oklab).map(&:coordinates)
189
+ avg_coords = oklab_coords.reduce([0, 0, 0]) do |sum, coords|
190
+ [sum[0] + coords[0], sum[1] + coords[1], sum[2] + coords[2]]
191
+ end
192
+ avg_coords.map! { |c| c / size }
193
+
194
+ Color.new(
195
+ ColorSpace.find(:oklab),
196
+ avg_coords,
197
+ @colors.map(&:alpha).sum / size
198
+ )
199
+ end
200
+
201
+ # Converts the colors in the palette to CSS-formatted strings.
202
+ #
203
+ # The format of the output can be specified with the format parameter.
204
+ #
205
+ # @param format [Symbol] The format to use for the CSS color strings.
206
+ # :hex - Outputs colors in hexadecimal format (e.g., "#RRGGBB")
207
+ # :rgb - Outputs colors in rgb() function format
208
+ # :oklab - Outputs colors in oklab() function format
209
+ # When any other value is provided, a default format is used.
210
+ # @return [Array<String>] An array of CSS-formatted color strings
211
+ def to_css(format: :hex)
212
+ to_a.map do |color|
213
+ case format
214
+ when :hex
215
+ Outputs::CSS.format_hex(color)
216
+ when :rgb
217
+ Outputs::CSS.format_rgb(color)
218
+ when :oklab
219
+ Outputs::CSS.format_oklab(color)
220
+ else
221
+ Outputs::CSS.format(color)
222
+ end
223
+ end
224
+ end
225
+
226
+ # Returns a string representation of the palette for inspection purposes.
227
+ #
228
+ # @return [String] A string containing the class name and a list of colors in the palette
229
+ def inspect
230
+ "#<#{self.class} colors=#{@colors.map(&:to_s)}>"
231
+ end
232
+
233
+ mixins_path = File.join(__dir__, "palette_mixins", "*.rb")
234
+ Dir[mixins_path].each do |file|
235
+ require file
236
+ mixin_name = File.basename(file, ".rb")
237
+ inflector = Dry::Inflector.new
238
+ mixin_module = Abachrome::PaletteMixins.const_get(inflector.camelize(mixin_name))
239
+ include mixin_module
240
+ end
241
+ end
242
+ end
243
+
244
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Abachrome::PaletteMixins::Interpolate - Color palette interpolation functionality
4
+ #
5
+ # This mixin provides methods for interpolating between adjacent colors in a palette to create
6
+ # smooth color transitions and gradients. The interpolation process inserts new colors between
7
+ # existing palette colors by blending them at calculated intervals, creating smoother color
8
+ # progressions ideal for gradients, color ramps, and visual transitions.
9
+ #
10
+ # Key features:
11
+ # - Insert specified number of interpolated colors between each adjacent color pair
12
+ # - Both non-destructive (interpolate) and destructive (interpolate!) variants
13
+ # - Uses color blending in the current color space for smooth transitions
14
+ # - Maintains original colors as anchor points in the interpolated result
15
+ # - High-precision decimal arithmetic for accurate color calculations
16
+ # - Preserves alpha values during interpolation process
17
+ #
18
+ # The mixin includes both immutable methods that return new palette instances and mutable
19
+ # methods that modify the current palette object in place, providing flexibility for
20
+ # different use cases and performance requirements. Interpolation is essential for creating
21
+ # smooth color gradients and ensuring adequate color resolution in palette-based applications.
22
+
23
+ module Abachrome
24
+ module PaletteMixins
25
+ module Interpolate
26
+ def interpolate(count_between = 1)
27
+ return self if count_between < 1 || size < 2
28
+
29
+ new_colors = []
30
+ @colors.each_cons(2) do |color1, color2|
31
+ new_colors << color1
32
+ step = "1.0".to_f / count_between + 1.to_f
33
+
34
+ (1..count_between).each do |i|
35
+ amount = step * i
36
+ new_colors << color1.blend(color2, amount)
37
+ end
38
+ end
39
+ new_colors << last
40
+
41
+ self.class.new(new_colors)
42
+ end
43
+
44
+ def interpolate!(count_between = 1)
45
+ interpolated = interpolate(count_between)
46
+ @colors = interpolated.colors
47
+ self
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module PaletteMixins
5
+ module Resample
6
+ def resample(new_size)
7
+ return self if new_size == size || empty?
8
+ return self.class.new([@colors.first]) if new_size == 1
9
+
10
+ step = (size - 1).to_f / (new_size - 1)
11
+
12
+ self.class.new(
13
+ (0...new_size).map do |i|
14
+ index = i * step
15
+ lower_index = index.floor
16
+ upper_index = [lower_index + 1, size - 1].min
17
+
18
+ if lower_index == upper_index
19
+ @colors[lower_index]
20
+ else
21
+ fraction = index - lower_index
22
+ @colors[lower_index].blend(@colors[upper_index], fraction)
23
+ end
24
+ end
25
+ )
26
+ end
27
+
28
+ def resample!(new_size)
29
+ resampled = resample(new_size)
30
+ @colors = resampled.colors
31
+ self
32
+ end
33
+
34
+ def expand(new_size)
35
+ raise ArgumentError, "New size must be larger than current size" if new_size <= size
36
+
37
+ resample(new_size)
38
+ end
39
+
40
+ def expand!(new_size)
41
+ raise ArgumentError, "New size must be larger than current size" if new_size <= size
42
+
43
+ resample!(new_size)
44
+ end
45
+
46
+ def reduce(new_size)
47
+ raise ArgumentError, "New size must be smaller than current size" if new_size >= size
48
+
49
+ resample(new_size)
50
+ end
51
+
52
+ def reduce!(new_size)
53
+ raise ArgumentError, "New size must be smaller than current size" if new_size >= size
54
+
55
+ resample!(new_size)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module PaletteMixins
5
+ module StretchLuminance
6
+ def stretch_luminance(new_min: 0.0, new_max: 1.0)
7
+ return self if empty?
8
+
9
+ new_min = new_min.to_f
10
+ new_max = new_max.to_f
11
+
12
+ oklab_colors = @colors.map(&:to_oklab)
13
+ current_min = oklab_colors.map { |c| c.coordinates[0] }.min
14
+ current_max = oklab_colors.map { |c| c.coordinates[0] }.max
15
+
16
+ range = current_max - current_min
17
+ new_range = new_max - new_min
18
+
19
+ self.class.new(
20
+ oklab_colors.map do |color|
21
+ l, a, b = color.coordinates
22
+ scaled_l = if range.zero?
23
+ new_min
24
+ else
25
+ new_min + ((l - current_min) * new_range / range)
26
+ end
27
+
28
+ Color.new(
29
+ ColorSpace.find(:oklab),
30
+ [scaled_l, a, b],
31
+ color.alpha
32
+ )
33
+ end
34
+ )
35
+ end
36
+
37
+ def stretch_luminance!(new_min: 0.0, new_max: 1.0)
38
+ stretched = stretch_luminance(new_min: new_min, new_max: new_max)
39
+ @colors = stretched.colors
40
+ self
41
+ end
42
+
43
+ def normalize_luminance
44
+ stretch_luminance(new_min: 0.0, new_max: 1.0)
45
+ end
46
+
47
+ def normalize_luminance!
48
+ stretch_luminance!(new_min: 0.0, new_max: 1.0)
49
+ end
50
+
51
+ def compress_luminance(amount = 0.5)
52
+ amount = amount.to_f
53
+ mid_point = "0.5".to_f
54
+ stretch_luminance(
55
+ new_min: mid_point - (mid_point * amount),
56
+ new_max: mid_point + (mid_point * amount)
57
+ )
58
+ end
59
+
60
+ def compress_luminance!(amount = 0.5)
61
+ amount = amount.to_f
62
+ mid_point = "0.5".to_f
63
+ stretch_luminance!(
64
+ new_min: mid_point - (mid_point * amount),
65
+ new_max: mid_point + (mid_point * amount)
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.