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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ColorMixins
5
+ module Lighten
6
+ def lighten(amount = 0.1)
7
+ amount = AbcDecimal(amount)
8
+ oklab = to_oklab
9
+ l, a, b = oklab.coordinates
10
+
11
+ new_l = l + amount
12
+ new_l = AbcDecimal("1.0") if new_l > 1
13
+ new_l = AbcDecimal("0.0") if new_l.negative?
14
+
15
+ Color.new(
16
+ ColorSpace.find(:oklab),
17
+ [new_l, a, b],
18
+ alpha
19
+ )
20
+ end
21
+
22
+ def lighten!(amount = 0.1)
23
+ lightened = lighten(amount)
24
+ @color_space = lightened.color_space
25
+ @coordinates = lightened.coordinates
26
+ @alpha = lightened.alpha
27
+ self
28
+ end
29
+
30
+ def darken(amount = 0.1)
31
+ lighten(-amount)
32
+ end
33
+
34
+ def darken!(amount = 0.1)
35
+ lighten!(-amount)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ColorMixins
5
+ module ToColorspace
6
+ def to_color_space(target_space)
7
+ return self if color_space == target_space
8
+
9
+ Converter.convert(self, target_space.name)
10
+ end
11
+
12
+ def to_color_space!(target_space)
13
+ unless color_space == target_space
14
+ converted = to_color_space(target_space)
15
+ @color_space = converted.color_space
16
+ @coordinates = converted.coordinates
17
+ end
18
+ self
19
+ end
20
+
21
+ def convert_to(space_name)
22
+ to_color_space(ColorSpace.find(space_name))
23
+ end
24
+
25
+ def convert_to!(space_name)
26
+ to_color_space!(ColorSpace.find(space_name))
27
+ end
28
+
29
+ def in_color_space(space_name)
30
+ convert_to(space_name)
31
+ end
32
+
33
+ def in_color_space!(space_name)
34
+ convert_to!(space_name)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../converter"
4
+
5
+ module Abachrome
6
+ module ColorMixins
7
+ module ToLrgb
8
+ def to_lrgb
9
+ return self if color_space.name == :lrgb
10
+
11
+ Converter.convert(self, :lrgb)
12
+ end
13
+
14
+ def to_lrgb!
15
+ unless color_space.name == :lrgb
16
+ lrgb_color = to_lrgb
17
+ @color_space = lrgb_color.color_space
18
+ @coordinates = lrgb_color.coordinates
19
+ end
20
+ self
21
+ end
22
+
23
+ def lred
24
+ to_lrgb.coordinates[0]
25
+ end
26
+
27
+ def lgreen
28
+ to_lrgb.coordinates[1]
29
+ end
30
+
31
+ def lblue
32
+ to_lrgb.coordinates[2]
33
+ end
34
+
35
+ def lrgb_values
36
+ to_lrgb.coordinates
37
+ end
38
+
39
+ def rgb_array
40
+ to_rgb.coordinates.map { |c| (c * 255).round.clamp(0, 255) }
41
+ end
42
+
43
+ def rgb_hex
44
+ r, g, b = rgb_array
45
+ format("#%02x%02x%02x", r, g, b)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../converter"
4
+
5
+ module Abachrome
6
+ module ColorMixins
7
+ module ToOklab
8
+ def to_oklab
9
+ return self if color_space.name == :oklab
10
+
11
+ Converter.convert(self, :oklab)
12
+ end
13
+
14
+ def to_oklab!
15
+ unless color_space.name == :oklab
16
+ oklab_color = to_oklab
17
+ @color_space = oklab_color.color_space
18
+ @coordinates = oklab_color.coordinates
19
+ end
20
+ self
21
+ end
22
+
23
+ def lightness
24
+ to_oklab.coordinates[0]
25
+ end
26
+
27
+ def l
28
+ to_oklab.coordinates[0]
29
+ end
30
+
31
+ def a
32
+ to_oklab.coordinates[1]
33
+ end
34
+
35
+ def b
36
+ to_oklab.coordinates[2]
37
+ end
38
+
39
+ def oklab_values
40
+ to_oklab.coordinates
41
+ end
42
+
43
+ def oklab_array
44
+ to_oklab.coordinates
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ColorMixins
5
+ module ToOklch
6
+ def to_oklch
7
+ return self if color_space.name == :oklch
8
+
9
+ if color_space.name == :oklab
10
+ Converters::OklabToOklch.convert(self)
11
+ else
12
+ # For other color spaces, convert to OKLab first
13
+ oklab_color = to_oklab
14
+ Converters::OklabToOklch.convert(oklab_color)
15
+ end
16
+ end
17
+
18
+ def to_oklch!
19
+ unless color_space.name == :oklch
20
+ oklch_color = to_oklch
21
+ @color_space = oklch_color.color_space
22
+ @coordinates = oklch_color.coordinates
23
+ end
24
+ self
25
+ end
26
+
27
+ def lightness
28
+ to_oklch.coordinates[0]
29
+ end
30
+
31
+ def chroma
32
+ to_oklch.coordinates[1]
33
+ end
34
+
35
+ def hue
36
+ to_oklch.coordinates[2]
37
+ end
38
+
39
+ def oklch_values
40
+ to_oklch.coordinates
41
+ end
42
+
43
+ def oklch_array
44
+ to_oklch.coordinates
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../converter"
4
+
5
+ module Abachrome
6
+ module ColorMixins
7
+ module ToSrgb
8
+ def to_srgb
9
+ return self if color_space.name == :srgb
10
+
11
+ Converter.convert(self, :srgb)
12
+ end
13
+
14
+ def to_rgb
15
+ # assume they mean srgb
16
+ to_srgb
17
+ end
18
+
19
+ def to_srgb!
20
+ unless color_space.name == :srgb
21
+ srgb_color = to_srgb
22
+ @color_space = srgb_color.color_space
23
+ @coordinates = srgb_color.coordinates
24
+ end
25
+ self
26
+ end
27
+
28
+ def to_rgb!
29
+ # assume they mean srgb
30
+ to_srgb!
31
+ end
32
+
33
+ def red
34
+ to_srgb.coordinates[0]
35
+ end
36
+
37
+ def green
38
+ to_srgb.coordinates[1]
39
+ end
40
+
41
+ def blue
42
+ to_srgb.coordinates[2]
43
+ end
44
+
45
+ def srgb_values
46
+ to_srgb.coordinates
47
+ end
48
+
49
+ def rgb_values
50
+ to_srgb.coordinates
51
+ end
52
+
53
+ def rgb_array
54
+ to_srgb.coordinates.map { |c| (c * 255).round.clamp(0, 255) }
55
+ end
56
+
57
+ def rgb_hex
58
+ r, g, b = rgb_array
59
+ format("#%02x%02x%02x", r, g, b)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ColorModels
5
+ class HSV < Base
6
+ #
7
+ # Internally, we use 0..1.0 values for hsv, unlike the standard 0..360, 0..255, 0..255.
8
+ #
9
+ # Values can be converted for output.
10
+ #
11
+
12
+ register :hsv, "HSV", %w[hue saturation value]
13
+
14
+ def valid_coordinates?(coordinates)
15
+ h, s, v = coordinates
16
+ h >= 0 && h <= 1.0 &&
17
+ s >= 0 && s <= 1.0 &&
18
+ v >= 0 && v <= 1.0
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ColorModels
5
+ class Oklab
6
+ end
7
+ end
8
+ end
9
+
10
+ ColorSpace.register(
11
+ :oklab,
12
+ "Oklab",
13
+ %w[l a b],
14
+ nil,
15
+ ["ok-lab"]
16
+ )
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ColorModels
5
+ class Oklch
6
+ def self.normalize(l, c, h)
7
+ l = AbcDecimal(l)
8
+ c = AbcDecimal(c)
9
+ h = AbcDecimal(h)
10
+
11
+ # Normalize hue to 0-360 range
12
+ h -= 360 while h >= 360
13
+ h += 360 while h.negative?
14
+
15
+ # Normalize lightness and chroma to 0-1 range
16
+ l = l.clamp(0, 1)
17
+ c = c.clamp(0, 1)
18
+
19
+ [l, c, h]
20
+ end
21
+
22
+ def self.to_oklab(l, c, h)
23
+ # Convert OKLCH to OKLab
24
+ h_rad = h * Math::PI / 180
25
+ a = c * Math.cos(h_rad)
26
+ b = c * Math.sin(h_rad)
27
+ [l, a, b]
28
+ end
29
+
30
+ def self.from_oklab(l, a, b)
31
+ # Convert OKLab to OKLCH
32
+ c = Math.sqrt((a * a) + (b * b))
33
+ h = Math.atan2(b, a) * 180 / Math::PI
34
+ h += 360 if h.negative?
35
+ [l, c, h]
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ ColorSpace.register(
42
+ :oklch,
43
+ "OKLCh",
44
+ %w[lightness chroma hue],
45
+ nil,
46
+ ["ok-lch"]
47
+ )
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ColorModels
5
+ class RGB
6
+ class << self
7
+ def normalize(r, g, b)
8
+ [r, g, b].map do |value|
9
+ case value
10
+ when String
11
+ if value.end_with?("%")
12
+ AbcDecimal(value.chomp("%")) / AbcDecimal(100)
13
+ else
14
+ AbcDecimal(value) / AbcDecimal(255)
15
+ end
16
+ when Numeric
17
+ if value > 1
18
+ AbcDecimal(value) / AbcDecimal(255)
19
+ else
20
+ AbcDecimal(value)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ class ColorSpace
5
+ class << self
6
+ def registry
7
+ @registry ||= {}
8
+ end
9
+
10
+ def register(name, &block)
11
+ registry[name.to_sym] = new(name, &block)
12
+ end
13
+
14
+ def alias(name, aliased_name)
15
+ registry[aliased_name.to_sym] = registry[name.to_sym]
16
+ end
17
+
18
+ def find(name)
19
+ registry[name.to_sym] or raise ArgumentError, "Unknown color space: #{name}"
20
+ end
21
+ end
22
+
23
+ attr_reader :name, :coordinates, :white_point, :color_model
24
+
25
+ def initialize(name)
26
+ @name = name.to_sym
27
+ yield self if block_given?
28
+ end
29
+
30
+ def coordinates=(*coords)
31
+ @coordinates = coords.flatten
32
+ end
33
+
34
+ def white_point=(point)
35
+ @white_point = point.to_sym
36
+ end
37
+
38
+ def color_model=(model)
39
+ @color_model = model.to_sym
40
+ end
41
+
42
+ def ==(other)
43
+ return false unless other.is_a?(ColorSpace)
44
+
45
+ name == other.name
46
+ end
47
+
48
+ def eql?(other)
49
+ self == other
50
+ end
51
+
52
+ def hash
53
+ name.hash
54
+ end
55
+
56
+ def id
57
+ name
58
+ end
59
+ end
60
+
61
+ ColorSpace.register(:srgb) do |s|
62
+ s.coordinates = %i[red green blue]
63
+ s.white_point = :D65
64
+ s.color_model = :srgb
65
+ end
66
+ ColorSpace.alias(:srgb, :rgb)
67
+
68
+ ColorSpace.register(:lrgb) do |s|
69
+ s.coordinates = %i[red green blue]
70
+ s.white_point = :D65
71
+ s.color_model = :lrgb
72
+ end
73
+
74
+ ColorSpace.register(:hsl) do |s|
75
+ s.coordinates = %i[hue saturation lightness]
76
+ s.white_point = :D65
77
+ s.color_model = :hsl
78
+ end
79
+
80
+ ColorSpace.register(:lab) do |s|
81
+ s.coordinates = %i[lightness a b]
82
+ s.white_point = :D65
83
+ s.color_model = :lab
84
+ end
85
+
86
+ ColorSpace.register(:oklab) do |s|
87
+ s.coordinates = %i[lightness a b]
88
+ s.white_point = :D65
89
+ s.color_model = :oklab
90
+ end
91
+
92
+ ColorSpace.register(:oklch) do |s|
93
+ s.coordinates = %i[lightness chroma hue]
94
+ s.white_point = :D65
95
+ s.color_model = :oklch
96
+ end
97
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ class Converter
5
+ class << self
6
+ def registry
7
+ @registry ||= {}
8
+ end
9
+
10
+ def register(from_space, to_space, converter_class)
11
+ registry[[from_space.to_s, to_space.to_s]] = converter_class
12
+ end
13
+
14
+ def convert(color, to_space_name)
15
+ to_space = ColorSpace.find(to_space_name)
16
+ return color if color.color_space == to_space
17
+
18
+ # convert model first
19
+ to_model = to_space.color_model
20
+ converter = find_converter(color.color_space.color_model, to_model.to_s)
21
+ raise "No converter found from #{color.color_space.color_model} to #{to_model}" unless converter
22
+
23
+ converter.convert(color)
24
+ end
25
+
26
+ # Automatically register all converters in the Converters namespace
27
+ def register_all_converters
28
+ Converters.constants.each do |const_name|
29
+ const = Converters.const_get(const_name)
30
+ next unless const.is_a?(Class)
31
+
32
+ # Parse from_space and to_space from class name (e.g., LrgbToOklab)
33
+ next unless const_name.to_s =~ /^(.+)To(.+)$/
34
+
35
+ from_space = ::Regexp.last_match(1).downcase.to_sym
36
+ to_space = ::Regexp.last_match(2).downcase.to_sym
37
+
38
+ # Register the converter
39
+ register(from_space, to_space, const)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def find_converter(from_space_name, to_space_name)
46
+ registry[[from_space_name.to_s, to_space_name.to_s]]
47
+ end
48
+ end
49
+ end
50
+
51
+ # Load all converter files
52
+ converters_path = File.join(__dir__, "converters", "*.rb")
53
+ Dir[converters_path].each do |file|
54
+ require file
55
+ end
56
+
57
+ # Auto-register all converters
58
+ Converter.register_all_converters
59
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module Converters
5
+ class Base
6
+ attr_reader :from_space, :to_space
7
+
8
+ def initialize(from_space, to_space)
9
+ @from_space = from_space
10
+ @to_space = to_space
11
+ end
12
+
13
+ def convert(color)
14
+ raise NotImplementedError, "Subclasses must implement #convert"
15
+ end
16
+
17
+ def self.raise_unless(color, model)
18
+ return if color.color_space.color_model == model
19
+
20
+ raise "#{color} is #{color.color_space.color_model}), expecting #{model}"
21
+ end
22
+
23
+ def can_convert?(color)
24
+ color.color_space == from_space
25
+ end
26
+
27
+ def self.register(from_space_id, to_space_id, converter_class)
28
+ @converters ||= {}
29
+ @converters[[from_space_id, to_space_id]] = converter_class
30
+ end
31
+
32
+ def self.find_converter(from_space_id, to_space_id)
33
+ @converters ||= {}
34
+ @converters[[from_space_id, to_space_id]]
35
+ end
36
+
37
+ def self.convert(color, to_space)
38
+ converter_class = find_converter(color.color_space.id, to_space.id)
39
+ unless converter_class
40
+ raise ConversionError,
41
+ "No converter found from #{color.color_space.name} to #{to_space.name}"
42
+ end
43
+
44
+ converter = converter_class.new(color.color_space, to_space)
45
+ converter.convert(color)
46
+ end
47
+
48
+ private
49
+
50
+ def validate_color!(color)
51
+ return if can_convert?(color)
52
+
53
+ raise ArgumentError, "Cannot convert color from #{color.color_space.name} (expected #{from_space.name})"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module Converters
5
+ class LrgbToOklab < Abachrome::Converters::Base
6
+ def self.convert(rgb_color)
7
+ raise_unless rgb_color, :lrgb
8
+
9
+ r, g, b = rgb_color.coordinates.map { |_| AbcDecimal(_) }
10
+
11
+ l = (AD("0.41222147079999993") * r) + (AD("0.5363325363") * g) + (AD("0.0514459929") * b)
12
+ m = (AD("0.2119034981999999") * r) + (AD("0.680699545099999") * g) + (AD("0.1073969566") * b)
13
+ s = (AD("0.08830246189999998") * r) + (AD("0.2817188376") * g) + (AD("0.6299787005000002") * b)
14
+
15
+ l_ = AbcDecimal(l)**Rational(1, 3)
16
+ m_ = AbcDecimal(m)**Rational(1, 3)
17
+ s_ = AbcDecimal(s)**Rational(1, 3)
18
+
19
+ lightness = (AD("0.2104542553") * l_) + (AD("0.793617785") * m_) - (AD("0.0040720468") * s_)
20
+ a = (AD("1.9779984951") * l_) - (AD("2.4285922050") * m_) + (AD("0.4505937099") * s_)
21
+ b = (AD("0.0259040371") * l_) + (AD("0.7827717662") * m_) - (AD("0.8086757660") * s_)
22
+
23
+ Color.new(ColorSpace.find(:oklab), [lightness, a, b], rgb_color.alpha)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module Converters
5
+ class LrgbToSrgb < Abachrome::Converters::Base
6
+ def self.convert(lrgb_color)
7
+ raise_unless lrgb_color, :lrgb
8
+ r, g, b = lrgb_color.coordinates.map { |c| to_srgb(AbcDecimal(c)) }
9
+
10
+ output_coords = [r, g, b]
11
+
12
+ Color.new(
13
+ ColorSpace.find(:srgb),
14
+ output_coords,
15
+ lrgb_color.alpha
16
+ )
17
+ end
18
+
19
+ def self.to_srgb(v)
20
+ v_abs = v.abs
21
+ v_sign = v.negative? ? -1 : 1
22
+ if v_abs <= AD("0.0031308")
23
+ v * AD("12.92")
24
+ else
25
+ v_sign * ((AD("1.055") * (v_abs**Rational(1.0, 2.4))) - AD("0.055"))
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end