color_contrast_calc 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'color_contrast_calc/color'
4
+
5
+ module ColorContrastCalc
6
+ ##
7
+ # Collection of modules that implement the main logic of
8
+ # instance methods of Color, +Color#find_*_threshold()+.
9
+
10
+ module ThresholdFinder
11
+ # @private
12
+
13
+ module Criteria
14
+ class SearchDirection
15
+ attr_reader :level, :target_ratio
16
+
17
+ def initialize(level)
18
+ @level = level
19
+ @target_ratio = Checker.level_to_ratio(level)
20
+ end
21
+ end
22
+
23
+ class ToDarkerSide < SearchDirection
24
+ # @private
25
+
26
+ def round(r)
27
+ (r * 10).floor / 10.0
28
+ end
29
+
30
+ # @private
31
+
32
+ def increment_condition(contrast_ratio)
33
+ contrast_ratio > @target_ratio
34
+ end
35
+ end
36
+
37
+ class ToBrighterSide < SearchDirection
38
+ # @private
39
+
40
+ def round(r)
41
+ (r * 10).ceil / 10.0
42
+ end
43
+
44
+ # @private
45
+
46
+ def increment_condition(contrast_ratio)
47
+ @target_ratio > contrast_ratio
48
+ end
49
+ end
50
+ end
51
+
52
+ # @private
53
+
54
+ def self.threshold_criteria(level, fixed_color, other_color)
55
+ if should_scan_darker_side?(fixed_color, other_color)
56
+ return Criteria::ToDarkerSide.new(level)
57
+ end
58
+
59
+ Criteria::ToBrighterSide.new(level)
60
+ end
61
+
62
+ # @private
63
+
64
+ def self.should_scan_darker_side?(fixed_color, other_color)
65
+ fixed_color.higher_luminance_than?(other_color) ||
66
+ fixed_color.same_luminance_as?(other_color) && fixed_color.light_color?
67
+ end
68
+
69
+ # @private
70
+
71
+ def self.binary_search_width(init_width, min)
72
+ i = 1
73
+ init_width = init_width.to_f
74
+ d = init_width / 2**i
75
+
76
+ while d > min
77
+ yield d
78
+ i += 1
79
+ d = init_width / 2**i
80
+ end
81
+ end
82
+
83
+ ##
84
+ # Module that implements the main logic of the instance method
85
+ # +Color#find_brightness_threshold()+.
86
+
87
+ module Brightness
88
+ ##
89
+ # Try to find a color who has a satisfying contrast ratio.
90
+ #
91
+ # The color returned by this method will be created by changing the
92
+ # brightness of +other_color+. Even when a color that satisfies the
93
+ # specified level is not found, the method returns a new color anyway.
94
+ # @param fixed_color [Color] The color which remains unchanged
95
+ # @param other_color [Color] Color before the adjustment of brightness
96
+ # @param level [String] "A", "AA" or "AAA"
97
+ # @return [Color] New color whose brightness is adjusted from that of
98
+ # +other_color+
99
+
100
+ def self.find(fixed_color, other_color, level = Checker::Level::AA)
101
+ criteria = ThresholdFinder.threshold_criteria(level,
102
+ fixed_color, other_color)
103
+ w = calc_upper_ratio_limit(other_color) / 2.0
104
+
105
+ upper_color = upper_limit_color(fixed_color, other_color, w * 2, level)
106
+ return upper_color if upper_color
107
+
108
+ r, sufficient_r = calc_brightness_ratio(fixed_color.relative_luminance,
109
+ other_color.rgb, criteria, w)
110
+
111
+ generate_satisfying_color(fixed_color, other_color, criteria,
112
+ r, sufficient_r)
113
+ end
114
+
115
+ def self.upper_limit_color(fixed_color, other_color, max_ratio, level)
116
+ limit_color = other_color.new_brightness_color(max_ratio)
117
+
118
+ if exceed_upper_limit?(fixed_color, other_color, limit_color, level)
119
+ limit_color
120
+ end
121
+ end
122
+
123
+ private_class_method :upper_limit_color
124
+
125
+ def self.exceed_upper_limit?(fixed_color, other_color, limit_color, level)
126
+ other_color.higher_luminance_than?(fixed_color) &&
127
+ !limit_color.sufficient_contrast?(fixed_color, level)
128
+ end
129
+
130
+ private_class_method :exceed_upper_limit?
131
+
132
+ def self.calc_brightness_ratio(fixed_luminance, other_rgb, criteria, w)
133
+ target_ratio = criteria.target_ratio
134
+ r = w
135
+ sufficient_r = nil
136
+
137
+ ThresholdFinder.binary_search_width(w, 0.01) do |d|
138
+ contrast_ratio = calc_contrast_ratio(fixed_luminance, other_rgb, r)
139
+
140
+ sufficient_r = r if contrast_ratio >= target_ratio
141
+ break if contrast_ratio == target_ratio
142
+
143
+ r += criteria.increment_condition(contrast_ratio) ? d : -d
144
+ end
145
+
146
+ [r, sufficient_r]
147
+ end
148
+
149
+ private_class_method :calc_brightness_ratio
150
+
151
+ def self.generate_satisfying_color(fixed_color, other_color, criteria,
152
+ r, sufficient_r)
153
+ level = criteria.level
154
+ nearest = other_color.new_brightness_color(criteria.round(r))
155
+
156
+ if sufficient_r && !nearest.sufficient_contrast?(fixed_color, level)
157
+ return other_color.new_brightness_color(criteria.round(sufficient_r))
158
+ end
159
+
160
+ nearest
161
+ end
162
+
163
+ private_class_method :generate_satisfying_color
164
+
165
+ def self.calc_contrast_ratio(fixed_luminance, other_rgb, r)
166
+ new_rgb = Converter::Brightness.calc_rgb(other_rgb, r)
167
+ new_luminance = Checker.relative_luminance(new_rgb)
168
+ Checker.luminance_to_contrast_ratio(fixed_luminance, new_luminance)
169
+ end
170
+
171
+ private_class_method :calc_contrast_ratio
172
+
173
+ # @private
174
+
175
+ def self.calc_upper_ratio_limit(color)
176
+ return 100 if color.same_color?(Color::BLACK)
177
+ darkest = color.rgb.reject(&:zero?).min
178
+ ((255.0 / darkest) * 100).ceil
179
+ end
180
+ end
181
+
182
+ ##
183
+ # Module that implements the main logic of the instance method
184
+ # +Color#find_lightness_threshold()+.
185
+
186
+ module Lightness
187
+ ##
188
+ # Try to find a color who has a satisfying contrast ratio.
189
+ #
190
+ # The color returned by this method will be created by changing the
191
+ # lightness of +other_color+. Even when a color that satisfies the
192
+ # specified level is not found, the method returns a new color anyway.
193
+ # @param fixed_color [Color] The color which remains unchanged
194
+ # @param other_color [Color] Color before the adjustment of lightness
195
+ # @param level [String] "A", "AA" or "AAA"
196
+ # @return [Color] New color whose lightness is adjusted from that of
197
+ # +other_color+
198
+
199
+ def self.find(fixed_color, other_color, level = Checker::Level::AA)
200
+ criteria = ThresholdFinder.threshold_criteria(level,
201
+ fixed_color, other_color)
202
+ init_l = other_color.hsl[2]
203
+ max, min = determine_minmax(fixed_color, other_color, init_l)
204
+
205
+ boundary_color = lightness_boundary_color(fixed_color, max, min, level)
206
+ return boundary_color if boundary_color
207
+
208
+ l, sufficient_l = calc_lightness_ratio(fixed_color, other_color.hsl,
209
+ criteria, max, min)
210
+
211
+ generate_satisfying_color(fixed_color, other_color.hsl, criteria,
212
+ l, sufficient_l)
213
+ end
214
+
215
+ def self.determine_minmax(fixed_color, other_color, init_l)
216
+ scan_darker_side = ThresholdFinder.should_scan_darker_side?(fixed_color,
217
+ other_color)
218
+ scan_darker_side ? [init_l, 0] : [100, init_l] # [max, min]
219
+ end
220
+
221
+ private_class_method :determine_minmax
222
+
223
+ def self.lightness_boundary_color(color, max, min, level)
224
+ if min.zero? && !color.sufficient_contrast?(Color::BLACK, level)
225
+ return Color::BLACK
226
+ end
227
+
228
+ if max == 100 && !color.sufficient_contrast?(Color::WHITE, level)
229
+ return Color::WHITE
230
+ end
231
+ end
232
+
233
+ private_class_method :lightness_boundary_color
234
+
235
+ def self.calc_lightness_ratio(fixed_color, other_hsl, criteria, max, min)
236
+ h, s, = other_hsl
237
+ l = (max + min) / 2.0
238
+ sufficient_l = nil
239
+
240
+ ThresholdFinder.binary_search_width(max - min, 0.01) do |d|
241
+ contrast_ratio = calc_contrast_ratio(fixed_color, [h, s, l])
242
+
243
+ sufficient_l = l if contrast_ratio >= criteria.target_ratio
244
+ break if contrast_ratio == criteria.target_ratio
245
+
246
+ l += criteria.increment_condition(contrast_ratio) ? d : -d
247
+ end
248
+
249
+ [l, sufficient_l]
250
+ end
251
+
252
+ private_class_method :calc_lightness_ratio
253
+
254
+ def self.calc_contrast_ratio(fixed_color, hsl)
255
+ fixed_color.contrast_ratio_against(Utils.hsl_to_rgb(hsl))
256
+ end
257
+
258
+ private_class_method :calc_contrast_ratio
259
+
260
+ def self.generate_satisfying_color(fixed_color, other_hsl, criteria,
261
+ l, sufficient_l)
262
+ h, s, = other_hsl
263
+ level = criteria.level
264
+ nearest = Color.new_from_hsl([h, s, l])
265
+
266
+ if sufficient_l && !nearest.sufficient_contrast?(fixed_color, level)
267
+ return Color.new_from_hsl([h, s, sufficient_l])
268
+ end
269
+
270
+ nearest
271
+ end
272
+
273
+ private_class_method :generate_satisfying_color
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'color_contrast_calc/shim'
4
+
5
+ module ColorContrastCalc
6
+ ##
7
+ # Utility functions that provide basic operations on colors.
8
+ #
9
+ # This module provides basic operations on colors given as RGB values
10
+ # (including their hex code presentations) or HSL values.
11
+
12
+ module Utils
13
+ using Shim unless //.respond_to? :match?
14
+
15
+ HSL_UPPER_LIMIT = [360, 100, 100].freeze
16
+
17
+ private_constant :HSL_UPPER_LIMIT
18
+
19
+ HEX_RE = /\A#?[0-9a-f]{3}([0-9a-f]{3})?\z/i
20
+
21
+ private_constant :HEX_RE
22
+
23
+ ##
24
+ # Convert a hex color code string to a RGB value.
25
+ #
26
+ # @param hex_code [String] Hex color code such as "#ffff00"
27
+ # @return [Array<Integer>] RGB value represented as an array of integers
28
+
29
+ def self.hex_to_rgb(hex_code)
30
+ hex_part = hex_code.start_with?('#') ? hex_code[1..-1] : hex_code
31
+
32
+ case hex_part.length
33
+ when 3
34
+ hex_part.chars.map {|c| c.hex * 17 }
35
+ when 6
36
+ [0, 2, 4].map {|i| hex_part[i, 2].hex }
37
+ end
38
+ end
39
+
40
+ ##
41
+ # Normalize a hex color code to a 6 digits, lowercased one.
42
+ #
43
+ # @param code [String] Hex color code such as "#ffff00", "#ff0" or "FFFF00"
44
+ # @param prefix [true, false] If set to False, "#" at the head of result is
45
+ # removed
46
+ # @return [String] 6-digit hexadecimal string in lowercase, with/without
47
+ # leading "#" depending on the value of +prefix+
48
+
49
+ def self.normalize_hex(code, prefix = true)
50
+ if code.length < 6
51
+ hex_part = code.start_with?('#') ? code[1..-1] : code
52
+ code = hex_part.chars.map {|c| c * 2 }.join
53
+ end
54
+
55
+ lowered = code.downcase
56
+ return lowered if prefix == lowered.start_with?('#')
57
+ prefix ? "##{lowered}" : lowered[1..-1]
58
+ end
59
+
60
+ ##
61
+ # Convert a RGB value to a hex color code.
62
+ #
63
+ # @param rgb [Array<Integer>] RGB value represented as an array of integers
64
+ # @return [String] Hex color code such as "#ffff00"
65
+
66
+ def self.rgb_to_hex(rgb)
67
+ format('#%02x%02x%02x', *rgb)
68
+ end
69
+
70
+ ##
71
+ # Convert HSL value to RGB value.
72
+ #
73
+ # @param hsl [Array<Float>] HSL value represented as an array of numbers
74
+ # @return [Array<Integer>] RGB value represented as an array of integers
75
+
76
+ def self.hsl_to_rgb(hsl)
77
+ # https://www.w3.org/TR/css3-color/#hsl-color
78
+ h = hsl[0] / 360.0
79
+ s = hsl[1] / 100.0
80
+ l = hsl[2] / 100.0
81
+ m2 = l <= 0.5 ? l * (s + 1) : l + s - l * s
82
+ m1 = l * 2 - m2
83
+ [h + 1 / 3.0, h, h - 1 / 3.0].map do |adjusted_h|
84
+ (hue_to_rgb(m1, m2, adjusted_h) * 255).round
85
+ end
86
+ end
87
+
88
+ # @private
89
+
90
+ def self.hue_to_rgb(m1, m2, h)
91
+ h += 1 if h < 0
92
+ h -= 1 if h > 1
93
+ return m1 + (m2 - m1) * h * 6 if h * 6 < 1
94
+ return m2 if h * 2 < 1
95
+ return m1 + (m2 - m1) * (2 / 3.0 - h) * 6 if h * 3 < 2
96
+ m1
97
+ end
98
+
99
+ private_class_method :hue_to_rgb
100
+
101
+ ##
102
+ # Convert HSL value to hex color code.
103
+ #
104
+ # @param hsl [Array<Float>] HSL value represented as an array of numbers
105
+ # @return [String] Hex color code such as "#ffff00"
106
+
107
+ def self.hsl_to_hex(hsl)
108
+ rgb_to_hex(hsl_to_rgb(hsl))
109
+ end
110
+
111
+ ##
112
+ # Convert RGB value to HSL value.
113
+ #
114
+ # @param rgb [Array<Integer>] RGB value represented as an array of integers
115
+ # @return [Array<Float>] HSL value represented as an array of numbers
116
+
117
+ def self.rgb_to_hsl(rgb)
118
+ [rgb_to_hue(rgb), rgb_to_saturation(rgb), rgb_to_lightness(rgb)]
119
+ end
120
+
121
+ # @private
122
+
123
+ def self.rgb_to_lightness(rgb)
124
+ (rgb.max + rgb.min) * 100 / 510.0
125
+ end
126
+
127
+ private_class_method :rgb_to_lightness
128
+
129
+ # @private
130
+
131
+ def self.rgb_to_saturation(rgb)
132
+ l = rgb_to_lightness(rgb)
133
+ minmax_with_diff(rgb) do |min, max, d|
134
+ (l <= 50 ? d / (max + min) : d / (510 - max - min)) * 100
135
+ end
136
+ end
137
+
138
+ private_class_method :rgb_to_saturation
139
+
140
+ # @private
141
+
142
+ def self.rgb_to_hue(rgb)
143
+ # References:
144
+ # Agoston, Max K. (2005).
145
+ # "Computer Graphics and Geometric Modeling: Implementation and Algorithms".
146
+ # London: Springer
147
+ #
148
+ # https://accessibility.kde.org/hsl-adjusted.php#hue
149
+
150
+ minmax_with_diff(rgb) do |_, _, d|
151
+ mi = rgb.each_with_index.max_by {|c| c[0] }[1] # max value index
152
+ h = mi * 120 + (rgb[(mi + 1) % 3] - rgb[(mi + 2) % 3]) * 60 / d
153
+
154
+ h < 0 ? h + 360 : h
155
+ end
156
+ end
157
+
158
+ private_class_method :rgb_to_hue
159
+
160
+ # @private
161
+
162
+ def self.minmax_with_diff(rgb)
163
+ min = rgb.min
164
+ max = rgb.max
165
+ return 0 if min == max
166
+ yield min, max, (max - min).to_f
167
+ end
168
+
169
+ private_class_method :minmax_with_diff
170
+
171
+ ##
172
+ # Convert hex color code to HSL value.
173
+ #
174
+ # @param hex_code [String] Hex color code such as "#ffff00"
175
+ # @return [Array<Float>] HSL value represented as an array of numbers
176
+
177
+ def self.hex_to_hsl(hex_code)
178
+ rgb_to_hsl(hex_to_rgb(hex_code))
179
+ end
180
+
181
+ ##
182
+ # Check if a given array is a valid representation of RGB color.
183
+ #
184
+ # @param rgb [Array<Integer>] RGB value represented as an array of integers
185
+ # @return [true, false] true if a valid RGB value is passed
186
+
187
+ def self.valid_rgb?(rgb)
188
+ rgb.length == 3 &&
189
+ rgb.all? {|c| c.is_a?(Integer) && c >= 0 && c <= 255 }
190
+ end
191
+
192
+ ##
193
+ # Check if a given array is a valid representation of HSL color.
194
+ #
195
+ # @param hsl [Array<Float>] HSL value represented as an array of numbers
196
+ # @return [true, false] true if a valid HSL value is passed
197
+
198
+ def self.valid_hsl?(hsl)
199
+ return false unless hsl.length == 3
200
+ hsl.each_with_index do |c, i|
201
+ return false if !c.is_a?(Numeric) || c < 0 || c > HSL_UPPER_LIMIT[i]
202
+ end
203
+ true
204
+ end
205
+
206
+ ##
207
+ # Check if a given string is a valid representation of RGB color.
208
+ #
209
+ # @param hex_code [String] RGB value in hex color code such as "#ffff00"
210
+ # @return [true, false] true if a vaild hex color code is passed
211
+
212
+ def self.valid_hex?(hex_code)
213
+ HEX_RE.match?(hex_code)
214
+ end
215
+
216
+ ##
217
+ # Check if given two hex color codes represent a same color.
218
+ #
219
+ # @param hex1 [String] RGB value in hex color code such as "#ffff00",
220
+ # "#ffff00", "#FFFF00" or "#ff0"
221
+ # @param hex2 [String] RGB value in hex color code such as "#ffff00",
222
+ # "#ffff00", "#FFFF00" or "#ff0"
223
+ # @return [true, false] true if given two colors are same
224
+
225
+ def self.same_hex_color?(hex1, hex2)
226
+ normalize_hex(hex1) == normalize_hex(hex2)
227
+ end
228
+
229
+ ##
230
+ # Check if a given string is consists of uppercase letters.
231
+ #
232
+ # @param str [String] string to be checked
233
+ # @return [true, false] true if letters in the passed string are all
234
+ # in uppercase.
235
+
236
+ def self.uppercase?(str)
237
+ !/[[:lower:]]/.match?(str)
238
+ end
239
+ end
240
+ end