color_contrast_calc 0.5.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ require 'color_contrast_calc/utils'
5
5
  require 'color_contrast_calc/converter'
6
6
  require 'color_contrast_calc/checker'
7
7
  require 'color_contrast_calc/threshold_finder'
8
+ require 'color_contrast_calc/transparency_calc'
8
9
  require 'color_contrast_calc/color'
9
10
  require 'color_contrast_calc/sorter'
10
11
 
@@ -12,12 +13,14 @@ module ColorContrastCalc
12
13
  ##
13
14
  # Return an instance of Color.
14
15
  #
15
- # As +color_value+, you can pass a predefined color name, or an
16
- # RGB value represented as an array of integers or a hex code such
17
- # as [255, 255, 0] or "#ffff00". +name+ is assigned to the returned
18
- # instance.
16
+ # As +color_value+, you can pass a predefined color name, an
17
+ # RGB value represented as an array of integers like [255, 255, 0],
18
+ # or a string such as a hex code like "#ffff00". +name+ is assigned
19
+ # to the returned instance.
19
20
  # @param color_value [String, Array<Integer>] Name of a predefined
20
- # color, hex color code or RGB value
21
+ # color, hex color code, rgb/hsl/hwb functions or RGB value.
22
+ # Yellow, for example, can be given as [255, 255, 0], "#ffff00",
23
+ # "rgb(255, 255, 255)", "hsl(60deg, 100% 50%)" or "hwb(60deg 0% 0%)".
21
24
  # @param name [String] Without specifying a name, a color keyword name
22
25
  # (if exists) or the value of normalized hex color code is assigned
23
26
  # to Color#name
@@ -52,6 +55,79 @@ module ColorContrastCalc
52
55
  Sorter.sort(colors, color_order, key_mapper)
53
56
  end
54
57
 
58
+ ##
59
+ # Calculate the contrast ratio of given colors.
60
+ #
61
+ # The definition of contrast ratio is given at
62
+ # {https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef}
63
+ #
64
+ # Please note that this method may be slow, as it internally creates
65
+ # Color instances.
66
+ #
67
+ # @param color1 [String, Array<Integer>, Color] color given as a string,
68
+ # an array of integers or a Color instance. Yellow, for example, can be
69
+ # given as "#ffff00", "#ff0", "rgb(255, 255, 0)", "hsl(60deg, 100%, 50%)",
70
+ # "hwb(60deg 0% 0%)" or [255, 255, 0].
71
+ # @param color2 [String, Array<Integer>, Color] color given as a string,
72
+ # an array of integers or a Color instance.
73
+ # @return [Float] Contrast ratio
74
+
75
+ def self.contrast_ratio(color1, color2)
76
+ Color.as_color(color1).contrast_ratio_against(Color.as_color(color2))
77
+ end
78
+
79
+ ##
80
+ # Calculate the contrast ratio of transparent colors.
81
+ #
82
+ # For the calculation, you have to specify three colors because
83
+ # when both of two colors to be compared are transparent,
84
+ # the third color put under them filters through them.
85
+ #
86
+ # @param foreground [String, Array<Integer>, Color] The uppermost
87
+ # color such as "rgb(255, 255, 0, 0.5)" or "hsl(60 100% 50% / 50%)"
88
+ # @param background [String, Array<Integer>, Color] The color placed
89
+ # between the others
90
+ # @param base [String, Array<Integer>, Color] The color placed in
91
+ # the bottom. When the backgound is completely opaque, this color
92
+ # is ignored.
93
+ # @return [Float] Contrast ratio
94
+
95
+ def self.contrast_ratio_with_opacity(foreground, background,
96
+ base = Color::WHITE)
97
+ params = [foreground, background, base].map do |c|
98
+ color = Color.as_color(c)
99
+ color.rgb + [color.opacity]
100
+ end
101
+
102
+ TransparencyCalc.contrast_ratio(*params)
103
+ end
104
+
105
+ ##
106
+ # Select from two colors the one of which the contrast ratio is higher
107
+ # than the other's, against a given color.
108
+ #
109
+ # Note that this method is tentatively provided and may be changed later
110
+ # including its name.
111
+ #
112
+ # @param color [String, Array<Integer>, Color] A color against which
113
+ # the contrast ratio of other two colors will be calculated
114
+ # @param light_base [String, Array<Integer>, Color] One of two colors
115
+ # which will be returned depending their contrast ratio: This one
116
+ # will be returned when the contast ratio of the colors happen to
117
+ # be same.
118
+ # @param dark_base [String, Array<Integer>, Color] One of two colors
119
+ # which will be returned depending their contrast ratio
120
+ # @return [String, Array<Integer>, Color] One of the values
121
+ # specified as +light_base+ and +dark_base+
122
+
123
+ def self.higher_contrast_base_color_for(color,
124
+ light_base: Color::WHITE,
125
+ dark_base: Color::BLACK)
126
+ ratio_with_light = contrast_ratio(color, light_base)
127
+ ratio_with_dark = contrast_ratio(color, dark_base)
128
+ ratio_with_light < ratio_with_dark ? dark_base : light_base
129
+ end
130
+
55
131
  ##
