abachrome 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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +10 -0
  3. data/CHANGELOG.md +5 -0
  4. data/README.md +99 -0
  5. data/demos/ncurses/plasma.rb +124 -0
  6. data/devenv.lock +100 -0
  7. data/devenv.nix +51 -0
  8. data/devenv.yaml +15 -0
  9. data/lib/abachrome/abc_decimal.rb +161 -0
  10. data/lib/abachrome/color.rb +74 -0
  11. data/lib/abachrome/color_mixins/blend.rb +45 -0
  12. data/lib/abachrome/color_mixins/lighten.rb +39 -0
  13. data/lib/abachrome/color_mixins/to_colorspace.rb +38 -0
  14. data/lib/abachrome/color_mixins/to_lrgb.rb +49 -0
  15. data/lib/abachrome/color_mixins/to_oklab.rb +48 -0
  16. data/lib/abachrome/color_mixins/to_oklch.rb +48 -0
  17. data/lib/abachrome/color_mixins/to_srgb.rb +63 -0
  18. data/lib/abachrome/color_models/hsv.rb +22 -0
  19. data/lib/abachrome/color_models/oklab.rb +16 -0
  20. data/lib/abachrome/color_models/oklch.rb +47 -0
  21. data/lib/abachrome/color_models/rgb.rb +28 -0
  22. data/lib/abachrome/color_space.rb +97 -0
  23. data/lib/abachrome/converter.rb +59 -0
  24. data/lib/abachrome/converters/base.rb +57 -0
  25. data/lib/abachrome/converters/lrgb_to_oklab.rb +27 -0
  26. data/lib/abachrome/converters/lrgb_to_srgb.rb +30 -0
  27. data/lib/abachrome/converters/oklab_to_lrgb.rb +42 -0
  28. data/lib/abachrome/converters/oklab_to_oklch.rb +23 -0
  29. data/lib/abachrome/converters/oklab_to_srgb.rb +17 -0
  30. data/lib/abachrome/converters/oklch_to_lrgb.rb +15 -0
  31. data/lib/abachrome/converters/oklch_to_oklab.rb +23 -0
  32. data/lib/abachrome/converters/oklch_to_srgb.rb +18 -0
  33. data/lib/abachrome/converters/srgb_to_lrgb.rb +27 -0
  34. data/lib/abachrome/converters/srgb_to_oklab.rb +15 -0
  35. data/lib/abachrome/converters/srgb_to_oklch.rb +18 -0
  36. data/lib/abachrome/gamut/base.rb +72 -0
  37. data/lib/abachrome/gamut/p3.rb +25 -0
  38. data/lib/abachrome/gamut/rec2020.rb +23 -0
  39. data/lib/abachrome/gamut/srgb.rb +27 -0
  40. data/lib/abachrome/illuminants/base.rb +33 -0
  41. data/lib/abachrome/illuminants/d50.rb +31 -0
  42. data/lib/abachrome/illuminants/d55.rb +27 -0
  43. data/lib/abachrome/illuminants/d65.rb +35 -0
  44. data/lib/abachrome/illuminants/d75.rb +27 -0
  45. data/lib/abachrome/named/css.rb +164 -0
  46. data/lib/abachrome/outputs/css.rb +117 -0
  47. data/lib/abachrome/palette.rb +131 -0
  48. data/lib/abachrome/palette_mixins/interpolate.rb +31 -0
  49. data/lib/abachrome/palette_mixins/resample.rb +59 -0
  50. data/lib/abachrome/palette_mixins/stretch_luminance.rb +70 -0
  51. data/lib/abachrome/parsers/hex.rb +50 -0
  52. data/lib/abachrome/to_abcd.rb +13 -0
  53. data/lib/abachrome/version.rb +5 -0
  54. data/lib/abachrome.rb +99 -0
  55. metadata +172 -0
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../color"
4
+ require_relative "../color_space"
5
+
6
+ module Abachrome
7
+ module Outputs
8
+ class CSS
9
+ def self.format(color, gamut: nil, companding: nil)
10
+ rgb_color = color.to_rgb
11
+ r, g, b = rgb_color.coordinates
12
+ a = rgb_color.alpha
13
+
14
+ # Apply gamut mapping if provided
15
+ r, g, b = gamut.map([r, g, b]) if gamut
16
+
17
+ # Apply companding if provided
18
+ if companding
19
+ r = companding.call(r)
20
+ g = companding.call(g)
21
+ b = companding.call(b)
22
+ end
23
+
24
+ # Convert to 8-bit values
25
+ r = (r * 255).round
26
+ g = (g * 255).round
27
+ b = (b * 255).round
28
+
29
+ # Format based on alpha value
30
+ return Kernel.format("rgba(%d, %d, %d, %.3f)", r, g, b, a) unless a == AbcDecimal.new("1.0")
31
+ return Kernel.format("#%02x%02x%02x", r, g, b) unless r == g && g == b
32
+
33
+ # Use shortened hex format for grayscale
34
+ hex = Kernel.format("%02x", r)
35
+ "##{hex}#{hex}#{hex}"
36
+ end
37
+
38
+ def self.format_hex(color, gamut: nil, companding: nil)
39
+ rgb_color = color.to_rgb
40
+ r, g, b = rgb_color.coordinates
41
+ a = rgb_color.alpha
42
+
43
+ # Apply gamut mapping if provided
44
+ r, g, b = gamut.map([r, g, b]) if gamut
45
+
46
+ # Apply companding if provided
47
+ if companding
48
+ r = companding.call(r)
49
+ g = companding.call(g)
50
+ b = companding.call(b)
51
+ end
52
+
53
+ r = (r * 255).round
54
+ g = (g * 255).round
55
+ b = (b * 255).round
56
+
57
+ if a == AbcDecimal.new("1.0")
58
+ Kernel.format("#%02x%02x%02x", r, g, b)
59
+ else
60
+ a = (a * 255).round
61
+ Kernel.format("#%02x%02x%02x%02x", r, g, b, a)
62
+ end
63
+ end
64
+
65
+ def self.format_rgb(color, gamut: nil, companding: nil)
66
+ rgb_color = color.to_rgb
67
+ r, g, b = rgb_color.coordinates
68
+ a = rgb_color.alpha
69
+
70
+ # Apply gamut mapping if provided
71
+ r, g, b = gamut.map([r, g, b]) if gamut
72
+
73
+ # Apply companding if provided
74
+ if companding
75
+ r = companding.call(r)
76
+ g = companding.call(g)
77
+ b = companding.call(b)
78
+ end
79
+
80
+ r = (r * 255).round
81
+ g = (g * 255).round
82
+ b = (b * 255).round
83
+
84
+ if a == AbcDecimal.new("1.0")
85
+ Kernel.format("rgb(%d, %d, %d)", r, g, b)
86
+ else
87
+ Kernel.format("rgba(%d, %d, %d, %.3f)", r, g, b, a)
88
+ end
89
+ end
90
+
91
+ def self.format_oklab(color, gamut: nil, companding: nil, precision: 3)
92
+ oklab_color = color.to_oklab
93
+ l, a, b = oklab_color.coordinates
94
+ alpha = oklab_color.alpha
95
+
96
+ # Apply gamut mapping if provided
97
+ l, a, b = gamut.map([l, a, b]) if gamut
98
+
99
+ # Apply companding if provided
100
+ if companding
101
+ l = companding.call(l)
102
+ a = companding.call(a)
103
+ b = companding.call(b)
104
+ end
105
+
106
+ # Format with appropriate precision
107
+ format_string = "%.#{precision}f %.#{precision}f %.#{precision}f"
108
+
109
+ if alpha == AbcDecimal.new("1.0")
110
+ Kernel.format("oklab(#{format_string})", l, a, b)
111
+ else
112
+ Kernel.format("oklab(#{format_string} / %.#{precision}f)", l, a, b, alpha)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ class Palette
5
+ attr_reader :colors
6
+
7
+ def initialize(colors = [])
8
+ @colors = colors.map { |c| c.is_a?(Color) ? c : Color.from_hex(c.to_s) }
9
+ end
10
+
11
+ def add(color)
12
+ color = Color.from_hex(color.to_s) unless color.is_a?(Color)
13
+ @colors << color
14
+ self
15
+ end
16
+
17
+ alias << add
18
+
19
+ def remove(color)
20
+ @colors.delete(color)
21
+ self
22
+ end
23
+
24
+ def clear
25
+ @colors.clear
26
+ self
27
+ end
28
+
29
+ def size
30
+ @colors.size
31
+ end
32
+
33
+ def empty?
34
+ @colors.empty?
35
+ end
36
+
37
+ def each(&block)
38
+ @colors.each(&block)
39
+ end
40
+ def each_with_index(&block)
41
+ @colors.each_with_index(&block)
42
+ end
43
+
44
+ def map(&block)
45
+ self.class.new(@colors.map(&block))
46
+ end
47
+
48
+ def to_a
49
+ @colors.dup
50
+ end
51
+
52
+ def [](index)
53
+ @colors[index]
54
+ end
55
+
56
+ def slice(start, length = nil)
57
+ new_colors = length ? @colors[start, length] : @colors[start]
58
+ self.class.new(new_colors)
59
+ end
60
+
61
+ def first
62
+ @colors.first
63
+ end
64
+
65
+ def last
66
+ @colors.last
67
+ end
68
+
69
+ def sort_by_lightness
70
+ self.class.new(@colors.sort_by(&:lightness))
71
+ end
72
+
73
+ def sort_by_saturation
74
+ self.class.new(@colors.sort_by { |c| c.to_oklab.coordinates[1] })
75
+ end
76
+
77
+ def blend_all(amount = 0.5)
78
+ return nil if empty?
79
+
80
+ result = first
81
+ @colors[1..].each do |color|
82
+ result = result.blend(color, amount)
83
+ end
84
+ result
85
+ end
86
+
87
+ def average
88
+ return nil if empty?
89
+
90
+ oklab_coords = @colors.map(&:to_oklab).map(&:coordinates)
91
+ avg_coords = oklab_coords.reduce([0, 0, 0]) do |sum, coords|
92
+ [sum[0] + coords[0], sum[1] + coords[1], sum[2] + coords[2]]
93
+ end
94
+ avg_coords.map! { |c| c / size }
95
+
96
+ Color.new(
97
+ ColorSpace.find(:oklab),
98
+ avg_coords,
99
+ @colors.map(&:alpha).sum / size
100
+ )
101
+ end
102
+
103
+ def to_css(format: :hex)
104
+ to_a.map do |color|
105
+ case format
106
+ when :hex
107
+ Outputs::CSS.format_hex(color)
108
+ when :rgb
109
+ Outputs::CSS.format_rgb(color)
110
+ when :oklab
111
+ Outputs::CSS.format_oklab(color)
112
+ else
113
+ Outputs::CSS.format(color)
114
+ end
115
+ end
116
+ end
117
+
118
+ def inspect
119
+ "#<#{self.class} colors=#{@colors.map(&:to_s)}>"
120
+ end
121
+
122
+ mixins_path = File.join(__dir__, "palette_mixins", "*.rb")
123
+ Dir[mixins_path].each do |file|
124
+ require file
125
+ mixin_name = File.basename(file, ".rb")
126
+ inflector = Dry::Inflector.new
127
+ mixin_module = Abachrome::PaletteMixins.const_get(inflector.camelize(mixin_name))
128
+ include mixin_module
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module PaletteMixins
5
+ module Interpolate
6
+ def interpolate(count_between = 1)
7
+ return self if count_between < 1 || size < 2
8
+
9
+ new_colors = []
10
+ @colors.each_cons(2) do |color1, color2|
11
+ new_colors << color1
12
+ step = AbcDecimal("1.0") / AbcDecimal(count_between + 1)
13
+
14
+ (1..count_between).each do |i|
15
+ amount = step * i
16
+ new_colors << color1.blend(color2, amount)
17
+ end
18
+ end
19
+ new_colors << last
20
+
21
+ self.class.new(new_colors)
22
+ end
23
+
24
+ def interpolate!(count_between = 1)
25
+ interpolated = interpolate(count_between)
26
+ @colors = interpolated.colors
27
+ self
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,70 @@
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 = AbcDecimal(new_min)
10
+ new_max = AbcDecimal(new_max)
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 = AbcDecimal(amount)
53
+ mid_point = AbcDecimal("0.5")
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 = AbcDecimal(amount)
62
+ mid_point = AbcDecimal("0.5")
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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module Parsers
5
+ class Hex
6
+ HEX_PATTERN = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{8})$/
7
+
8
+ def self.parse(input)
9
+ hex = input.gsub(/^#/, "")
10
+ return nil unless hex.match?(HEX_PATTERN)
11
+
12
+ case hex.length
13
+ when 3
14
+ parse_short_hex(hex)
15
+ when 4
16
+ parse_short_hex_with_alpha(hex)
17
+ when 6
18
+ parse_full_hex(hex)
19
+ when 8
20
+ parse_full_hex_with_alpha(hex)
21
+ end
22
+ end
23
+
24
+ def self.parse_short_hex(hex)
25
+ r, g, b = hex.chars.map { |c| (c + c).to_i(16) }
26
+ Color.from_rgb(r / 255.0, g / 255.0, b / 255.0)
27
+ end
28
+
29
+ def self.parse_short_hex_with_alpha(hex)
30
+ r, g, b, a = hex.chars.map { |c| (c + c).to_i(16) }
31
+ Color.from_rgb(r / 255.0, g / 255.0, b / 255.0, a / 255.0)
32
+ end
33
+
34
+ def self.parse_full_hex(hex)
35
+ r = hex[0, 2].to_i(16)
36
+ g = hex[2, 2].to_i(16)
37
+ b = hex[4, 2].to_i(16)
38
+ Color.from_rgb(r / 255.0, g / 255.0, b / 255.0)
39
+ end
40
+
41
+ def self.parse_full_hex_with_alpha(hex)
42
+ r = hex[0, 2].to_i(16)
43
+ g = hex[2, 2].to_i(16)
44
+ b = hex[4, 2].to_i(16)
45
+ a = hex[6, 2].to_i(16)
46
+ Color.from_rgb(r / 255.0, g / 255.0, b / 255.0, a / 255.0)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ToAbcd
5
+ def to_abcd
6
+ AbcDecimal.new(self)
7
+ end
8
+ end
9
+ end
10
+
11
+ [Numeric, String, Rational].each do |klass|
12
+ klass.include(Abachrome::ToAbcd)
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ VERSION = "0.1.0"
5
+ end
data/lib/abachrome.rb ADDED
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abachrome/to_abcd"
4
+
5
+ module Abachrome
6
+ module_function
7
+
8
+ autoload :AbcDecimal, "abachrome/abc_decimal"
9
+ autoload :Color, "abachrome/color"
10
+ autoload :Palette, "abachrome/palette"
11
+ autoload :ColorSpace, "abachrome/color_space"
12
+ autoload :Converter, "abachrome/converter"
13
+ autoload :Gamut, "abachrome/gamut/base"
14
+ autoload :ToAbcd, "abachrome/to_abcd"
15
+ autoload :VERSION, "abachrome/version"
16
+
17
+ module ColorModels
18
+ autoload :HSV, "abachrome/color_models/hsv"
19
+ autoload :Oklab, "abachrome/color_models/oklab"
20
+ autoload :RGB, "abachrome/color_models/rgb"
21
+ end
22
+
23
+ module ColorMixins
24
+ autoload :ToLrgb, "abachrome/color_mixins/to_lrgb"
25
+ autoload :ToOklab, "abachrome/color_mixins/to_oklab"
26
+ end
27
+
28
+ module Converters
29
+ autoload :Base, "abachrome/converters/base"
30
+ autoload :LrgbToOklab, "abachrome/converters/lrgb_to_oklab"
31
+ autoload :OklabToLrgb, "abachrome/converters/oklab_to_lrgb"
32
+ end
33
+
34
+ module Gamut
35
+ autoload :P3, "abachrome/gamut/p3"
36
+ autoload :Rec2020, "abachrome/gamut/rec2020"
37
+ autoload :SRGB, "abachrome/gamut/srgb"
38
+ end
39
+
40
+ module Illuminants
41
+ autoload :Base, "abachrome/illuminants/base"
42
+ autoload :D50, "abachrome/illuminants/d50"
43
+ autoload :D55, "abachrome/illuminants/d55"
44
+ autoload :D65, "abachrome/illuminants/d65"
45
+ autoload :D75, "abachrome/illuminants/d75"
46
+ end
47
+
48
+ module Named
49
+ autoload :CSS, "abachrome/named/css"
50
+ end
51
+
52
+ module Outputs
53
+ autoload :CSS, "abachrome/outputs/css"
54
+ end
55
+
56
+ module Parsers
57
+ autoload :Hex, "abachrome/parsers/hex"
58
+ end
59
+
60
+ def create_color(space_name, *coordinates, alpha: 1.0)
61
+ space = ColorSpace.find(space_name)
62
+ Color.new(space, coordinates, alpha)
63
+ end
64
+
65
+ def from_rgb(r, g, b, alpha = 1.0)
66
+ Color.from_rgb(r, g, b, alpha)
67
+ end
68
+
69
+ def from_oklab(l, a, b, alpha = 1.0)
70
+ Color.from_oklab(l, a, b, alpha)
71
+ end
72
+
73
+ def from_oklch(l, a, b, alpha = 1.0)
74
+ Color.from_oklch(l, a, b, alpha)
75
+ end
76
+
77
+ def from_hex(hex_str)
78
+ Parsers::Hex.parse(hex_str)
79
+ end
80
+
81
+ def from_name(color_name)
82
+ rgb_values = Named::CSS::ColorNames[color_name.downcase]
83
+ return nil unless rgb_values
84
+
85
+ from_rgb(*rgb_values.map { |v| v / 255.0 })
86
+ end
87
+
88
+ def convert(color, to_space)
89
+ Converter.convert(color, to_space)
90
+ end
91
+
92
+ def register_color_space(name, &block)
93
+ ColorSpace.register(name, &block)
94
+ end
95
+
96
+ def register_converter(from_space, to_space, converter)
97
+ Converter.register(from_space, to_space, converter)
98
+ end
99
+ end