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.
- 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
|