56
132
  # Return an array of named colors.
57
133
  #
@@ -2,16 +2,13 @@
2
2
 
3
3
  require 'color_contrast_calc/utils'
4
4
  require 'color_contrast_calc/checker'
5
+ require 'color_contrast_calc/invalid_color_representation_error'
5
6
  require 'color_contrast_calc/threshold_finder'
7
+ require 'color_contrast_calc/color_function_parser'
6
8
  require 'color_contrast_calc/deprecated'
7
9
  require 'json'
8
10
 
9
11
  module ColorContrastCalc
10
- ##
11
- # Error raised if creating a Color instance with invalid value.
12
-
13
- class InvalidColorRepresentationError < StandardError; end
14
-
15
12
  ##
16
13
  # Represent specific colors.
17
14
  #
@@ -48,8 +45,7 @@ module ColorContrastCalc
48
45
  # @return [Color] Instance of Color
49
46
 
50
47
  def from_rgb(rgb, name = nil)
51
- !name && List::HEX_TO_COLOR[Utils.rgb_to_hex(rgb)] ||
52
- Color.new(rgb, name)
48
+ !name && List::HEX_TO_COLOR[Utils.rgb_to_hex(rgb)] || new(rgb, name)
53
49
  end
54
50
 
55
51
  ##
@@ -61,8 +57,7 @@ module ColorContrastCalc
61
57
 
62
58
  def from_hex(hex, name = nil)
63
59
  normalized_hex = Utils.normalize_hex(hex)
64
- !name && List::HEX_TO_COLOR[normalized_hex] ||
65
- Color.new(normalized_hex, name)
60
+ !name && List::HEX_TO_COLOR[normalized_hex] || new(normalized_hex, name)
66
61
  end
67
62
 
68
63
  ##
@@ -73,45 +68,61 @@ module ColorContrastCalc
73
68
  # @return [Color] Instance of Color
74
69
 
75
70
  def from_hsl(hsl, name = nil)
76
- rgb = Utils.hsl_to_rgb(hsl)
77
- !name && List::HEX_TO_COLOR[Utils.rgb_to_hex(rgb)] ||
78
- Color.new(rgb, name)
71
+ if hsl.length == 4
72
+ rgb = Utils.hsl_to_rgb(hsl[0, 3])
73
+ return new(rgb.push(hsl.last), name) unless opaque?(hsl)
74
+ end
75
+
76
+ rgb ||= Utils.hsl_to_rgb(hsl)
77
+ !name && List::HEX_TO_COLOR[Utils.rgb_to_hex(rgb)] || new(rgb, name)
79
78
  end
80
79
 
81
80
  ##
82
81
  # Return an instance of Color.
83
82
  #
84
- # As +color_value+, you can pass a predefined color name, or an
85
- # RGB value represented as an array of integers or a hex code such
86
- # as [255, 255, 0] or "#ffff00". +name+ is assigned to the returned
87
- # instance.
83
+ # As +color_value+, you can pass a predefined color name, an
84
+ # RGB value represented as an array of integers like [255, 255, 0],
85
+ # or a string such as a hex code like "#ffff00". +name+ is assigned
86
+ # to the returned instance.
88
87
  # @param color_value [String, Array<Integer>] Name of a predefined
89
- # color, hex color code or RGB value
88
+ # color, hex color code, rgb/hsl/hwb functions or RGB value.
89
+ # Yellow, for example, can be given as [255, 255, 0], "#ffff00",
90
+ # "rgb(255, 255, 255)", "hsl(60deg, 100% 50%)" or "hwb(60deg 0% 0%)".
90
91
  # @param name [String] Without specifying a name, a color keyword name
91
92
  # (if exists) or the value of normalized hex color code is assigned
92
93
  # to Color#name
93
94
  # @return [Color] Instance of Color
94
95
 
95
96
  def color_from(color_value, name = nil)
96
- error_message = 'A color should be given as an array or string.'
97
-
98
97
  if !color_value.is_a?(String) && !color_value.is_a?(Array)
99
- raise InvalidColorRepresentationError, error_message
98
+ raise InvalidColorRepresentationError.from_value(color_value)
100
99
  end
101
100
 
102
- return color_from_rgb(color_value, name) if color_value.is_a?(Array)
101
+ if color_value.is_a?(Array)
102
+ return color_from_rgba(color_value, name) if color_value.length == 4
103
+ return color_from_rgb(color_value, name)
104
+ end
105
+
106
+ return color_from_func(color_value, name) if function?(color_value)
103
107
  color_from_str(color_value, name)
104
108
  end
105
109
 
110
+ def function?(color_value)
111
+ /\A(?:rgba?|hsla?|hwb)/i =~ color_value
112
+ end
113
+
114
+ private :function?
115
+
106
116
  ##
107
117
  # Return an instance of Color.
108
118
  #
109
119
  # As +color_value+, you can pass a Color instance, a predefined color
