diff --git a/lib/sass/compiler/host/protofier.rb b/lib/sass/compiler/host/protofier.rb index f58bdf5b..3140f211 100644 --- a/lib/sass/compiler/host/protofier.rb +++ b/lib/sass/compiler/host/protofier.rb @@ -25,37 +25,15 @@ def to_proto(obj) number: Number.to_proto(obj) ) when Sass::Value::Color - if obj.instance_eval { !defined?(@hue) } - EmbeddedProtocol::Value.new( - color: EmbeddedProtocol::Value::Color.new( - channel1: obj.red, - channel2: obj.green, - channel3: obj.blue, - alpha: obj.alpha.to_f, - space: 'rgb' - ) - ) - elsif obj.instance_eval { !defined?(@saturation) } - EmbeddedProtocol::Value.new( - color: EmbeddedProtocol::Value::Color.new( - channel1: obj.hue.to_f, - channel2: obj.whiteness.to_f, - channel3: obj.blackness.to_f, - alpha: obj.alpha.to_f, - space: 'hwb' - ) - ) - else - EmbeddedProtocol::Value.new( - color: EmbeddedProtocol::Value::Color.new( - channel1: obj.hue.to_f, - channel2: obj.saturation.to_f, - channel3: obj.lightness.to_f, - alpha: obj.alpha.to_f, - space: 'hsl' - ) + EmbeddedProtocol::Value.new( + color: EmbeddedProtocol::Value::Color.new( + channel1: obj.send(:channel0_or_nil), + channel2: obj.send(:channel1_or_nil), + channel3: obj.send(:channel2_or_nil), + alpha: obj.send(:alpha_or_nil), + space: obj.space ) - end + ) when Sass::Value::ArgumentList EmbeddedProtocol::Value.new( argument_list: EmbeddedProtocol::Value::ArgumentList.new( @@ -134,31 +112,14 @@ def from_proto(proto) when :number Number.from_proto(obj) when :color - case obj.space - when 'rgb' - Sass::Value::Color.new( - red: obj.channel1, - green: obj.channel2, - blue: obj.channel3, - alpha: obj.alpha - ) - when 'hsl' - Sass::Value::Color.new( - hue: obj.channel1, - saturation: obj.channel2, - lightness: obj.channel3, - alpha: obj.alpha - ) - when 'hwb' - Sass::Value::Color.new( - hue: obj.channel1, - whiteness: obj.channel2, - blackness: obj.channel3, - alpha: obj.alpha - ) - else - raise NotImplementedError, 'CSS Color Level 4 will be supported in a future release' - end + Sass::Value::Color.send( + :for_space, + obj.space, + obj.channel1, + obj.channel2, + obj.channel3, + obj.alpha + ) when :argument_list Sass::Value::ArgumentList.new( obj.contents.map do |element| diff --git a/lib/sass/value/color.rb b/lib/sass/value/color.rb index bcfd1914..1b057fb6 100644 --- a/lib/sass/value/color.rb +++ b/lib/sass/value/color.rb @@ -1,5 +1,11 @@ # frozen_string_literal: true +require_relative 'color/channel' +require_relative 'color/conversions' +require_relative 'color/gamut_map_method' +require_relative 'color/interpolation_method' +require_relative 'color/space' + module Sass module Value # Sass's color type. @@ -18,90 +24,149 @@ class Color # @param lightness [Numeric] # @param whiteness [Numeric] # @param blackness [Numeric] + # @param a [Numeric] + # @param b [Numeric] + # @param chroma [Numeric] + # @param x [Numeric] + # @param y [Numeric] + # @param z [Numeric] # @param alpha [Numeric] - def initialize(red: nil, - green: nil, - blue: nil, - hue: nil, - saturation: nil, - lightness: nil, - whiteness: nil, - blackness: nil, - alpha: 1) - @alpha = alpha.nil? ? 1 : FuzzyMath.assert_between(alpha, 0, 1, 'alpha') - if red && green && blue - @red = FuzzyMath.assert_between(FuzzyMath.round(red), 0, 255, 'red') - @green = FuzzyMath.assert_between(FuzzyMath.round(green), 0, 255, 'green') - @blue = FuzzyMath.assert_between(FuzzyMath.round(blue), 0, 255, 'blue') - elsif hue && saturation && lightness - @hue = hue % 360 - @saturation = FuzzyMath.assert_between(saturation, 0, 100, 'saturation') - @lightness = FuzzyMath.assert_between(lightness, 0, 100, 'lightness') - elsif hue && whiteness && blackness - @hue = hue % 360 - @whiteness = FuzzyMath.assert_between(whiteness, 0, 100, 'whiteness') - @blackness = FuzzyMath.assert_between(blackness, 0, 100, 'blackness') - hwb_to_rgb - @whiteness = @blackness = nil - else - raise Sass::ScriptError, 'Invalid Color' + # @param space [::String] + # @overload initialize(red: nil, green: nil, blue: nil, alpha: nil, space: 'rgb') + # @overload initialize(hue: nil, saturation: nil, lightness: nil, alpha: nil, space: 'hsl') + # @overload initialize(hue: nil, whiteness: nil, blackness: nil, alpha: nil, space: 'hwb') + # @overload initialize(lightness: nil, a: nil, b: nil, alpha: nil, space: 'lab') + # @overload initialize(lightness: nil, a: nil, b: nil, alpha: nil, space: 'oklab') + # @overload initialize(lightness: nil, chroma: nil, hue: nil, alpha: nil, space: 'lch') + # @overload initialize(lightness: nil, chroma: nil, hue: nil, alpha: nil, space: 'oklch') + # @overload initialize(red: nil, green: nil, blue: nil, alpha: nil, space: 'a98-rgb') + # @overload initialize(red: nil, green: nil, blue: nil, alpha: nil, space: 'display-p3') + # @overload initialize(red: nil, green: nil, blue: nil, alpha: nil, space: 'prophoto-rgb') + # @overload initialize(red: nil, green: nil, blue: nil, alpha: nil, space: 'rec2020') + # @overload initialize(red: nil, green: nil, blue: nil, alpha: nil, space: 'srgb') + # @overload initialize(red: nil, green: nil, blue: nil, alpha: nil, space: 'srgb-linear') + # @overload initialize(x: nil, y: nil, z: nil, alpha: nil, space: 'xyz') + # @overload initialize(x: nil, y: nil, z: nil, alpha: nil, space: 'xyz-d50') + # @overload initialize(x: nil, y: nil, z: nil, alpha: nil, space: 'xyz-d65') + def initialize(**options) + unless options.key?(:space) + options[:space] = case options + in {red: _, green: _, blue: _} + 'rgb' + in {hue: _, saturation: _, lightness: _} + 'hsl' + in {hue: _, whiteness: _, blackness: _} + 'hwb' + else + raise Sass::ScriptError.new('No color space found', 'space') + end end - end - # @return [Integer] - def red - hsl_to_rgb unless defined?(@red) + space = Space.from_name(options[:space]) - @red + keys = _assert_options(space, options) + + _initialize_for_space_internal(space, + options[keys[0]], + options[keys[1]], + options[keys[2]], + options.fetch(:alpha, 1)) end - # @return [Integer] - def green - hsl_to_rgb unless defined?(@green) + # @return [::String] + def space + _space.name + end - @green + # @param space [::String] + # @return [Color] + def to_space(space) + _to_space(Space.from_name(space)) end - # @return [Integer] - def blue - hsl_to_rgb unless defined?(@blue) + # @param space [::String] + # @return [::Boolean] + def in_gamut?(space = nil) + return to_space(space)._in_gamut? unless space.nil? - @blue + _in_gamut? end - # @return [Numeric] - def hue - rgb_to_hsl unless defined?(@hue) + # @param method [::String] + # @param space [::String] + # @return [Color] + def to_gamut(method:, space: nil) + return to_space(space).to_gamut(method:)._to_space(_space) unless space.nil? - @hue + _to_gamut(GamutMapMethod.from_name(method, 'method')) end - # @return [Numeric] - def saturation - rgb_to_hsl unless defined?(@saturation) + # @return [Array] + def channels_or_nil + [channel0_or_nil, channel1_or_nil, channel2_or_nil].freeze + end - @saturation + # @return [Array] + def channels + [channel0, channel1, channel2].freeze end + # @param channel [::String] + # @param space [::String] # @return [Numeric] - def lightness - rgb_to_hsl unless defined?(@lightness) + def channel(channel, space: nil) + return to_space(space).channel(channel) unless space.nil? + + channels = _space.channels + return channel0 if channel == channels[0].name + return channel1 if channel == channels[1].name + return channel2 if channel == channels[2].name + return alpha if channel == 'alpha' - @lightness + raise Sass::ScriptError.new("Color #{self} doesn't have a channel named \"#{channel}\".", channel) end - # @return [Numeric] - def whiteness - @whiteness ||= Rational([red, green, blue].min, 255) * 100 + # @param channel [::String] + # @return [::Boolean] + def channel_missing?(channel) + channels = _space.channels + return channel0_missing? if channel == channels[0].name + return channel1_missing? if channel == channels[1].name + return channel2_missing? if channel == channels[2].name + return alpha_missing? if channel == 'alpha' + + raise Sass::ScriptError.new("Color #{self} doesn't have a channel named \"#{channel}\".", channel) end - # @return [Numeric] - def blackness - @blackness ||= 100 - (Rational([red, green, blue].max, 255) * 100) + # @param channel [::String] + # @param space [::String] + # @return [::Boolean] + def channel_powerless?(channel, space: nil) + return to_space(space).channel_powerless?(channel) unless space.nil? + + channels = _space.channels + return channel0_powerless? if channel == channels[0].name + return channel1_powerless? if channel == channels[1].name + return channel2_powerless? if channel == channels[2].name + return false if channel == 'alpha' + + raise Sass::ScriptError.new("Color #{self} doesn't have a channel named \"#{channel}\".", channel) end - # @return [Numeric] - attr_reader :alpha + # @param other [Color] + # @param method [::String] + # @param weight [Numeric] + # @return [Color] + def interpolate(other, method: nil, weight: nil) + interpolation_method = if !method.nil? + InterpolationMethod.new(_space, HueInterpolationMethod.from_name(method)) + elsif !_space.polar? + InterpolationMethod.new(_space) + else + InterpolationMethod.new(_space, :shorter) + end + _interpolate(other, interpolation_method, weight:) + end # @param red [Numeric] # @param green [Numeric] @@ -111,52 +176,175 @@ def blackness # @param lightness [Numeric] # @param whiteness [Numeric] # @param blackness [Numeric] + # @param a [Numeric] + # @param b [Numeric] + # @param chroma [Numeric] + # @param x [Numeric] + # @param y [Numeric] + # @param z [Numeric] # @param alpha [Numeric] + # @param space [::String] + # @overload change(red: nil, green: nil, blue: nil, alpha: nil, space: 'rgb') + # @overload change(hue: nil, saturation: nil, lightness: nil, alpha: nil, space: 'hsl') + # @overload change(hue: nil, whiteness: nil, blackness: nil, alpha: nil, space: 'hwb') + # @overload change(lightness: nil, a: nil, b: nil, alpha: nil, space: 'lab') + # @overload change(lightness: nil, a: nil, b: nil, alpha: nil, space: 'oklab') + # @overload change(lightness: nil, chroma: nil, hue: nil, alpha: nil, space: 'lch') + # @overload change(lightness: nil, chroma: nil, hue: nil, alpha: nil, space: 'oklch') + # @overload change(red: nil, green: nil, blue: nil, alpha: nil, space: 'a98-rgb') + # @overload change(red: nil, green: nil, blue: nil, alpha: nil, space: 'display-p3') + # @overload change(red: nil, green: nil, blue: nil, alpha: nil, space: 'prophoto-rgb') + # @overload change(red: nil, green: nil, blue: nil, alpha: nil, space: 'rec2020') + # @overload change(red: nil, green: nil, blue: nil, alpha: nil, space: 'srgb') + # @overload change(red: nil, green: nil, blue: nil, alpha: nil, space: 'srgb-linear') + # @overload change(x: nil, y: nil, z: nil, alpha: nil, space: 'xyz') + # @overload change(x: nil, y: nil, z: nil, alpha: nil, space: 'xyz-d50') + # @overload change(x: nil, y: nil, z: nil, alpha: nil, space: 'xyz-d65') # @return [Color] - def change(red: nil, - green: nil, - blue: nil, - hue: nil, - saturation: nil, - lightness: nil, - whiteness: nil, - blackness: nil, - alpha: nil) - if whiteness || blackness - Sass::Value::Color.new(hue: hue || self.hue, - whiteness: whiteness || self.whiteness, - blackness: blackness || self.blackness, - alpha: alpha || self.alpha) - elsif hue || saturation || lightness - Sass::Value::Color.new(hue: hue || self.hue, - saturation: saturation || self.saturation, - lightness: lightness || self.lightness, - alpha: alpha || self.alpha) - elsif red || green || blue - Sass::Value::Color.new(red: red ? FuzzyMath.round(red) : self.red, - green: green ? FuzzyMath.round(green) : self.green, - blue: blue ? FuzzyMath.round(blue) : self.blue, - alpha: alpha || self.alpha) - else - dup.instance_eval do - @alpha = FuzzyMath.assert_between(alpha, 0, 1, 'alpha') - self + def change(**options) + space_set_explictly = !options[:space].nil? + space = space_set_explictly ? Space.from_name(options[:space]) : _space + + if legacy? && !space_set_explictly + case options + in {whiteness: _} | {blackness: _} + space = Space::HWB + in {saturation: _} | {lightness: _} + space = Space::HSL + in {hue: _} + space = if _space == Space::HWB + Space::HWB + else + Space::HSL + end + in {red: _} | {blue: _} | {green: _} + space = Space::RGB + else + end + + if space != _space + # deprecated end end + + keys = _assert_options(space, options) + + color = _to_space(space) + + changed_color = if space_set_explictly + Color.send(:for_space_internal, + space, + options.fetch(keys[0], color.channel0_or_nil), + options.fetch(keys[1], color.channel1_or_nil), + options.fetch(keys[2], color.channel2_or_nil), + options.fetch(:alpha, color.alpha_or_nil)) + else + changed_channel0_or_nil = options[keys[0]] + changed_channel1_or_nil = options[keys[1]] + changed_channel2_or_nil = options[keys[2]] + changed_alpha_or_nil = options[:alpha] + Color.send(:for_space_internal, + space, + changed_channel0_or_nil.nil? ? color.channel0_or_nil : changed_channel0_or_nil, + changed_channel1_or_nil.nil? ? color.channel1_or_nil : changed_channel1_or_nil, + changed_channel2_or_nil.nil? ? color.channel2_or_nil : changed_channel2_or_nil, + changed_alpha_or_nil.nil? ? color.alpha_or_nil : changed_alpha_or_nil) + end + + changed_color._to_space(_space) + end + + # return [Numeric] + def alpha + @alpha_or_nil.nil? ? 0 : @alpha_or_nil + end + + # return [::Boolean] + def legacy? + _space.legacy? + end + + # @deprecated + # @return [Numeric] + def red + _to_space(Space::RGB).channel('red').round + end + + # @deprecated + # @return [Numeric] + def green + _to_space(Space::RGB).channel('green').round + end + + # @deprecated + # @return [Numeric] + def blue + _to_space(Space::RGB).channel('blue').round + end + + # @deprecated + # @return [Numeric] + def hue + _to_space(Space::HSL).channel('hue') + end + + # @deprecated + # @return [Numeric] + def saturation + _to_space(Space::HSL).channel('saturation') + end + + # @deprecated + # @return [Numeric] + def lightness + _to_space(Space::HSL).channel('lightness') + end + + # @deprecated + # @return [Numeric] + def whiteness + _to_space(Space::HWB).channel('whiteness') + end + + # @deprecated + # @return [Numeric] + def blackness + _to_space(Space::HWB).channel('blackness') end # @return [::Boolean] def ==(other) - other.is_a?(Sass::Value::Color) && - other.red == red && - other.green == green && - other.blue == blue && - other.alpha == alpha + return false unless other.is_a?(Sass::Value::Color) + + if legacy? + return false unless other.legacy? + return false unless FuzzyMath.equals_nilable(other.alpha_or_nil, alpha_or_nil) + + if _space == other._space + FuzzyMath.equals_nilable(other.channel0_or_nil, channel0_or_nil) && + FuzzyMath.equals_nilable(other.channel1_or_nil, channel1_or_nil) && + FuzzyMath.equals_nilable(other.channel2_or_nil, channel2_or_nil) + else + _to_space(Space::RGB) == other._to_space(Space::RGB) + end + else + other._space == _space && + FuzzyMath.equals_nilable(other.channel0_or_nil, channel0_or_nil) && + FuzzyMath.equals_nilable(other.channel1_or_nil, channel1_or_nil) && + FuzzyMath.equals_nilable(other.channel2_or_nil, channel2_or_nil) && + FuzzyMath.equals_nilable(other.alpha_or_nil, alpha_or_nil) + end end # @return [Integer] def hash - @hash ||= [red, green, blue, alpha].hash + @hash ||= [ + _space, + channel0_or_nil&.finite? ? (channel0_or_nil * FuzzyMath::INVERSE_EPSILON).round : channel0_or_nil, # rubocop:disable Security/CompoundHash + channel1_or_nil&.finite? ? (channel1_or_nil * FuzzyMath::INVERSE_EPSILON).round : channel1_or_nil, # rubocop:disable Security/CompoundHash + channel2_or_nil&.finite? ? (channel2_or_nil * FuzzyMath::INVERSE_EPSILON).round : channel2_or_nil, # rubocop:disable Security/CompoundHash + alpha_or_nil&.finite? ? (alpha_or_nil * FuzzyMath::INVERSE_EPSILON).round : alpha_or_nil # rubocop:disable Security/CompoundHash + ].hash end # @return [Color] @@ -164,89 +352,276 @@ def assert_color(_name = nil) self end + protected + + attr_reader :channel0_or_nil, :channel1_or_nil, :channel2_or_nil, :alpha_or_nil + + def channel0 + @channel0_or_nil.nil? ? 0 : @channel0_or_nil + end + + def channel0_missing? + @channel0_or_nil.nil? + end + + def channel0_powerless? + case _space + when Space::HSL + FuzzyMath.equals(channel1, 0) + when Space::HWB + FuzzyMath.greater_than_or_equals(channel1 + channel2, 100) + else + false + end + end + + def channel1 + @channel1_or_nil.nil? ? 0 : @channel1_or_nil + end + + def channel1_missing? + @channel1_or_nil.nil? + end + + def channel1_powerless? + false + end + + def channel2 + @channel2_or_nil.nil? ? 0 : @channel2_or_nil + end + + def channel2_missing? + @channel2_or_nil.nil? + end + + def channel2_powerless? + case _space + when Space::LCH, Space::OKLCH + FuzzyMath.equals(channel1, 0) + else + false + end + end + + def alpha_missing? + @alpha_or_nil.nil? + end + + def _space + @space + end + + def _to_space(space) + return self if _space == space + + _space.convert(space, channel0_or_nil, channel1_or_nil, channel2_or_nil, alpha) + end + + def _in_gamut? + return true unless _space.bounded? + + _is_channel_in_gamut(channel0, _space.channels[0]) && + _is_channel_in_gamut(channel1, _space.channels[1]) && + _is_channel_in_gamut(channel2, _space.channels[2]) + end + + def _to_gamut(method) + _in_gamut? ? self : method.map(self) + end + private - def rgb_to_hsl - scaled_red = Rational(red, 255) - scaled_green = Rational(green, 255) - scaled_blue = Rational(blue, 255) - - max = [scaled_red, scaled_green, scaled_blue].max - min = [scaled_red, scaled_green, scaled_blue].min - delta = max - min - - if max == min - @hue = 0 - elsif max == scaled_red - @hue = ((scaled_green - scaled_blue) * 60 / delta) % 360 - elsif max == scaled_green - @hue = (((scaled_blue - scaled_red) * 60 / delta) + 120) % 360 - elsif max == scaled_blue - @hue = (((scaled_red - scaled_green) * 60 / delta) + 240) % 360 + def _assert_options(space, options) + keys = space.channels.map do |channel| + channel.name.to_sym + end << :alpha << :space + options.each_key do |key| + unless keys.include?(key) + raise Sass::ScriptError.new("`#{key}` is not a valid channel in `#{space.name}`.", key) + end end + keys + end - lightness = @lightness = (max + min) * 50 - - @saturation = if max == min - 0 - elsif lightness < 50 - delta * 100 / (max + min) - else - delta * 100 / (2 - max - min) - end - end - - def hsl_to_rgb - scaled_hue = Rational(hue, 360) - scaled_saturation = Rational(saturation, 100) - scaled_lightness = Rational(lightness, 100) - - tmp2 = if scaled_lightness <= 0.5 - scaled_lightness * (scaled_saturation + 1) - else - scaled_lightness + scaled_saturation - (scaled_lightness * scaled_saturation) - end - tmp1 = (scaled_lightness * 2) - tmp2 - @red = FuzzyMath.round(hsl_hue_to_rgb(tmp1, tmp2, scaled_hue + Rational(1, 3)) * 255) - @green = FuzzyMath.round(hsl_hue_to_rgb(tmp1, tmp2, scaled_hue) * 255) - @blue = FuzzyMath.round(hsl_hue_to_rgb(tmp1, tmp2, scaled_hue - Rational(1, 3)) * 255) - end - - def hsl_hue_to_rgb(tmp1, tmp2, hue) - hue += 1 if hue.negative? - hue -= 1 if hue > 1 - - if hue < Rational(1, 6) - tmp1 + ((tmp2 - tmp1) * hue * 6) - elsif hue < Rational(1, 2) - tmp2 - elsif hue < Rational(2, 3) - tmp1 + ((tmp2 - tmp1) * (Rational(2, 3) - hue) * 6) + def _initialize_for_space_internal(space, channel0, channel1, channel2, alpha = 1) + case space + when Space::HSL + _initialize_for_space( + space, + _normalize_hue(channel0, invert: !channel1.nil? && FuzzyMath.less_than(channel1, 0)), + channel1&.abs, + channel2, + alpha + ) + when Space::HWB + _initialize_for_space(space, _normalize_hue(channel0, invert: false), channel1, channel2, alpha) + when Space::LCH, Space::OKLCH + _initialize_for_space( + space, + channel0, + channel1&.abs, + _normalize_hue(channel2, invert: !channel1.nil? && FuzzyMath.less_than(channel1, 0)), + alpha + ) else - tmp1 + _initialize_for_space(space, channel0, channel1, channel2, alpha) end end - def hwb_to_rgb - scaled_hue = Rational(hue, 360) - scaled_whiteness = Rational(whiteness, 100) - scaled_blackness = Rational(blackness, 100) + def _initialize_for_space(space, channel0_or_nil, channel1_or_nil, channel2_or_nil, alpha) + @space = space + @channel0_or_nil = channel0_or_nil + @channel1_or_nil = channel1_or_nil + @channel2_or_nil = channel2_or_nil + @alpha_or_nil = alpha + + FuzzyMath.assert_between(@alpha_or_nil, 0, 1, 'alpha') unless @alpha_or_nil.nil? + end + + def _normalize_hue(hue, invert:) + return hue if hue.nil? - sum = scaled_whiteness + scaled_blackness - if sum > 1 - scaled_whiteness /= sum - scaled_blackness /= sum + ((hue % 360) + 360 + (invert ? 180 : 0)) % 360 + end + + def _is_channel_in_gamut(value, channel) + case channel + when LinearChannel + FuzzyMath.less_than_or_equals(value, channel.max) && FuzzyMath.greater_than_or_equals(value, channel.min) + else + true end + end - factor = 1 - scaled_whiteness - scaled_blackness - @red = hwb_hue_to_rgb(factor, scaled_whiteness, scaled_hue + Rational(1, 3)) - @green = hwb_hue_to_rgb(factor, scaled_whiteness, scaled_hue) - @blue = hwb_hue_to_rgb(factor, scaled_whiteness, scaled_hue - Rational(1, 3)) + def _interpolate(other, method, weight: nil) + weight = 0.5 if weight.nil? + if weight.negative? || weight > 1 + raise Sass::ScriptError.new("Expected #{wieght} to be within 0 and 1.", 'weight') + end + + return other if FuzzyMath.equals(weight, 0) + return self if FuzzyMath.equals(weight, 1) + + color1 = _to_space(method.space) + color2 = other._to_space(method.space) + + c1_missing0 = _analogous_channel_missing?(self, color1, 0) + c1_missing1 = _analogous_channel_missing?(self, color1, 1) + c1_missing2 = _analogous_channel_missing?(self, color1, 2) + c2_missing0 = _analogous_channel_missing?(other, color2, 0) + c2_missing1 = _analogous_channel_missing?(other, color2, 1) + c2_missing2 = _analogous_channel_missing?(other, color2, 2) + c1_channel0 = (c1_missing0 ? color2 : color1).channel0 + c1_channel1 = (c1_missing1 ? color2 : color1).channel1 + c1_channel2 = (c1_missing2 ? color2 : color1).channel2 + c2_channel0 = (c2_missing0 ? color1 : color2).channel0 + c2_channel1 = (c2_missing1 ? color1 : color2).channel1 + c2_channel2 = (c2_missing2 ? color1 : color2).channel2 + c1_alpha = alpha_or_nil.nil? ? other.alpha : alpha_or_nil + c2_alpha = other.alpha_or_nil.nil? ? alpha : other.alpha_or_nil + + c1_multiplier = (alpha_or_nil.nil? ? 1 : alpha_or_nil) * weight + c2_multiplier = (other.alpha_or_nil.nil? ? 1 : other.alpha_or_nil) * (1 - weight) + mixed_alpha = alpha_missing? && other.alpha_missing? ? nil : (c1_alpha * weight) + (c2_alpha * (1 - weight)) + mixed0 = if c1_missing0 && c2_missing0 + nil + else + ((c1_channel0 * c1_multiplier) + (c2_channel0 * c2_multiplier)) / + (mixed_alpha.nil? ? 1 : mixed_alpha) + end + mixed1 = if c1_missing1 && c2_missing1 + nil + else + ((c1_channel1 * c1_multiplier) + (c2_channel1 * c2_multiplier)) / + (mixed_alpha.nil? ? 1 : mixed_alpha) + end + mixed2 = if c1_missing2 && c2_missing2 + nil + else + ((c1_channel2 * c1_multiplier) + (c2_channel2 * c2_multiplier)) / + (mixed_alpha.nil? ? 1 : mixed_alpha) + end + + case method.space + when Space::HSL, Space::HWB + Color.send(:for_space_internal, + method.space, + c1_missing0 && c2_missing0 ? nil : _interpolate_hues(c1_channel0, c2_channel0, method.hue, weight), + mixed1, + mixed2, + mixed_alpha) + when Space::LCH, Space::OKLCH + Color.send(:for_space_internal, + method.space, + mixed0, + mixed1, + c1_missing2 && c2_missing2 ? nil : _interpolate_hues(c1_channel2, c2_channel2, method.hue, weight), + mixed_alpha) + else + Color.send(:_for_space, + method.space, mixed0, mixed1, mixed2, mixed_alpha) + end._to_space(_space) end - def hwb_hue_to_rgb(factor, scaled_whiteness, scaled_hue) - channel = (hsl_hue_to_rgb(0, 1, scaled_hue) * factor) + scaled_whiteness - FuzzyMath.round(channel * 255) + def _analogous_channel_missing?(original, output, output_channel_index) + return true if output.channels_or_nil[output_channel_index].nil? + + return false if original.equal?(output) + + output_channel = output.space.channels[output_channel_index] + original_channel = original.space.channels.find do |channel| + output_channel.analogous?(channel) + end + + return false if original_channel.nil? + + original.channel_missing?(original_channel.name) + end + + def _interpolate_hues(hue1, hue2, method, weight) + case method + when :shorter + diff = hue2 - hue1 + if diff > 180 + hue1 += 360 + elsif diff < -180 + hue2 += 360 + end + when :longer + diff = hue2 - hue1 + if diff.positive? && diff < 180 + hue2 += 360 + elsif diff > -180 && diff <= 0 + hue1 += 360 + end + when :increasing + hue2 += 360 if hue2 < hue1 + when :decreasing + hue1 += 360 if hue1 < hue2 + end + + (hue1 * weight) + (hue2 * (1 - weight)) + end + + class << self + private + + def for_space(space, channel0_or_nil, channel1_or_nil, channel2_or_nil, alpha) + _for_space(Space.from_name(space), channel0_or_nil, channel1_or_nil, channel2_or_nil, alpha) + end + + def for_space_internal(space, channel0, channel1, channel2, alpha) + o = allocate + o.send(:_initialize_for_space_internal, space, channel0, channel1, channel2, alpha) + o + end + + def _for_space(space, channel0_or_nil, channel1_or_nil, channel2_or_nil, alpha) + o = allocate + o.send(:_initialize_for_space, space, channel0_or_nil, channel1_or_nil, channel2_or_nil, alpha) + o + end end end end diff --git a/lib/sass/value/color/channel.rb b/lib/sass/value/color/channel.rb new file mode 100644 index 00000000..ec7089aa --- /dev/null +++ b/lib/sass/value/color/channel.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/channel.dart + class ColorChannel + # @return [::String] + attr_reader :name + + # @return [::Boolean] + def polar_angle? + @polar_angle + end + + # @return [::String, nil] + attr_reader :associated_unit + + # @param name [::String] + # @param polar_angle [::Boolean] + # @param associated_unit [::String] + def initialize(name, polar_angle:, associated_unit: nil) + @name = name + @polar_angle = polar_angle + @associated_unit = associated_unit + end + + # @return [::Boolean] + def analogous?(other) + case [name, other.name] + in ['red' | 'x', 'red' | 'x'] | + ['green' | 'y', 'green' | 'y'] | + ['blue' | 'z', 'blue' | 'z'] | + ['chroma' | 'saturation', 'chroma' | 'saturation'] | + ['lightness', 'lightness'] | + ['hue', 'hue'] + true + else + false + end + end + end + + private_constant :ColorChannel + + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/channel.dart + class LinearChannel < ColorChannel + # @return [Numeric] + attr_reader :min, :max + + # @return [::Boolean] + attr_reader :requires_percent, :lower_clamped, :upper_clamped + + # @param name [::String] + # @param min [Numeric] + # @param max [Numeric] + # @param requires_percent [::Boolean] + # @param lower_clamped [::Boolean] + # @param upper_clamped [::Boolean] + # @param conventionally_percent [::Boolean] + def initialize(name, min, max, requires_percent: false, lower_clamped: false, upper_clamped: false, + conventionally_percent: nil) + super(name, + polar_angle: false, + associated_unit: if conventionally_percent.nil? ? (min.zero? && max == 100) : conventionally_percent + '%' + end) + @min = min + @max = max + @requires_percent = requires_percent + @lower_clamped = lower_clamped + @upper_clamped = upper_clamped + end + end + + private_constant :LinearChannel + end + end +end diff --git a/lib/sass/value/color/conversions.rb b/lib/sass/value/color/conversions.rb new file mode 100644 index 00000000..927212b7 --- /dev/null +++ b/lib/sass/value/color/conversions.rb @@ -0,0 +1,464 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + # @see https:#www.w3.org/TR/css-color-4/#color-conversion-code. + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/conversions.dart + module Conversions + # The D50 white point. + D50 = [0.3457 / 0.3585, 1.00000, (1.0 - 0.3457 - 0.3585) / 0.3585].freeze + + # The transformation matrix for converting LMS colors to OKLab. + # + # Note that this can't be directly multiplied with {XYZ_D65_TO_LMS}; see Color + # Level 4 spec for details on how to convert between XYZ and OKLab. + LMS_TO_OKLAB = [ + 0.2104542553, 0.7936177850, -0.0040720468, + 1.9779984951, -2.4285922050, 0.4505937099, + 0.0259040371, 0.7827717662, -0.8086757660 + ].freeze + + # The transformation matrix for converting OKLab colors to LMS. + # + # Note that this can't be directly multiplied with {LMS_TO_XYZ_D65}; see Color + # Level 4 spec for details on how to convert between XYZ and OKLab. + OKLAB_TO_LMS = [ + 0.99999999845051981432, 0.396337792173767856780, 0.215803758060758803390, + 1.00000000888176077670, -0.105561342323656349400, -0.063854174771705903402, + 1.00000005467241091770, -0.089484182094965759684, -1.291485537864091739900 + ].freeze + + # The transformation matrix for converting linear-light srgb colors to + # linear-light display-p3. + LINEAR_SRGB_TO_LINEAR_DISPLAY_P3 = [ + 0.82246196871436230, 0.17753803128563775, 0.00000000000000000, + 0.03319419885096161, 0.96680580114903840, 0.00000000000000000, + 0.01708263072112003, 0.07239744066396346, 0.91051992861491650 + ].freeze + + # The transformation matrix for converting linear-light display-p3 colors to + # linear-light srgb. + LINEAR_DISPLAY_P3_TO_LINEAR_SRGB = [ + 1.22494017628055980, -0.22494017628055996, 0.00000000000000000, + -0.04205695470968816, 1.04205695470968800, 0.00000000000000000, + -0.01963755459033443, -0.07863604555063188, 1.09827360014096630 + ].freeze + + # The transformation matrix for converting linear-light srgb colors to + # linear-light a98-rgb. + LINEAR_SRGB_TO_LINEAR_A98_RGB = [ + 0.71512560685562470, 0.28487439314437535, 0.00000000000000000, + 0.00000000000000000, 1.00000000000000000, 0.00000000000000000, + 0.00000000000000000, 0.04116194845011846, 0.95883805154988160 + ].freeze + + # The transformation matrix for converting linear-light a98-rgb colors to + # linear-light srgb. + LINEAR_A98_RGB_TO_LINEAR_SRGB = [ + 1.39835574396077830, -0.39835574396077830, 0.00000000000000000, + 0.00000000000000000, 1.00000000000000000, 0.00000000000000000, + 0.00000000000000000, -0.04292898929447326, 1.04292898929447330 + ].freeze + + # The transformation matrix for converting linear-light srgb colors to + # linear-light rec2020. + LINEAR_SRGB_TO_LINEAR_REC2020 = [ + 0.62740389593469900, 0.32928303837788370, 0.04331306568741722, + 0.06909728935823208, 0.91954039507545870, 0.01136231556630917, + 0.01639143887515027, 0.08801330787722575, 0.89559525324762400 + ].freeze + + # The transformation matrix for converting linear-light rec2020 colors to + # linear-light srgb. + LINEAR_REC2020_TO_LINEAR_SRGB = [ + 1.66049100210843450, -0.58764113878854950, -0.07284986331988487, + -0.12455047452159074, 1.13289989712596030, -0.00834942260436947, + -0.01815076335490530, -0.10057889800800737, 1.11872966136291270 + ].freeze + + # The transformation matrix for converting linear-light srgb colors to xyz. + LINEAR_SRGB_TO_XYZ_D65 = [ + 0.41239079926595950, 0.35758433938387796, 0.18048078840183430, + 0.21263900587151036, 0.71516867876775590, 0.07219231536073371, + 0.01933081871559185, 0.11919477979462598, 0.95053215224966060 + ].freeze + + # The transformation matrix for converting xyz colors to linear-light srgb. + XYZ_D65_TO_LINEAR_SRGB = [ + 3.24096994190452130, -1.53738317757009350, -0.49861076029300330, + -0.96924363628087980, 1.87596750150772060, 0.04155505740717561, + 0.05563007969699360, -0.20397695888897657, 1.05697151424287860 + ].freeze + + # The transformation matrix for converting linear-light srgb colors to lms. + LINEAR_SRGB_TO_LMS = [ + 0.41222147080000016, 0.53633253629999990, 0.05144599290000001, + 0.21190349820000007, 0.68069954509999990, 0.10739695660000000, + 0.08830246190000005, 0.28171883759999994, 0.62997870050000000 + ].freeze + + # The transformation matrix for converting lms colors to linear-light srgb. + LMS_TO_LINEAR_SRGB = [ + 4.07674166134799300, -3.30771159040819240, 0.23096992872942781, + -1.26843800409217660, 2.60975740066337240, -0.34131939631021974, + -0.00419608654183720, -0.70341861445944950, 1.70761470093094480 + ].freeze + + # The transformation matrix for converting linear-light srgb colors to + # linear-light prophoto-rgb. + LINEAR_SRGB_TO_LINEAR_PROPHOTO_RGB = [ + 0.52927697762261160, 0.33015450197849283, 0.14056852039889556, + 0.09836585954044917, 0.87347071290696180, 0.02816342755258900, + 0.01687534092138684, 0.11765941425612084, 0.86546524482249230 + ].freeze + + # The transformation matrix for converting linear-light prophoto-rgb colors to + # linear-light srgb. + LINEAR_PROPHOTO_RGB_TO_LINEAR_SRGB = [ + 2.03438084951699600, -0.72763578993413420, -0.30674505958286180, + -0.22882573163305037, 1.23174254119010480, -0.00291680955705449, + -0.00855882878391742, -0.15326670213803720, 1.16182553092195470 + ].freeze + + # The transformation matrix for converting linear-light srgb colors to xyz-d50. + LINEAR_SRGB_TO_XYZ_D50 = [ + 0.43606574687426936, 0.38515150959015960, 0.14307841996513868, + 0.22249317711056518, 0.71688701309448240, 0.06061980979495235, + 0.01392392146316939, 0.09708132423141015, 0.71409935681588070 + ].freeze + + # The transformation matrix for converting xyz-d50 colors to linear-light srgb. + XYZ_D50_TO_LINEAR_SRGB = [ + 3.13413585290011780, -1.61738599801804200, -0.49066221791109754, + -0.97879547655577770, 1.91625437739598840, 0.03344287339036693, + 0.07195539255794733, -0.22897675981518200, 1.40538603511311820 + ].freeze + + # The transformation matrix for converting linear-light display-p3 colors to + # linear-light a98-rgb. + LINEAR_DISPLAY_P3_TO_LINEAR_A98_RGB = [ + 0.86400513747404840, 0.13599486252595164, 0.00000000000000000, + -0.04205695470968816, 1.04205695470968800, 0.00000000000000000, + -0.02056038078232985, -0.03250613804550798, 1.05306651882783790 + ].freeze + + # The transformation matrix for converting linear-light a98-rgb colors to + # linear-light display-p3. + LINEAR_A98_RGB_TO_LINEAR_DISPLAY_P3 = [ + 1.15009441814101840, -0.15009441814101834, 0.00000000000000000, + 0.04641729862941844, 0.95358270137058150, 0.00000000000000000, + 0.02388759479083904, 0.02650477632633013, 0.94960762888283080 + ].freeze + + # The transformation matrix for converting linear-light display-p3 colors to + # linear-light rec2020. + LINEAR_DISPLAY_P3_TO_LINEAR_REC2020 = [ + 0.75383303436172180, 0.19859736905261630, 0.04756959658566187, + 0.04574384896535833, 0.94177721981169350, 0.01247893122294812, + -0.00121034035451832, 0.01760171730108989, 0.98360862305342840 + ].freeze + + # The transformation matrix for converting linear-light rec2020 colors to + # linear-light display-p3. + LINEAR_REC2020_TO_LINEAR_DISPLAY_P3 = [ + 1.34357825258433200, -0.28217967052613570, -0.06139858205819628, + -0.06529745278911953, 1.07578791584857460, -0.01049046305945495, + 0.00282178726170095, -0.01959849452449406, 1.01677670726279310 + ].freeze + + # The transformation matrix for converting linear-light display-p3 colors to + # xyz. + LINEAR_DISPLAY_P3_TO_XYZ_D65 = [ + 0.48657094864821626, 0.26566769316909294, 0.19821728523436250, + 0.22897456406974884, 0.69173852183650620, 0.07928691409374500, + 0.00000000000000000, 0.04511338185890257, 1.04394436890097570 + ].freeze + + # The transformation matrix for converting xyz colors to linear-light + # display-p3. + XYZ_D65_TO_LINEAR_DISPLAY_P3 = [ + 2.49349691194142450, -0.93138361791912360, -0.40271078445071684, + -0.82948896956157490, 1.76266406031834680, 0.02362468584194359, + 0.03584583024378433, -0.07617238926804170, 0.95688452400768730 + ].freeze + + # The transformation matrix for converting linear-light display-p3 colors to + # lms. + LINEAR_DISPLAY_P3_TO_LMS = [ + 0.48137985442585490, 0.46211836973903553, 0.05650177583510960, + 0.22883194490233110, 0.65321681282840370, 0.11795124216926511, + 0.08394575573016760, 0.22416526885956980, 0.69188897541026260 + ].freeze + + # The transformation matrix for converting lms colors to linear-light + # display-p3. + LMS_TO_LINEAR_DISPLAY_P3 = [ + 3.12776898667772140, -2.25713579553953770, 0.12936680863610234, + -1.09100904738343900, 2.41333175827934370, -0.32232271065457110, + -0.02601081320950207, -0.50804132569306730, 1.53405213885176520 + ].freeze + + # The transformation matrix for converting linear-light display-p3 colors to + # linear-light prophoto-rgb. + LINEAR_DISPLAY_P3_TO_LINEAR_PROPHOTO_RGB = [ + 0.63168691934035890, 0.21393038569465722, 0.15438269496498390, + 0.08320371426648458, 0.88586513676302430, 0.03093114897049121, + -0.00127273456473881, 0.05075510433665735, 0.95051763022808140 + ].freeze + + # The transformation matrix for converting linear-light prophoto-rgb colors to + # linear-light display-p3. + LINEAR_PROPHOTO_RGB_TO_LINEAR_DISPLAY_P3 = [ + 1.63257560870691790, -0.37977161848259840, -0.25280399022431950, + -0.15370040233755072, 1.16670254724250140, -0.01300214490495082, + 0.01039319529676572, -0.06280731264959440, 1.05241411735282870 + ].freeze + + # The transformation matrix for converting linear-light display-p3 colors to + # xyz-d50. + LINEAR_DISPLAY_P3_TO_XYZ_D50 = [ + 0.51514644296811600, 0.29200998206385770, 0.15713925139759397, + 0.24120032212525520, 0.69222254113138180, 0.06657713674336294, + -0.00105013914714014, 0.04187827018907460, 0.78427647146852570 + ].freeze + + # The transformation matrix for converting xyz-d50 colors to linear-light + # display-p3. + XYZ_D50_TO_LINEAR_DISPLAY_P3 = [ + 2.40393412185549730, -0.99003044249559310, -0.39761363181465614, + -0.84227001614546880, 1.79895801610670820, 0.01604562477090472, + 0.04819381686413303, -0.09738519815446048, 1.27367136933212730 + ].freeze + + # The transformation matrix for converting linear-light a98-rgb colors to + # linear-light rec2020. + LINEAR_A98_RGB_TO_LINEAR_REC2020 = [ + 0.87733384166365680, 0.07749370651571998, 0.04517245182062317, + 0.09662259146620378, 0.89152732024418050, 0.01185008828961569, + 0.02292106270284839, 0.04303668501067932, 0.93404225228647230 + ].freeze + + # The transformation matrix for converting linear-light rec2020 colors to + # linear-light a98-rgb. + LINEAR_REC2020_TO_LINEAR_A98_RGB = [ + 1.15197839471591630, -0.09750305530240860, -0.05447533941350766, + -0.12455047452159074, 1.13289989712596030, -0.00834942260436947, + -0.02253038278105590, -0.04980650742838876, 1.07233689020944460 + ].freeze + + # The transformation matrix for converting linear-light a98-rgb colors to xyz. + LINEAR_A98_RGB_TO_XYZ_D65 = [ + 0.57666904291013080, 0.18555823790654627, 0.18822864623499472, + 0.29734497525053616, 0.62736356625546600, 0.07529145849399789, + 0.02703136138641237, 0.07068885253582714, 0.99133753683763890 + ].freeze + + # The transformation matrix for converting xyz colors to linear-light a98-rgb. + XYZ_D65_TO_LINEAR_A98_RGB = [ + 2.04158790381074600, -0.56500697427885960, -0.34473135077832950, + -0.96924363628087980, 1.87596750150772060, 0.04155505740717561, + 0.01344428063203102, -0.11836239223101823, 1.01517499439120540 + ].freeze + + # The transformation matrix for converting linear-light a98-rgb colors to lms. + LINEAR_A98_RGB_TO_LMS = [ + 0.57643226147714040, 0.36991322114441194, 0.05365451737844765, + 0.29631647387335260, 0.59167612662650690, 0.11200739940014041, + 0.12347825480374285, 0.21949869580674647, 0.65702304938951070 + ].freeze + + # The transformation matrix for converting lms colors to linear-light a98-rgb. + LMS_TO_LINEAR_A98_RGB = [ + 2.55403684790806950, -1.62197620262602140, 0.06793935455575403, + -1.26843800409217660, 2.60975740066337240, -0.34131939631021974, + -0.05623474718052319, -0.56704183411879500, 1.62327658124261400 + ].freeze + + # The transformation matrix for converting linear-light a98-rgb colors to + # linear-light prophoto-rgb. + LINEAR_A98_RGB_TO_LINEAR_PROPHOTO_RGB = [ + 0.74011750180477920, 0.11327951328898105, 0.14660298490623970, + 0.13755046469802620, 0.83307708026948400, 0.02937245503248977, + 0.02359772990871766, 0.07378347703906656, 0.90261879305221580 + ].freeze + + # The transformation matrix for converting linear-light prophoto-rgb colors to + # linear-light a98-rgb. + LINEAR_PROPHOTO_RGB_TO_LINEAR_A98_RGB = [ + 1.38965124815152000, -0.16945907691487766, -0.22019217123664242, + -0.22882573163305037, 1.23174254119010480, -0.00291680955705449, + -0.01762544368426068, -0.09625702306122665, 1.11388246674548740 + ].freeze + + # The transformation matrix for converting linear-light a98-rgb colors to + # xyz-d50. + LINEAR_A98_RGB_TO_XYZ_D50 = [ + 0.60977504188618140, 0.20530000261929401, 0.14922063192409227, + 0.31112461220464155, 0.62565323083468560, 0.06322215696067286, + 0.01947059555648168, 0.06087908649415867, 0.74475492045981980 + ].freeze + + # The transformation matrix for converting xyz-d50 colors to linear-light + # a98-rgb. + XYZ_D50_TO_LINEAR_A98_RGB = [ + 1.96246703637688060, -0.61074234048150730, -0.34135809808271540, + -0.97879547655577770, 1.91625437739598840, 0.03344287339036693, + 0.02870443944957101, -0.14067486633170680, 1.34891418141379370 + ].freeze + + # The transformation matrix for converting linear-light rec2020 colors to xyz. + LINEAR_REC2020_TO_XYZ_D65 = [ + 0.63695804830129130, 0.14461690358620838, 0.16888097516417205, + 0.26270021201126703, 0.67799807151887100, 0.05930171646986194, + 0.00000000000000000, 0.02807269304908750, 1.06098505771079090 + ].freeze + + # The transformation matrix for converting xyz colors to linear-light rec2020. + XYZ_D65_TO_LINEAR_REC2020 = [ + 1.71665118797126760, -0.35567078377639240, -0.25336628137365980, + -0.66668435183248900, 1.61648123663493900, 0.01576854581391113, + 0.01763985744531091, -0.04277061325780865, 0.94210312123547400 + ].freeze + + # The transformation matrix for converting linear-light rec2020 colors to lms. + LINEAR_REC2020_TO_LMS = [ + 0.61675578719908560, 0.36019839939276255, 0.02304581340815186, + 0.26513306398328140, 0.63583936407771060, 0.09902757183900800, + 0.10010263423281572, 0.20390651940192997, 0.69599084636525430 + ].freeze + + # The transformation matrix for converting lms colors to linear-light rec2020. + LMS_TO_LINEAR_REC2020 = [ + 2.13990673569556170, -1.24638950878469060, 0.10648277296448995, + -0.88473586245815630, 2.16323098210838260, -0.27849511943390290, + -0.04857375801465988, -0.45450314291725170, 1.50307690088646130 + ].freeze + + # The transformation matrix for converting linear-light rec2020 colors to + # linear-light prophoto-rgb. + LINEAR_REC2020_TO_LINEAR_PROPHOTO_RGB = [ + 0.83518733312972350, 0.04886884858605698, 0.11594381828421951, + 0.05403324519953363, 0.92891840856920440, 0.01704834623126199, + -0.00234203897072539, 0.03633215316169465, 0.96600988580903070 + ].freeze + + # The transformation matrix for converting linear-light prophoto-rgb colors to + # linear-light rec2020. + LINEAR_PROPHOTO_RGB_TO_LINEAR_REC2020 = [ + 1.20065932951740800, -0.05756805370122346, -0.14309127581618444, + -0.06994154955888504, 1.08061789759721400, -0.01067634803832895, + 0.00554147334294746, -0.04078219298657951, 1.03524071964363200 + ].freeze + + # The transformation matrix for converting linear-light rec2020 colors to + # xyz-d50. + LINEAR_REC2020_TO_XYZ_D50 = [ + 0.67351546318827600, 0.16569726370390453, 0.12508294953738705, + 0.27905900514112060, 0.67531800574910980, 0.04562298910976962, + -0.00193242713400438, 0.02997782679282923, 0.79705920285163550 + ].freeze + + # The transformation matrix for converting xyz-d50 colors to linear-light + # rec2020. + XYZ_D50_TO_LINEAR_REC2020 = [ + 1.64718490467176600, -0.39368189813164710, -0.23595963848828266, + -0.68266410741738180, 1.64771461274440760, 0.01281708338512084, + 0.02966887665275675, -0.06292589642970030, 1.25355782018657710 + ].freeze + + # The transformation matrix for converting xyz colors to lms. + XYZ_D65_TO_LMS = [ + 0.81902244321643190, 0.36190625628012210, -0.12887378261216414, + 0.03298366719802710, 0.92928684689655460, 0.03614466816999844, + 0.04817719956604625, 0.26423952494422764, 0.63354782581369370 + ].freeze + + # The transformation matrix for converting lms colors to xyz. + LMS_TO_XYZ_D65 = [ + 1.22687987337415570, -0.55781499655548140, 0.28139105017721590, + -0.04057576262431372, 1.11228682939705960, -0.07171106666151703, + -0.07637294974672143, -0.42149332396279143, 1.58692402442724180 + ].freeze + + # The transformation matrix for converting xyz colors to linear-light + # prophoto-rgb. + XYZ_D65_TO_LINEAR_PROPHOTO_RGB = [ + 1.40319046337749790, -0.22301514479051668, -0.10160668507413790, + -0.52623840216330720, 1.48163196292346440, 0.01701879027252688, + -0.01120226528622150, 0.01824640347962099, 0.91124722749150480 + ].freeze + + # The transformation matrix for converting linear-light prophoto-rgb colors to + # xyz. + LINEAR_PROPHOTO_RGB_TO_XYZ_D65 = [ + 0.75559074229692100, 0.11271984265940525, 0.08214534209534540, + 0.26832184357857190, 0.71511525666179120, 0.01656289975963685, + 0.00391597276242580, -0.01293344283684181, 1.09807522083429450 + ].freeze + + # The transformation matrix for converting xyz colors to xyz-d50. + XYZ_D65_TO_XYZ_D50 = [ + 1.04792979254499660, 0.02294687060160952, -0.05019226628920519, + 0.02962780877005567, 0.99043442675388000, -0.01707379906341879, + -0.00924304064620452, 0.01505519149029816, 0.75187428142813700 + ].freeze + + # The transformation matrix for converting xyz-d50 colors to xyz. + XYZ_D50_TO_XYZ_D65 = [ + 0.95547342148807520, -0.02309845494876452, 0.06325924320057065, + -0.02836970933386358, 1.00999539808130410, 0.02104144119191730, + 0.01231401486448199, -0.02050764929889898, 1.33036592624212400 + ].freeze + + # The transformation matrix for converting lms colors to linear-light + # prophoto-rgb. + LMS_TO_LINEAR_PROPHOTO_RGB = [ + 1.73835514985815240, -0.98795095237343430, 0.24959580241648663, + -0.70704942624914860, 1.93437008438177620, -0.22732065793919040, + -0.08407883426424761, -0.35754059702097796, 1.44161943124947150 + ].freeze + + # The transformation matrix for converting linear-light prophoto-rgb colors to + # lms. + LINEAR_PROPHOTO_RGB_TO_LMS = [ + 0.71544846349294310, 0.35279154798172740, -0.06824001147467047, + 0.27441165509049420, 0.66779764080811480, 0.05779070400139092, + 0.10978443849083751, 0.18619828746596980, 0.70401727404319270 + ].freeze + + # The transformation matrix for converting lms colors to xyz-d50. + LMS_TO_XYZ_D50 = [ + 1.28858621583908840, -0.53787174651736210, 0.21358120705405403, + -0.00253389352489796, 1.09231682453266550, -0.08978293089853581, + -0.06937383312514489, -0.29500839218634667, 1.18948682779245090 + ].freeze + + # The transformation matrix for converting xyz-d50 colors to lms. + XYZ_D50_TO_LMS = [ + 0.77070004712402500, 0.34924839871072740, -0.11202352004249890, + 0.00559650559780223, 0.93707232493333150, 0.06972569131301698, + 0.04633715253432816, 0.25277530868525870, 0.85145807371608350 + ].freeze + + # The transformation matrix for converting linear-light prophoto-rgb colors to + # xyz-d50. + LINEAR_PROPHOTO_RGB_TO_XYZ_D50 = [ + 0.79776664490064230, 0.13518129740053308, 0.03134773412839220, + 0.28807482881940130, 0.71183523424187300, 0.00008993693872564, + 0.00000000000000000, 0.00000000000000000, 0.82510460251046020 + ].freeze + + # The transformation matrix for converting xyz-d50 colors to linear-light + # prophoto-rgb. + XYZ_D50_TO_LINEAR_PROPHOTO_RGB = [ + 1.34578688164715830, -0.25557208737979464, -0.05110186497554526, + -0.54463070512490190, 1.50824774284514680, 0.02052744743642139, + 0.00000000000000000, 0.00000000000000000, 1.21196754563894520 + ].freeze + end + + private_constant :Conversions + end + end +end diff --git a/lib/sass/value/color/gamut_map_method.rb b/lib/sass/value/color/gamut_map_method.rb new file mode 100644 index 00000000..5742ffb7 --- /dev/null +++ b/lib/sass/value/color/gamut_map_method.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/gamut_map_method.dart + module GamutMapMethod + # @return [::String] + attr_reader :name + + # @param name [::String] + def initialize(name) + @name = name + end + + class << self + # @param name [::String] + # @param argument_name [::String] + # @return [GamutMapMethod] + def from_name(name, argument_name = nil) + case name + when 'clip' + CLIP + when 'local-minde' + LOCAL_MINDE + else + raise Sass::ScriptError.new("Unknown gamut map method \"#{name}\".", argument_name) + end + end + end + + # @param color [Color] + # @return [Color] + def map(color) + raise NotImplementedError, "[BUG] gamut map method #{name} doesn't implement map." + end + end + + private_constant :GamutMapMethod + end + end +end + +require_relative 'gamut_map_method/clip' +require_relative 'gamut_map_method/local_minde' diff --git a/lib/sass/value/color/gamut_map_method/clip.rb b/lib/sass/value/color/gamut_map_method/clip.rb new file mode 100644 index 00000000..c6a2b434 --- /dev/null +++ b/lib/sass/value/color/gamut_map_method/clip.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module GamutMapMethod + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/gamut_map_method/local_minde.dart + class Clip + include GamutMapMethod + + def initialize + super('clip') + end + + def map(color) + space = color.send(:_space) + Color.send(:for_space_internal, + space, + _clamp_channel(color.send(:channel0_or_nil), space.channels[0]), + _clamp_channel(color.send(:channel1_or_nil), space.channels[1]), + _clamp_channel(color.send(:channel2_or_nil), space.channels[2]), + color.send(:alpha_or_nil)) + end + + private + + def _clamp_channel(value, channel) + return nil if value.nil? + + case channel + when LinearChannel + FuzzyMath.clamp_like_css(value, channel.min, channel.max) + else + value + end + end + end + + private_constant :Clip + + CLIP = Clip.new + end + end + end +end diff --git a/lib/sass/value/color/gamut_map_method/local_minde.rb b/lib/sass/value/color/gamut_map_method/local_minde.rb new file mode 100644 index 00000000..19034b3d --- /dev/null +++ b/lib/sass/value/color/gamut_map_method/local_minde.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module GamutMapMethod + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/gamut_map_method/local_minde.dart + class LocalMinde + include GamutMapMethod + + # A constant from the gamut-mapping algorithm. + JND = 0.02 + + private_constant :JND + + # A constant from the gamut-mapping algorithm. + EPSILON = 0.0001 + + private_constant :EPSILON + + def initialize + super('local-minde') + end + + def map(color) + original_oklch = color.send(:_to_space, Space::OKLCH) + lightness = original_oklch.send(:channel0_or_nil) + hue = original_oklch.send(:channel2_or_nil) + alpha = original_oklch.send(:alpha_or_nil) + + if FuzzyMath.greater_than_or_equals(lightness.nil? ? 0 : lightness, 1) + if color.legacy? + return Color.send(:_for_space, + Space::RGB, 255, 255, 255, color.send(:alpha_or_nil)) + .send(:_to_space, color.send(:_space)) + else + return Color.send(:for_space_internal, + color.send(:_space), 1, 1, 1, color.send(:alpha_or_nil)) + end + elsif FuzzyMath.less_than_or_equals(lightness.nil? ? 0 : lightness, 0) + return Color.send(:_for_space, + Space::RGB, 0, 0, 0, color.send(:alpha_or_nil)) + .send(:_to_space, color.send(:_space)) + end + + clipped = color.send(:_to_gamut, CLIP) + return clipped if _delta_eok(clipped, color) < JND + + min = 0.0 + max = original_oklch.send(:channel1) + min_in_gamut = true + while max - min > EPSILON + chroma = (min + max) / 2 + + current = Space::OKLCH.convert(color.send(:_space), lightness, chroma, hue, alpha) + + if min_in_gamut && current.in_gamut? + min = chroma + next + end + + clipped = current.send(:_to_gamut, CLIP) + e = _delta_eok(clipped, current) + + if e < JND + return clipped if JND - e < EPSILON + + min_in_gamut = false + min = chroma + else + max = chroma + end + end + clipped + end + + private + + def _delta_eok(color1, color2) + lab1 = color1.send(:_to_space, Space::OKLAB) + lab2 = color2.send(:_to_space, Space::OKLAB) + Math.sqrt(((lab1.send(:channel0) - lab2.send(:channel0))**2) + + ((lab1.send(:channel1) - lab2.send(:channel1))**2) + + ((lab1.send(:channel2) - lab2.send(:channel2))**2)) + end + end + + private_constant :LocalMinde + + LOCAL_MINDE = LocalMinde.new + end + end + end +end diff --git a/lib/sass/value/color/interpolation_method.rb b/lib/sass/value/color/interpolation_method.rb new file mode 100644 index 00000000..a4cc1e0f --- /dev/null +++ b/lib/sass/value/color/interpolation_method.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/interpolation_method.dart + class InterpolationMethod + # @return [Space] + attr_reader :space + + # @return [Symbol, nil] + attr_reader :hue + + # @param space [Space] + # @param hue [Symbol] + def initialize(space, hue = nil) + @space = space + @hue = if space.polar? + hue.nil? ? :shorter : hue + end + + return unless !space.polar? && !hue.nil? + + raise Sass::ScriptError, + "Hue interpolation method may not be set for rectangular color space #{space.name}." + end + end + + private_constant :InterpolationMethod + + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/interpolation_method.dart + module HueInterpolationMethod + class << self + # @param name [::String] + # @param argument_name [::String] + # @return [Symbol] + def from_name(name, argument_name = nil) + case name + when 'decreasing', 'increasing', 'longer', 'shorter' + name.to_sym + else + raise Sass::ScriptError.new("Unknown hue interpolation method \"#{name}\".", argument_name) + end + end + end + end + + private_constant :HueInterpolationMethod + end + end +end diff --git a/lib/sass/value/color/space.rb b/lib/sass/value/color/space.rb new file mode 100644 index 00000000..741fa159 --- /dev/null +++ b/lib/sass/value/color/space.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space.dart + module Space + # @return [::String] + attr_reader :name + + # @return [Array] + attr_reader :channels + + # @return [::Boolean] + def bounded? + raise NotImplementedError + end + + # @return [::Boolean] + def legacy? + false + end + + # @return [::Boolean] + def polar? + false + end + + # @param name [::String] + # @param channels [Array] + def initialize(name, channels) + @name = name + @channels = channels + end + + class << self + # @param name [::String] + # @param argument_name [::String] + # @return [Space] + def from_name(name, argument_name = nil) + case name.downcase + when 'rgb' + RGB + when 'hwb' + HWB + when 'hsl' + HSL + when 'srgb' + SRGB + when 'srgb-linear' + SRGB_LINEAR + when 'display-p3' + DISPLAY_P3 + when 'a98-rgb' + A98_RGB + when 'prophoto-rgb' + PROPHOTO_RGB + when 'rec2020' + REC2020 + when 'xyz', 'xyz-d65' + XYZ_D65 + when 'xyz-d50' + XYZ_D50 + when 'lab' + LAB + when 'lch' + LCH + when 'oklab' + OKLAB + when 'oklch' + OKLCH + else + raise Sass::ScriptError.new("Unknown color space \"#{name}\".", argument_name) + end + end + end + + # @param dest [Space] + # @param channel0 [Numeric] + # @param channel1 [Numeric] + # @param channel2 [Numeric] + # @param alpha [Numeric] + # @return [Color] + def convert(dest, channel0, channel1, channel2, alpha) + convert_linear(dest, channel0, channel1, channel2, alpha) + end + + protected + + def convert_linear(dest, red, green, blue, alpha, + missing_lightness: false, + missing_chroma: false, + missing_hue: false, + missing_a: false, + missing_b: false) + linear_dest = case dest + when HSL, HWB + SRGB + when LAB, LCH + XYZ_D50 + when OKLAB, OKLCH + LMS + else + dest + end + if linear_dest == self + transformed_red = red + transformed_green = green + transformed_blue = blue + else + linear_red = to_linear(red.nil? ? 0 : red) + linear_green = to_linear(green.nil? ? 0 : green) + linear_blue = to_linear(blue.nil? ? 0 : blue) + matrix = transformation_matrix(linear_dest) + + # (matrix * [linear_red, linear_green, linear_blue]).map(linear_dest.from_linear) + transformed_red = linear_dest.from_linear((matrix[0] * linear_red) + + (matrix[1] * linear_green) + + (matrix[2] * linear_blue)) + transformed_green = linear_dest.from_linear((matrix[3] * linear_red) + + (matrix[4] * linear_green) + + (matrix[5] * linear_blue)) + transformed_blue = linear_dest.from_linear((matrix[6] * linear_red) + + (matrix[7] * linear_green) + + (matrix[8] * linear_blue)) + end + + case dest + when HSL, HWB + SRGB.convert(dest, transformed_red, transformed_green, transformed_blue, alpha, + missing_lightness:, + missing_chroma:, + missing_hue:) + when LAB, LCH + XYZ_D50.convert(dest, transformed_red, transformed_green, transformed_blue, alpha, + missing_lightness:, + missing_chroma:, + missing_hue:, + missing_a:, + missing_b:) + when OKLAB, OKLCH + LMS.convert(dest, transformed_red, transformed_green, transformed_blue, alpha, + missing_lightness:, + missing_chroma:, + missing_hue:, + missing_a:, + missing_b:) + else + Color.send(:_for_space, + dest, + red.nil? ? nil : transformed_red, + green.nil? ? nil : transformed_green, + blue.nil? ? nil : transformed_blue, + alpha) + end + end + + # @param channel [Numeric] + # @return [Numeric] + def to_linear(channel) + raise NotImplementedError, "[BUG] Color space #{name} doesn't support linear conversions." + end + + # @param channel [Numeric] + # @return [Numeric] + def from_linear(channel) + raise NotImplementedError, "[BUG] Color space #{name} doesn't support linear conversions." + end + + # @param dest [Space] + # @return [Array] + def transformation_matrix(dest) + raise NotImplementedError, "[BUG] Color space conversion from #{name} to #{dest.name} not implemented." + end + end + + private_constant :Space + end + end +end + +require_relative 'space/utils' +require_relative 'space/a98_rgb' +require_relative 'space/display_p3' +require_relative 'space/hsl' +require_relative 'space/hwb' +require_relative 'space/lab' +require_relative 'space/lch' +require_relative 'space/lms' +require_relative 'space/oklab' +require_relative 'space/oklch' +require_relative 'space/prophoto_rgb' +require_relative 'space/rec2020' +require_relative 'space/rgb' +require_relative 'space/srgb' +require_relative 'space/srgb_linear' +require_relative 'space/xyz_d50' +require_relative 'space/xyz_d65' diff --git a/lib/sass/value/color/space/a98_rgb.rb b/lib/sass/value/color/space/a98_rgb.rb new file mode 100644 index 00000000..bdfe0d54 --- /dev/null +++ b/lib/sass/value/color/space/a98_rgb.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/a98_rgb.dart + class A98Rgb + include Space + + def bounded? + true + end + + def initialize + super('a98-rgb', Utils::RGB_CHANNELS) + end + + def to_linear(channel) + FuzzyMath.sign(channel) * (channel.abs**(563 / 256.0)) + end + + def from_linear(channel) + FuzzyMath.sign(channel) * (channel.abs**(256 / 563.0)) + end + + private + + def transformation_matrix(dest) + case dest + when DISPLAY_P3 + Conversions::LINEAR_A98_RGB_TO_LINEAR_DISPLAY_P3 + when LMS + Conversions::LINEAR_A98_RGB_TO_LMS + when PROPHOTO_RGB + Conversions::LINEAR_A98_RGB_TO_LINEAR_PROPHOTO_RGB + when REC2020 + Conversions::LINEAR_A98_RGB_TO_LINEAR_REC2020 + when RGB, SRGB, SRGB_LINEAR + Conversions::LINEAR_A98_RGB_TO_LINEAR_SRGB + when XYZ_D50 + Conversions::LINEAR_A98_RGB_TO_XYZ_D50 + when XYZ_D65 + Conversions::LINEAR_A98_RGB_TO_XYZ_D65 + else + super + end + end + end + + private_constant :A98Rgb + + A98_RGB = A98Rgb.new + end + end + end +end diff --git a/lib/sass/value/color/space/display_p3.rb b/lib/sass/value/color/space/display_p3.rb new file mode 100644 index 00000000..ed7a1ccf --- /dev/null +++ b/lib/sass/value/color/space/display_p3.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/display_p3.dart + class DisplayP3 + include Space + + def bounded? + true + end + + def initialize + super('display-p3', Utils::RGB_CHANNELS) + end + + def to_linear(channel) + Utils.srgb_and_display_p3_to_linear(channel) + end + + def from_linear(channel) + Utils.srgb_and_display_p3_from_linear(channel) + end + + private + + def transformation_matrix(dest) + case dest + when A98_RGB + Conversions::LINEAR_DISPLAY_P3_TO_LINEAR_A98_RGB + when LMS + Conversions::LINEAR_DISPLAY_P3_TO_LMS + when PROPHOTO_RGB + Conversions::LINEAR_DISPLAY_P3_TO_LINEAR_PROPHOTO_RGB + when REC2020 + Conversions::LINEAR_DISPLAY_P3_TO_LINEAR_REC2020 + when RGB, SRGB, SRGB_LINEAR + Conversions::LINEAR_DISPLAY_P3_TO_LINEAR_SRGB + when XYZ_D50 + Conversions::LINEAR_DISPLAY_P3_TO_XYZ_D50 + when XYZ_D65 + Conversions::LINEAR_DISPLAY_P3_TO_XYZ_D65 + else + super + end + end + end + + private_constant :DisplayP3 + + DISPLAY_P3 = DisplayP3.new + end + end + end +end diff --git a/lib/sass/value/color/space/hsl.rb b/lib/sass/value/color/space/hsl.rb new file mode 100644 index 00000000..5135cdc2 --- /dev/null +++ b/lib/sass/value/color/space/hsl.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/hsl.dart + class Hsl + include Space + + def bounded? + true + end + + def legacy? + true + end + + def polar? + true + end + + def initialize + super('hsl', [ + Utils::HUE_CHANNEL, + LinearChannel.new('saturation', 0, 100, requires_percent: true, lower_clamped: true).freeze, + LinearChannel.new('lightness', 0, 100, requires_percent: true).freeze + ].freeze) + end + + def convert(dest, hue, saturation, lightness, alpha) + scaled_hue = ((hue.nil? ? 0 : hue) / 360.0) % 1 + scaled_saturation = (saturation.nil? ? 0 : saturation) / 100.0 + scaled_lightness = (lightness.nil? ? 0 : lightness) / 100.0 + + m2 = if scaled_lightness <= 0.5 + scaled_lightness * (scaled_saturation + 1) + else + scaled_lightness + scaled_saturation - (scaled_lightness * scaled_saturation) + end + m1 = (scaled_lightness * 2) - m2 + + SRGB.convert( + dest, + Utils.hue_to_rgb(m1, m2, scaled_hue + (1 / 3.0)), + Utils.hue_to_rgb(m1, m2, scaled_hue), + Utils.hue_to_rgb(m1, m2, scaled_hue - (1 / 3.0)), + alpha, + missing_lightness: lightness.nil?, + missing_chroma: saturation.nil?, + missing_hue: hue.nil? + ) + end + end + + private_constant :Hsl + + HSL = Hsl.new + end + end + end +end diff --git a/lib/sass/value/color/space/hwb.rb b/lib/sass/value/color/space/hwb.rb new file mode 100644 index 00000000..822135b2 --- /dev/null +++ b/lib/sass/value/color/space/hwb.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/hwb.dart + class Hwb + include Space + + def bounded? + true + end + + def legacy? + true + end + + def polar? + true + end + + def initialize + super('hwb', [ + Utils::HUE_CHANNEL, + LinearChannel.new('whiteness', 0, 100, requires_percent: true).freeze, + LinearChannel.new('blackness', 0, 100, requires_percent: true).freeze + ].freeze) + end + + def convert(dest, hue, whiteness, blackness, alpha) + scaled_hue = (hue.nil? ? 0 : hue) % 360 / 360.0 + scaled_whiteness = (whiteness.nil? ? 0 : whiteness) / 100.0 + scaled_blackness = (blackness.nil? ? 0 : blackness) / 100.0 + + sum = scaled_whiteness + scaled_blackness + if sum > 1 + scaled_whiteness /= sum + scaled_blackness /= sum + end + + factor = 1 - scaled_whiteness - scaled_blackness + + to_rgb = lambda do |hue_| + (Utils.hue_to_rgb(0, 1, hue_) * factor) + scaled_whiteness + end + + SRGB.convert(dest, + to_rgb.call(scaled_hue + (1 / 3.0)), + to_rgb.call(scaled_hue), + to_rgb.call(scaled_hue - (1 / 3.0)), + alpha, + missing_hue: hue.nil?) + end + end + + private_constant :Hwb + + HWB = Hwb.new + end + end + end +end diff --git a/lib/sass/value/color/space/lab.rb b/lib/sass/value/color/space/lab.rb new file mode 100644 index 00000000..164db01e --- /dev/null +++ b/lib/sass/value/color/space/lab.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/lab.dart + class Lab + include Space + + def bounded? + false + end + + def initialize + super('lab', [ + LinearChannel.new('lightness', 0, 100, lower_clamped: true, upper_clamped: true).freeze, + LinearChannel.new('a', -125, 125).freeze, + LinearChannel.new('b', -125, 125).freeze + ].freeze) + end + + def convert(dest, lightness, a, b, alpha, # rubocop:disable Naming/MethodParameterName + missing_chroma: false, missing_hue: false) + case dest + when LAB + powerless_ab = lightness.nil? || FuzzyMath.equals(lightness, 0) + Color.send( + :_for_space, + dest, + lightness, + a.nil? || powerless_ab ? nil : a, + b.nil? || powerless_ab ? nil : b, + alpha + ) + when LCH + Utils.lab_to_lch(dest, lightness, a, b, alpha) + else + missing_lightness = lightness.nil? + lightness = 0 if missing_lightness + + f1 = (lightness + 16) / 116.0 + + XYZ_D50.convert( + dest, + _convert_f_to_x_or_z(((a.nil? ? 0 : a) / 500.0) + f1) * Conversions::D50[0], + (if lightness > Utils::LAB_KAPPA * Utils::LAB_EPSILON + (((lightness + 16) / 116.0)**3) + else + lightness / Utils::LAB_KAPPA + end) * Conversions::D50[1], + _convert_f_to_x_or_z(f1 - ((b.nil? ? 0 : b) / 200.0)) * Conversions::D50[2], + alpha, + missing_lightness:, + missing_chroma:, + missing_hue:, + missing_a: a.nil?, + missing_b: b.nil? + ) + end + end + + private + + def _convert_f_to_x_or_z(component) + cubed = (component**3) + 0.0 + cubed > Utils::LAB_EPSILON ? cubed : ((116 * component) - 16) / Utils::LAB_KAPPA + end + end + + private_constant :Lab + + LAB = Lab.new + end + end + end +end diff --git a/lib/sass/value/color/space/lch.rb b/lib/sass/value/color/space/lch.rb new file mode 100644 index 00000000..b375deb7 --- /dev/null +++ b/lib/sass/value/color/space/lch.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/lch.dart + class Lch + include Space + + def bounded? + false + end + + def polar? + true + end + + def initialize + super('lch', [ + LinearChannel.new('lightness', 0, 100, lower_clamped: true, upper_clamped: true).freeze, + LinearChannel.new('chroma', 0, 150, lower_clamped: true).freeze, + Utils::HUE_CHANNEL + ].freeze) + end + + def convert(dest, lightness, chroma, hue, alpha) + missing_chroma = chroma.nil? + missing_hue = hue.nil? + chroma = 0 if missing_chroma + hue = 0 if missing_hue + + hue_radians = hue * Math::PI / 180 + LAB.convert( + dest, + lightness, + chroma * Math.cos(hue_radians), + chroma * Math.sin(hue_radians), + alpha, + missing_chroma:, + missing_hue: + ) + end + end + + private_constant :Lch + + LCH = Lch.new + end + end + end +end diff --git a/lib/sass/value/color/space/lms.rb b/lib/sass/value/color/space/lms.rb new file mode 100644 index 00000000..97444a9c --- /dev/null +++ b/lib/sass/value/color/space/lms.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/lms.dart + class Lms + include Space + + def bounded? + false + end + + def initialize + super('lms', [ + LinearChannel.new('long', 0, 1).freeze, + LinearChannel.new('medium', 0, 1).freeze, + LinearChannel.new('short', 0, 1).freeze + ].freeze) + end + + def convert(dest, long, medium, short, alpha, + missing_lightness: false, + missing_chroma: false, + missing_hue: false, + missing_a: false, + missing_b: false) + case dest + when OKLAB + long_scaled = _cube_root_preserving_sign(long.nil? ? 0 : long) + medium_scaled = _cube_root_preserving_sign(medium.nil? ? 0 : medium) + short_scaled = _cube_root_preserving_sign(short.nil? ? 0 : short) + + Color.send( + :_for_space, + dest, + unless missing_lightness + (Conversions::LMS_TO_OKLAB[0] * long_scaled) + + (Conversions::LMS_TO_OKLAB[1] * medium_scaled) + + (Conversions::LMS_TO_OKLAB[2] * short_scaled) + end, + unless missing_a + (Conversions::LMS_TO_OKLAB[3] * long_scaled) + + (Conversions::LMS_TO_OKLAB[4] * medium_scaled) + + (Conversions::LMS_TO_OKLAB[5] * short_scaled) + end, + unless missing_b + (Conversions::LMS_TO_OKLAB[6] * long_scaled) + + (Conversions::LMS_TO_OKLAB[7] * medium_scaled) + + (Conversions::LMS_TO_OKLAB[8] * short_scaled) + end, + alpha + ) + when OKLCH + long_scaled = _cube_root_preserving_sign(long.nil? ? 0 : long) + medium_scaled = _cube_root_preserving_sign(medium.nil? ? 0 : medium) + short_scaled = _cube_root_preserving_sign(short.nil? ? 0 : short) + + Utils.lab_to_lch( + dest, + unless missing_lightness + (Conversions::LMS_TO_OKLAB[0] * long_scaled) + + (Conversions::LMS_TO_OKLAB[1] * medium_scaled) + + (Conversions::LMS_TO_OKLAB[2] * short_scaled) + end, + unless missing_a + (Conversions::LMS_TO_OKLAB[3] * long_scaled) + + (Conversions::LMS_TO_OKLAB[4] * medium_scaled) + + (Conversions::LMS_TO_OKLAB[5] * short_scaled) + end, + unless missing_b + (Conversions::LMS_TO_OKLAB[6] * long_scaled) + + (Conversions::LMS_TO_OKLAB[7] * medium_scaled) + + (Conversions::LMS_TO_OKLAB[8] * short_scaled) + end, + alpha + ) + else + convert_linear(dest, long, medium, short, alpha, + missing_lightness:, + missing_chroma:, + missing_hue:, + missing_a:, + missing_b:) + end + end + + def to_linear(channel) + channel + end + + def from_linear(channel) + channel + end + + private + + def transformation_matrix(dest) + case dest + when A98_RGB + Conversions::LMS_TO_LINEAR_A98_RGB + when DISPLAY_P3 + Conversions::LMS_TO_LINEAR_DISPLAY_P3 + when PROPHOTO_RGB + Conversions::LMS_TO_LINEAR_PROPHOTO_RGB + when REC2020 + Conversions::LMS_TO_LINEAR_REC2020 + when RGB, SRGB, SRGB_LINEAR + Conversions::LMS_TO_LINEAR_SRGB + when XYZ_D50 + Conversions::LMS_TO_XYZ_D50 + when XYZ_D65 + Conversions::LMS_TO_XYZ_D65 + else + super + end + end + + def _cube_root_preserving_sign(number) + (number.abs**(1 / 3.0)) * FuzzyMath.sign(number) + end + end + + private_constant :Lms + + LMS = Lms.new + + private_constant :LMS + end + end + end +end diff --git a/lib/sass/value/color/space/oklab.rb b/lib/sass/value/color/space/oklab.rb new file mode 100644 index 00000000..563e83aa --- /dev/null +++ b/lib/sass/value/color/space/oklab.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/oklab.dart + class Oklab + include Space + + def bounded? + false + end + + def initialize + super('oklab', [ + LinearChannel.new('lightness', 0, 1, + conventionally_percent: true, lower_clamped: true, upper_clamped: true).freeze, + LinearChannel.new('a', -0.4, 0.4).freeze, + LinearChannel.new('b', -0.4, 0.4).freeze + ].freeze) + end + + def convert(dest, lightness, a, b, alpha, # rubocop:disable Naming/MethodParameterName + missing_chroma: false, missing_hue: false) + case dest + when OKLCH + Utils.lab_to_lch(dest, lightness, a, b, alpha) + else + missing_lightness = lightness.nil? + missing_a = a.nil? + missing_b = b.nil? + lightness = 0 if missing_lightness + a = 0 if missing_a + b = 0 if missing_b + LMS.convert( + dest, + (( + (Conversions::OKLAB_TO_LMS[0] * lightness) + + (Conversions::OKLAB_TO_LMS[1] * a) + + (Conversions::OKLAB_TO_LMS[2] * b) + )**3) + 0.0, + (( + (Conversions::OKLAB_TO_LMS[3] * lightness) + + (Conversions::OKLAB_TO_LMS[4] * a) + + (Conversions::OKLAB_TO_LMS[5] * b) + )**3) + 0.0, + (( + (Conversions::OKLAB_TO_LMS[6] * lightness) + + (Conversions::OKLAB_TO_LMS[7] * a) + + (Conversions::OKLAB_TO_LMS[8] * b) + )**3) + 0.0, + alpha, + missing_lightness:, + missing_chroma:, + missing_hue:, + missing_a:, + missing_b: + ) + end + end + end + + private_constant :Oklab + + OKLAB = Oklab.new + end + end + end +end diff --git a/lib/sass/value/color/space/oklch.rb b/lib/sass/value/color/space/oklch.rb new file mode 100644 index 00000000..645f7118 --- /dev/null +++ b/lib/sass/value/color/space/oklch.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/oklch.dart + class Oklch + include Space + + def bounded? + false + end + + def polar? + true + end + + def initialize + super('oklch', [ + LinearChannel.new('lightness', 0, 1, + conventionally_percent: true, lower_clamped: true, upper_clamped: true).freeze, + LinearChannel.new('chroma', 0, 0.4, lower_clamped: true).freeze, + Utils::HUE_CHANNEL + ].freeze) + end + + def convert(dest, lightness, chroma, hue, alpha) + missing_chroma = chroma.nil? + missing_hue = hue.nil? + chroma = 0 if missing_chroma + hue = 0 if missing_hue + + hue_radians = hue * Math::PI / 180 + OKLAB.convert( + dest, + lightness, + chroma * Math.cos(hue_radians), + chroma * Math.sin(hue_radians), + alpha, + missing_chroma:, + missing_hue: + ) + end + end + + private_constant :Oklch + + OKLCH = Oklch.new + end + end + end +end diff --git a/lib/sass/value/color/space/prophoto_rgb.rb b/lib/sass/value/color/space/prophoto_rgb.rb new file mode 100644 index 00000000..88ac5947 --- /dev/null +++ b/lib/sass/value/color/space/prophoto_rgb.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/prophoto_rgb.dart + class ProphotoRgb + include Space + + def bounded? + true + end + + def initialize + super('prophoto-rgb', Utils::RGB_CHANNELS) + end + + def to_linear(channel) + abs = channel.abs + abs <= 16 / 512.0 ? channel / 16.0 : FuzzyMath.sign(channel) * (abs**1.8) + end + + def from_linear(channel) + abs = channel.abs + abs >= 1 / 512.0 ? FuzzyMath.sign(channel) * (abs**(1 / 1.8)) : 16 * channel + end + + private + + def transformation_matrix(dest) + case dest + when A98_RGB + Conversions::LINEAR_PROPHOTO_RGB_TO_LINEAR_A98_RGB + when DISPLAY_P3 + Conversions::LINEAR_PROPHOTO_RGB_TO_LINEAR_DISPLAY_P3 + when LMS + Conversions::LINEAR_PROPHOTO_RGB_TO_LMS + when REC2020 + Conversions::LINEAR_PROPHOTO_RGB_TO_LINEAR_REC2020 + when RGB, SRGB, SRGB_LINEAR + Conversions::LINEAR_PROPHOTO_RGB_TO_LINEAR_SRGB + when XYZ_D50 + Conversions::LINEAR_PROPHOTO_RGB_TO_XYZ_D50 + when XYZ_D65 + Conversions::LINEAR_PROPHOTO_RGB_TO_XYZ_D65 + else + super + end + end + end + + private_constant :ProphotoRgb + + PROPHOTO_RGB = ProphotoRgb.new + end + end + end +end diff --git a/lib/sass/value/color/space/rec2020.rb b/lib/sass/value/color/space/rec2020.rb new file mode 100644 index 00000000..4d3febfa --- /dev/null +++ b/lib/sass/value/color/space/rec2020.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/rec2020.dart + class Rec2020 + include Space + + # A constant used in the rec2020 gamma encoding/decoding functions. + ALPHA = 1.09929682680944 + + private_constant :ALPHA + + # A constant used in the rec2020 gamma encoding/decoding functions. + BETA = 0.018053968510807 + + private_constant :BETA + + def bounded? + true + end + + def initialize + super('rec2020', Utils::RGB_CHANNELS) + end + + def to_linear(channel) + abs = channel.abs + abs < BETA * 4.5 ? channel / 4.5 : FuzzyMath.sign(channel) * (((abs + ALPHA - 1) / ALPHA)**(1 / 0.45)) + end + + def from_linear(channel) + abs = channel.abs + abs > BETA ? FuzzyMath.sign(channel) * ((ALPHA * (abs**0.45)) - (ALPHA - 1)) : 4.5 * channel + end + + private + + def transformation_matrix(dest) + case dest + when A98_RGB + Conversions::LINEAR_REC2020_TO_LINEAR_A98_RGB + when DISPLAY_P3 + Conversions::LINEAR_REC2020_TO_LINEAR_DISPLAY_P3 + when LMS + Conversions::LINEAR_REC2020_TO_LMS + when PROPHOTO_RGB + Conversions::LINEAR_REC2020_TO_LINEAR_PROPHOTO_RGB + when RGB, SRGB, SRGB_LINEAR + Conversions::LINEAR_REC2020_TO_LINEAR_SRGB + when XYZ_D50 + Conversions::LINEAR_REC2020_TO_XYZ_D50 + when XYZ_D65 + Conversions::LINEAR_REC2020_TO_XYZ_D65 + else + super + end + end + end + + private_constant :Rec2020 + + REC2020 = Rec2020.new + end + end + end +end diff --git a/lib/sass/value/color/space/rgb.rb b/lib/sass/value/color/space/rgb.rb new file mode 100644 index 00000000..a872e0c3 --- /dev/null +++ b/lib/sass/value/color/space/rgb.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/rgb.dart + class Rgb + include Space + + def bounded? + true + end + + def legacy? + true + end + + def initialize + super('rgb', [ + LinearChannel.new('red', 0, 255, lower_clamped: true, upper_clamped: true).freeze, + LinearChannel.new('green', 0, 255, lower_clamped: true, upper_clamped: true).freeze, + LinearChannel.new('blue', 0, 255, lower_clamped: true, upper_clamped: true).freeze + ].freeze) + end + + def convert(dest, red, green, blue, alpha) + SRGB.convert( + dest, + red.nil? ? nil : red / 255.0, + green.nil? ? nil : green / 255.0, + blue.nil? ? nil : blue / 255.0, + alpha + ) + end + + def to_linear(channel) + Utils.srgb_and_display_p3_to_linear(channel / 255.0) + end + + def from_linear(channel) + Utils.srgb_and_display_p3_from_linear(channel) * 255 + end + end + + private_constant :Rgb + + RGB = Rgb.new + end + end + end +end diff --git a/lib/sass/value/color/space/srgb.rb b/lib/sass/value/color/space/srgb.rb new file mode 100644 index 00000000..624081d4 --- /dev/null +++ b/lib/sass/value/color/space/srgb.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/srgb.dart + class Srgb + include Space + + def bounded? + true + end + + def initialize + super('srgb', Utils::RGB_CHANNELS) + end + + def convert(dest, red, green, blue, alpha, + missing_lightness: false, + missing_chroma: false, + missing_hue: false) + case dest + when HSL, HWB + red = 0 if red.nil? + green = 0 if green.nil? + blue = 0 if blue.nil? + + max = [red, green, blue].max + min = [red, green, blue].min + delta = max - min + + hue = if max == min + 0.0 + elsif max == red + (60.0 * (green - blue) / delta) + 360 + elsif max == green + (60.0 * (blue - red) / delta) + 120 + else # max == blue + (60.0 * (red - green) / delta) + 240 + end + + if dest == HSL + lightness = (min + max) / 2.0 + + saturation = if [0, 1].include?(lightness) + 0.0 + else + 100.0 * (max - lightness) / [lightness, 1 - lightness].min + end + if saturation.negative? + hue += 180 + saturation = saturation.abs + end + + Color.send( + :for_space_internal, + dest, + missing_hue || FuzzyMath.equals(saturation, 0) ? nil : hue % 360, + missing_chroma ? nil : saturation, + missing_lightness ? nil : lightness * 100, + alpha + ) + else + whiteness = min * 100 + blackness = 100 - (max * 100) + + Color.send( + :for_space_internal, + dest, + missing_hue || FuzzyMath.greater_than_or_equals(whiteness + blackness, 100) ? nil : hue % 360, + whiteness, + blackness, + alpha + ) + end + when RGB + Color.send( + :_for_space, + dest, + red.nil? ? nil : red * 255, + green.nil? ? nil : green * 255, + blue.nil? ? nil : blue * 255, + alpha + ) + when SRGB_LINEAR + Color.send( + :_for_space, + dest, + red.nil? ? nil : to_linear(red), + green.nil? ? nil : to_linear(green), + blue.nil? ? nil : to_linear(blue), + alpha + ) + else + convert_linear(dest, red, green, blue, alpha, + missing_lightness:, + missing_chroma:, + missing_hue:) + end + end + + def to_linear(channel) + Utils.srgb_and_display_p3_to_linear(channel) + end + + def from_linear(channel) + Utils.srgb_and_display_p3_from_linear(channel) + end + + private + + def transformation_matrix(dest) + case dest + when A98_RGB + Conversions::LINEAR_SRGB_TO_LINEAR_A98_RGB + when DISPLAY_P3 + Conversions::LINEAR_SRGB_TO_LINEAR_DISPLAY_P3 + when LMS + Conversions::LINEAR_SRGB_TO_LMS + when PROPHOTO_RGB + Conversions::LINEAR_SRGB_TO_LINEAR_PROPHOTO_RGB + when REC2020 + Conversions::LINEAR_SRGB_TO_LINEAR_REC2020 + when XYZ_D50 + Conversions::LINEAR_SRGB_TO_XYZ_D50 + when XYZ_D65 + Conversions::LINEAR_SRGB_TO_XYZ_D65 + else + super + end + end + end + + private_constant :Srgb + + SRGB = Srgb.new + end + end + end +end diff --git a/lib/sass/value/color/space/srgb_linear.rb b/lib/sass/value/color/space/srgb_linear.rb new file mode 100644 index 00000000..6662342b --- /dev/null +++ b/lib/sass/value/color/space/srgb_linear.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/srgb_linear.dart + class SrgbLinear + include Space + + def bounded? + true + end + + def initialize + super('srgb-linear', Utils::RGB_CHANNELS) + end + + def convert(dest, red, green, blue, alpha) + case dest + when HSL, HWB, RGB, SRGB + SRGB.convert( + dest, + red.nil? ? nil : Utils.srgb_and_display_p3_from_linear(red), + green.nil? ? nil : Utils.srgb_and_display_p3_from_linear(green), + blue.nil? ? nil : Utils.srgb_and_display_p3_from_linear(blue), + alpha + ) + else + super + end + end + + def to_linear(channel) + channel + end + + def from_linear(channel) + channel + end + + private + + def transformation_matrix(dest) + case dest + when A98_RGB + Conversions::LINEAR_SRGB_TO_LINEAR_A98_RGB + when DISPLAY_P3 + Conversions::LINEAR_SRGB_TO_LINEAR_DISPLAY_P3 + when PROPHOTO_RGB + Conversions::LINEAR_SRGB_TO_LINEAR_PROPHOTO_RGB + when REC2020 + Conversions::LINEAR_SRGB_TO_LINEAR_REC2020 + when XYZ_D65 + Conversions::LINEAR_SRGB_TO_XYZ_D65 + when XYZ_D50 + Conversions::LINEAR_SRGB_TO_XYZ_D50 + when LMS + Conversions::LINEAR_SRGB_TO_LMS + else + super + end + end + end + + private_constant :SrgbLinear + + SRGB_LINEAR = SrgbLinear.new + end + end + end +end diff --git a/lib/sass/value/color/space/utils.rb b/lib/sass/value/color/space/utils.rb new file mode 100644 index 00000000..f91854c0 --- /dev/null +++ b/lib/sass/value/color/space/utils.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/utils.dart + module Utils + module_function + + # A constant used to convert Lab to/from XYZ. + LAB_KAPPA = Rational(24_389, 27) # 29^3/3^3 + + # A constant used to convert Lab to/from XYZ. + LAB_EPSILON = Rational(216, 24_389) # 6^3/29^3 + + # The hue channel shared across all polar color spaces. + HUE_CHANNEL = ColorChannel.new('hue', polar_angle: true, associated_unit: 'deg').freeze + + # The color channels shared across all RGB color spaces (except the legacy RGB space). + RGB_CHANNELS = [ + LinearChannel.new('red', 0, 1).freeze, + LinearChannel.new('green', 0, 1).freeze, + LinearChannel.new('blue', 0, 1).freeze + ].freeze + + # The color channels shared across both XYZ color spaces. + XYZ_CHANNELS = [ + LinearChannel.new('x', 0, 1).freeze, + LinearChannel.new('y', 0, 1).freeze, + LinearChannel.new('z', 0, 1).freeze + ].freeze + + # Converts a legacy HSL/HWB hue to an RGB channel. + # + # The algorithm comes from from the CSS3 spec: + # http://www.w3.org/TR/css3-color/#hsl-color. + # @param m1 [Numeric] + # @param m2 [Numeric] + # @param hue [Numeric] + # @return [Numeric] + def hue_to_rgb(m1, m2, hue) # rubocop:disable Naming/MethodParameterName + hue += 1 if hue.negative? + hue -= 1 if hue > 1 + + if hue < 1 / 6.0 + m1 + ((m2 - m1) * hue * 6) + elsif hue < 1 / 2.0 + m2 + elsif hue < 2 / 3.0 + m1 + ((m2 - m1) * ((2 / 3.0) - hue) * 6) + else + m1 + end + end + + # The algorithm for converting a single `srgb` or `display-p3` channel to + # linear-light form. + # @param [Numeric] + # @return [Numeric] + def srgb_and_display_p3_to_linear(channel) + abs = channel.abs + abs < 0.04045 ? channel / 12.92 : FuzzyMath.sign(channel) * (((abs + 0.055) / 1.055)**2.4) + end + + # The algorithm for converting a single `srgb` or `display-p3` channel to + # gamma-corrected form. + # @param [Numeric] + # @return [Numeric] + def srgb_and_display_p3_from_linear(channel) + abs = channel.abs + abs <= 0.0031308 ? channel * 12.92 : FuzzyMath.sign(channel) * ((1.055 * (abs**(1 / 2.4))) - 0.055) + end + + # Converts a Lab or OKLab color to LCH or OKLCH, respectively. + # + # The [missing_chroma] and [missing_hue] arguments indicate whether this came + # from a color that was missing its chroma or hue channels, respectively. + # @param dest [Space] + # @param lightness [Numeric] + # @param a [Numeric] + # @param b [Numeric] + # @param alpha [Numeric] + # @return [Color] + def lab_to_lch(dest, lightness, a, b, alpha, # rubocop:disable Naming/MethodParameterName + missing_chroma: false, missing_hue: false) + chroma = Math.sqrt(((a.nil? ? 0 : a)**2) + ((b.nil? ? 0 : b)**2)) + hue = if missing_hue || FuzzyMath.equals(chroma, 0) + nil + else + Math.atan2(b.nil? ? 0 : b, a.nil? ? 0 : a) * 180 / Math::PI + end + + Color.send( + :for_space_internal, + dest, + lightness, + missing_chroma ? nil : chroma, + hue.nil? || hue >= 0 ? hue : hue + 360, + alpha + ) + end + end + + private_constant :Utils + end + end + end +end diff --git a/lib/sass/value/color/space/xyz_d50.rb b/lib/sass/value/color/space/xyz_d50.rb new file mode 100644 index 00000000..f6cc11c0 --- /dev/null +++ b/lib/sass/value/color/space/xyz_d50.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/xyz_d50.dart + class XyzD50 + include Space + + def bounded? + false + end + + def initialize + super('xyz-d50', Utils::XYZ_CHANNELS) + end + + def convert(dest, x, y, z, alpha, # rubocop:disable Naming/MethodParameterName + missing_lightness: false, + missing_chroma: false, + missing_hue: false, + missing_a: false, + missing_b: false) + case dest + when LAB, LCH + f0 = _convert_component_to_lab_f((x.nil? ? 0 : x) / Conversions::D50[0]) + f1 = _convert_component_to_lab_f((y.nil? ? 0 : y) / Conversions::D50[1]) + f2 = _convert_component_to_lab_f((z.nil? ? 0 : z) / Conversions::D50[2]) + lightness = missing_lightness ? nil : (116 * f1) - 16 + a = 500 * (f0 - f1) + b = 200 * (f1 - f2) + + if dest == LAB + Color.send(:_for_space, + dest, + lightness, + missing_a ? nil : a, + missing_b ? nil : b, + alpha) + else + Utils.lab_to_lch(dest, lightness, a, b, alpha, missing_chroma:, missing_hue:) + end + else + convert_linear(dest, x, y, z, alpha, + missing_lightness:, + missing_chroma:, + missing_hue:, + missing_a:, + missing_b:) + end + end + + def to_linear(channel) + channel + end + + def from_linear(channel) + channel + end + + private + + def _convert_component_to_lab_f(component) + if component > Utils::LAB_EPSILON + (component**(1 / 3.0)) + 0.0 + else + ((Utils::LAB_KAPPA * component) + 16) / 116.0 + end + end + + def transformation_matrix(dest) + case dest + when A98_RGB + Conversions::XYZ_D50_TO_LINEAR_A98_RGB + when DISPLAY_P3 + Conversions::XYZ_D50_TO_LINEAR_DISPLAY_P3 + when LMS + Conversions::XYZ_D50_TO_LMS + when PROPHOTO_RGB + Conversions::XYZ_D50_TO_LINEAR_PROPHOTO_RGB + when REC2020 + Conversions::XYZ_D50_TO_LINEAR_REC2020 + when RGB, SRGB, SRGB_LINEAR + Conversions::XYZ_D50_TO_LINEAR_SRGB + when XYZ_D65 + Conversions::XYZ_D50_TO_XYZ_D65 + else + super + end + end + end + + private_constant :XyzD50 + + XYZ_D50 = XyzD50.new + end + end + end +end diff --git a/lib/sass/value/color/space/xyz_d65.rb b/lib/sass/value/color/space/xyz_d65.rb new file mode 100644 index 00000000..33a83dfd --- /dev/null +++ b/lib/sass/value/color/space/xyz_d65.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Sass + module Value + class Color + module Space + # @see https://github.com/sass/dart-sass/blob/main/lib/src/value/color/space/xyz_d65.dart + class XyzD65 + include Space + + def bounded? + false + end + + def initialize + super('xyz', Utils::XYZ_CHANNELS) + end + + def to_linear(channel) + channel + end + + def from_linear(channel) + channel + end + + private + + def transformation_matrix(dest) + case dest + when A98_RGB + Conversions::XYZ_D65_TO_LINEAR_A98_RGB + when DISPLAY_P3 + Conversions::XYZ_D65_TO_LINEAR_DISPLAY_P3 + when LMS + Conversions::XYZ_D65_TO_LMS + when PROPHOTO_RGB + Conversions::XYZ_D65_TO_LINEAR_PROPHOTO_RGB + when REC2020 + Conversions::XYZ_D65_TO_LINEAR_REC2020 + when RGB, SRGB, SRGB_LINEAR + Conversions::XYZ_D65_TO_LINEAR_SRGB + when XYZ_D50 + Conversions::XYZ_D65_TO_XYZ_D50 + else + super + end + end + end + + private_constant :XyzD65 + + XYZ_D65 = XyzD65.new + end + end + end +end diff --git a/lib/sass/value/fuzzy_math.rb b/lib/sass/value/fuzzy_math.rb index 5fc6bc63..c4ada5dd 100644 --- a/lib/sass/value/fuzzy_math.rb +++ b/lib/sass/value/fuzzy_math.rb @@ -6,14 +6,27 @@ module Value module FuzzyMath PRECISION = 10 - EPSILON = 10.pow(-PRECISION - 1) + EPSILON = 10**(-PRECISION - 1) - INVERSE_EPSILON = 1 / EPSILON + INVERSE_EPSILON = 10**(PRECISION + 1) module_function def equals(number1, number2) - (number1 - number2).abs < EPSILON + return true if number1 == number2 + + (number1 - number2).abs <= EPSILON && + (number1 * INVERSE_EPSILON).round == + (number2 * INVERSE_EPSILON).round + end + + def equals_nilable(number1, number2) + return true if number1 == number2 + return false if number1.nil? || number2.nil? + + (number1 - number2).abs <= EPSILON && + (number1 * INVERSE_EPSILON).round == + (number2 * INVERSE_EPSILON).round end def less_than(number1, number2) @@ -51,6 +64,16 @@ def round(number) end end + def sign(number) + if number.positive? + 1 + elsif number.negative? + -1 + else + 0 + end + end + def between(number, min, max) return min if equals(number, min) return max if equals(number, max) @@ -66,6 +89,10 @@ def assert_between(number, min, max, name) raise Sass::ScriptError.new("#{number} must be between #{min} and #{max}.", name) end + def clamp_like_css(number, lower_bound, upper_bound) + number.to_f.nan? ? lower_bound : number.clamp(lower_bound, upper_bound) + end + def hash(number) if number.finite? (number * INVERSE_EPSILON).round.hash diff --git a/lib/sass/value/string.rb b/lib/sass/value/string.rb index 1738355d..618dc762 100644 --- a/lib/sass/value/string.rb +++ b/lib/sass/value/string.rb @@ -52,7 +52,7 @@ def sass_index_to_string_index(sass_index, name = nil) index.negative? ? text.length + index : index - 1 end - # @return [String] + # @return [::String] def to_s @quoted ? Serializer.serialize_quoted_string(@text) : Serializer.serialize_unquoted_string(@text) end diff --git a/spec/sass/value/color/constructors.rb b/spec/sass/value/color/constructors.rb new file mode 100644 index 00000000..de084d72 --- /dev/null +++ b/spec/sass/value/color/constructors.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color/constructors.ts +module ColorConstructors + module_function + + def _alpha_to_kwargs(*args) + case args.length + when 0 + {} + when 1 + { alpha: args[0] } + else + raise ArgumentError + end + end + + def legacy_rgb(red, green, blue, *args) + Sass::Value::Color.new(red:, green:, blue:, **_alpha_to_kwargs(*args)) + end + + def rgb(red, green, blue, *args) + Sass::Value::Color.new(red:, green:, blue:, **_alpha_to_kwargs(*args), space: 'rgb') + end + + def legacy_hsl(hue, saturation, lightness, *args) + Sass::Value::Color.new(hue:, saturation:, lightness:, **_alpha_to_kwargs(*args)) + end + + def hsl(hue, saturation, lightness, *args) + Sass::Value::Color.new(hue:, saturation:, lightness:, **_alpha_to_kwargs(*args), space: 'hsl') + end + + def legacy_hwb(hue, whiteness, blackness, *args) + Sass::Value::Color.new(hue:, whiteness:, blackness:, **_alpha_to_kwargs(*args)) + end + + def hwb(hue, whiteness, blackness, *args) + Sass::Value::Color.new(hue:, whiteness:, blackness:, **_alpha_to_kwargs(*args), space: 'hwb') + end + + def lab(lightness, a, b, *args) # rubocop:disable Naming/MethodParameterName + Sass::Value::Color.new(lightness:, a:, b:, **_alpha_to_kwargs(*args), space: 'lab') + end + + def oklab(lightness, a, b, *args) # rubocop:disable Naming/MethodParameterName + Sass::Value::Color.new(lightness:, a:, b:, **_alpha_to_kwargs(*args), space: 'oklab') + end + + def lch(lightness, chroma, hue, *args) + Sass::Value::Color.new(lightness:, chroma:, hue:, **_alpha_to_kwargs(*args), space: 'lch') + end + + def oklch(lightness, chroma, hue, *args) + Sass::Value::Color.new(lightness:, chroma:, hue:, **_alpha_to_kwargs(*args), space: 'oklch') + end + + def srgb(red, green, blue, *args) + Sass::Value::Color.new(red:, green:, blue:, **_alpha_to_kwargs(*args), space: 'srgb') + end + + def srgb_linear(red, green, blue, *args) + Sass::Value::Color.new(red:, green:, blue:, **_alpha_to_kwargs(*args), space: 'srgb-linear') + end + + def rec2020(red, green, blue, *args) + Sass::Value::Color.new(red:, green:, blue:, **_alpha_to_kwargs(*args), space: 'rec2020') + end + + def display_p3(red, green, blue, *args) + Sass::Value::Color.new(red:, green:, blue:, **_alpha_to_kwargs(*args), space: 'display-p3') + end + + def a98_rgb(red, green, blue, *args) + Sass::Value::Color.new(red:, green:, blue:, **_alpha_to_kwargs(*args), space: 'a98-rgb') + end + + def prophoto_rgb(red, green, blue, *args) + Sass::Value::Color.new(red:, green:, blue:, **_alpha_to_kwargs(*args), space: 'prophoto-rgb') + end + + def xyz(x, y, z, *args) # rubocop:disable Naming/MethodParameterName + Sass::Value::Color.new(x:, y:, z:, **_alpha_to_kwargs(*args), space: 'xyz') + end + + def xyz_d50(x, y, z, *args) # rubocop:disable Naming/MethodParameterName + Sass::Value::Color.new(x:, y:, z:, **_alpha_to_kwargs(*args), space: 'xyz-d50') + end + + def xyz_d65(x, y, z, *args) # rubocop:disable Naming/MethodParameterName + Sass::Value::Color.new(x:, y:, z:, **_alpha_to_kwargs(*args), space: 'xyz-d65') + end +end diff --git a/spec/sass/value/color/interpolation_examples.rb b/spec/sass/value/color/interpolation_examples.rb new file mode 100644 index 00000000..68467b1d --- /dev/null +++ b/spec/sass/value/color/interpolation_examples.rb @@ -0,0 +1,437 @@ +# frozen_string_literal: true + +# @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color/interpolation-examples.ts +COLOR_INTERPOLATIONS = { + lab: [ + [ + { + weight: 0.5 + }, + [58.614201646094955, 10.016665992350433, -8.387820174394456] + ], + [ + { + weight: 1 + }, + [78.27047872644108, 35.20288139978972, 1.0168442562642044] + ], + [ + { + weight: 0 + }, + [38.95792456574883, -15.169549415088852, -17.792484605053115] + ] + ], + oklab: [ + [ + { + weight: 0.5 + }, + [0.6476500020040917, 0.02748550994678843, -0.023408287379941606] + ], + [ + { + weight: 1 + }, + [0.8241000000000002, 0.10608808442731632, 0.0015900762693974446] + ], + [ + { + weight: 0 + }, + [0.47120000400818335, -0.05111706453373946, -0.048406651029280656] + ] + ], + lch: [ + [ + { + weight: 0.5 + }, + [58.61420164622054, 29.299459370089924, 295.6021177856686] + ], + [ + { + weight: 1 + }, + [78.27047872644108, 35.21756424128674, 1.6545432253797685] + ], + [ + { + weight: 0 + }, + [38.957924566, 23.38135449889311, 229.5496923459574] + ], + [ + { + weight: 0.5, + method: 'shorter' + }, + [58.61420164622054, 29.299459370089924, 295.6021177856686] + ], + [ + { + weight: 0.5, + method: 'longer' + }, + [58.61420164622054, 29.299459370089924, 115.60211778566858] + ], + [ + { + weight: 0.5, + method: 'increasing' + }, + [58.61420164622054, 29.299459370089924, 115.60211778566858] + ], + [ + { + weight: 0.5, + method: 'decreasing' + }, + [58.61420164622054, 29.299459370089924, 295.6021177856686] + ] + ], + oklch: [ + [ + { + weight: 0.5 + }, + [0.6476500020040917, 0.08824999343187809, 292.1493505923757] + ], + [ + { + weight: 1 + }, + [0.8241, 0.1061, 0.8586999999999989] + ], + [ + { + weight: 0 + }, + [0.47120000400818335, 0.07039998686375618, 223.4400011847514] + ], + [ + { + weight: 0.5, + method: 'shorter' + }, + [0.6476500020040917, 0.08824999343187809, 292.1493505923757] + ], + [ + { + weight: 0.5, + method: 'longer' + }, + [0.6476500020040917, 0.08824999343187809, 112.1493505923757] + ], + [ + { + weight: 0.5, + method: 'increasing' + }, + [0.6476500020040917, 0.08824999343187809, 112.1493505923757] + ], + [ + { + weight: 0.5, + method: 'decreasing' + }, + [0.6476500020040917, 0.08824999343187809, 292.1493505923757] + ] + ], + srgb: [ + [ + { + weight: 0.5 + }, + [0.5744899843543774, 0.5252921410815925, 0.6147851204581418] + ], + [ + { + weight: 1 + }, + [0.9999785463111585, 0.6599448662991679, 0.758373017125016] + ], + [ + { + weight: 0 + }, + [0.14900142239759617, 0.39063941586401707, 0.47119722379126755] + ] + ], + 'srgb-linear': [ + [ + { + weight: 0.5 + }, + [0.5096647054609955, 0.25972630442483197, 0.36200193586790025] + ], + [ + { + weight: 1 + }, + [0.999951196094508, 0.3930503811476254, 0.5356603778005655] + ], + [ + { + weight: 0 + }, + [0.01937821482748292, 0.12640222770203852, 0.18834349393523497] + ] + ], + 'display-p3': [ + [ + { + weight: 0.5 + }, + [0.5836172975616658, 0.530184139982079, 0.609686907635745] + ], + [ + { + weight: 1 + }, + [0.9510333333617188, 0.6749909745845027, 0.7568568353546363] + ], + [ + { + weight: 0 + }, + [0.2162012617616128, 0.38537730537965537, 0.46251697991685353] + ] + ], + 'a98-rgb': [ + [ + { + weight: 0.5 + }, + [0.5865373142666512, 0.5222346343208055, 0.6071485436534567] + ], + [ + { + weight: 1 + }, + [0.9172837001828321, 0.6540226622083835, 0.7491144397116841] + ], + [ + { + weight: 0 + }, + [0.25579092835047035, 0.3904466064332277, 0.4651826475952292] + ] + ], + 'prophoto-rgb': [ + [ + { + weight: 0.5 + }, + [0.5427623847027483, 0.4757813439417372, 0.5419635636962455] + ], + [ + { + weight: 1 + }, + [0.842345736209146, 0.6470539622987257, 0.7003583323790157] + ], + [ + { + weight: 0 + }, + [0.2431790331963506, 0.3045087255847488, 0.38356879501347535] + ] + ], + rec2020: [ + [ + { + weight: 0.5 + }, + [0.5494120530883964, 0.4907232619435038, 0.5681615893671463] + ], + [ + { + weight: 1 + }, + [0.8837118321235519, 0.6578067923850563, 0.7273197917658354] + ], + [ + { + weight: 0 + }, + [0.21511227405324085, 0.32363973150195124, 0.4090033869684574] + ] + ], + xyz: [ + [ + { + weight: 0.5 + }, + [0.36838948901950813, 0.3202564721891328, 0.38490473490885063] + ], + [ + { + weight: 1 + }, + [0.6495957411726918, 0.5323965129525022, 0.575341840710865] + ], + [ + { + weight: 0 + }, + [0.08718323686632445, 0.10811643142576338, 0.19446762910683624] + ] + ], + 'xyz-d50': [ + [ + { + weight: 0.5 + }, + [0.3740759617070767, 0.3215358224064546, 0.2908164562135577] + ], + [ + { + weight: 1 + }, + [0.6640698533004002, 0.5367266625281085, 0.4345958246720296] + ], + [ + { + weight: 0 + }, + [0.08408207011375313, 0.10634498228480066, 0.14703708775508573] + ] + ], + rgb: [ + [ + { + weight: 0.5 + }, + [146.56944672156501, 134.1448156837381, 157.00580432872178] + ], + [ + { + weight: 1 + }, + [254.9945293093454, 168.28594090628783, 193.38511936687908] + ], + [ + { + weight: 0 + }, + [38.14436413378462, 100.00369046118837, 120.62648929056449] + ] + ], + hsl: [ + [ + { + weight: 0.5 + }, + [268.816848125996, 75.96890150160368, 57.00305146997975] + ], + [ + { + weight: 1 + }, + [342.6320467744765, 99.98738302509669, 82.99617063051632] + ], + [ + { + weight: 0 + }, + [195.00164947751546, 51.95041997811069, 31.009932309443187] + ], + [ + { + weight: 0.5, + method: 'shorter' + }, + [268.816848125996, 75.96890150160368, 57.00305146997975] + ], + [ + { + weight: 0.5, + method: 'longer' + }, + [88.816848125996, 75.96890150160368, 57.00305146997975] + ], + [ + { + weight: 0.5, + method: 'increasing' + }, + [88.816848125996, 75.96890150160368, 57.00305146997975] + ], + [ + { + weight: 0.5, + method: 'decreasing' + }, + [268.816848125996, 75.96890150160368, 57.00305146997975] + ] + ], + hwb: [ + [ + { + weight: 0.5 + }, + [268.816848125996, 40.447314434838205, 26.4412114948787] + ], + [ + { + weight: 1 + }, + [342.6320467744765, 65.99448662991679, 0.002145368884157506] + ], + [ + { + weight: 0 + }, + [195.00164947751546, 14.90014223975961, 52.880277620873244] + ], + [ + { + weight: 0.5, + method: 'shorter' + }, + [268.816848125996, 40.447314434838205, 26.4412114948787] + ], + [ + { + weight: 0.5, + method: 'longer' + }, + [88.816848125996, 40.447314434838205, 26.4412114948787] + ], + [ + { + weight: 0.5, + method: 'increasing' + }, + [88.816848125996, 40.447314434838205, 26.4412114948787] + ], + [ + { + weight: 0.5, + method: 'decreasing' + }, + [268.816848125996, 40.447314434838205, 26.4412114948787] + ] + ], + 'xyz-d65': [ + [ + { + weight: 0.5 + }, + [0.36838948901950813, 0.3202564721891328, 0.38490473490885063] + ], + [ + { + weight: 1 + }, + [0.6495957411726918, 0.5323965129525022, 0.575341840710865] + ], + [ + { + weight: 0 + }, + [0.08718323686632445, 0.10811643142576338, 0.19446762910683624] + ] + ] +}.freeze diff --git a/spec/sass/value/color/spaces.rb b/spec/sass/value/color/spaces.rb new file mode 100644 index 00000000..c5bcd9ed --- /dev/null +++ b/spec/sass/value/color/spaces.rb @@ -0,0 +1,346 @@ +# frozen_string_literal: true + +require_relative 'constructors' + +# @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color/spaces.ts +COLOR_SPACES = { + lab: { + constructor: :lab, + name: 'lab', + is_legacy: false, + is_polar: false, + pink: [78.27047872644108, 35.20288139978972, 1.0168442562642044], + blue: [38.95792456574883, -15.169549415088856, -17.792484605053115], + channels: %w[lightness a b], + ranges: [ + [0, 100], + [-125, 125], + [-125, 125] + ], + has_out_of_gamut: false, + gamut_examples: [ + [ + [50, 150, 150], + [50, 150, 150] + ] + ] + }, + oklab: { + constructor: :oklab, + name: 'oklab', + is_legacy: false, + is_polar: false, + pink: [0.8241000000000002, 0.10608808442731632, 0.0015900762693974446], + blue: [0.47120000400818335, -0.05111706453373946, -0.048406651029280656], + channels: %w[lightness a b], + ranges: [ + [0, 1], + [-0.4, 0.4], + [-0.4, 0.4] + ], + has_out_of_gamut: false, + gamut_examples: [ + [ + [0.5, 1, 1], + [0.5, 1, 1] + ] + ] + }, + lch: { + constructor: :lch, + name: 'lch', + is_legacy: false, + is_polar: true, + pink: [78.27047872644108, 35.21756424128674, 1.6545432253797676], + blue: [38.957924566, 23.38135449889311, 229.54969234595737], + channels: %w[lightness chroma hue], + has_powerless: true, + ranges: [ + [0, 100], + [0, 150], + [0, 360] + ], + has_out_of_gamut: false, + gamut_examples: [ + [ + [50, 200, 480], + [50, 200, 480] + ] + ] + }, + oklch: { + constructor: :oklch, + name: 'oklch', + is_legacy: false, + is_polar: true, + pink: [0.8241, 0.1061, 0.8587], + blue: [0.47120000400818335, 0.07039998686375618, 223.44000118475142], + channels: %w[lightness chroma hue], + has_powerless: true, + ranges: [ + [0, 1], + [0, 0.4], + [0, 360] + ], + has_out_of_gamut: false, + gamut_examples: [ + [ + [0.5, 1, 480], + [0.5, 1, 480] + ] + ] + }, + srgb: { + constructor: :srgb, + name: 'srgb', + is_legacy: false, + is_polar: false, + pink: [0.9999785463111585, 0.6599448662991679, 0.758373017125016], + blue: [0.14900142239759614, 0.39063941586401707, 0.47119722379126755], + channels: %w[red green blue], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: true, + gamut_examples: [ + [[0.5, 2, 2], { clip: [0.5, 1, 1], 'local-minde': [1, 1, 1] }] + ] + }, + 'srgb-linear': { + constructor: :srgb_linear, + name: 'srgb-linear', + is_legacy: false, + is_polar: false, + pink: [0.999951196094508, 0.3930503811476254, 0.5356603778005655], + blue: [0.019378214827482948, 0.12640222770203852, 0.18834349393523495], + channels: %w[red green blue], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: true, + gamut_examples: [ + [[0.5, 2, 2], { clip: [0.5, 1, 1], 'local-minde': [1, 1, 1] }] + ] + }, + 'display-p3': { + constructor: :display_p3, + name: 'display-p3', + is_legacy: false, + is_polar: false, + pink: [0.9510333333617188, 0.6749909745845027, 0.7568568353546363], + blue: [0.21620126176161275, 0.38537730537965537, 0.46251697991685353], + channels: %w[red green blue], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: true, + gamut_examples: [ + [[0.5, 2, 2], { clip: [0.5, 1, 1], 'local-minde': [1, 1, 1] }] + ] + }, + 'a98-rgb': { + constructor: :a98_rgb, + name: 'a98-rgb', + is_legacy: false, + is_polar: false, + pink: [0.9172837001828321, 0.6540226622083835, 0.7491144397116841], + blue: [0.2557909283504703, 0.3904466064332277, 0.4651826475952292], + channels: %w[red green blue], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: true, + gamut_examples: [ + [[0.5, 2, 2], { clip: [0.5, 1, 1], 'local-minde': [1, 1, 1] }] + ] + }, + 'prophoto-rgb': { + constructor: :prophoto_rgb, + name: 'prophoto-rgb', + is_legacy: false, + is_polar: false, + pink: [0.842345736209146, 0.6470539622987257, 0.7003583323790157], + blue: [0.24317903319635056, 0.3045087255847488, 0.38356879501347535], + channels: %w[red green blue], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: true, + gamut_examples: [ + [[0.5, 2, 2], { clip: [0.5, 1, 1], 'local-minde': [1, 1, 1] }] + ] + }, + rec2020: { + constructor: :rec2020, + name: 'rec2020', + is_legacy: false, + is_polar: false, + pink: [0.8837118321235519, 0.6578067923850563, 0.7273197917658354], + blue: [0.2151122740532409, 0.32363973150195124, 0.4090033869684574], + channels: %w[red green blue], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: true, + gamut_examples: [ + [[0.5, 2, 2], { clip: [0.5, 1, 1], 'local-minde': [1, 1, 1] }] + ] + }, + xyz: { + constructor: :xyz, + name: 'xyz', + is_legacy: false, + is_polar: false, + pink: [0.6495957411726918, 0.5323965129525022, 0.575341840710865], + blue: [0.08718323686632441, 0.1081164314257634, 0.19446762910683627], + channels: %w[x y z], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: false, + gamut_examples: [ + [ + [0.5, 2, 2], + [0.5, 2, 2] + ] + ] + }, + 'xyz-d50': { + constructor: :xyz_d50, + name: 'xyz-d50', + is_legacy: false, + is_polar: false, + pink: [0.6640698533004002, 0.5367266625281085, 0.4345958246720296], + blue: [0.08408207011375313, 0.10634498228480066, 0.1470370877550857], + channels: %w[x y z], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: false, + gamut_examples: [ + [ + [0.5, 2, 2], + [0.5, 2, 2] + ] + ] + }, + 'xyz-d65': { + constructor: :xyz_d65, + name: 'xyz', + is_legacy: false, + is_polar: false, + pink: [0.6495957411726918, 0.5323965129525022, 0.575341840710865], + blue: [0.08718323686632441, 0.1081164314257634, 0.19446762910683627], + channels: %w[x y z], + ranges: [ + [0, 1], + [0, 1], + [0, 1] + ], + has_out_of_gamut: false, + gamut_examples: [ + [ + [0.5, 2, 2], + [0.5, 2, 2] + ] + ] + }, + rgb: { + constructor: :rgb, + name: 'rgb', + is_legacy: true, + is_polar: false, + pink: [254.9945293093454, 168.28594090628783, 193.38511936687908], + blue: [38.144364133784602, 100.003690461188378, 120.626489290564506], + channels: %w[red green blue], + ranges: [ + [0, 255], + [0, 255], + [0, 255] + ], + has_out_of_gamut: true, + gamut_examples: [ + [ + [300, 300, 300], + [255, 255, 255] + ] + ] + }, + hsl: { + constructor: :hsl, + name: 'hsl', + is_legacy: true, + is_polar: true, + pink: [342.63204677447646, 99.98738302509669, 82.99617063051632], + blue: [195.0016494775154, 51.95041997811069, 31.009932309443183], + channels: %w[hue saturation lightness], + has_powerless: true, + ranges: [ + [0, 360], + [0, 100], + [0, 100] + ], + has_out_of_gamut: true, + gamut_examples: [ + [ + [0.5, 110, 50], + { + clip: [0.5, 100, 50], + 'local-minde': [2.9140262667, 100, 52.0514687465] + } + ] + ] + }, + hwb: { + constructor: :hwb, + name: 'hwb', + is_legacy: true, + is_polar: true, + pink: [342.63204677447646, 65.99448662991679, 0.002145368884157506], + blue: [195.0016494775154, 14.900142239759612, 52.880277620873244], + channels: %w[hue whiteness blackness], + has_powerless: true, + ranges: [ + [0, 360], + [0, 100], + [0, 100] + ], + has_out_of_gamut: true, + gamut_examples: [ + [ + [0.5, -3, -7], + { clip: [0.5, 0, 0], 'local-minde': [3.4921217446, 11.2665189221, 0] } + ] + ] + } +}.freeze + +COLOR_SPACES.each_value do |value| + value.each_key do |key| + if key == :constructor + value.define_singleton_method(key) do |*args| + ColorConstructors.send(value[key], *args) + end + else + value.define_singleton_method(key) do + value[key] + end + end + end +end diff --git a/spec/sass/value/color/utils.rb b/spec/sass/value/color/utils.rb new file mode 100644 index 00000000..abb6cf95 --- /dev/null +++ b/spec/sass/value/color/utils.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ColorUtils + module_function + + def channel_cases(ch1, ch2, ch3) + [ + [ch1, ch2, ch3], + [nil, ch2, ch3], + [nil, nil, ch3], + [ch1, nil, ch3], + [ch1, nil, nil], + [ch1, ch2, nil], + [nil, ch2, nil], + [nil, nil, nil] + ].flat_map do |channels| + [ + channels, + [*channels, 1], + [*channels, 0], + [*channels, 0.5], + [*channels, nil] + ] + end + end + + CHANNEL_NAMES = %w[ + red + green + blue + hue + saturation + lightness + whiteness + blackness + a + b + x + y + z + chroma + ].freeze +end diff --git a/spec/sass/value/color_4_channels_spec.rb b/spec/sass/value/color_4_channels_spec.rb new file mode 100644 index 00000000..fb3a8443 --- /dev/null +++ b/spec/sass/value/color_4_channels_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative 'color/spaces' +require_relative 'color/utils' + +describe Sass::Value::Color do + # @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color/color-4-channels.test.ts + describe 'Color 4 Channels' do + COLOR_SPACES.each_value do |space| + describe space.name do + subject(:color) do + space.constructor(*space.pink) + end + + describe 'channels_or_nil' do + it 'returns an array' do + expect(color.channels_or_nil).to fuzzy_match_array(space.pink) + end + + it 'returns channel value or nil, excluding alpha' do + pink_cases = ColorUtils.channel_cases(*space.pink) + pink_cases.each do |channels| + color = space.constructor(*channels) + expect(color.channels_or_nil).to fuzzy_match_array(channels.first(3)) + end + end + end + + describe 'channels' do + it 'returns an array' do + expect(color.channels).to fuzzy_match_array(space.pink) + end + + it 'returns channel value or nil, excluding alpha' do + pink_cases = ColorUtils.channel_cases(*space.pink) + pink_cases.each do |channels| + expected = channels.first(3).map do |channel| + channel || 0 + end + color = space.constructor(*channels) + expect(color.channels).to fuzzy_match_array(expected) + end + end + + it 'channel_missing?' do + pink_cases = ColorUtils.channel_cases(*space.pink) + pink_cases.each do |channels| + expected = channels.map(&:nil?) + expected << false if expected.length == 3 + color = space.constructor(*channels) + expect(color.channel_missing?(space.channels[0])).to be(expected[0]) + expect(color.channel_missing?(space.channels[1])).to be(expected[1]) + expect(color.channel_missing?(space.channels[2])).to be(expected[2]) + expect(color.channel_missing?('alpha')).to be(expected[3]) + end + end + end + + describe 'channel' do + describe 'without space specified' do + it 'throws an error if channel not in space' do + channels_not_in_space = ColorUtils::CHANNEL_NAMES.dup + space.channels.each do |channel| + channels_not_in_space.delete(channel) + end + channels_not_in_space.each do |channel| + expect { color.channel(channel) }.to raise_error(Sass::ScriptError) + end + end + + it 'returns value if no space specified' do + space.channels.each_with_index do |channel, index| + expect(color.channel(channel)).to fuzzy_eq(space.pink[index]) + end + expect(color.channel('alpha')).to eq(1) + end + + it 'returns 0 for missing channels' do + nil_color = space.constructor(nil, nil, nil, nil) + space.channels.each do |channel| + expect(nil_color.channel(channel)).to eq(0) + end + expect(nil_color.channel('alpha')).to eq(0) + end + end + + describe 'with space specified' do + COLOR_SPACES.each_value do |destination_space| + it "throws an error if channel not in #{destination_space.name}" do + channels_not_in_space = ColorUtils::CHANNEL_NAMES.dup + destination_space.channels.each do |channel| + channels_not_in_space.delete(channel) + end + channels_not_in_space.each do |channel| + expect do + color.channel(channel, space: destination_space.name) + end.to raise_error(Sass::ScriptError) + end + end + + it "returns value when #{destination_space.name} is specified" do + destination_space.channels.each_with_index do |channel, index| + expect(color.channel(channel, + space: destination_space.name)).to fuzzy_eq(destination_space.pink[index]) + end + expect(color.channel('alpha', space: destination_space.name)).to eq(1) + end + end + end + end + + describe 'alpha' do + it 'returns value if set' do + expect(space.constructor(*space.pink, 0).alpha).to eq(0) + expect(space.constructor(*space.pink, 1).alpha).to eq(1) + expect(space.constructor(*space.pink, 0.5).alpha).to eq(0.5) + end + + it 'returns 1 if not set' do + no_alpha_color = space.constructor(0, 0, 0) + expect(no_alpha_color.alpha).to eq(1) + end + + it 'returns 0 if missing' do + no_alpha_color = space.constructor(0, 0, 0, nil) + expect(no_alpha_color.alpha).to eq(0) + end + end + end + + next if %w[hsl hwb lch oklch].include?(space.name) + + describe 'channel_powerless?' do + range1, range2, range3 = space.ranges + [0, 1].repeated_permutation(3) do |i, j, k| + powerless = [false, false, false] + color = space.constructor(range1[i], range2[j], range3[k]) + it "#{color.space}(#{color.channels.join(',')}): #{powerless}" do + COLOR_SPACES[color.space.to_sym].channels.each_with_index do |channel, index| + expect(color.channel_powerless?(channel)).to be(powerless[index]) + end + end + end + end + end + + describe 'channel_powerless?' do + { + 'for HWB' => { + # If the combined `whiteness + blackness` is great than or equal to + # `100%`, then the `hue` channel is powerless. + described_class.new(hue: 0, whiteness: 0, blackness: 100, space: 'hwb') => [true, false, false], + described_class.new(hue: 0, whiteness: 100, blackness: 0, space: 'hwb') => [true, false, false], + described_class.new(hue: 0, whiteness: 50, blackness: 50, space: 'hwb') => [true, false, false], + described_class.new(hue: 0, whiteness: 60, blackness: 60, space: 'hwb') => [true, false, false], + described_class.new(hue: 0, whiteness: -100, blackness: 200, space: 'hwb') => [true, false, false], + described_class.new(hue: 0, whiteness: 200, blackness: -100, space: 'hwb') => [true, false, false], + described_class.new(hue: 100, whiteness: 0, blackness: 100, space: 'hwb') => [true, false, false], + described_class.new(hue: 0, whiteness: 0, blackness: 0, space: 'hwb') => nil, + described_class.new(hue: 0, whiteness: 49, blackness: 50, space: 'hwb') => nil, + described_class.new(hue: 0, whiteness: -1, blackness: 100, space: 'hwb') => nil, + described_class.new(hue: 100, whiteness: 0, blackness: 0, space: 'hwb') => nil + }, + 'for HSL' => { + # If the saturation of an HSL color is 0%, then the hue component is + # powerless. + described_class.new(hue: 0, saturation: 0, lightness: 0, space: 'hsl') => [true, false, false], + described_class.new(hue: 0, saturation: 0, lightness: 100, space: 'hsl') => [true, false, false], + described_class.new(hue: 100, saturation: 0, lightness: 0, space: 'hsl') => [true, false, false], + described_class.new(hue: 0, saturation: 100, lightness: 0, space: 'hsl') => nil, + described_class.new(hue: 0, saturation: 100, lightness: 100, space: 'hsl') => nil, + described_class.new(hue: 100, saturation: 100, lightness: 100, space: 'hsl') => nil, + described_class.new(hue: 100, saturation: 100, lightness: 0, space: 'hsl') => nil + }, + 'for LCH' => { + # If the `chroma` value is 0%, then the `hue` channel is powerless. + described_class.new(lightness: 0, chroma: 0, hue: 0, space: 'lch') => [false, false, true], + described_class.new(lightness: 0, chroma: 0, hue: 100, space: 'lch') => [false, false, true], + described_class.new(lightness: 100, chroma: 0, hue: 0, space: 'lch') => [false, false, true], + described_class.new(lightness: 0, chroma: 100, hue: 0, space: 'lch') => nil, + described_class.new(lightness: 0, chroma: 100, hue: 100, space: 'lch') => nil, + described_class.new(lightness: 100, chroma: 100, hue: 100, space: 'lch') => nil, + described_class.new(lightness: 100, chroma: 100, hue: 0, space: 'lch') => nil + }, + 'for OKLCH' => { + # If the `chroma` value is 0%, then the `hue` channel is powerless. + described_class.new(lightness: 0, chroma: 0, hue: 0, space: 'oklch') => [false, false, true], + described_class.new(lightness: 0, chroma: 0, hue: 100, space: 'oklch') => [false, false, true], + described_class.new(lightness: 100, chroma: 0, hue: 0, space: 'oklch') => [false, false, true], + described_class.new(lightness: 0, chroma: 100, hue: 0, space: 'oklch') => nil, + described_class.new(lightness: 0, chroma: 100, hue: 100, space: 'oklch') => nil, + described_class.new(lightness: 100, chroma: 100, hue: 100, space: 'oklch') => nil, + described_class.new(lightness: 100, chroma: 100, hue: 0, space: 'oklch') => nil + } + }.each do |name, group| + describe name do + group.each do |color, powerless| + powerless = [false, false, false] if powerless.nil? + it "#{color.space}(#{color.channels.join(', ')}): #{powerless}" do + COLOR_SPACES[color.space.to_sym].channels.each_with_index do |channel, index| + expect(color.channel_powerless?(channel)).to be(powerless[index]) + end + end + end + end + end + end + end +end diff --git a/spec/sass/value/color_4_conversions_spec.rb b/spec/sass/value/color_4_conversions_spec.rb new file mode 100644 index 00000000..6bb03fa4 --- /dev/null +++ b/spec/sass/value/color_4_conversions_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative 'color/spaces' +require_relative 'color/interpolation_examples' + +describe Sass::Value::Color do + # @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color/color-4-conversions.test.ts + describe 'Color 4 Conversions' do + COLOR_SPACES.each_value do |space| + describe space.name do + subject(:blue) do + space.constructor(*space.blue) + end + + let(:color) do + space.constructor(*space.pink) + end + + describe 'to_space' do + COLOR_SPACES.each_value do |destination_space| + it "converts pink to #{destination_space.name}" do + res = color.to_space(destination_space.name) + expect(res.space).to eq(destination_space.name) + expect(res).to fuzzy_eq(destination_space.constructor(*destination_space.pink)) + end + end + end + + describe 'interpolate' do + it 'interpolates examples' do + COLOR_INTERPOLATIONS[space.name.to_sym].each do |input, output| + res = color.interpolate(blue, **input) + output_color = space.constructor(*output) + expect(res).to fuzzy_eq(output_color) + end + end + end + + describe 'change' do + it 'changes all channels in own space' do + space.channels.each_with_index do |channel_name, index| + expected_channels = space.pink.dup + expected_channels[index] = 0 + expect(color.change(**{ channel_name.to_sym => 0 })).to fuzzy_eq(space.constructor(*expected_channels)) + end + expect(color.change(alpha: 0)).to fuzzy_eq(space.constructor(*space.pink, 0)) + end + + it 'change with explicit undefined makes no change' do + expect(color.change).to fuzzy_eq(space.constructor(*space.pink)) + expect(color.change).to fuzzy_eq(space.constructor(*space.pink, 1)) + end + + it 'explicit nil sets channel to missing' do + space.channels.each_with_index do |channel_name, index| + expected_channels = space.pink.dup + expected_channels[index] = nil + changed = color.change(**{ channel_name.to_sym => nil }, space: space.name) + expect(changed).to fuzzy_eq(space.constructor(*expected_channels)) + expect(changed.channel_missing?(channel_name)).to be(true) + end + expect(color.change(alpha: nil, space: space.name)).to fuzzy_eq(space.constructor(*space.pink, nil)) + end + + describe 'allows out-of-range channel values' do + base_color = space.constructor( + (space.ranges[0][0] + space.ranges[0][1]) / 2.0, + (space.ranges[1][0] + space.ranges[1][1]) / 2.0, + (space.ranges[2][0] + space.ranges[2][1]) / 2.0 + ) + + 3.times do |i| + channel = space.channels[i] + next if channel == 'hue' + + it "for #{channel}" do + above_range = space.ranges[i][1] + 10 + below_range = space.ranges[i][0] - 10 + above = base_color.change(**{ channel.to_sym => above_range }) + below = base_color.change(**{ channel.to_sym => below_range }) + + expect(above.channels[i]).to eq(above_range) + + case channel + when 'saturation' + expect(below.channels[i]).to eq(below_range.abs) + expect(below.channels[0]).to eq((base_color.channels[0] + 180) % 360) + when 'chroma' + expect(below.channels[i]).to eq(below_range.abs) + expect(below.channels[2]).to eq((base_color.channels[2] + 180) % 360) + else + expect(below.channels[i]).to eq(below_range) + end + end + end + end + + COLOR_SPACES.each_value do |destination_space| + it "changes all channels with space set to #{destination_space.name}" do + destination_space.channels.each_with_index do |channel, index| + destination_channels = destination_space.pink.dup + + # Certain channel values cause equality issues on 1-3 of 16*16*3 + # cases. 0.45 is a magic number that works around this until the + # root cause is determined. + scale = 0.45 + channel_value = destination_space.ranges[index][1] * scale + + destination_channels[index] = channel_value + expected = destination_space.constructor(*destination_channels).to_space(space.name) + + expect(color.change(**{ channel.to_sym => channel_value }, + space: destination_space.name)).to fuzzy_eq(expected) + end + end + end + + it 'throws on invalid alpha' do + expect { color.change(alpha: -1) }.to raise_error(Sass::ScriptError) + expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) + end + end + + describe 'in_gamut?' do + it 'is true for in gamut colors in own space' do + expect(color.in_gamut?).to be(true) + end + + COLOR_SPACES.each_value do |destination_space| + it "is true for in gamut colors in #{destination_space.name}" do + expect(color.in_gamut?(destination_space.name)).to be(true) + end + end + + it "is #{!space.has_out_of_gamut} for out of range colors in own space" do + out_of_gamut = space.constructor(*space.gamut_examples[0][0]) + expect(out_of_gamut.in_gamut?).to be(!space.has_out_of_gamut) + end + end + + describe 'to_gamut' do + space.gamut_examples.each do |input, outputs| + outputs = { clip: outputs, 'local-minde': outputs } if outputs.is_a?(Array) + outputs.each do |method, output| + describe method.to_s do + it "in own space, #{input} -> #{output}" do + res = space.constructor(*input).to_gamut(method: method.to_s) + expect(res).to fuzzy_eq(space.constructor(*output)) + end + end + end + end + end + end + end + end +end diff --git a/spec/sass/value/color_4_nonparametizable_spec.rb b/spec/sass/value/color_4_nonparametizable_spec.rb new file mode 100644 index 00000000..5ca5c459 --- /dev/null +++ b/spec/sass/value/color_4_nonparametizable_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative 'color/constructors' + +describe Sass::Value::Color do + # @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color/color-4-nonparametizable.test.ts + describe 'Color 4 Non-parametizable' do + describe 'to_gamut' do + [ + [ + ColorConstructors.oklch(0.8, 2, 150), + 'display-p3', + { + 'local-minde': ColorConstructors.oklch( + 0.80777568417, + 0.3262439045, + 148.1202740275 + ), + clip: ColorConstructors.oklch( + 0.848829286984, + 0.3685278106, + 145.6449503702 + ) + } + ], + [ + ColorConstructors.oklch(0.8, 2, 150), + 'srgb', + { + 'local-minde': ColorConstructors.oklch( + 0.809152570179, + 0.2379027576, + 147.4021477687 + ), + clip: ColorConstructors.oklch( + 0.866439611536, + 0.2948272403, + 142.4953388878 + ) + } + ] + ].each do |input, space, outputs| + describe "with space #{space}" do + outputs.each do |method, output| + it "with method #{method}" do + expect(input.to_gamut(space:, method: method.to_s)).to fuzzy_eq(output) + end + end + end + end + end + + it 'channel with space specified, missing returns 0' do + [ + [ColorConstructors.oklch(nil, nil, nil), 'lch', 'hue'], + [ColorConstructors.oklch(nil, nil, nil), 'lch', 'lightness'], + [ColorConstructors.oklch(nil, nil, nil), 'hsl', 'hue'], + [ColorConstructors.oklch(nil, nil, nil), 'hsl', 'lightness'], + [ColorConstructors.xyz(nil, nil, nil), 'lab', 'lightness'] + ].each do |color, space, channel| + expect(color.channel(channel, space:)).to eq(0) + end + end + end +end diff --git a/spec/sass/value/color_4_spaces_spec.rb b/spec/sass/value/color_4_spaces_spec.rb new file mode 100644 index 00000000..746eff10 --- /dev/null +++ b/spec/sass/value/color_4_spaces_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative 'color/spaces' + +describe Sass::Value::Color do + # @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color/color-4-spaces.test.ts + describe 'Color 4 Spaces' do + COLOR_SPACES.each_value do |space| + describe space.name do + subject(:color) do + space.constructor(*space.pink) + end + + it 'is a value' do + expect(color).to be_a(Sass::Value) + end + + it 'is a color' do + expect(color).to be_a(described_class) + expect(color.assert_color).to be(color) + end + + it "isn't any other type" do + expect { color.assert_boolean }.to raise_error(Sass::ScriptError) + expect { color.assert_calculation }.to raise_error(Sass::ScriptError) + expect { color.assert_function }.to raise_error(Sass::ScriptError) + expect { color.assert_map }.to raise_error(Sass::ScriptError) + expect(color.to_map).to be_nil + expect { color.assert_mixin }.to raise_error(Sass::ScriptError) + expect { color.assert_number }.to raise_error(Sass::ScriptError) + expect { color.assert_string }.to raise_error(Sass::ScriptError) + end + + describe 'allows out-of-range channel values' do + average1 = (space.ranges[0][0] + space.ranges[0][1]) / 2.0 + average2 = (space.ranges[1][0] + space.ranges[1][1]) / 2.0 + average3 = (space.ranges[2][0] + space.ranges[2][1]) / 2.0 + + 3.times do |i| + channel = space.channels[i] + next if channel == 'hue' + + it "for #{channel}" do + above_range = space.ranges[i][1] + 10 + below_range = space.ranges[i][0] - 10 + above = space.constructor( + i == 0 ? above_range : average1, + i == 1 ? above_range : average2, + i == 2 ? above_range : average3 + ) + below = space.constructor( + i == 0 ? below_range : average1, + i == 1 ? below_range : average2, + i == 2 ? below_range : average3 + ) + + expect(above.channels[i]).to eq(above_range) + + case channel + when 'saturation' + expect(below.channels[i]).to eq(below_range.abs) + expect(below.channels[0]).to eq((average1 + 180) % 360) + when 'chroma' + expect(below.channels[i]).to eq(below_range.abs) + expect(below.channels[2]).to eq((average3 + 180) % 360) + else + expect(below.channels[i]).to eq(below_range) + end + end + end + end + + it 'throws on invalid alpha' do + expect { space.constructor(*space.pink, -1) }.to raise_error(Sass::ScriptError) + expect { space.constructor(*space.pink, 1.1) }.to raise_error(Sass::ScriptError) + end + + it "returns name for #{space.name}" do + expect(color.space).to eq(space.name) + end + + it "legacy? returns #{space.is_legacy} for #{space.name}" do + expect(color.legacy?).to be(space.is_legacy) + end + end + end + end +end diff --git a/spec/sass/value/color_legacy_spec.rb b/spec/sass/value/color_legacy_spec.rb new file mode 100644 index 00000000..e8e35b84 --- /dev/null +++ b/spec/sass/value/color_legacy_spec.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative 'color/constructors' + +describe Sass::Value::Color do + # @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color/legacy.test.ts + describe 'Legacy Color' do + def legacy_rgb(...) + ColorConstructors.legacy_rgb(...) + end + + def legacy_hsl(...) + ColorConstructors.legacy_hsl(...) + end + + def legacy_hwb(...) + ColorConstructors.legacy_hwb(...) + end + + describe 'construction' do + describe 'type' do + subject(:color) do + legacy_rgb(18, 52, 86) + end + + it 'is a value' do + expect(color).to be_a(Sass::Value) + end + + it 'is a color' do + expect(color).to be_a(described_class) + expect(color.assert_color).to be(color) + end + + it "isn't any other type" do + expect { color.assert_boolean }.to raise_error(Sass::ScriptError) + expect { color.assert_calculation }.to raise_error(Sass::ScriptError) + expect { color.assert_function }.to raise_error(Sass::ScriptError) + expect { color.assert_map }.to raise_error(Sass::ScriptError) + expect(color.to_map).to be_nil + expect { color.assert_mixin }.to raise_error(Sass::ScriptError) + expect { color.assert_number }.to raise_error(Sass::ScriptError) + expect { color.assert_string }.to raise_error(Sass::ScriptError) + end + end + + describe 'rgb()' do + it 'allows valid values' do + expect { legacy_rgb(0, 0, 0, 0) }.not_to raise_error + expect { legacy_rgb(255, 255, 255, 1) }.not_to raise_error + end + + it 'disallows invalid alpha values' do + expect { legacy_rgb(0, 0, 0, -0.1) }.to raise_error(Sass::ScriptError) + expect { legacy_rgb(0, 0, 0, 1.1) }.to raise_error(Sass::ScriptError) + end + + it 'allows out-of-gamut values which were invalid before color 4' do + expect { legacy_rgb(-1, 0, 0, 0) }.not_to raise_error + expect { legacy_rgb(0, -1, 0, 0) }.not_to raise_error + expect { legacy_rgb(0, 0, -1, 0) }.not_to raise_error + expect { legacy_rgb(256, 0, 0, 0) }.not_to raise_error + expect { legacy_rgb(0, 256, 0, 0) }.not_to raise_error + expect { legacy_rgb(0, 0, 256, 0) }.not_to raise_error + end + + it 'does not round channels to the nearest integer' do + expect(legacy_rgb(0.1, 50.4, 90.3).channels).to fuzzy_match_array([0.1, 50.4, 90.3]) + expect(legacy_rgb(-0.1, 50.5, 90.7).channels).to fuzzy_match_array([-0.1, 50.5, 90.7]) + end + end + + describe 'hsl()' do + it 'allows valid values' do + expect { legacy_hsl(0, 0, 0, 0) }.not_to raise_error + expect { legacy_hsl(4320, 100, 100, 1) }.not_to raise_error + expect { legacy_hsl(0, -0.1, 0, 0) }.not_to raise_error + expect { legacy_hsl(0, 0, -0.1, 0) }.not_to raise_error + expect { legacy_hsl(0, 100.1, 0, 0) }.not_to raise_error + expect { legacy_hsl(0, 0, 100.1, 0) }.not_to raise_error + end + + it 'disallows invalid alpha values' do + expect { legacy_hsl(0, 0, 0, -0.1) }.to raise_error(Sass::ScriptError) + expect { legacy_hsl(0, 0, 0, 1.1) }.to raise_error(Sass::ScriptError) + end + end + + describe 'hwb()' do + it 'allows valid values' do + expect { legacy_hwb(0, 0, 0, 0) }.not_to raise_error + expect { legacy_hwb(4320, 100, 100, 1) }.not_to raise_error + expect { legacy_hwb(0, -0.1, 0, 0) }.not_to raise_error + expect { legacy_hwb(0, 0, -0.1, 0) }.not_to raise_error + expect { legacy_hwb(0, 100.1, 0, 0) }.not_to raise_error + expect { legacy_hwb(0, 0, 100.1, 0) }.not_to raise_error + end + + it 'disallows invalid alpha values' do + expect { legacy_hwb(0, 0, 0, -0.1) }.to raise_error(Sass::ScriptError) + expect { legacy_hwb(0, 0, 0, 1.1) }.to raise_error(Sass::ScriptError) + end + end + end + + describe 'an RGB color' do + subject(:color) do + legacy_rgb(18, 52, 86) + end + + it 'has RGB channels' do + expect(color.red).to eq(18) + expect(color.green).to eq(52) + expect(color.blue).to eq(86) + end + + it 'has HSL channels' do + expect(color.hue).to eq(210) + expect(color.saturation).to eq(65.3846153846154) + expect(color.lightness).to eq(20.392156862745097) + end + + it 'has HWB channels' do + expect(color.hue).to eq(210) + expect(color.whiteness).to eq(7.0588235294117645) + expect(color.blackness).to eq(66.27450980392157) + end + + it 'has an alpha channel' do + expect(color.alpha).to eq(1) + end + + it 'equals the same color even in a different color space' do + expect(color).to eq(legacy_rgb(18, 52, 86)) + expect(color).to eq(legacy_hsl(210, 65.3846153846154, 20.392156862745097)) + expect(color).to eq(legacy_hwb(210, 7.0588235294117645, 66.27450980392157)) + end + end + + describe 'an HSL color' do + subject(:color) do + legacy_hsl(120, 42, 42) + end + + it 'has RGB channels' do + expect(color.red).to eq(62) + expect(color.green).to eq(152) + expect(color.blue).to eq(62) + end + + it 'has HSL channels' do + expect(color.hue).to eq(120) + expect(color.saturation).to eq(42) + expect(color.lightness).to eq(42) + end + + it 'has HWB channels' do + expect(color.hue).to eq(120) + expect(color.whiteness).to eq(24.360000000000003) + expect(color.blackness).to eq(40.36000000000001) + end + + it 'has an alpha channel' do + expect(color.alpha).to eq(1) + end + + it 'equals the same color even in a different color space' do + expect(color).to eq(legacy_rgb(62.118, 152.082, 62.118)) + expect(color).to eq(legacy_hsl(120, 42, 42)) + expect(color).to eq(legacy_hwb(120, 24.36, 40.36)) + end + + it 'allows negative hue' do + expect(legacy_hsl(-240, 42, 42).hue).to be(120) + expect(legacy_hsl(-240, 42, 42)).to eq(color) + end + end + + describe 'an HWB color' do + subject(:color) do + legacy_hwb(120, 42, 42) + end + + it 'has RGB channels' do + expect(color.red).to eq(107) + expect(color.green).to eq(148) + expect(color.blue).to eq(107) + end + + it 'has HSL channels' do + expect(color.hue).to eq(120) + expect(color.saturation).to eq(16.000000000000014) + expect(color.lightness).to eq(50) + end + + it 'has HWB channels' do + expect(color.hue).to eq(120) + expect(color.whiteness).to eq(42) + expect(color.blackness).to eq(42) + end + + it 'has an alpha channel' do + expect(color.alpha).to eq(1) + end + + it 'equals the same color even in a different color space' do + expect(color).to eq(legacy_rgb(107.1, 147.9, 107.1)) + expect(color).to eq(legacy_hsl(120, 16, 50)) + expect(color).to eq(legacy_hwb(120, 42, 42)) + end + + it 'allows negative hue' do + expect(legacy_hwb(-240, 42, 42).hue).to eq(120) + expect(legacy_hwb(-240, 42, 42)).to eq(color) + end + end + + describe 'changing color values' do + describe 'change() for RGB' do + subject(:color) do + legacy_rgb(18, 52, 86) + end + + it 'changes RGB values' do + expect(color.change(red: 0)).to eq(legacy_rgb(0, 52, 86)) + expect(color.change(green: 0)).to eq(legacy_rgb(18, 0, 86)) + expect(color.change(blue: 0)).to eq(legacy_rgb(18, 52, 0)) + expect(color.change(alpha: 0.5)).to eq(legacy_rgb(18, 52, 86, 0.5)) + expect(color.change(red: 0, green: 0, blue: 0, alpha: 0.5)).to eq(legacy_rgb(0, 0, 0, 0.5)) + end + + it 'allows valid values' do + expect(color.change(red: 0).channel('red')).to eq(0) + expect(color.change(red: 255).channel('red')).to eq(255) + expect(color.change(green: 0).channel('green')).to eq(0) + expect(color.change(green: 255).channel('green')).to eq(255) + expect(color.change(blue: 0).channel('blue')).to eq(0) + expect(color.change(blue: 255).channel('blue')).to eq(255) + expect(color.change(alpha: 0).alpha).to eq(0) + expect(color.change(alpha: 1).alpha).to eq(1) + expect(color.change(red: nil).channel('red')).to eq(18) + end + + it 'allows out of range values which were invalid before color 4' do + expect { color.change(red: -1) }.not_to raise_error + expect { color.change(red: 256) }.not_to raise_error + expect { color.change(green: -1) }.not_to raise_error + expect { color.change(green: 256) }.not_to raise_error + expect { color.change(blue: -1) }.not_to raise_error + expect { color.change(blue: 256) }.not_to raise_error + end + + it 'disallows invalid alpha values' do + expect { color.change(alpha: -0.1) }.to raise_error(Sass::ScriptError) + expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) + end + + it 'does not round channels to the nearest integer' do + expect(color.change(red: 0.1, green: 50.4, blue: 90.3).channels).to fuzzy_match_array([0.1, 50.4, 90.3]) + expect(color.change(red: -0.1, green: 50.5, blue: 90.9).channels).to fuzzy_match_array([-0.1, 50.5, 90.9]) + end + end + + describe 'change() for HSL' do + subject(:color) do + legacy_hsl(210, 65.3846153846154, 20.392156862745097) + end + + it 'changes HSL values' do + expect(color.change(hue: 120)).to eq(legacy_hsl(120, 65.3846153846154, 20.392156862745097)) + expect(color.change(hue: -120)).to eq(legacy_hsl(240, 65.3846153846154, 20.392156862745097)) + expect(color.change(saturation: 42)).to eq(legacy_hsl(210, 42, 20.392156862745097)) + expect(color.change(lightness: 42)).to eq(legacy_hsl(210, 65.3846153846154, 42)) + expect(color.change(alpha: 0.5)).to eq(legacy_hsl(210, 65.3846153846154, 20.392156862745097, 0.5)) + expect(color.change(hue: 120, saturation: 42, lightness: 42, alpha: 0.5)).to eq(legacy_hsl(120, 42, 42, 0.5)) + end + + it 'allows valid values' do + expect(color.change(saturation: 0).channel('saturation')).to eq(0) + expect(color.change(saturation: 100).channel('saturation')).to eq(100) + expect(color.change(lightness: 0).channel('lightness')).to eq(0) + expect(color.change(lightness: 100).channel('lightness')).to eq(100) + expect(color.change(alpha: 0).alpha).to eq(0) + expect(color.change(alpha: 1).alpha).to eq(1) + expect(color.change(lightness: -0.1).channel('lightness')).to eq(-0.1) + expect(color.change(lightness: 100.1).channel('lightness')).to eq(100.1) + expect(color.change(hue: nil).channel('hue')).to eq(210) + end + + it 'disallows invalid alpha values' do + expect { color.change(alpha: -0.1) }.to raise_error(Sass::ScriptError) + expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) + end + end + + describe 'change() for HWB' do + subject(:color) do + legacy_hwb(210, 7.0588235294117645, 66.27450980392157) + end + + it 'changes HWB values' do + expect(color.change(hue: 120)).to eq(legacy_hwb(120, 7.0588235294117645, 66.27450980392157)) + expect(color.change(hue: -120)).to eq(legacy_hwb(240, 7.0588235294117645, 66.27450980392157)) + expect(color.change(whiteness: 42)).to eq(legacy_hwb(210, 42, 66.27450980392157)) + expect(color.change(whiteness: 50)).to eq(legacy_hwb(210, 50, 66.27450980392157)) + expect(color.change(blackness: 42)).to eq(legacy_hwb(210, 7.0588235294117645, 42)) + expect(color.change(alpha: 0.5)).to eq(legacy_hwb(210, 7.0588235294117645, 66.27450980392157, 0.5)) + expect(color.change(hue: 120, whiteness: 42, blackness: 42, alpha: 0.5)).to eq(legacy_hwb(120, 42, 42, 0.5)) + end + + it 'allows valid values' do + expect(color.change(whiteness: 0).channel('whiteness')).to eq(0) + expect(color.change(whiteness: 100).channel('whiteness')).to eq(100) + expect(color.change(blackness: 0).channel('blackness')).to eq(0) + expect(color.change(blackness: 100).channel('blackness')).to eq(100) + expect(color.change(alpha: 0).alpha).to eq(0) + expect(color.change(alpha: 1).alpha).to eq(1) + expect(color.change(hue: nil).channel('hue')).to eq(210) + end + + it 'disallows invalid alpha values' do + expect { color.change(alpha: -0.1) }.to raise_error(Sass::ScriptError) + expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) + end + end + + describe 'change(alpha:)' do + subject(:color) do + legacy_rgb(18, 52, 86) + end + + it 'changes the alpha value' do + expect(color.change(alpha: 0.5)).to eq(legacy_rgb(18, 52, 86, 0.5)) + end + + it 'allows valid alphas' do + expect(color.change(alpha: 0).alpha).to eq(0) + expect(color.change(alpha: 1).alpha).to eq(1) + end + + it 'rejects invalid alphas' do + expect { color.change(alpha: -0.1) }.to raise_error(Sass::ScriptError) + expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) + end + end + end + end +end diff --git a/spec/sass/value/color_spec.rb b/spec/sass/value/color_spec.rb deleted file mode 100644 index 0ad5b044..00000000 --- a/spec/sass/value/color_spec.rb +++ /dev/null @@ -1,331 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# @see https://github.com/sass/sass-spec/blob/main/js-api-spec/value/color.test.ts -describe Sass::Value::Color do - def rgb(red, green, blue, alpha = nil) - Sass::Value::Color.new(red:, green:, blue:, alpha:) - end - - def hsl(hue, saturation, lightness, alpha = nil) - Sass::Value::Color.new(hue:, saturation:, lightness:, alpha:) - end - - def hwb(hue, whiteness, blackness, alpha = nil) - Sass::Value::Color.new(hue:, whiteness:, blackness:, alpha:) - end - - describe 'construction' do - describe 'type' do - subject(:color) do - rgb(18, 52, 86) - end - - it 'is a value' do - expect(color).to be_a(Sass::Value) - end - - it 'is a color' do - expect(color).to be_a(described_class) - expect(color.assert_color).to be(color) - end - - it "isn't any other type" do - expect { color.assert_boolean }.to raise_error(Sass::ScriptError) - expect { color.assert_calculation }.to raise_error(Sass::ScriptError) - expect { color.assert_function }.to raise_error(Sass::ScriptError) - expect { color.assert_map }.to raise_error(Sass::ScriptError) - expect(color.to_map).to be_nil - expect { color.assert_mixin }.to raise_error(Sass::ScriptError) - expect { color.assert_number }.to raise_error(Sass::ScriptError) - expect { color.assert_string }.to raise_error(Sass::ScriptError) - end - end - - describe 'rgb()' do - it 'allows valid values' do - expect { rgb(0, 0, 0, 0) }.not_to raise_error - expect { rgb(255, 255, 255, 1) }.not_to raise_error - end - - it 'disallows invalid values' do - expect { rgb(-1, 0, 0, 0) }.to raise_error(Sass::ScriptError) - expect { rgb(0, -1, 0, 0) }.to raise_error(Sass::ScriptError) - expect { rgb(0, 0, -1, 0) }.to raise_error(Sass::ScriptError) - expect { rgb(0, 0, 0, -0.1) }.to raise_error(Sass::ScriptError) - expect { rgb(256, 0, 0, 0) }.to raise_error(Sass::ScriptError) - expect { rgb(0, 256, 0, 0) }.to raise_error(Sass::ScriptError) - expect { rgb(0, 0, 256, 0) }.to raise_error(Sass::ScriptError) - expect { rgb(0, 0, 0, 1.1) }.to raise_error(Sass::ScriptError) - end - - it 'rounds channels to the nearest integer' do - expect(rgb(0.1, 50.4, 90.3)).to eq(rgb(0, 50, 90)) - expect(rgb(-0.1, 50.5, 90.7)).to eq(rgb(0, 51, 91)) - end - end - - describe 'hsl()' do - it 'allows valid values' do - expect { hsl(0, 0, 0, 0) }.not_to raise_error - expect { hsl(4320, 100, 100, 1) }.not_to raise_error - end - - it 'disallows invalid values' do - expect { hsl(0, -0.1, 0, 0) }.to raise_error(Sass::ScriptError) - expect { hsl(0, 0, -0.1, 0) }.to raise_error(Sass::ScriptError) - expect { hsl(0, 0, 0, -0.1) }.to raise_error(Sass::ScriptError) - expect { hsl(0, 100.1, 0, 0) }.to raise_error(Sass::ScriptError) - expect { hsl(0, 0, 100.1, 0) }.to raise_error(Sass::ScriptError) - expect { hsl(0, 0, 0, 1.1) }.to raise_error(Sass::ScriptError) - end - end - - describe 'hwb()' do - it 'allows valid values' do - expect { hwb(0, 0, 0, 0) }.not_to raise_error - expect { hwb(4320, 100, 100, 1) }.not_to raise_error - end - - it 'disallows invalid values' do - expect { hwb(0, -0.1, 0, 0) }.to raise_error(Sass::ScriptError) - expect { hwb(0, 0, -0.1, 0) }.to raise_error(Sass::ScriptError) - expect { hwb(0, 0, 0, -0.1) }.to raise_error(Sass::ScriptError) - expect { hwb(0, 100.1, 0, 0) }.to raise_error(Sass::ScriptError) - expect { hwb(0, 0, 100.1, 0) }.to raise_error(Sass::ScriptError) - expect { hwb(0, 0, 0, 1.1) }.to raise_error(Sass::ScriptError) - end - end - end - - describe 'an RGB color' do - subject(:color) do - rgb(18, 52, 86) - end - - it 'has RGB channels' do - expect(color.red).to eq(18) - expect(color.green).to eq(52) - expect(color.blue).to eq(86) - end - - it 'has HSL channels' do - expect(color.hue).to eq(210) - expect(color.saturation).to eq(65.38461538461539) - expect(color.lightness).to eq(20.392156862745097) - end - - it 'has HWB channels' do - expect(color.hue).to eq(210) - expect(color.whiteness).to eq(7.0588235294117645) - expect(color.blackness).to eq(66.27450980392157) - end - - it 'has an alpha channel' do - expect(color.alpha).to eq(1) - end - - it 'equals the same color' do - expect(color).to eq(rgb(18, 52, 86)) - expect(color).to eq(hsl(210, 65.38461538461539, 20.392156862745097)) - expect(color).to eq(hwb(210, 7.0588235294117645, 66.27450980392157)) - end - end - - describe 'an HSL color' do - subject(:color) do - hsl(120, 42, 42) - end - - it 'has RGB channels' do - expect(color.red).to eq(62) - expect(color.green).to eq(152) - expect(color.blue).to eq(62) - end - - it 'has HSL channels' do - expect(color.hue).to eq(120) - expect(color.saturation).to eq(42) - expect(color.lightness).to eq(42) - end - - it 'has HWB channels' do - expect(color.hue).to eq(120) - expect(color.whiteness).to eq(24.313725490196077) - expect(color.blackness).to eq(40.3921568627451) - end - - it 'has an alpha channel' do - expect(color.alpha).to eq(1) - end - - it 'equals the same color' do - expect(color).to eq(rgb(62, 152, 62)) - expect(color).to eq(hsl(120, 42, 42)) - expect(color).to eq(hwb(120, 24.313725490196077, 40.3921568627451)) - end - - it 'allows negative hue' do - expect(hsl(-240, 42, 42).hue).to be(120) - expect(hsl(-240, 42, 42)).to eq(color) - end - end - - describe 'an HWB color' do - subject(:color) do - hwb(120, 42, 42) - end - - it 'has RGB channels' do - expect(color.red).to eq(107) - expect(color.green).to eq(148) - expect(color.blue).to eq(107) - end - - it 'has HSL channels' do - expect(color.hue).to eq(120) - expect(color.saturation).to eq(16.07843137254902) - expect(color.lightness).to eq(50) - end - - it 'has HWB channels' do - expect(color.hue).to eq(120) - expect(color.whiteness).to eq(41.96078431372549) - expect(color.blackness).to eq(41.96078431372549) - end - - it 'has an alpha channel' do - expect(color.alpha).to eq(1) - end - - it 'equals the same color' do - expect(color).to eq(rgb(107, 148, 107)) - expect(color).to eq(hsl(120, 16.078431372549026, 50)) - expect(color).to eq(hwb(120, 41.96078431372549, 41.96078431372549)) - end - - it 'allows negative hue' do - expect(hwb(-240, 42, 42).hue).to eq(120) - expect(hwb(-240, 42, 42)).to eq(color) - end - end - - describe 'changing color values' do - subject(:color) do - rgb(18, 52, 86) - end - - describe 'change() for RGB' do - it 'changes RGB values' do - expect(color.change(red: 0)).to eq(rgb(0, 52, 86)) - expect(color.change(green: 0)).to eq(rgb(18, 0, 86)) - expect(color.change(blue: 0)).to eq(rgb(18, 52, 0)) - expect(color.change(alpha: 0.5)).to eq(rgb(18, 52, 86, 0.5)) - expect(color.change(red: 0, green: 0, blue: 0, alpha: 0.5)).to eq(rgb(0, 0, 0, 0.5)) - end - - it 'allows valid values' do - expect(color.change(red: 0).red).to eq(0) - expect(color.change(red: 255).red).to eq(255) - expect(color.change(green: 0).green).to eq(0) - expect(color.change(green: 255).green).to eq(255) - expect(color.change(blue: 0).blue).to eq(0) - expect(color.change(blue: 255).blue).to eq(255) - expect(color.change(alpha: 0).alpha).to eq(0) - expect(color.change(alpha: 1).alpha).to eq(1) - end - - it 'disallows invalid values' do - expect { color.change(red: -1) }.to raise_error(Sass::ScriptError) - expect { color.change(red: 256) }.to raise_error(Sass::ScriptError) - expect { color.change(green: -1) }.to raise_error(Sass::ScriptError) - expect { color.change(green: 256) }.to raise_error(Sass::ScriptError) - expect { color.change(blue: -1) }.to raise_error(Sass::ScriptError) - expect { color.change(blue: 256) }.to raise_error(Sass::ScriptError) - expect { color.change(alpha: -0.1) }.to raise_error(Sass::ScriptError) - expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) - end - - it 'rounds channels to the nearest integer' do - expect(color.change(red: 0.1, green: 50.4, blue: 90.3)).to eq(rgb(0, 50, 90)) - expect(color.change(red: -0.1, green: 50.5, blue: 90.9)).to eq(rgb(0, 51, 91)) - end - end - - describe 'change() for HSL' do - it 'changes HSL values' do - expect(color.change(hue: 120)).to eq(hsl(120, 65.3846153846154, 20.392156862745097)) - expect(color.change(hue: -120)).to eq(hsl(240, 65.3846153846154, 20.392156862745097)) - expect(color.change(saturation: 42)).to eq(hsl(210, 42, 20.392156862745097)) - expect(color.change(lightness: 42)).to eq(hsl(210, 65.3846153846154, 42)) - expect(color.change(alpha: 0.5)).to eq(hsl(210, 65.3846153846154, 20.392156862745097, 0.5)) - expect(color.change(hue: 120, saturation: 42, lightness: 42, alpha: 0.5)).to eq(hsl(120, 42, 42, 0.5)) - end - - it 'allows valid values' do - expect(color.change(saturation: 0).saturation).to eq(0) - expect(color.change(saturation: 100).saturation).to eq(100) - expect(color.change(lightness: 0).lightness).to eq(0) - expect(color.change(lightness: 100).lightness).to eq(100) - expect(color.change(alpha: 0).alpha).to eq(0) - expect(color.change(alpha: 1).alpha).to eq(1) - end - - it 'disallows invalid values' do - expect { color.change(saturation: -0.1) }.to raise_error(Sass::ScriptError) - expect { color.change(saturation: 100.1) }.to raise_error(Sass::ScriptError) - expect { color.change(lightness: -0.1) }.to raise_error(Sass::ScriptError) - expect { color.change(lightness: 100.1) }.to raise_error(Sass::ScriptError) - expect { color.change(alpha: -0.1) }.to raise_error(Sass::ScriptError) - expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) - end - end - - describe 'change() for HWB' do - it 'changes HWB values' do - expect(color.change(hue: 120)).to eq(hwb(120, 7.0588235294117645, 66.27450980392157)) - expect(color.change(hue: -120)).to eq(hwb(240, 7.0588235294117645, 66.27450980392157)) - expect(color.change(whiteness: 42)).to eq(hwb(210, 42, 66.27450980392157)) - expect(color.change(whiteness: 50)).to eq(hwb(210, 50, 66.27450980392157)) - expect(color.change(blackness: 42)).to eq(hwb(210, 7.0588235294117645, 42)) - expect(color.change(alpha: 0.5)).to eq(hwb(210, 7.0588235294117645, 66.27450980392157, 0.5)) - expect(color.change(hue: 120, whiteness: 42, blackness: 42, alpha: 0.5)).to eq(hwb(120, 42, 42, 0.5)) - end - - it 'allows valid values' do - expect(color.change(whiteness: 0).whiteness).to eq(0) - expect(color.change(whiteness: 100).whiteness).to eq(60.0) - expect(color.change(blackness: 0).blackness).to eq(0) - expect(color.change(blackness: 100).blackness).to eq(93.33333333333333) - expect(color.change(alpha: 0).alpha).to eq(0) - expect(color.change(alpha: 1).alpha).to eq(1) - end - - it 'disallows invalid values' do - expect { color.change(whiteness: -0.1) }.to raise_error(Sass::ScriptError) - expect { color.change(whiteness: 100.1) }.to raise_error(Sass::ScriptError) - expect { color.change(blackness: -0.1) }.to raise_error(Sass::ScriptError) - expect { color.change(blackness: 100.1) }.to raise_error(Sass::ScriptError) - expect { color.change(alpha: -0.1) }.to raise_error(Sass::ScriptError) - expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) - end - end - - describe 'changeAlpha()' do - it 'changes the alpha value' do - expect(color.change(alpha: 0.5)).to eq(rgb(18, 52, 86, 0.5)) - end - - it 'allows valid alphas' do - expect(color.change(alpha: 0).alpha).to eq(0) - expect(color.change(alpha: 1).alpha).to eq(1) - end - - it 'rejects invalid alphas' do - expect { color.change(alpha: -0.1) }.to raise_error(Sass::ScriptError) - expect { color.change(alpha: 1.1) }.to raise_error(Sass::ScriptError) - end - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 078f5f82..82106479 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -43,3 +43,33 @@ supports_block_expectations end + +epsilon = Sass::Value.const_get(:FuzzyMath)::EPSILON + +RSpec::Matchers.matcher :fuzzy_eq do |expected| + match do |actual| + case expected + when Sass::Value::Color + expect { actual.assert_color }.not_to raise_error + expect(actual.channels_or_nil).to fuzzy_match_array(expected.channels_or_nil) + expect(actual.channel_missing?('alpha')).to eq(expected.channel_missing?('alpha')) + expect(actual.alpha).to fuzzy_eq(expected.alpha) + when Numeric + expect(actual).to be_within(epsilon).of(expected) + else + expect(actual).to eq(expected) + end + end +end + +RSpec::Matchers.matcher :fuzzy_match_array do |expected| + match do |actual| + expect(actual).to match_array(expected.map do |obj| + if obj.is_a?(Numeric) + a_value_within(epsilon * 10).of(obj) + else + obj + end + end) + end +end