lib/color.rb



# frozen_string_literal: true

# # \Color -- \Color Math in Ruby
#
# - **code**: [github.com/halostatue/color](https://github.com/halostatue/color)
# - **issues**: [github.com/halostatue/color/issues](https://github.com/halostatue/color/issues)
# - **changelog**: [CHANGELOG](rdoc-ref:CHANGELOG.md)
#
# \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:
#
# - Color classes are immutable Data objects; they are no longer mutable.
# - RGB named colors are no longer loaded on gem startup, but must be required explicitly
#   (this is _not_ done via `autoload` because there are more than 100 named colors with
#   spelling variations) with `require "color/rgb/colors"`.
# - Color palettes have been removed.
# - `Color::CSS` and `Color::CSS#[]` have been removed.
module Color
  ##
  # The maximum "resolution" for color math; if any value is less than or equal to this
  # value, it is treated as zero.
  EPSILON = 1e-5

  ##
  # 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.
  TOLERANCE = 1e-4

  # :stopdoc:
  CIELAB = Data.define(:l, :a, :b)
  CMYK = Data.define(:c, :m, :y, :k)
  Grayscale = Data.define(:g)
  HSL = Data.define(:h, :s, :l)
  RGB = Data.define(:r, :g, :b, :names)
  XYZ = Data.define(:x, :y, :z)
  YIQ = Data.define(:y, :i, :q)
  # :startdoc:

  ##
  # It is useful to know the number of components in some cases. Since most colors are
  # defined with three components, we define a constant value here. Color classes that
  # require more or less should override this.
  #
  # We _could_ define this as `members.count`, but this would require a special case
  # for Color::RGB _regardless_ because there's an additional member for RGB colors
  # (names).
  def components = 3 # :nodoc:

  ##
  # 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.
  def ==(other)
    other.is_a?(Color) && to_internal.zip(coerce(other).to_internal).all? { near?(_1, _2) }
  end

  ##
  # Apply the provided block to each color component in turn, returning a new color
  # instance.
  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"
require "color/cielab"
require "color/rgb"
require "color/xyz"
require "color/yiq"

require "color/version"