color_contrast_calc 0.1.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.
@@ -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