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,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,171 @@
1
+ {
2
+ "nodes": {
3
+ "devenv": {
4
+ "locked": {
5
+ "dir": "src/modules",
6
+ "lastModified": 1760162706,
7
+ "owner": "cachix",
8
+ "repo": "devenv",
9
+ "rev": "0d5ad578728fe4bce66eb4398b8b1e66deceb4e4",
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": 1747046372,
23
+ "owner": "edolstra",
24
+ "repo": "flake-compat",
25
+ "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
26
+ "type": "github"
27
+ },
28
+ "original": {
29
+ "owner": "edolstra",
30
+ "repo": "flake-compat",
31
+ "type": "github"
32
+ }
33
+ },
34
+ "flake-compat_2": {
35
+ "flake": false,
36
+ "locked": {
37
+ "lastModified": 1747046372,
38
+ "owner": "edolstra",
39
+ "repo": "flake-compat",
40
+ "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
41
+ "type": "github"
42
+ },
43
+ "original": {
44
+ "owner": "edolstra",
45
+ "repo": "flake-compat",
46
+ "type": "github"
47
+ }
48
+ },
49
+ "flake-utils": {
50
+ "inputs": {
51
+ "systems": "systems"
52
+ },
53
+ "locked": {
54
+ "lastModified": 1731533236,
55
+ "owner": "numtide",
56
+ "repo": "flake-utils",
57
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
58
+ "type": "github"
59
+ },
60
+ "original": {
61
+ "owner": "numtide",
62
+ "repo": "flake-utils",
63
+ "type": "github"
64
+ }
65
+ },
66
+ "git-hooks": {
67
+ "inputs": {
68
+ "flake-compat": "flake-compat",
69
+ "gitignore": "gitignore",
70
+ "nixpkgs": [
71
+ "nixpkgs"
72
+ ]
73
+ },
74
+ "locked": {
75
+ "lastModified": 1759523803,
76
+ "owner": "cachix",
77
+ "repo": "git-hooks.nix",
78
+ "rev": "cfc9f7bb163ad8542029d303e599c0f7eee09835",
79
+ "type": "github"
80
+ },
81
+ "original": {
82
+ "owner": "cachix",
83
+ "repo": "git-hooks.nix",
84
+ "type": "github"
85
+ }
86
+ },
87
+ "gitignore": {
88
+ "inputs": {
89
+ "nixpkgs": [
90
+ "git-hooks",
91
+ "nixpkgs"
92
+ ]
93
+ },
94
+ "locked": {
95
+ "lastModified": 1709087332,
96
+ "owner": "hercules-ci",
97
+ "repo": "gitignore.nix",
98
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
99
+ "type": "github"
100
+ },
101
+ "original": {
102
+ "owner": "hercules-ci",
103
+ "repo": "gitignore.nix",
104
+ "type": "github"
105
+ }
106
+ },
107
+ "nixpkgs": {
108
+ "locked": {
109
+ "lastModified": 1758532697,
110
+ "owner": "cachix",
111
+ "repo": "devenv-nixpkgs",
112
+ "rev": "207a4cb0e1253c7658c6736becc6eb9cace1f25f",
113
+ "type": "github"
114
+ },
115
+ "original": {
116
+ "owner": "cachix",
117
+ "ref": "rolling",
118
+ "repo": "devenv-nixpkgs",
119
+ "type": "github"
120
+ }
121
+ },
122
+ "nixpkgs-ruby": {
123
+ "inputs": {
124
+ "flake-compat": "flake-compat_2",
125
+ "flake-utils": "flake-utils",
126
+ "nixpkgs": [
127
+ "nixpkgs"
128
+ ]
129
+ },
130
+ "locked": {
131
+ "lastModified": 1759902829,
132
+ "owner": "bobvanderlinden",
133
+ "repo": "nixpkgs-ruby",
134
+ "rev": "5fba6c022a63f1e76dee4da71edddad8959f088a",
135
+ "type": "github"
136
+ },
137
+ "original": {
138
+ "owner": "bobvanderlinden",
139
+ "repo": "nixpkgs-ruby",
140
+ "type": "github"
141
+ }
142
+ },
143
+ "root": {
144
+ "inputs": {
145
+ "devenv": "devenv",
146
+ "git-hooks": "git-hooks",
147
+ "nixpkgs": "nixpkgs",
148
+ "nixpkgs-ruby": "nixpkgs-ruby",
149
+ "pre-commit-hooks": [
150
+ "git-hooks"
151
+ ]
152
+ }
153
+ },
154
+ "systems": {
155
+ "locked": {
156
+ "lastModified": 1681028828,
157
+ "owner": "nix-systems",
158
+ "repo": "default",
159
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
160
+ "type": "github"
161
+ },
162
+ "original": {
163
+ "owner": "nix-systems",
164
+ "repo": "default",
165
+ "type": "github"
166
+ }
167
+ }
168
+ },
169
+ "root": "root",
170
+ "version": 7
171
+ }
data/devenv.nix ADDED
@@ -0,0 +1,52 @@
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];
9
+
10
+ languages.ruby.enable = true;
11
+ languages.ruby.version = "3.3.2";
12
+
13
+ languages.javascript.enable = true;
14
+ languages.javascript.npm.enable = true;
15
+ languages.javascript.bun.enable = true;
16
+
17
+ # https://devenv.sh/languages/
18
+ # languages.rust.enable = true;
19
+
20
+ # https://devenv.sh/processes/
21
+ # processes.cargo-watch.exec = "cargo-watch";
22
+
23
+ # https://devenv.sh/services/
24
+ # services.postgres.enable = true;
25
+
26
+ # https://devenv.sh/scripts/
27
+ scripts.hello.exec = ''
28
+ echo hello from $GREET
29
+ '';
30
+
31
+ enterShell = ''
32
+ hello
33
+ git --version
34
+ '';
35
+
36
+ # https://devenv.sh/tasks/
37
+ # tasks = {
38
+ # "myproj:setup".exec = "mytool build";
39
+ # "devenv:enterShell".after = [ "myproj:setup" ];
40
+ # };
41
+
42
+ # https://devenv.sh/tests/
43
+ enterTest = ''
44
+ echo "Running tests"
45
+ git --version | grep --color=auto "${pkgs.git.version}"
46
+ '';
47
+
48
+ # https://devenv.sh/pre-commit-hooks/
49
+ # pre-commit.hooks.shellcheck.enable = true;
50
+
51
+ # See full reference at https://devenv.sh/reference/options/
52
+ }
data/devenv.yaml ADDED
@@ -0,0 +1,8 @@
1
+ inputs:
2
+ nixpkgs:
3
+ url: github:cachix/devenv-nixpkgs/rolling
4
+ nixpkgs-ruby:
5
+ url: github:bobvanderlinden/nixpkgs-ruby
6
+ inputs:
7
+ nixpkgs:
8
+ follows: nixpkgs
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Abachrome::Color - Core color representation class
4
+ #
5
+ # This is the central color class that represents colors across multiple color spaces
6
+ # including sRGB, OKLAB, OKLCH, and linear RGB. The Color class encapsulates color
7
+ # coordinates, alpha values, and color space information while providing methods
8
+ # for color creation, conversion, and manipulation.
9
+ #
10
+ # Key features:
11
+ # - Create colors from RGB, OKLAB, OKLCH values with factory methods
12
+ # - Automatic coordinate validation against color space definitions
13
+ # - Immutable color objects with equality and hash support
14
+ # - Extensible through mixins for color space conversions and operations
15
+ # - High-precision decimal arithmetic using AbcDecimal for accurate calculations
16
+ # - Support for alpha (opacity) values with proper handling in conversions
17
+ #
18
+ # The class uses a mixin system to dynamically include functionality for converting
19
+ # between color spaces, blending operations, and lightness adjustments. All coordinate
20
+ # values are stored as AbcDecimal objects to maintain precision during color science
21
+ # calculations and transformations.
22
+
23
+ require "dry-inflector"
24
+ require_relative "color_space"
25
+
26
+ module Abachrome
27
+ class Color
28
+ attr_reader :color_space, :coordinates, :alpha
29
+
30
+ # Initializes a new Color object with the specified color space, coordinates, and alpha value.
31
+ #
32
+ # @param color_space [ColorSpace] The color space for this color instance
33
+ # @param coordinates [Array<Numeric, String>] The color coordinates in the specified color space
34
+ # @param alpha [Numeric, String] The alpha (opacity) value, between 0.0 and 1.0 (default: 1.0)
35
+ # @raise [ArgumentError] If the coordinates are invalid for the specified color space
36
+ # @return [Color] A new Color instance
37
+ def initialize(color_space, coordinates, alpha = "1.0".to_f)
38
+ @color_space = color_space
39
+ @coordinates = coordinates.map { |c| c.to_s.to_f }
40
+ @alpha = AbcDecimal.new(alpha.to_s)
41
+
42
+ validate_coordinates!
43
+ end
44
+
45
+ mixins_path = File.join(__dir__, "color_mixins", "*.rb")
46
+ Dir[mixins_path].each do |file|
47
+ require file
48
+ mixin_name = File.basename(file, ".rb")
49
+ inflector = Dry::Inflector.new
50
+ mixin_module = Abachrome::ColorMixins.const_get(inflector.camelize(mixin_name))
51
+ include mixin_module
52
+ end
53
+
54
+ # Creates a new Color instance from RGB values
55
+ #
56
+ # @param r [Numeric] The red component value (typically 0-1)
57
+ # @param g [Numeric] The green component value (typically 0-1)
58
+ # @param b [Numeric] The blue component value (typically 0-1)
59
+ # @param a [Numeric] The alpha (opacity) component value (0-1), defaults to 1.0 (fully opaque)
60
+ # @return [Abachrome::Color] A new Color instance in the sRGB color space
61
+ def self.from_rgb(r, g, b, a = 1.0)
62
+ space = ColorSpace.find(:srgb)
63
+ new(space, [r, g, b], a)
64
+ end
65
+
66
+ # Creates a new Color instance from LRGB values
67
+ #
68
+ # @param r [Numeric] The red component value (typically 0-1)
69
+ # @param g [Numeric] The green component value (typically 0-1)
70
+ # @param b [Numeric] The blue component value (typically 0-1)
71
+ # @param a [Numeric] The alpha (opacity) component value (0-1), defaults to 1.0 (fully opaque)
72
+ # @return [Abachrome::Color] A new Color instance in the sRGB color space
73
+ def self.from_lrgb(r, g, b, a = 1.0)
74
+ space = ColorSpace.find(:lrgb)
75
+ new(space, [r, g, b], a)
76
+ end
77
+
78
+ # Creates a new Color object with OKLAB values.
79
+ #
80
+ # @param l [Float] The lightness component (L) of the OKLAB color space
81
+ # @param a [Float] The green-red component (a) of the OKLAB color space
82
+ # @param b [Float] The blue-yellow component (b) of the OKLAB color space
83
+ # @param alpha [Float] The alpha (opacity) value, from 0.0 to 1.0
84
+ # @return [Abachrome::Color] A new Color object in the OKLAB color space
85
+ def self.from_oklab(l, a, b, alpha = 1.0)
86
+ space = ColorSpace.find(:oklab)
87
+ new(space, [l, a, b], alpha)
88
+ end
89
+
90
+ # Creates a new color instance in the OKLCH color space.
91
+ #
92
+ # @param l [Numeric] The lightness component (L), typically in range 0..1
93
+ # @param c [Numeric] The chroma component (C), typically starting from 0 with no upper bound
94
+ # @param h [Numeric] The hue component (H) in degrees, typically in range 0..360
95
+ # @param alpha [Float] The alpha (opacity) component, in range 0..1, defaults to 1.0 (fully opaque)
96
+ # @return [Abachrome::Color] A new Color instance in the OKLCH color space
97
+ def self.from_oklch(l, c, h, alpha = 1.0)
98
+ space = ColorSpace.find(:oklch)
99
+ new(space, [l, c, h], alpha)
100
+ end
101
+
102
+ # Creates a new Color instance from YIQ values
103
+ #
104
+ # @param y [Numeric] The luma (brightness) component, typically in range 0 to 1
105
+ # @param i [Numeric] The in-phase component (orange-blue), typically in range -0.5957 to 0.5957
106
+ # @param q [Numeric] The quadrature component (purple-green), typically in range -0.5226 to 0.5226
107
+ # @param alpha [Numeric] The alpha (opacity) component value (0-1), defaults to 1.0 (fully opaque)
108
+ # @return [Abachrome::Color] A new Color instance in the YIQ color space
109
+ def self.from_yiq(y, i, q, alpha = 1.0)
110
+ space = ColorSpace.find(:yiq)
111
+ new(space, [y, i, q], alpha)
112
+ end
113
+
114
+ # Creates a new Color instance from CMYK values
115
+ #
116
+ # @param c [Numeric] The cyan component, typically in range 0 to 1
117
+ # @param m [Numeric] The magenta component, typically in range 0 to 1
118
+ # @param y [Numeric] The yellow component, typically in range 0 to 1
119
+ # @param k [Numeric] The key/black component, typically in range 0 to 1
120
+ # @param alpha [Numeric] The alpha (opacity) component value (0-1), defaults to 1.0 (fully opaque)
121
+ # @return [Abachrome::Color] A new Color instance in the CMYK color space
122
+ def self.from_cmyk(c, m, y, k, alpha = 1.0)
123
+ space = ColorSpace.find(:cmyk)
124
+ new(space, [c, m, y, k], alpha)
125
+ end
126
+
127
+ # Compares this color instance with another for equality.
128
+ #
129
+ # Two colors are considered equal if they have the same color space,
130
+ # coordinates, and alpha value.
131
+ #
132
+ # @param other [Object] The object to compare with
133
+ # @return [Boolean] true if the colors are equal, false otherwise
134
+ def ==(other)
135
+ return false unless other.is_a?(Color)
136
+
137
+ color_space == other.color_space &&
138
+ coordinates == other.coordinates &&
139
+ alpha == other.alpha
140
+ end
141
+
142
+ # Checks if this color is equal to another color object.
143
+ #
144
+ # @param other [Object] The object to compare with
145
+ # @return [Boolean] true if the two colors are equal, false otherwise
146
+ # @see ==
147
+ def eql?(other)
148
+ self == other
149
+ end
150
+
151
+ # Generates a hash code for this color instance
152
+ # based on its color space, coordinates, and alpha value.
153
+ # The method first converts these components to strings,
154
+ # then computes a hash of the resulting array.
155
+ #
156
+ # @return [Integer] a hash code that can be used for equality comparison
157
+ # and as a hash key in Hash objects
158
+ def hash
159
+ [color_space, coordinates, alpha].map(&:to_s).hash
160
+ end
161
+
162
+ # Returns a string representation of the color in the format "ColorSpaceName(coord1, coord2, coord3, alpha)"
163
+ #
164
+ # @return [String] A human-readable string representation of the color showing its
165
+ # color space name, coordinate values rounded to 3 decimal places, and alpha value
166
+ # (if not 1.0)
167
+ def to_s
168
+ coord_str = coordinates.map { |c| c.to_f.round(3) }.join(", ")
169
+ alpha_str = alpha == AbcDecimal.new("1.0") ? "" : ", #{alpha.to_f.round(3)}"
170
+ "#{color_space.name}(#{coord_str}#{alpha_str})"
171
+ end
172
+
173
+ # Returns the color as a hexadecimal string representation.
174
+ #
175
+ # @return [String] A hex color string in the format "#RRGGBB" or "#RRGGBBAA" if alpha < 1.0
176
+ def to_hex
177
+ require_relative "outputs/css"
178
+ Outputs::CSS.format_hex(self)
179
+ end
180
+
181
+ private
182
+
183
+ # Validates that the number of coordinates matches the expected number for the color space.
184
+ # Compares the size of the coordinates array with the number of coordinates
185
+ # defined in the associated color space.
186
+ # @raise [ArgumentError] when the number of coordinates doesn't match the color space definition
187
+ # @return [nil] if validation passes
188
+ def validate_coordinates!
189
+ return if coordinates.size == color_space.coordinates.size
190
+
191
+ raise ArgumentError,
192
+ "Expected #{color_space.coordinates.size} coordinates for #{color_space.name}, got #{coordinates.size}"
193
+ end
194
+ end
195
+ end
196
+
197
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Abachrome::ColorMixins::Blend - Color blending and mixing functionality
4
+ #
5
+ # This mixin provides methods for blending and mixing colors together in various color spaces.
6
+ # The blend operation interpolates between two colors by a specified amount, creating smooth
7
+ # color transitions. All blending operations preserve alpha values and can be performed in
8
+ # the current color space or a specified target color space for optimal results.
9
+ #
10
+ # Key features:
11
+ # - Linear interpolation between colors with configurable blend amounts
12
+ # - Support for blending in different color spaces (sRGB, OKLAB, OKLCH)
13
+ # - Both non-destructive (blend/mix) and destructive (blend!/mix!) variants
14
+ # - Automatic color space conversion when blending colors from different spaces
15
+ # - High-precision decimal arithmetic for accurate color calculations
16
+ #
17
+ # The mixin includes both immutable methods that return new color instances and mutable
18
+ # methods that modify the current color object in place, providing flexibility for
19
+ # different use cases and performance requirements.
20
+
21
+ module Abachrome
22
+ module ColorMixins
23
+ module Blend
24
+ # @method blend
25
+ # Interpolates between two colors, creating a new color that is a blend of the two.
26
+ # The blend happens in the specified color space or the current color space if none is provided.
27
+ # #
28
+ # @param [Abachrome::Color] other The color to blend with
29
+ # @param [Float, Integer, #to_d] amount The blend amount between 0 and 1, where 0 returns the original color and 1 returns the other color. Defaults to 0.5 (midpoint)
30
+ # @param [Symbol, nil] target_color_space The color space to perform the blend in (optional)
31
+ # @return [Abachrome::Color] A new color representing the blend of the two colors
32
+ # @example Blend two colors equally
33
+ # red.blend(blue, 0.5)
34
+ # @example Blend with 25% of another color
35
+ # red.blend(blue, 0.25)
36
+ # @example Blend in a specific color space
37
+ # red.blend(blue, 0.5, target_color_space: :oklab)
38
+ def blend(other, amount = 0.5, target_color_space: nil)
39
+ amount = amount.to_f
40
+
41
+ source = target_color_space ? to_color_space(target_color_space) : self
42
+ other = other.to_color_space(source.color_space)
43
+
44
+ l1, a1, b1 = coordinates.map { |_| _.to_f }
45
+ l2, a2, b2 = other.coordinates.map { |_| _.to_f }
46
+
47
+ blended_l = (1 - amount.to_f * l1) + (amount.to_f * l2)
48
+ blended_a = (1 - amount.to_f * a1) + (amount.to_f * a2)
49
+ blended_b = (1 - amount.to_f * b1) + (amount.to_f * b2)
50
+
51
+ blended_alpha = alpha + ((other.alpha - alpha) * amount)
52
+
53
+ Color.new(
54
+ color_space,
55
+ [blended_l, blended_a, blended_b],
56
+ blended_alpha
57
+ )
58
+ end
59
+
60
+ # Blends this color with another color by the specified amount.
61
+ # This is a destructive version of the blend method, modifying the current
62
+ # color in place.
63
+ #
64
+ # @param other [Abachrome::Color] The color to blend with
65
+ # @param amount [Float] The blend amount, between 0.0 and 1.0, where 0.0 is
66
+ # this color and 1.0 is the other color (default: 0.5)
67
+ # @return [Abachrome::Color] Returns self after modification
68
+ def blend!(other, amount = 0.5)
69
+ blended = blend(other, amount)
70
+ @color_space = blended.color_space
71
+ @coordinates = blended.coordinates
72
+ @alpha = blended.alpha
73
+ self
74
+ end
75
+
76
+ # Alias for the blend method that mixes two colors together.
77
+ #
78
+ # @param other [Abachrome::Color] The color to mix with
79
+ # @param amount [Float] The amount to mix, between 0.0 and 1.0, where 0.0 returns the original color and 1.0 returns the other color (default: 0.5)
80
+ # @return [Abachrome::Color] A new color resulting from the mix of the two colors
81
+ def mix(other, amount = 0.5)
82
+ blend(other, amount)
83
+ end
84
+
85
+ # Mix the current color with another color.
86
+ #
87
+ # This method is an alias for blend!. It combines the current color with
88
+ # the provided color at the specified amount.
89
+ #
90
+ # @param other [Abachrome::Color] The color to mix with the current color
91
+ # @param amount [Numeric] The amount of the other color to mix in, from 0 to 1 (default: 0.5)
92
+ # @return [self] Returns the modified color object
93
+ def mix!(other, amount = 0.5)
94
+ blend!(other, amount)
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.