color_contrast_calc 0.5.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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