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.
- checksums.yaml +5 -5
- data/.rubocop.yml +9 -1
- data/.travis.yml +13 -5
- data/README.ja.md +80 -11
- data/README.md +83 -11
- data/color_contrast_calc.gemspec +6 -6
- data/examples/color_instance.rb +2 -1
- data/examples/sort_colors.rb +17 -9
- data/lib/color_contrast_calc.rb +81 -5
- data/lib/color_contrast_calc/color.rb +115 -43
- data/lib/color_contrast_calc/color_function_parser.rb +649 -0
- data/lib/color_contrast_calc/converter.rb +52 -0
- data/lib/color_contrast_calc/data/color_keywords.json +1 -0
- data/lib/color_contrast_calc/deprecated.rb +1 -1
- data/lib/color_contrast_calc/invalid_color_representation_error.rb +44 -0
- data/lib/color_contrast_calc/sorter.rb +151 -107
- data/lib/color_contrast_calc/transparency_calc.rb +55 -0
- data/lib/color_contrast_calc/utils.rb +89 -5
- data/lib/color_contrast_calc/version.rb +1 -1
- metadata +17 -15
data/lib/color_contrast_calc.rb
CHANGED
@@ -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,
|
16
|
-
# RGB value represented as an array of integers
|
17
|
-
# as
|
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
|
-
|
77
|
-
|
78
|
-
|
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,
|
85
|
-
# RGB value represented as an array of integers
|
86
|
-
# as
|
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
|
98
|
+
raise InvalidColorRepresentationError.from_value(color_value)
|
100
99
|
end
|
101
100
|
|
102
|
-
|
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,
|
111
|
-
#
|
112
|
-
# returned instance.
|
113
|
-
# @param color_value [
|
114
|
-
#
|
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
|
130
|
-
|
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
|
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] ||
|
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
|
143
|
-
|
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
|
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] ||
|
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
|
-
|
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 =
|
311
|
-
|
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 =
|
328
|
-
|
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
|
-
|
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
|