110
- # name, or an RGB value represented as an array of integers or a hex
111
- # code such as [255, 255, 0] or "#ffff00". +name+ is assigned to the
112
- # returned instance.
113
- # @param color_value [Color, String, Array<Integer>] An instance of
114
- # Color, a name of a predefined, color, hex color code or RGB value
120
+ # name, an RGB value represented as an array of integers like
121
+ # [255, 255, 0], or a string such as a hex code like "#ffff00".
122
+ # +name+ is assigned to the returned instance.
123
+ # @param color_value [String, Array<Integer>] An instance of Color,
124
+ # a predefined color name, hex color code, rgb/hsl/hwb functions
125
+ # or RGB value
115
126
  # @param name [String] Without specifying a name, a color keyword name
116
127
  # (if exists) or the value of normalized hex color code is assigned
117
128
  # to Color#name
@@ -126,31 +137,60 @@ module ColorContrastCalc
126
137
  color_from(color_value, name)
127
138
  end
128
139
 
129
- def color_from_rgb(rgb_value, name = nil)
130
- error_message = 'An RGB value should be given in form of [r, g, b].'
140
+ def opaque?(color_value)
141
+ color_value[-1] == Utils::MAX_OPACITY
142
+ end
143
+
144
+ private :opaque?
145
+
146
+ def color_from_rgba(rgba_value, name = nil)
147
+ unless Utils.valid_rgb?(rgba_value[0, 3])
148
+ raise InvalidColorRepresentationError.from_value(rgba_value)
149
+ end
150
+
151
+ return color_from_rgb(rgba_value[0, 3], name) if opaque?(rgba_value)
152
+
153
+ new(rgba_value, name)
154
+ end
155
+
156
+ private :color_from_rgba
131
157
 
158
+ def color_from_rgb(rgb_value, name = nil)
132
159
  unless Utils.valid_rgb?(rgb_value)
133
- raise InvalidColorRepresentationError, error_message
160
+ raise InvalidColorRepresentationError.from_value(rgb_value)
134
161
  end
135
162
 
136
163
  hex_code = Utils.rgb_to_hex(rgb_value)
137
- !name && List::HEX_TO_COLOR[hex_code] || Color.new(rgb_value, name)
164
+ !name && List::HEX_TO_COLOR[hex_code] || new(rgb_value, name)
138
165
  end
139
166
 
140
167
  private :color_from_rgb
141
168
 
142
- def color_from_str(color_value, name = nil)
143
- error_message = 'A hex code is in form of "#xxxxxx" where 0 <= x <= f.'
169
+ def color_from_func(color_value, name = nil)
170
+ conv = ColorFunctionParser.parse(color_value)
171
+ if conv.scheme == ColorFunctionParser::Scheme::HSL ||
172
+ conv.scheme == ColorFunctionParser::Scheme::HSLA
173
+ return from_hsl(conv.to_a, name || color_value)
174
+ end
175
+
176
+ name ||= color_value
144
177
 
178
+ return color_from_rgb(conv.rgb, name) if conv.opaque?
179
+ color_from_rgba(conv.rgba, name)
180
+ end
181
+
182
+ private :color_from_func
183
+
184
+ def color_from_str(color_value, name = nil)
145
185
  named_color = !name && List::NAME_TO_COLOR[color_value]
146
186
  return named_color if named_color
147
187
 
148
188
  unless Utils.valid_hex?(color_value)
149
- raise InvalidColorRepresentationError, error_message
189
+ raise InvalidColorRepresentationError.from_value(color_value)
150
190
  end
151
191
 
152
192
  hex_code = Utils.normalize_hex(color_value)
153
- !name && List::HEX_TO_COLOR[hex_code] || Color.new(hex_code, name)
193
+ !name && List::HEX_TO_COLOR[hex_code] || new(hex_code, name)
154
194
  end
155
195
 
156
196
  private :color_from_str
@@ -171,7 +211,7 @@ module ColorContrastCalc
171
211
  # @!attribute [r] relative_luminance
172
212
  # @return [Float] Relative luminance of the color
173
213
 
174
- attr_reader :rgb, :hex, :name, :relative_luminance
214
+ attr_reader :rgb, :hex, :name, :relative_luminance, :opacity
175
215
 
176
216
  ##
177
217
  # Create a new instance of Color.
@@ -184,12 +224,19 @@ module ColorContrastCalc
184
224
  # @return [Color] New instance of Color
185
225
 
186
226
  def initialize(rgb, name = nil)
187
- @rgb = rgb.is_a?(String) ? Utils.hex_to_rgb(rgb) : rgb
227
+ @rgb = rgb.is_a?(String) ? Utils.hex_to_rgb(rgb) : rgb.dup
228
+ @opacity = @rgb.length == 4 ? @rgb.pop : 1.0
188
229
  @hex = Utils.rgb_to_hex(@rgb)
189
230
  @name = name || common_name
190
231
  @relative_luminance = Checker.relative_luminance(@rgb)
191
232
  end
192
233
 
234
+ def create(rgb, name = nil)
235
+ self.class.new(rgb, name)
236
+ end
237
+
238
+ private :create
239
+
193
240
  ##
194
241
  # Return HSL value of the color.
