module Color
Color – Color Math in Ruby
-
changelog: CHANGELOG
Color is a Ruby library to provide RGB, CMYK, HSL, and other color space manipulation support to applications that require it. It provides optional named RGB colors that are commonly supported in HTML, SVG, and X11 applications.
The Color library performs purely mathematical manipulation of the colors based on color theory without reference to device color profiles (such as sRGB or Adobe RGB). For most purposes, when working with RGB and HSL color spaces, this won’t matter. Absolute color spaces (like CIE LAB and CIE XYZ) cannot be reliably converted to relative color spaces (like RGB) without color profiles. When necessary for conversions, Color provides D65 and D50 reference white values in Color::XYZ.
Color 2.1 fixes a Color::XYZ bug where the values were improperly clamped and adds more Color::XYZ white points for standard illuminants. It builds on the Color 2.0 major release, dropping support for all versions of Ruby prior to 3.2 as well as removing or renaming a number of features. The main breaking changes are:
-
Colorclasses are immutable Data objects; they are no longer mutable. -
RGBnamed colors are no longer loaded on gem startup, but must be required explicitly (this is not done viaautoloadbecause there are more than 100 named colors with spelling variations) withrequire "color/rgb/colors". -
Colorpalettes have been removed. -
Color::CSSandColor::CSS#[]have been removed.
Constants
- EPSILON
-
The maximum “resolution” for color math; if any value is less than or equal to this value, it is treated as zero.
- TOLERANCE
-
The tolerance for comparing the components of two colors. In general, colors are considered equal if all of their components are within this tolerance value of each other.
Public Instance Methods
Source
# File lib/color.rb, line 70 def ==(other) other.is_a?(Color) && to_internal.zip(coerce(other).to_internal).all? { near?(_1, _2) } end
Compares the other color to this one. The other color will be coerced to the same type as the current color. Such converted color comparisons will always be more approximate than non-converted comparisons.
All values are compared as floating-point values, so two colors will be reported equivalent if all component values are within TOLERANCE of each other.
Source
# File lib/color.rb, line 145 def from_internal(...) = self.class.from_internal(...) ## # Returns `true` if the value is less than EPSILON. def near_zero?(value) = (value.abs <= Color::EPSILON) # :nodoc: ## # Returns `true` if the value is within EPSILON of zero or less than zero. def near_zero_or_less?(value) = (value < 0.0 or near_zero?(value)) # :nodoc: ## # Returns +true+ if the value is within EPSILON of one. def near_one?(value) = near_zero?(value - 1.0) # :nodoc: ## # Returns +true+ if the value is within EPSILON of one or more than one. def near_one_or_more?(value) = (value > 1.0 or near_one?(value)) # :nodoc: ## # Returns +true+ if the two values provided are near each other. def near?(x, y) = (x - y).abs <= Color::TOLERANCE # :nodoc: ## def to_degrees(radians) # :nodoc: if radians < 0 (Math::PI + radians % -Math::PI) * (180 / Math::PI) + 180 else (radians % Math::PI) * (180 / Math::PI) end end ## def to_radians(degrees) # :nodoc: degrees = ((degrees % 360) + 360) % 360 if degrees >= 180 Math::PI * (degrees - 360) / 180.0 else Math::PI * degrees / 180.0 end end ## # Normalizes the value to the range (0.0) .. (1.0). module_function def normalize(value, range = 0.0..1.0) # :nodoc: value = value.clamp(range) if near?(value, range.begin) range.begin elsif near?(value, range.end) range.end else value end end ## # Translates a value from range `from` to range `to`. Both ranges must be closed. # As 0.0 .. 1.0 is a common internal range, it is the default for `from`. # # This is based on the formula: # # [a, b] ← from ← [from.begin, from.end] # [c, d] ← to ← [to.begin, to.end] # # y = (((x - a) * (d - c)) / (b - a)) + c # # The value is clamped to the values of `to`. module_function def translate_range(x, to:, from: 0.0..1.0) # :nodoc: a, b = [from.begin, from.end] c, d = [to.begin, to.end] y = (((x - a) * (d - c)) / (b - a)) + c y.clamp(to) end ## # Normalizes the value to the specified range. def normalize_to_range(value, range) # :nodoc: range = (range.end..range.begin) if range.end < range.begin if value <= range.begin range.begin elsif value >= range.end range.end else value end end ## # Normalize the value to the range (0) .. (255). def normalize_byte(value) = normalize_to_range(value, 0..255).to_i # :nodoc: ## # Normalize the value to the range (0) .. (65535). def normalize_word(value) = normalize_to_range(value, 0..65535).to_i # :nodoc: end require "color/cmyk" require "color/grayscale" require "color/hsl" require "color/cielab" require
Source
# File lib/color.rb, line 77 def map(&block) = self.class.from_internal(*to_internal.map(&block)) ## # Apply the provided block to the color component pairs in turn, returning a new color # instance. def map_with(other, &block) = self.class.from_internal(*zip(other).map(&block)) ## # Zip the color component pairs together. def zip(other) = to_internal.zip(coerce(other).to_internal) ## # Multiplies each component value by the scaling factor or factors, returning a new # color object with the scaled values. # # If a single scaling factor is provided, it is applied to all components: # # ```ruby # rgb = Color::RGB::Wheat # => RGB [#f5deb3] # rgb.scale(0.75) # => RGB [#b8a786] # ``` # # If more than one scaling factor is provided, there must be exactly one factor for each # color component of the color object or an `ArgumentError` will be raised. # # ```ruby # rgb = Color::RGB::Wheat # => RGB [#f5deb3] # # 0xf5 * 0 == 0x00, 0xde * 0.5 == 0x6f, 0xb3 * 2 == 0x166 (clamped to 0xff) # rgb.scale(0, 0.5, 2) # => RGB [#006fff] # # rgb.scale(1, 2) # => Invalid scaling factors [1, 2] for Color::RGB (ArgumentError) # ``` def scale(*factors) if factors.size == 1 factor = factors.first map { _1 * factor } elsif factors.size != components raise ArgumentError, "Invalid scaling factors #{factors.inspect} for #{self.class}" else new_components = to_internal.zip(factors).map { _1 * _2 } self.class.from_internal(*new_components) end end ## def css_value(value, format = nil) # :nodoc: if value.nil? "none" elsif near_zero?(value) "0" else suffix = case format in :percent "%" in :degrees "deg" else "" end "%3.2f%s" % [value, suffix] end end private ## def from_internal(...) = self.class.from_internal(...) ## # Returns `true` if the value is less than EPSILON. def near_zero?(value) = (value.abs <= Color::EPSILON) # :nodoc: ## # Returns `true` if the value is within EPSILON of zero or less than zero. def near_zero_or_less?(value) = (value < 0.0 or near_zero?(value)) # :nodoc: ## # Returns +true+ if the value is within EPSILON of one. def near_one?(value) = near_zero?(value - 1.0) # :nodoc: ## # Returns +true+ if the value is within EPSILON of one or more than one. def near_one_or_more?(value) = (value > 1.0 or near_one?(value)) # :nodoc: ## # Returns +true+ if the two values provided are near each other. def near?(x, y) = (x - y).abs <= Color::TOLERANCE # :nodoc: ## def to_degrees(radians) # :nodoc: if radians < 0 (Math::PI + radians % -Math::PI) * (180 / Math::PI) + 180 else (radians % Math::PI) * (180 / Math::PI) end end ## def to_radians(degrees) # :nodoc: degrees = ((degrees % 360) + 360) % 360 if degrees >= 180 Math::PI * (degrees - 360) / 180.0 else Math::PI * degrees / 180.0 end end ## # Normalizes the value to the range (0.0) .. (1.0). module_function def normalize(value, range = 0.0..1.0) # :nodoc: value = value.clamp(range) if near?(value, range.begin) range.begin elsif near?(value, range.end) range.end else value end end ## # Translates a value from range `from` to range `to`. Both ranges must be closed. # As 0.0 .. 1.0 is a common internal range, it is the default for `from`. # # This is based on the formula: # # [a, b] ← from ← [from.begin, from.end] # [c, d] ← to ← [to.begin, to.end] # # y = (((x - a) * (d - c)) / (b - a)) + c # # The value is clamped to the values of `to`. module_function def translate_range(x, to:, from: 0.0..1.0) # :nodoc: a, b = [from.begin, from.end] c, d = [to.begin, to.end] y = (((x - a) * (d - c)) / (b - a)) + c y.clamp(to) end ## # Normalizes the value to the specified range. def normalize_to_range(value, range) # :nodoc: range = (range.end..range.begin) if range.end < range.begin if value <= range.begin range.begin elsif value >= range.end range.end else value end end ## # Normalize the value to the range (0) .. (255). def normalize_byte(value) = normalize_to_range(value, 0..255).to_i # :nodoc: ## # Normalize the value to the range (0) .. (65535). def normalize_word(value) = normalize_to_range(value, 0..65535).to_i # :nodoc: end require "color/cmyk" require "color/grayscale" require "color/hsl"
Apply the provided block to each color component in turn, returning a new color instance.
Source
# File lib/color.rb, line 82 def map_with(other, &block) = self.class.from_internal(*zip(other).map(&block)) ## # Zip the color component pairs together. def zip(other) = to_internal.zip(coerce(other).to_internal) ## # Multiplies each component value by the scaling factor or factors, returning a new # color object with the scaled values. # # If a single scaling factor is provided, it is applied to all components: # # ```ruby # rgb = Color::RGB::Wheat # => RGB [#f5deb3] # rgb.scale(0.75) # => RGB [#b8a786] # ``` # # If more than one scaling factor is provided, there must be exactly one factor for each # color component of the color object or an `ArgumentError` will be raised. # # ```ruby # rgb = Color::RGB::Wheat # => RGB [#f5deb3] # # 0xf5 * 0 == 0x00, 0xde * 0.5 == 0x6f, 0xb3 * 2 == 0x166 (clamped to 0xff) # rgb.scale(0, 0.5, 2) # => RGB [#006fff] # # rgb.scale(1, 2) # => Invalid scaling factors [1, 2] for Color::RGB (ArgumentError) # ``` def scale(*factors) if factors.size == 1 factor = factors.first map { _1 * factor } elsif factors.size != components raise ArgumentError, "Invalid scaling factors #{factors.inspect} for #{self.class}" else new_components = to_internal.zip(factors).map { _1 * _2 } self.class.from_internal(*new_components) end end ## def css_value(value, format = nil) # :nodoc: if value.nil? "none" elsif near_zero?(value) "0" else suffix = case format in :percent "%" in :degrees "deg" else "" end "%3.2f%s" % [value, suffix] end end private ## def from_internal(...) = self.class.from_internal(...) ## # Returns `true` if the value is less than EPSILON. def near_zero?(value) = (value.abs <= Color::EPSILON) # :nodoc: ## # Returns `true` if the value is within EPSILON of zero or less than zero. def near_zero_or_less?(value) = (value < 0.0 or near_zero?(value)) # :nodoc: ## # Returns +true+ if the value is within EPSILON of one. def near_one?(value) = near_zero?(value - 1.0) # :nodoc: ## # Returns +true+ if the value is within EPSILON of one or more than one. def near_one_or_more?(value) = (value > 1.0 or near_one?(value)) # :nodoc: ## # Returns +true+ if the two values provided are near each other. def near?(x, y) = (x - y).abs <= Color::TOLERANCE # :nodoc: ## def to_degrees(radians) # :nodoc: if radians < 0 (Math::PI + radians % -Math::PI) * (180 / Math::PI) + 180 else (radians % Math::PI) * (180 / Math::PI) end end ## def to_radians(degrees) # :nodoc: degrees = ((degrees % 360) + 360) % 360 if degrees >= 180 Math::PI * (degrees - 360) / 180.0 else Math::PI * degrees / 180.0 end end ## # Normalizes the value to the range (0.0) .. (1.0). module_function def normalize(value, range = 0.0..1.0) # :nodoc: value = value.clamp(range) if near?(value, range.begin) range.begin elsif near?(value, range.end) range.end else value end end ## # Translates a value from range `from` to range `to`. Both ranges must be closed. # As 0.0 .. 1.0 is a common internal range, it is the default for `from`. # # This is based on the formula: # # [a, b] ← from ← [from.begin, from.end] # [c, d] ← to ← [to.begin, to.end] # # y = (((x - a) * (d - c)) / (b - a)) + c # # The value is clamped to the values of `to`. module_function def translate_range(x, to:, from: 0.0..1.0) # :nodoc: a, b = [from.begin, from.end] c, d = [to.begin, to.end] y = (((x - a) * (d - c)) / (b - a)) + c y.clamp(to) end ## # Normalizes the value to the specified range. def normalize_to_range(value, range) # :nodoc: range = (range.end..range.begin) if range.end < range.begin if value <= range.begin range.begin elsif value >= range.end range.end else value end end ## # Normalize the value to the range (0) .. (255). def normalize_byte(value) = normalize_to_range(value, 0..255).to_i # :nodoc: ## # Normalize the value to the range (0) .. (65535). def normalize_word(value) = normalize_to_range(value, 0..65535).to_i # :nodoc: end require "color/cmyk" require "color/grayscale" require "color/hsl" require
Apply the provided block to the color component pairs in turn, returning a new color instance.
Source
# File lib/color.rb, line 109 def scale(*factors) if factors.size == 1 factor = factors.first map { _1 * factor } elsif factors.size != components raise ArgumentError, "Invalid scaling factors #{factors.inspect} for #{self.class}" else new_components = to_internal.zip(factors).map { _1 * _2 } self.class.from_internal(*new_components) end end
Multiplies each component value by the scaling factor or factors, returning a new color object with the scaled values.
If a single scaling factor is provided, it is applied to all components:
rgb = Color::RGB::Wheat # => RGB [#f5deb3] rgb.scale(0.75) # => RGB [#b8a786]
If more than one scaling factor is provided, there must be exactly one factor for each color component of the color object or an ArgumentError will be raised.
rgb = Color::RGB::Wheat # => RGB [#f5deb3] # 0xf5 * 0 == 0x00, 0xde * 0.5 == 0x6f, 0xb3 * 2 == 0x166 (clamped to 0xff) rgb.scale(0, 0.5, 2) # => RGB [#006fff] rgb.scale(1, 2) # => Invalid scaling factors [1, 2] for Color::RGB (ArgumentError)
Source
# File lib/color.rb, line 86 def zip(other) = to_internal.zip(coerce(other).to_internal) ## # Multiplies each component value by the scaling factor or factors, returning a new # color object with the scaled values. # # If a single scaling factor is provided, it is applied to all components: # # ```ruby # rgb = Color::RGB::Wheat # => RGB [#f5deb3] # rgb.scale(0.75) # => RGB [#b8a786] # ``` # # If more than one scaling factor is provided, there must be exactly one factor for each # color component of the color object or an `ArgumentError` will be raised. # # ```ruby # rgb = Color::RGB::Wheat # => RGB [#f5deb3] # # 0xf5 * 0 == 0x00, 0xde * 0.5 == 0x6f, 0xb3 * 2 == 0x166 (clamped to 0xff) # rgb.scale(0, 0.5, 2) # => RGB [#006fff] # # rgb.scale(1, 2) # => Invalid scaling factors [1, 2] for Color::RGB (ArgumentError) # ``` def scale(*factors) if factors.size == 1 factor = factors.first map { _1 * factor } elsif factors.size != components raise ArgumentError, "Invalid scaling factors #{factors.inspect} for #{self.class}" else new_components = to_internal.zip(factors).map { _1 * _2 } self.class.from_internal(*new_components) end end ## def css_value(value, format = nil) # :nodoc: if value.nil? "none" elsif near_zero?(value) "0" else suffix = case format in :percent "%" in :degrees "deg" else "" end "%3.2f%s" % [value, suffix] end end private ## def from_internal(...) = self.class.from_internal(...) ## # Returns `true` if the value is less than EPSILON. def near_zero?(value) = (value.abs <= Color::EPSILON) # :nodoc: ## # Returns `true` if the value is within EPSILON of zero or less than zero. def near_zero_or_less?(value) = (value < 0.0 or near_zero?(value)) # :nodoc: ## # Returns +true+ if the value is within EPSILON of one. def near_one?(value) = near_zero?(value - 1.0) # :nodoc: ## # Returns +true+ if the value is within EPSILON of one or more than one. def near_one_or_more?(value) = (value > 1.0 or near_one?(value)) # :nodoc: ## # Returns +true+ if the two values provided are near each other. def near?(x, y) = (x - y).abs <= Color::TOLERANCE # :nodoc: ## def to_degrees(radians) # :nodoc: if radians < 0 (Math::PI + radians % -Math::PI) * (180 / Math::PI) + 180 else (radians % Math::PI) * (180 / Math::PI) end end ## def to_radians(degrees) # :nodoc: degrees = ((degrees % 360) + 360) % 360 if degrees >= 180 Math::PI * (degrees - 360) / 180.0 else Math::PI * degrees / 180.0 end end ## # Normalizes the value to the range (0.0) .. (1.0). module_function def normalize(value, range = 0.0..1.0) # :nodoc: value = value.clamp(range) if near?(value, range.begin) range.begin elsif near?(value, range.end) range.end else value end end ## # Translates a value from range `from` to range `to`. Both ranges must be closed. # As 0.0 .. 1.0 is a common internal range, it is the default for `from`. # # This is based on the formula: # # [a, b] ← from ← [from.begin, from.end] # [c, d] ← to ← [to.begin, to.end] # # y = (((x - a) * (d - c)) / (b - a)) + c # # The value is clamped to the values of `to`. module_function def translate_range(x, to:, from: 0.0..1.0) # :nodoc: a, b = [from.begin, from.end] c, d = [to.begin, to.end] y = (((x - a) * (d - c)) / (b - a)) + c y.clamp(to) end ## # Normalizes the value to the specified range. def normalize_to_range(value, range) # :nodoc: range = (range.end..range.begin) if range.end < range.begin if value <= range.begin range.begin elsif value >= range.end range.end else value end end ## # Normalize the value to the range (0) .. (255). def normalize_byte(value) = normalize_to_range(value, 0..255).to_i # :nodoc: ## # Normalize the value to the range (0) .. (65535). def normalize_word(value) = normalize_to_range(value, 0..65535).to_i # :nodoc: end require "color/cmyk" require "color/grayscale" require "color/hsl" require "color/cielab"
Zip the color component pairs together.