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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 88fab17313d84aca8190acb8c108af84d1129ce9e240353e2afec2225fc5aa37
4
+ data.tar.gz: f98569f28bf4a1fa967f43bc8538de967e7dfa34f40f22749099b51b8b7512ac
5
+ SHA512:
6
+ metadata.gz: 1df4f29f6700b0488656b3a7c7dc22a1aa72cfab8d6a11cfc89363acd57488a39e9ded2d79f397c2727e7cbb5a2b203f41975cdbc891de3028435b3d40be1915
7
+ data.tar.gz: d31cb69b1412ab7e5c06349ce2e44b9b1ecf1324d2b862d26e19adbf496bfeeec0c1195382bf5f4092d0fe12407e4d9d9024c7fe1065e52b3b353f94e55a79a4
data/.rubocop.yml ADDED
@@ -0,0 +1,10 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.0
4
+
5
+ Style/StringLiterals:
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ EnforcedStyle: double_quotes
10
+
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-02-09
4
+
5
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # Abachrome
2
+
3
+ Abachrome is a Ruby gem for parsing, manipulating, and managing colors. It provides a robust set of tools for working with various color formats including hex, RGB, HSL, and named colors.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'abachrome'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ $ gem install abachrome
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Basic Color Creation
28
+
29
+ ```ruby
30
+ # Create colors in different ways
31
+ color = Abachrome.from_rgb(1.0, 0.0, 0.0) # Red using RGB values
32
+ color = Abachrome.from_hex('#FF0000') # Red using hex
33
+ color = Abachrome.from_name('red') # Red using CSS color name
34
+
35
+ # Create color with alpha
36
+ color = Abachrome.from_rgb(1.0, 0.0, 0.0, 0.5) # Semi-transparent red
37
+ ```
38
+
39
+ ### Color Space Conversion
40
+
41
+ ```ruby
42
+ # Convert between color spaces
43
+ rgb_color = Abachrome.from_rgb(1.0, 0.0, 0.0)
44
+ oklab_color = rgb_color.to_oklab # Convert to Oklab
45
+ rgb_again = oklab_color.to_rgb # Convert back to RGB
46
+ ```
47
+
48
+ ### Color Output Formats
49
+
50
+ ```ruby
51
+ color = Abachrome.from_rgb(1.0, 0.0, 0.0)
52
+
53
+ # Different output formats
54
+ color.rgb_hex # => "#ff0000"
55
+ Abachrome::Outputs::CSS.format(color) # => "#ff0000"
56
+ Abachrome::Outputs::CSS.format_rgb(color) # => "rgb(255, 0, 0)"
57
+ ```
58
+
59
+ ### Working with Color Gamuts
60
+
61
+ ```ruby
62
+ # Check if color is within gamut
63
+ srgb_gamut = Abachrome::Gamut::SRGB.new
64
+ color = Abachrome.from_rgb(1.2, -0.1, 0.5)
65
+ mapped_color = srgb_gamut.map(color.coordinates) # Map color to gamut
66
+ ```
67
+
68
+ ## Features
69
+
70
+ - Support for multiple color spaces (RGB, HSL, Lab, Oklab)
71
+ - Color space conversion
72
+ - Gamut mapping
73
+ - CSS color parsing and formatting
74
+ - Support for CSS named colors
75
+ - High-precision color calculations using BigDecimal
76
+ - Alpha channel support
77
+
78
+ ## Requirements
79
+
80
+ - Ruby >= 3.0.0
81
+
82
+ ## License
83
+
84
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
85
+
86
+ ## Development
87
+
88
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
89
+
90
+ To install this gem onto your local machine, run `bundle exec rake install`.
91
+
92
+ ## Contributing
93
+
94
+ Bug reports and pull requests are welcome on GitHub at https://github.com/durableprogramming/abachrome.
95
+
96
+ # Acknowledgement
97
+
98
+ We'd like to thank the excellent Color.js and culori color libraries, which helped inspire this project and
99
+ inform its design.
@@ -0,0 +1,124 @@
1
+ require 'bundler/setup'
2
+
3
+ require "curses"
4
+ require "abachrome"
5
+
6
+ DELAY = 0.0016
7
+ PLASMA_SCALE = 0.02
8
+ TIME_SCALE = 2.0
9
+
10
+ # Generate base hue for the palette
11
+ base_hue = rand(0..360)
12
+ base_color = Abachrome.from_oklch(0.5, 0.2, base_hue)
13
+
14
+ # Create 255 shades palette interpolating from black to base_color to white
15
+ black = Abachrome.from_rgb(0, 0, 0)
16
+ white = Abachrome.from_rgb(1, 1, 1)
17
+ palette = Abachrome::Palette.new([black, base_color, white])
18
+ palette = palette.interpolate(32)
19
+
20
+ def plasma(x, y, t)
21
+ x_scaled = x * PLASMA_SCALE
22
+ y_scaled = y * PLASMA_SCALE
23
+ t_scaled = t * TIME_SCALE
24
+
25
+ value = Math.sin(x_scaled) + Math.sin(y_scaled) +
26
+ Math.sin(x_scaled + y_scaled + t_scaled) +
27
+ Math.sin(Math.sqrt((x_scaled * x_scaled + y_scaled * y_scaled)) + t_scaled)
28
+
29
+ # Normalize to 0..1 range
30
+ (value + 4) / 8
31
+ end
32
+
33
+ def pick_color(value, palette)
34
+ normalized = (value * (palette.size - 1))
35
+
36
+ # Error diffusion dithering
37
+ error = normalized - normalized.floor
38
+ if rand < error
39
+ normalized.ceil
40
+ else
41
+ normalized.floor
42
+ end + 1
43
+ end
44
+
45
+ begin
46
+ require 'curses'
47
+
48
+ Curses.init_screen
49
+ Curses.start_color
50
+ Curses.curs_set(0)
51
+ Curses.noecho
52
+ Curses.cbreak
53
+ Curses.stdscr.nodelay = 1
54
+
55
+ total_colors = palette.size
56
+ bar_width = 50
57
+
58
+ Curses.setpos(Curses.lines / 2 - 1, (Curses.cols - 20) / 2)
59
+ Curses.addstr("Initializing colors")
60
+
61
+ Curses.use_default_colors
62
+ palette.each_with_index do |color, i|
63
+ progress = ((i + 1).to_f / total_colors * bar_width).to_i
64
+
65
+ Curses.attron(Curses.color_pair(0)) {
66
+ Curses.setpos(Curses.lines / 2, (Curses.cols - bar_width) / 2)
67
+ Curses.addstr("[" + "#" * progress + " " * (bar_width - progress) + "]")
68
+
69
+ Curses.setpos(Curses.lines / 2 + 1, (Curses.cols - 20) / 2)
70
+ percentage = ((i + 1).to_f / total_colors * 100).to_i
71
+ Curses.addstr("#{percentage}% complete")
72
+ }
73
+
74
+ Curses.refresh
75
+
76
+ rgb = color.rgb_array.map { |_| ((_ * 1000)/255).to_i }
77
+ Curses.init_color(i + 1, rgb[0], rgb[1], rgb[2])
78
+ Curses.init_pair(i + 1, i + 1, i + 1)
79
+
80
+ end
81
+
82
+ Curses.setpos(Curses.lines / 2 + 2, (Curses.cols - 20) / 2)
83
+ Curses.addstr("Done!")
84
+ Curses.refresh
85
+
86
+ height = Curses.lines
87
+ width = Curses.cols
88
+ start_time = Time.now
89
+ frame_count = 0
90
+ last_fps_update = start_time
91
+
92
+ loop do
93
+
94
+ t = Time.now - start_time
95
+ frame_count += 1
96
+
97
+ # Update FPS counter every second
98
+ if Time.now - last_fps_update >= 1
99
+ fps = frame_count / (Time.now - last_fps_update)
100
+ frame_count = 0
101
+ last_fps_update = Time.now
102
+ fps_str = format("FPS: %.1f", fps.to_f)
103
+ Curses.setpos(height - 1, width - fps_str.length)
104
+ Curses.addstr(fps_str)
105
+ Curses.setpos(height - 1, 0)
106
+ Curses.addstr('Frame' + frame_count.to_s)
107
+ end
108
+
109
+ height.times do |y|
110
+ width.times do |x|
111
+ value = plasma(x, y, t)
112
+ color_index = pick_color(value, palette)
113
+ Curses.setpos(y, x)
114
+ Curses.attron(Curses.color_pair(color_index.to_i)) { Curses.addstr("X") }
115
+ end
116
+ end
117
+
118
+ Curses.refresh
119
+ sleep(DELAY)
120
+ end
121
+
122
+ ensure
123
+ Curses.close_screen
124
+ end
data/devenv.lock ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "nodes": {
3
+ "devenv": {
4
+ "locked": {
5
+ "dir": "src/modules",
6
+ "lastModified": 1739172804,
7
+ "owner": "cachix",
8
+ "repo": "devenv",
9
+ "rev": "f8be0ed0141abab89cefb20d3d375740671fbee1",
10
+ "type": "github"
11
+ },
12
+ "original": {
13
+ "dir": "src/modules",
14
+ "owner": "cachix",
15
+ "repo": "devenv",
16
+ "type": "github"
17
+ }
18
+ },
19
+ "flake-compat": {
20
+ "flake": false,
21
+ "locked": {
22
+ "lastModified": 1733328505,
23
+ "owner": "edolstra",
24
+ "repo": "flake-compat",
25
+ "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
26
+ "type": "github"
27
+ },
28
+ "original": {
29
+ "owner": "edolstra",
30
+ "repo": "flake-compat",
31
+ "type": "github"
32
+ }
33
+ },
34
+ "gitignore": {
35
+ "inputs": {
36
+ "nixpkgs": [
37
+ "pre-commit-hooks",
38
+ "nixpkgs"
39
+ ]
40
+ },
41
+ "locked": {
42
+ "lastModified": 1709087332,
43
+ "owner": "hercules-ci",
44
+ "repo": "gitignore.nix",
45
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
46
+ "type": "github"
47
+ },
48
+ "original": {
49
+ "owner": "hercules-ci",
50
+ "repo": "gitignore.nix",
51
+ "type": "github"
52
+ }
53
+ },
54
+ "nixpkgs": {
55
+ "locked": {
56
+ "lastModified": 1733477122,
57
+ "owner": "cachix",
58
+ "repo": "devenv-nixpkgs",
59
+ "rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857",
60
+ "type": "github"
61
+ },
62
+ "original": {
63
+ "owner": "cachix",
64
+ "ref": "rolling",
65
+ "repo": "devenv-nixpkgs",
66
+ "type": "github"
67
+ }
68
+ },
69
+ "pre-commit-hooks": {
70
+ "inputs": {
71
+ "flake-compat": "flake-compat",
72
+ "gitignore": "gitignore",
73
+ "nixpkgs": [
74
+ "nixpkgs"
75
+ ]
76
+ },
77
+ "locked": {
78
+ "lastModified": 1737465171,
79
+ "owner": "cachix",
80
+ "repo": "pre-commit-hooks.nix",
81
+ "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
82
+ "type": "github"
83
+ },
84
+ "original": {
85
+ "owner": "cachix",
86
+ "repo": "pre-commit-hooks.nix",
87
+ "type": "github"
88
+ }
89
+ },
90
+ "root": {
91
+ "inputs": {
92
+ "devenv": "devenv",
93
+ "nixpkgs": "nixpkgs",
94
+ "pre-commit-hooks": "pre-commit-hooks"
95
+ }
96
+ }
97
+ },
98
+ "root": "root",
99
+ "version": 7
100
+ }
data/devenv.nix ADDED
@@ -0,0 +1,51 @@
1
+ { pkgs, lib, config, inputs, ... }:
2
+
3
+ {
4
+ # https://devenv.sh/basics/
5
+ env.GREET = "devenv";
6
+
7
+ # https://devenv.sh/packages/
8
+ packages = [ pkgs.git pkgs.ncurses pkgs.asciinema pkgs.asciinema-agg];
9
+
10
+ languages.ruby.enable = true;
11
+
12
+ languages.javascript.enable = true;
13
+ languages.javascript.npm.enable = true;
14
+ languages.javascript.bun.enable = true;
15
+
16
+ # https://devenv.sh/languages/
17
+ # languages.rust.enable = true;
18
+
19
+ # https://devenv.sh/processes/
20
+ # processes.cargo-watch.exec = "cargo-watch";
21
+
22
+ # https://devenv.sh/services/
23
+ # services.postgres.enable = true;
24
+
25
+ # https://devenv.sh/scripts/
26
+ scripts.hello.exec = ''
27
+ echo hello from $GREET
28
+ '';
29
+
30
+ enterShell = ''
31
+ hello
32
+ git --version
33
+ '';
34
+
35
+ # https://devenv.sh/tasks/
36
+ # tasks = {
37
+ # "myproj:setup".exec = "mytool build";
38
+ # "devenv:enterShell".after = [ "myproj:setup" ];
39
+ # };
40
+
41
+ # https://devenv.sh/tests/
42
+ enterTest = ''
43
+ echo "Running tests"
44
+ git --version | grep --color=auto "${pkgs.git.version}"
45
+ '';
46
+
47
+ # https://devenv.sh/pre-commit-hooks/
48
+ # pre-commit.hooks.shellcheck.enable = true;
49
+
50
+ # See full reference at https://devenv.sh/reference/options/
51
+ }
data/devenv.yaml ADDED
@@ -0,0 +1,15 @@
1
+ # yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
2
+ inputs:
3
+ nixpkgs:
4
+ url: github:cachix/devenv-nixpkgs/rolling
5
+
6
+ # If you're using non-OSS software, you can set allowUnfree to true.
7
+ # allowUnfree: true
8
+
9
+ # If you're willing to use a package that's vulnerable
10
+ # permittedInsecurePackages:
11
+ # - "openssl-1.1.1w"
12
+
13
+ # If you have more than one devenv you can merge them
14
+ #imports:
15
+ # - ./backend
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "forwardable"
5
+
6
+ module Abachrome
7
+ class AbcDecimal
8
+ extend Forwardable
9
+ DEFAULT_PRECISION = (ENV["ABC_DECIMAL_PRECISION"] || "24").to_i
10
+
11
+ attr_accessor :value, :precision
12
+
13
+ def_delegators :@value, :to_i, :to_f, :zero?, :nonzero?
14
+
15
+ def initialize(value, precision = DEFAULT_PRECISION)
16
+ @precision = precision
17
+ @value = case value
18
+ when AbcDecimal
19
+ value.value
20
+ when BigDecimal
21
+ value
22
+ when Rational
23
+ value
24
+ else
25
+ BigDecimal(value.to_s, precision)
26
+ end
27
+ end
28
+
29
+ def to_s
30
+ if @value.is_a?(Rational)
31
+ BigDecimal(@value, precision).to_s("F")
32
+ else
33
+ @value.to_s("F") # different behaviour than default BigDecimal
34
+ end
35
+ end
36
+
37
+ def to_f
38
+ @value.to_f
39
+ end
40
+
41
+ def self.from_string(str, precision = DEFAULT_PRECISION)
42
+ new(str, precision)
43
+ end
44
+
45
+ def self.from_rational(rational, precision = DEFAULT_PRECISION)
46
+ new(rational, precision)
47
+ end
48
+
49
+ def self.from_float(float, precision = DEFAULT_PRECISION)
50
+ new(float, precision)
51
+ end
52
+
53
+ def self.from_integer(integer, precision = DEFAULT_PRECISION)
54
+ new(integer, precision)
55
+ end
56
+
57
+ def +(other)
58
+ other_value = other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value
59
+ self.class.new(@value + other_value)
60
+ end
61
+
62
+ def -(other)
63
+ other_value = other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value
64
+ self.class.new(@value - other_value)
65
+ end
66
+
67
+ def *(other)
68
+ other_value = other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value
69
+ self.class.new(@value * other_value)
70
+ end
71
+
72
+ def /(other)
73
+ other_value = other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value
74
+ self.class.new(@value / other_value)
75
+ end
76
+
77
+ def %(other)
78
+ other_value = other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value
79
+ self.class.new(@value % other_value)
80
+ end
81
+
82
+ def clamp(min,max)
83
+ @value.clamp(AbcDecimal(min),AbcDecimal(max))
84
+ end
85
+
86
+ def **(other)
87
+ if other.is_a?(Rational)
88
+ self.class.new(@value**other)
89
+ else
90
+ other_value = other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value
91
+ self.class.new(@value**other_value)
92
+ end
93
+ end
94
+
95
+ def coerce(other)
96
+ [self.class.new(other), self]
97
+ end
98
+
99
+ def inspect
100
+ "#{self.class}('#{self}')"
101
+ end
102
+
103
+ def ==(other)
104
+ @value == (other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value)
105
+ end
106
+
107
+ def <=>(other)
108
+ @value <=> (other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value)
109
+ end
110
+
111
+ def >(other)
112
+ @value > (other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value)
113
+ end
114
+
115
+ def >=(other)
116
+ @value >= (other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value)
117
+ end
118
+
119
+ def <(other)
120
+ @value < (other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value)
121
+ end
122
+
123
+ def <=(other)
124
+ @value <= (other.is_a?(AbcDecimal) ? other.value : AbcDecimal(other).value)
125
+ end
126
+
127
+ def clamp(*args)
128
+ AbcDecimal(@value.clamp(*args))
129
+ end
130
+
131
+ def round(*args)
132
+ AbcDecimal(@value.round(*args))
133
+ end
134
+
135
+ def abs(*args)
136
+ AbcDecimal(@value.abs(*args))
137
+ end
138
+
139
+ def sqrt
140
+ AbcDecimal(Math.sqrt(@value))
141
+ end
142
+
143
+ def negative?
144
+ @value.negative?
145
+ end
146
+
147
+ def self.atan2(y, x)
148
+ y_value = y.is_a?(AbcDecimal) ? y.value : AbcDecimal(y).value
149
+ x_value = x.is_a?(AbcDecimal) ? x.value : AbcDecimal(x).value
150
+ new(Math.atan2(y_value, x_value))
151
+ end
152
+ end
153
+ end
154
+
155
+ def AbcDecimal(*args)
156
+ Abachrome::AbcDecimal.new(*args)
157
+ end
158
+
159
+ def AD(*args)
160
+ Abachrome::AbcDecimal.new(*args)
161
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-inflector"
4
+ require_relative "abc_decimal"
5
+ require_relative "color_space"
6
+
7
+ module Abachrome
8
+ class Color
9
+ attr_reader :color_space, :coordinates, :alpha
10
+
11
+ def initialize(color_space, coordinates, alpha = AbcDecimal("1.0"))
12
+ @color_space = color_space
13
+ @coordinates = coordinates.map { |c| AbcDecimal(c.to_s) }
14
+ @alpha = AbcDecimal.new(alpha.to_s)
15
+
16
+ validate_coordinates!
17
+ end
18
+
19
+ mixins_path = File.join(__dir__, "color_mixins", "*.rb")
20
+ Dir[mixins_path].each do |file|
21
+ require file
22
+ mixin_name = File.basename(file, ".rb")
23
+ inflector = Dry::Inflector.new
24
+ mixin_module = Abachrome::ColorMixins.const_get(inflector.camelize(mixin_name))
25
+ include mixin_module
26
+ end
27
+
28
+ def self.from_rgb(r, g, b, a = 1.0)
29
+ space = ColorSpace.find(:srgb)
30
+ new(space, [r, g, b], a)
31
+ end
32
+
33
+ def self.from_oklab(l, a, b, alpha = 1.0)
34
+ space = ColorSpace.find(:oklab)
35
+ new(space, [l, a, b], alpha)
36
+ end
37
+
38
+ def self.from_oklch(l, c, h, alpha = 1.0)
39
+ space = ColorSpace.find(:oklch)
40
+ new(space, [l, c, h], alpha)
41
+ end
42
+
43
+ def ==(other)
44
+ return false unless other.is_a?(Color)
45
+
46
+ color_space == other.color_space &&
47
+ coordinates == other.coordinates &&
48
+ alpha == other.alpha
49
+ end
50
+
51
+ def eql?(other)
52
+ self == other
53
+ end
54
+
55
+ def hash
56
+ [color_space, coordinates, alpha].map(&:to_s).hash
57
+ end
58
+
59
+ def to_s
60
+ coord_str = coordinates.map { |c| c.to_f.round(3) }.join(", ")
61
+ alpha_str = alpha == AbcDecimal.new("1.0") ? "" : ", #{alpha.to_f.round(3)}"
62
+ "#{color_space.name}(#{coord_str}#{alpha_str})"
63
+ end
64
+
65
+ private
66
+
67
+ def validate_coordinates!
68
+ return if coordinates.size == color_space.coordinates.size
69
+
70
+ raise ArgumentError,
71
+ "Expected #{color_space.coordinates.size} coordinates for #{color_space.name}, got #{coordinates.size}"
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module ColorMixins
5
+ module Blend
6
+ def blend(other, amount = 0.5, target_color_space: nil)
7
+ amount = AbcDecimal(amount)
8
+
9
+ source = target_color_space ? to_color_space(target_color_space) : self
10
+ other = other.to_color_space(source.color_space)
11
+
12
+ l1, a1, b1 = coordinates.map { |_| AbcDecimal(_) }
13
+ l2, a2, b2 = other.coordinates.map { |_| AbcDecimal(_) }
14
+
15
+ blended_l = (AbcDecimal(1 - amount) * l1) + (AbcDecimal(amount) * l2)
16
+ blended_a = (AbcDecimal(1 - amount) * a1) + (AbcDecimal(amount) * a2)
17
+ blended_b = (AbcDecimal(1 - amount) * b1) + (AbcDecimal(amount) * b2)
18
+
19
+ blended_alpha = alpha + ((other.alpha - alpha) * amount)
20
+
21
+ Color.new(
22
+ color_space,
23
+ [blended_l, blended_a, blended_b],
24
+ blended_alpha
25
+ )
26
+ end
27
+
28
+ def blend!(other, amount = 0.5)
29
+ blended = blend(other, amount)
30
+ @color_space = blended.color_space
31
+ @coordinates = blended.coordinates
32
+ @alpha = blended.alpha
33
+ self
34
+ end
35
+
36
+ def mix(other, amount = 0.5)
37
+ blend(other, amount)
38
+ end
39
+
40
+ def mix!(other, amount = 0.5)
41
+ blend!(other, amount)
42
+ end
43
+ end
44
+ end
45
+ end