195
242
  #
@@ -204,6 +251,20 @@ module ColorContrastCalc
204
251
  @hsl ||= Utils.rgb_to_hsl(@rgb)
205
252
  end
206
253
 
254
+ ##
255
+ # Return HWB value of the color.
256
+ #
257
+ # The value is calculated from the RGB value, so if you create
258
+ # the instance by Color.color_from method, the value used to
259
+ # create the color does not necessarily correspond to the value
260
+ # of this property.
261
+ #
262
+ # @return [Array<Float>] HWB value represented as an array of numbers
263
+
264
+ def hwb
265
+ @hwb ||= Utils.rgb_to_hwb(@rgb)
266
+ end
267
+
207
268
  ##
208
269
  # Return a {https://www.w3.org/TR/SVG/types.html#ColorKeywords
209
270
  # color keyword name} when the name corresponds to the hex code
@@ -212,8 +273,7 @@ module ColorContrastCalc
212
273
  # @return [String] Color keyword name or hex color code
213
274
 
214
275
  def common_name
215
- named_color = List::HEX_TO_COLOR[@hex]
216
- named_color && named_color.name || @hex
276
+ List::HEX_TO_COLOR[@hex]&.name || @hex
217
277
  end
218
278
 
219
279
  ##
@@ -294,6 +354,18 @@ module ColorContrastCalc
294
354
  generate_new_color(Converter::Grayscale, ratio, name)
295
355
  end
296
356
 
357
+ ##
358
+ # Return a complementary color of the original color.
359
+ #
360
+ # @param name [String] You can name the color to be created.
361
+ # Without this option, the value of normalized hex color
362
+ # code is assigned instead.
363
+ # @return [Color] New complementary color
364
+ def complementary(name = nil)
365
+ minmax = rgb.minmax.reduce {|min, max| min + max }
366
+ create(rgb.map {|c| minmax - c }, name)
367
+ end
368
+
297
369
  ##
298
370
  # Try to find a color who has a satisfying contrast ratio.
299
371
  #
@@ -307,8 +379,8 @@ module ColorContrastCalc
307
379
  # of +other_color+
308
380
 
309
381
  def find_brightness_threshold(other_color, level = Checker::Level::AA)
310
- other_color = Color.new(other_color) unless other_color.is_a? Color
311
- Color.new(ThresholdFinder::Brightness.find(rgb, other_color.rgb, level))
382
+ other_color = create(other_color) unless other_color.is_a? Color
383
+ create(ThresholdFinder::Brightness.find(rgb, other_color.rgb, level))
312
384
  end
313
385
 
314
386
  ##
@@ -324,8 +396,8 @@ module ColorContrastCalc
324
396
  # of +other_color+
325
397
 
326
398
  def find_lightness_threshold(other_color, level = Checker::Level::AA)
327
- other_color = Color.new(other_color) unless other_color.is_a? Color
328
- Color.new(ThresholdFinder::Lightness.find(rgb, other_color.rgb, level))
399
+ other_color = create(other_color) unless other_color.is_a? Color
400
+ create(ThresholdFinder::Lightness.find(rgb, other_color.rgb, level))
329
401
  end
330
402
 
331
403
  ##
@@ -466,7 +538,7 @@ module ColorContrastCalc
466
538
 
467
539
  def generate_new_color(calc, ratio, name = nil)
468
540
  new_rgb = calc.calc_rgb(rgb, ratio)
469
- self.class.new(new_rgb, name)
541
+ create(new_rgb, name)
470
542
  end
471
543
 
472
544
  private :generate_new_color
@@ -0,0 +1,649 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+ require 'stringio'
5
+ require 'color_contrast_calc/utils'
6
+ require 'color_contrast_calc/invalid_color_representation_error'
7
+
8
+ module ColorContrastCalc
9
+ ##
10
+ # Module that converts RGB/HSL/HWB functions into data apt for calculation.
11
+
12
+ module ColorFunctionParser
13
+ ##
14
+ # Define types of color functions.
15
+
16
+ module Scheme
17
+ RGB = 'rgb'
18
+ RGBA = 'rgba'
19
+ HSL = 'hsl'
20
+ HSLA = 'hsla'
21
+ HWB = 'hwb'
22
+ end
23
+
24
+ ##
25
+ # Supported units
26
+
27
+ module Unit
28
+ PERCENT = '%'
29
+ DEG = 'deg'
30
+ GRAD = 'grad'
31
+ RAD = 'rad'
32
+ TURN = 'turn'
33
+ end
34
+
35
+ ##
36
+ # Validate the unit of each parameter in a color functions.
37
+
38
+ class Validator
39
+ include Unit
40
+
41
+ POS = %w[1st 2nd 3rd].freeze
42
+
43
+ private_constant :POS
44
+
45
+ def initialize
46
+ @config = yield
47
+ @scheme = @config[:scheme]
48
+ end
49
+
50
+ def format_to_function(parameters)
51
+ params = parameters.map {|param| "#{param[:number]}#{param[:unit]}" }
52
+ "#{@scheme}(#{params.join(' ')})"
53
+ end
54
+
55
+ private :format_to_function
56
+
57
+ def error_message(parameters, passed_unit, pos, original_value = nil)
58
+ color_func = original_value || format_to_function(parameters)
59
+
60
+ if passed_unit
61
+ return format('"%s" is not allowed for %s.',
62
+ passed_unit, format_to_function(parameters))
63
+ end
64
+
65
+ format('A unit is required for the %s parameter of %s.',
66
+ POS[pos], color_func)
67
+ end
68
+
69
+ private :error_message
70
+
71
+ # @private
72
+ def validate_units(parameters, original_value = nil)
73
+ parameters.each_with_index do |param, i|
74
+ passed_unit = param[:unit]
75
+
76
+ unless @config[:units][i].include? passed_unit
77
+ raise InvalidColorRepresentationError,
78
+ error_message(parameters, passed_unit, i, original_value)
79
+ end
80
+ end
81
+
82
+ true
83
+ end
84
+
85
+ # @private
86
+ RGB = Validator.new do
87
+ {
88
+ scheme: Scheme::RGB,
89
+ units: [
90
+ [nil, PERCENT],
91
+ [nil, PERCENT],
92
+ [nil, PERCENT],
93
+ [nil, PERCENT]
94
+ ]
95
+ }
96
+ end
97
+
98
+ # @private
99
+ HSL = Validator.new do
100
+ {
101
+ scheme: Scheme::HSL,
102
+ units: [
103
+ [nil, DEG, GRAD, RAD, TURN],
104
+ [PERCENT],
105
+ [PERCENT],
106
+ [nil, PERCENT]
107
+ ]
108
+ }
109
+ end
110
+
111
+ # @private
112
+ HWB = Validator.new do
113
+ {
114
+ scheme: Scheme::HWB,
115
+ units: [
116
+ [nil, DEG, GRAD, RAD, TURN],
117
+ [PERCENT],
118
+ [PERCENT],
119
+ [nil, PERCENT]
120
+ ]
121
+ }
122
+ end
123
+
124
+ VALIDATORS = {
125
+ Scheme::RGB => RGB,
126
+ Scheme::RGBA => RGB,
127
+ Scheme::HSL => HSL,
128
+ Scheme::HSLA => HSL,
129
+ Scheme::HWB => HWB
130
+ }.freeze
131
+
132
+ private_constant :VALIDATORS
133
+
134
+ def self.validate(parsed_value, original_value = nil)
135
+ scheme = parsed_value[:scheme]
136
+ params = parsed_value[:parameters]
137
+ VALIDATORS[scheme].validate_units(params, original_value)
138
+ end
139
+ end
140
+
141
+ ##
142
+ # Hold information about a parsed RGB/HSL/HWB function.
143
+ #
144
+ # This class is intended to be used internally in ColorFunctionParser,
145
+ # so do not rely on the current class name and its interfaces.
146
+ # They may change in the future.
147
+
148
+ class ColorFunction
149
+ UNIT_CONV = {
150
+ Unit::PERCENT => proc do |n, base|
151
+ if base == 255
152
+ (n.to_f * base / 100.0).round
153
+ else
154
+ n.to_f
155
+ end
156
+ end,
157
+ Unit::DEG => proc {|n| n.to_f },
158
+ Unit::GRAD => proc {|n| n.to_f * 9 / 10 },
159
+ Unit::TURN => proc {|n| n.to_f * 360 },
160
+ Unit::RAD => proc {|n| n.to_f * 180 / Math::PI }
161
+ }
162
+
163
+ UNIT_CONV.default = proc {|n| /\./ =~ n ? n.to_f : n.to_i }
164
+ UNIT_CONV.freeze
165
+
166
+ private_constant :UNIT_CONV
167
+
168
+ ##
169
+ # @!attribute [r] scheme
170
+ # @return [String] Type of function: 'rgb' or 'hsl'
171
+ # @!attribute [r] source
172
+ # @return [String] The original RGB/HSL/HWB function before
173
+ # the conversion
174
+
175
+ attr_reader :scheme, :source
176
+
177
+ ##
178
+ # @private
179
+ #
180
+ # Parameters passed to this method is generated by
181
+ # ColorFunctionParser.parse() and the manual creation of
182
+ # instances of this class by end users is not expected.
183
+
184
+ def initialize(parsed_value)
185
+ @scheme = parsed_value[:scheme]
186
+ @params = parsed_value[:parameters]
187
+ @source = parsed_value[:source]
188
+ @normalized = normalize_params
189
+ normalize_opacity!(@normalized)
190
+ end
191
+
192
+ def convert_unit(param, base = nil)
193
+ UNIT_CONV[param[:unit]][param[:number], base]
194
+ end
195
+
196
+ private :convert_unit
197
+
198
+ def normalize_params
199
+ raise NotImplementedError, 'Overwrite the method in a subclass'
200
+ end
201
+
202
+ private :normalize_params
203
+
204
+ def color_components
205
+ return @normalized if @normalized.length == 3
206
+ @normalized[0, 3]
207
+ end
208
+
209
+ private :color_components
210
+
211
+ def normalize_opacity!(normalized)
212
+ return unless @params.length == 4
213
+
214
+ param = @params.last
215
+ n = param[:number]
216
+ base = param[:unit] == Unit::PERCENT ? 100 : 1
217
+ normalized[-1] = n.to_f / base
218
+ end
219
+
220
+ private :normalize_opacity!
221
+
222
+ ##
223
+ # Return the RGB value gained from a RGB/HSL/HWB function.
224
+ #
225
+ # @return [Array<Integer>] RGB value represented as an array
226
+
227
+ def rgb
228
+ raise NotImplementedError, 'Overwrite the method in a subclass'
229
+ end
230
+
231
+ ##
232
+ # Return the parameters of a RGB/HSL/HWB function as an array of
233
+ # Integer/Float.
234
+ # The unit for H, S, L is assumed to be deg, %, % respectively.
235
+ #
236
+ # @return [Array<Integer, Float>] RGB/HSL/HWB value represented
237
+ # as an array
238
+
239
+ def to_a
240
+ @normalized
241
+ end
242
+
243
+ ##
244
+ # Return the opacity of a color presented as a RGB/HSL/HWB
245
+ # function. The returned value is normalized to a floating number
246
+ # between 0 and 1.
247
+ #
248
+ # @return [Float] Normalized opacity
249
+
250
+ def opacity
251
+ @opacity ||= @normalized.length == 3 ? 1.0 : @normalized.last
252
+ end
253
+
254
+ ##
255
+ # Return the RGBA value gained from a RGB/HSL/HWB function.
256
+ # The opacity is normalized to a floating number between 0 and 1.
257
+ #
258
+ # @return [Array<Integer, Float>] RGBA value represented as an array.
259
+
260
+ def rgba
261
+ rgb + [opacity]
262
+ end
263
+
264
+ ##
265
+ # Return true when the Color is completely opaque.
266
+ #
267
+ # @return [true, false] return true when the opacity equals 1.0
268
+
269
+ def opaque?
270
+ opacity == Utils::MAX_OPACITY
271
+ end
272
+
273
+ # @private
274
+ class Rgb < self
275
+ def normalize_params
276
+ @params.map {|param| convert_unit(param, 255) }
277
+ end
278
+
279
+ alias rgb color_components
280
+
281
+ public :rgb
282
+ end
283
+
284
+ # @private
285
+ class Hsl < self
286
+ def normalize_params
287
+ @params.map {|param| convert_unit(param) }
288
+ end
289
+
290
+ def rgb
291
+ Utils.hsl_to_rgb(color_components)
292
+ end
293
+ end
294
+
295
+ # @private
296
+ class Hwb < self
297
+ def normalize_params
298
+ @params.map {|param| convert_unit(param) }
299
+ end
300
+
301
+ def rgb
302
+ Utils.hwb_to_rgb(color_components)
303
+ end
304
+ end
305
+
306
+ # @private
307
+ def self.create(parsed_value, original_value)
308
+ Validator.validate(parsed_value, original_value)
309
+ case parsed_value[:scheme]
310
+ when Scheme::RGB, Scheme::RGBA
311
+ Rgb.new(parsed_value)
312
+ when Scheme::HSL, Scheme::HSLA
313
+ Hsl.new(parsed_value)
314
+ when Scheme::HWB
315
+ Hwb.new(parsed_value)
316
+ end
317
+ end
318
+ end
319
+
320
+ # @private
321
+ module TokenRe
322
+ SPACES = /\s+/.freeze
323
+ SCHEME = /rgba?|hsla?|hwb/i.freeze
324
+ OPEN_PAREN = /\(/.freeze
325
+ CLOSE_PAREN = /\)/.freeze
326
+ COMMA = /,/.freeze
327
+ SLASH = %r{/}.freeze
328
+ NUMBER = /(?:\d+)(?:\.\d+)?|\.\d+/.freeze
329
+ UNIT = /%|deg|grad|rad|turn/.freeze
330
+ end
331
+
332
+ # @private
333
+ module ErrorReporter
334
+ MAX_SOURCE_LENGTH = 60
335
+
336
+ private_constant :MAX_SOURCE_LENGTH
337
+
338
+ def self.compose_error_message(scanner, message)
339
+ color_value = sanitized_source(scanner)
340
+ out = StringIO.new
341
+ out.print message
342
+ out.print ' '
343
+ ErrorReporter.print_error_pos!(out, color_value, scanner.charpos)
344
+ out.puts
345
+ out.string
346
+ end
347
+
348
+ def self.format_error_message(scanner, re)
349
+ out = StringIO.new
350
+ color_value = sanitized_source(scanner)
351
+
352
+ out.print format('"%s" is not a valid code. ', color_value)
353
+ print_error_pos!(out, color_value, scanner.charpos)
354
+ out.puts " while searching with #{re}"
355
+
356
+ out.string
357
+ end
358
+
359
+ def self.print_error_pos!(out, color_value, pos)
360
+ out.puts 'An error occurred at:'
361
+ out.puts color_value
362
+ out.print "#{' ' * pos}^"
363
+ end
364
+
365
+ def self.sanitized_source(scanner)
366
+ src = scanner.string
367
+ parsed = src[0, scanner.charpos]
368
+ max_src = src[0, MAX_SOURCE_LENGTH]
369
+
370
+ return max_src if /\A[[:ascii:]&&[:^cntrl:]]+\Z/.match(max_src)
371
+
372
+ suspicious_chars = max_src[parsed.length, MAX_SOURCE_LENGTH]
373
+ "#{parsed}#{suspicious_chars.inspect[1..-2]}"
374
+ end
375
+
376
+ private_class_method :sanitized_source
377
+ end
378
+
379
+ class Parser
380
+ class << self
381
+ attr_accessor :parsers, :main, :value, :function
382
+ end
383
+
384
+ def skip_spaces!(scanner)
385
+ scanner.scan(TokenRe::SPACES)
386
+ end
387
+
388
+ private :skip_spaces!
389
+
390
+ def read_scheme!(scanner)
391
+ scheme = read_token!(scanner, TokenRe::SCHEME).downcase
392
+
393
+ parsed_value = {
394
+ scheme: scheme,
395
+ parameters: []
396
+ }
397
+
398
+ parser = Parser.parsers[scheme] || self
399
+
400
+ parser.read_open_paren!(scanner, parsed_value)
401
+ end
402
+
403
+ def format_error_message(scanner, re)
404
+ ErrorReporter.format_error_message(scanner, re)
405
+ end
406
+
407
+ private :format_error_message
408
+
409
+ def source_until_current_pos(scanner)
410
+ scanner.string[0, scanner.charpos]
411
+ end
412
+
413
+ private :source_until_current_pos
414
+
415
+ def fix_value!(parsed_value, scanner)
416
+ parsed_value[:source] = source_until_current_pos(scanner).strip
417
+ parsed_value
418
+ end
419
+
420
+ private :fix_value!
421
+
422
+ def read_token!(scanner, re)
423
+ skip_spaces!(scanner)
424
+ token = scanner.scan(re)
425
+
426
+ return token if token
427
+
428
+ error_message = format_error_message(scanner, re)
429
+ raise InvalidColorRepresentationError, error_message
430
+ end
431
+
432
+ private :read_token!
433
+
434
+ def read_open_paren!(scanner, parsed_value)
435
+ read_token!(scanner, TokenRe::OPEN_PAREN)
436
+
437
+ read_parameters!(scanner, parsed_value)
438
+ end
439
+
440
+ protected :read_open_paren!
441
+
442
+ def read_close_paren!(scanner)
443
+ scanner.scan(TokenRe::CLOSE_PAREN)
444
+ end
445
+
446
+ private :read_close_paren!
447
+
448
+ def read_parameters!(scanner, parsed_value)
449
+ read_number!(scanner, parsed_value)
450
+ end
451
+
452
+ private :read_parameters!
453
+
454
+ def read_number!(scanner, parsed_value)
455
+ number = read_token!(scanner, TokenRe::NUMBER)
456
+
457
+ parsed_value[:parameters].push({ number: number, unit: nil })
458
+
459
+ read_unit!(scanner, parsed_value)
460
+ end
461
+
462
+ protected :read_number!
463
+
464
+ def read_unit!(scanner, parsed_value)
465
+ unit = scanner.scan(TokenRe::UNIT)
466
+
467
+ parsed_value[:parameters].last[:unit] = unit if unit
468
+
469
+ read_separator!(scanner, parsed_value)
470
+ end
471
+
472
+ private :read_unit!
473
+
474
+ def read_separator!(scanner, parsed_value)
475
+ if next_spaces_as_separator?(scanner)
476
+ return Parser.function.read_number!(scanner, parsed_value)
477
+ end
478
+
479
+ Parser.value.read_comma!(scanner, parsed_value)
480
+ end
481
+
482
+ private :read_separator!
483
+
484
+ def check_next_token(scanner, re)
485
+ cur_pos = scanner.pos
486
+ skip_spaces!(scanner)
487
+ result = scanner.check(re)
488
+ scanner.pos = cur_pos
489
+ result
490
+ end
491
+
492
+ private :check_next_token
493
+
494
+ def next_spaces_as_separator?(scanner)
495
+ cur_pos = scanner.pos
496
+ spaces = skip_spaces!(scanner)
497
+ next_token_is_number = scanner.check(TokenRe::NUMBER)
498
+ scanner.pos = cur_pos
499
+ spaces && next_token_is_number
500
+ end
501
+
502
+ private :next_spaces_as_separator?
503
+
504
+ def read_comma!(scanner, parsed_value)
505
+ skip_spaces!(scanner)
506
+
507
+ return fix_value!(parsed_value, scanner) if read_close_paren!(scanner)
508
+
509
+ read_token!(scanner, TokenRe::COMMA)
510
+ read_number!(scanner, parsed_value)
511
+ end
512
+
513
+ protected :read_comma!
514
+ end
515
+
516
+ class ValueParser < Parser
517
+ def read_separator!(scanner, parsed_value)
518
+ if next_spaces_as_separator?(scanner)
519
+ error_message = report_wrong_separator!(scanner, parsed_value)
520
+ raise InvalidColorRepresentationError, error_message
521
+ end
522
+
523
+ read_comma!(scanner, parsed_value)
524
+ end
525
+
526
+ def report_wrong_separator!(scanner, parsed_value)
527
+ scheme = parsed_value[:scheme].upcase
528
+ template = '" " and "," as a separator should not be used mixedly in %s functions.'
529
+ message = format(template, scheme)
530
+ ErrorReporter.compose_error_message(scanner, message)
531
+ end
532
+
533
+ private :report_wrong_separator!
534
+ end
535
+
536
+ class FunctionParser < Parser
537
+ def read_separator!(scanner, parsed_value)
538
+ if next_spaces_as_separator?(scanner)
539
+ error_if_opacity_separator_expected(scanner, parsed_value)
540
+ read_number!(scanner, parsed_value)
541
+ elsif opacity_separator_is_next?(scanner, parsed_value)
542
+ read_opacity!(scanner, parsed_value)
543
+ else
544
+ read_comma!(scanner, parsed_value)
545
+ end
546
+ end
547
+
548
+ private :read_separator!
549
+
550
+ def error_if_opacity_separator_expected(scanner, parsed_value)
551
+ return unless parsed_value[:parameters].length == 3
552
+
553
+ error_message = report_wrong_opacity_separator!(scanner, parsed_value)
554
+ raise InvalidColorRepresentationError, error_message
555
+ end
556
+
557
+ private :error_if_opacity_separator_expected
558
+
559
+ def report_wrong_opacity_separator!(scanner, parsed_value)
560
+ scheme = parsed_value[:scheme].upcase
561
+ message = "\"/\" is expected as a separator for opacity in #{scheme} functions."
562
+ ErrorReporter.compose_error_message(scanner, message)
563
+ end
564
+
565
+ private :report_wrong_opacity_separator!
566
+
567
+ def opacity_separator_is_next?(scanner, parsed_value)
568
+ parsed_value[:parameters].length == 3 &&
569
+ check_next_token(scanner, TokenRe::SLASH)
570
+ end
571
+
572
+ private :opacity_separator_is_next?
573
+
574
+ def read_opacity!(scanner, parsed_value)
575
+ read_token!(scanner, TokenRe::SLASH)
576
+ read_number!(scanner, parsed_value)
577
+ end
578
+
579
+ private :read_opacity!
580
+
581
+ def read_comma!(scanner, parsed_value)
582
+ skip_spaces!(scanner)
583
+
584
+ if scanner.check(TokenRe::COMMA)
585
+ wrong_separator_error(scanner, parsed_value)
586
+ end
587
+
588
+ return fix_value!(parsed_value, scanner) if read_close_paren!(scanner)
589
+
590
+ read_number!(scanner, parsed_value)
591
+ end
592
+
593
+ def report_wrong_separator!(scanner, parsed_value)
594
+ scheme = parsed_value[:scheme].upcase
595
+ message = "\",\" is not a valid separator for #{scheme} functions."
596
+ ErrorReporter.compose_error_message(scanner, message)
597
+ end
598
+
599
+ private :report_wrong_separator!
600
+
601
+ def wrong_separator_error(scanner, parsed_value)
602
+ error_message = report_wrong_separator!(scanner, parsed_value)
603
+ raise InvalidColorRepresentationError, error_message
604
+ end
605
+
606
+ private :wrong_separator_error
607
+ end
608
+
609
+ Parser.parsers = {
610
+ Scheme::HWB => FunctionParser.new
611
+ }
612
+
613
+ Parser.main = Parser.new
614
+ Parser.value = ValueParser.new
615
+ Parser.function = FunctionParser.new
616
+
617
+ ##
618
+ # Parse an RGB/HSL/HWB function and store the result as an instance of
619
+ # ColorFunctionParser::ColorFunction.
620
+ #
621
+ # @param color_value [String] RGB/HSL/HWB function defined at
622
+ # https://www.w3.org/TR/2019/WD-css-color-4-20191105/
623
+ # @return [ColorFunction] An instance of ColorFunctionParser::ColorFunction
624
+
625
+ def self.parse(color_value)
626
+ parsed_value = Parser.main.read_scheme!(StringScanner.new(color_value))
627
+ ColorFunction.create(parsed_value, color_value)
628
+ end
629
+
630
+ ##
631
+ # Return An RGB value gained from an RGB/HSL/HWB function.
632
+ #
633
+ # @return [Array<Integer>] RGB value represented as an array
634
+
635
+ def self.to_rgb(color_value)
636
+ parse(color_value).rgb
637
+ end
638
+
639
+ ##
640
+ # Return An RGBA value gained from an RGB/HSL/HWB function.
641
+ # The opacity is normalized to a floating number between 0 and 1.
642
+ #
643
+ # @return [Array<Integer, Float>] RGBA value represented as an array
644
+
645
+ def self.to_rgba(color_value)
646
+ parse(color_value).rgba
647
+ end
648
+ end
649
+ end