imgwire 0.2.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b912ed96b11a03a1ed5017b4dacb98af41a3bffebd7b34254a11fbc18d398b5
4
- data.tar.gz: 57f6e83b710c3884283da8e6a772d0ad3e8e4da1cdb5696180aea496709f91ac
3
+ metadata.gz: df6fe59538abf5207310fee7e35a2bc69122f3e989c79bad3a674979553ddc62
4
+ data.tar.gz: ea52a41f725f7d9ddd339151eb1e350a815b39276caddb1db643ba8627da9a50
5
5
  SHA512:
6
- metadata.gz: 97686c66dfad3d7bba07244ad8b0040bab9b29c8bb3a01a9e69e953e26eb20dc9c29401950624bb8ec61981c43169fce44dbea8eda8d87c8d7cde496a3344f9d
7
- data.tar.gz: 2f268dd8db5e7479d7bde706427d533a3918f72cfe3f32d8c0f7b318c70ab6e17ab19e07a3c6dbc54cbb8d85286ee0c3df5b5ea1bea4b0f7540b925e7339824b
6
+ metadata.gz: fd1bfa952e82bf07d9f0c3dd517d17e6863e15a1a8107ab743c262f64a72f71a866599f2a7f79d5ff9e8c5c2ad7dcd217319732429b4e93f5c7ec0665a31d442
7
+ data.tar.gz: 813abc30206d27d06d109af62d0a82b6bd8068244d050a6bdfe8fbb1a4317e84012aa429c63c2d23fb8d6fecd8311469ebe2a34c92cda7229d87716fef9f3eae
data/CODEGEN_VERSION CHANGED
@@ -1 +1 @@
1
- ff2563481f5f13c58b28b6ed0a20ff40145415791abd150cfbe89fc4b71ed7df
1
+ b57e439d6e9c3859c7bddb889658b5ff808503ccc136ea28a56e0fd57a1678aa
data/README.md CHANGED
@@ -1,6 +1,9 @@
1
+ ![imgwire.dev Logo](https://cdn.imgwire.dev/6b024480-a5ac-426d-b539-2e4fccc4c6ac/26f80c13-48bd-4bb9-866e-5e9392b11a6a/4ba5fe50-433b-40db-a847-938d2081c21a?w=280&quality=80)
2
+
1
3
  # `imgwire`
2
4
 
3
5
  [![RubyGems version](https://img.shields.io/gem/v/imgwire.svg)](https://rubygems.org/gems/imgwire)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
4
7
  [![CI](https://github.com/Blackhawk-Software/imgwire-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/Blackhawk-Software/imgwire-ruby/actions/workflows/ci.yml)
5
8
  [![Release](https://github.com/Blackhawk-Software/imgwire-ruby/actions/workflows/release.yml/badge.svg)](https://github.com/Blackhawk-Software/imgwire-ruby/actions/workflows/release.yml)
6
9
 
@@ -8,6 +11,9 @@
8
11
 
9
12
  Use it in Rails apps, workers, jobs, and other backend runtimes to authenticate with a Server API Key, upload files from Ruby IO objects, manage server-side resources, and generate image transformation URLs without rebuilding imgwire request plumbing yourself.
10
13
 
14
+ > [!TIP]
15
+ > Obtain an API key by signing up at [imgwire.dev](https://imgwire.dev). Read the full API & SDK documentation [here](https://docs.imgwire.dev/guides/backend-quickstart).
16
+
11
17
  ## Installation
12
18
 
13
19
  ```bash
@@ -114,6 +120,30 @@ puts image.url(
114
120
  )
115
121
  ```
116
122
 
123
+ The helper accepts the transform names and aliases from
124
+ `docs/imgwire-url-transformations-guide.md`. Multi-field transforms can be
125
+ passed as their URL string syntax or as Ruby hashes:
126
+
127
+ ```ruby
128
+ puts image.url(
129
+ gradient: {
130
+ colors: ["#0b1f5e", "#ff2a2a"],
131
+ angle: 90,
132
+ opacity: 0.25,
133
+ blend: "overlay"
134
+ },
135
+ watermark_url: "https://example.com/logo.png",
136
+ watermark_position: {
137
+ gravity: "southeast",
138
+ x: -24,
139
+ y: -24,
140
+ opacity: 0.85
141
+ }
142
+ )
143
+ ```
144
+
145
+ Out-of-range transform values are omitted from the generated URL.
146
+
117
147
  ## Generation
118
148
 
119
149
  From a clean checkout:
data/lib/imgwire/image.rb CHANGED
@@ -1,25 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'uri'
4
+ require 'imgwire/url_transformations'
4
5
 
5
6
  module Imgwire
6
7
  class Image < ImgwireGenerated::ImageSchema
7
8
  PRESETS = %w[thumbnail small medium large].freeze
8
- ROTATE_ANGLES = [0, 90, 180, 270, 360].freeze
9
- FORMATS = %w[jpg png avif gif webp auto].freeze
10
-
11
- RULES = {
12
- 'background' => %w[background bg],
13
- 'crop' => %w[crop],
14
- 'enlarge' => %w[enlarge],
15
- 'format' => %w[format fm],
16
- 'gravity' => %w[gravity],
17
- 'height' => %w[height h],
18
- 'quality' => %w[quality q],
19
- 'rotate' => %w[rotate rot],
20
- 'strip_metadata' => %w[strip_metadata strip],
21
- 'width' => %w[width w]
22
- }.freeze
23
9
 
24
10
  def self.wrap(value)
25
11
  return value if value.is_a?(self)
@@ -38,7 +24,7 @@ module Imgwire
38
24
  def url(options = {})
39
25
  options = symbolize_keys(options)
40
26
  path = build_preset_path(options[:preset])
41
- query = build_query(options.except(:preset))
27
+ query = Imgwire::URLTransformations.build_query(options.except(:preset))
42
28
 
43
29
  uri = URI.parse(cdn_url)
44
30
  uri.path = path
@@ -63,69 +49,5 @@ module Imgwire
63
49
  uri = URI.parse(cdn_url)
64
50
  "#{uri.path}@#{preset}"
65
51
  end
66
-
67
- def build_query(options)
68
- present = []
69
-
70
- RULES.each do |canonical, aliases|
71
- matches = aliases.filter_map do |name|
72
- symbol = name.to_sym
73
- [symbol, options[symbol]] if options.key?(symbol)
74
- end
75
-
76
- next if matches.empty?
77
- raise ArgumentError, "Duplicate transformation rule: #{canonical}" if matches.length > 1
78
-
79
- value = normalize_rule(canonical, matches.first.last)
80
- present << [canonical, value] unless value.nil?
81
- end
82
-
83
- present
84
- end
85
-
86
- def normalize_rule(canonical, value)
87
- case canonical
88
- when 'background'
89
- string = value.to_s.delete_prefix('#')
90
- unless string.match?(/\A[\da-fA-F]{6}\z/)
91
- raise ArgumentError,
92
- "Invalid transformation rule value for #{canonical}"
93
- end
94
-
95
- string.downcase
96
- when 'crop', 'gravity'
97
- value.to_s
98
- when 'enlarge', 'strip_metadata'
99
- value ? 'true' : nil
100
- when 'format'
101
- string = value.to_s
102
- unless FORMATS.include?(string)
103
- raise ArgumentError,
104
- "Invalid transformation rule value for #{canonical}"
105
- end
106
-
107
- string
108
- when 'height', 'quality', 'width'
109
- integer = Integer(value)
110
- unless integer.positive?
111
- raise ArgumentError,
112
- "Invalid transformation rule value for #{canonical}"
113
- end
114
-
115
- integer.to_s
116
- when 'rotate'
117
- integer = Integer(value)
118
- unless ROTATE_ANGLES.include?(integer)
119
- raise ArgumentError,
120
- "Invalid transformation rule value for #{canonical}"
121
- end
122
-
123
- integer.to_s
124
- else
125
- raise ArgumentError, "Unsupported transformation rule: #{canonical}"
126
- end
127
- rescue ArgumentError, TypeError
128
- raise ArgumentError, "Invalid transformation rule value for #{canonical}"
129
- end
130
52
  end
131
53
  end
@@ -0,0 +1,929 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Imgwire
8
+ # rubocop:disable Metrics/ModuleLength
9
+ module URLTransformations
10
+ RULES = {
11
+ 'adjust' => %w[a adjust],
12
+ 'background' => %w[bg background],
13
+ 'background_alpha' => %w[bga background_alpha],
14
+ 'blur' => %w[bl blur],
15
+ 'brightness' => %w[br brightness],
16
+ 'chroma_subsampling' => %w[chroma_subsampling],
17
+ 'color_profile' => %w[cp icc color_profile],
18
+ 'colorize' => %w[col colorize],
19
+ 'contrast' => %w[co contrast],
20
+ 'crop' => %w[c crop],
21
+ 'dpi' => %w[dpi],
22
+ 'dpr' => %w[dpr],
23
+ 'duotone' => %w[dt duotone],
24
+ 'enlarge' => %w[el enlarge],
25
+ 'extend' => %w[ex extend],
26
+ 'extend_aspect_ratio' => %w[exar extend_ar extend_aspect_ratio],
27
+ 'flip' => %w[fl flip],
28
+ 'format' => %w[f format ext extension fm],
29
+ 'gradient' => %w[gr gradient],
30
+ 'gravity' => %w[g gravity],
31
+ 'height' => %w[h height],
32
+ 'hue' => %w[hu hue],
33
+ 'keep_copyright' => %w[kcr keep_copyright],
34
+ 'lightness' => %w[l lightness],
35
+ 'min-height' => %w[mh min_height min-height],
36
+ 'min-width' => %w[mw min_width min-width],
37
+ 'monochrome' => %w[mc monochrome],
38
+ 'negate' => %w[neg negate],
39
+ 'normalize' => %w[norm normalise normalize],
40
+ 'padding' => %w[pd padding],
41
+ 'pixelate' => %w[pix pixelate],
42
+ 'progressive' => %w[progressive],
43
+ 'quality' => %w[q quality],
44
+ 'resizing_algorithm' => %w[ra resizing_algorithm],
45
+ 'resizing_type' => %w[resizing_type],
46
+ 'rotate' => %w[rot rotate],
47
+ 'saturation' => %w[sa saturation],
48
+ 'sharpen' => %w[sh sharpen],
49
+ 'strip_color_profile' => %w[scp strip_color_profile],
50
+ 'strip_metadata' => %w[sm strip_metadata strip],
51
+ 'watermark' => %w[wm watermark],
52
+ 'watermark_position' => %w[wmp watermark_offset watermark_position],
53
+ 'watermark_rotate' => %w[wmr wm_rot watermark_rotate],
54
+ 'watermark_shadow' => %w[wmsh watermark_shadow],
55
+ 'watermark_size' => %w[wms watermark_size],
56
+ 'watermark_text' => %w[wmt watermark_text],
57
+ 'watermark_url' => %w[wmu watermark_url],
58
+ 'width' => %w[w width],
59
+ 'zoom' => %w[z zoom]
60
+ }.freeze
61
+
62
+ COLOR_PROFILES = %w[srgb rgb16 cmyk keep preserve].freeze
63
+ CHROMA_SUBSAMPLING_VALUES = %w[4:2:0 4:4:4 auto].freeze
64
+ FORMATS = %w[auto jpg jpeg png webp avif gif tiff].freeze
65
+ FLIP_VALUES = %w[vertical horizontal both].freeze
66
+ RESIZING_ALGORITHMS = %w[nearest cubic mitchell lanczos2 lanczos3].freeze
67
+ RESIZING_TYPES = %w[cover contain fill inside outside].freeze
68
+ RESIZING_TYPE_ALIASES = {
69
+ 'auto' => 'inside',
70
+ 'fill-down' => 'inside',
71
+ 'fit' => 'inside',
72
+ 'force' => 'fill'
73
+ }.freeze
74
+ GRAVITY_VALUES = %w[
75
+ ce center north south east west northeast northwest southeast southwest attention entropy
76
+ ].freeze
77
+ GRAVITY_ALIASES = {
78
+ 'n' => 'north',
79
+ 'no' => 'north',
80
+ 's' => 'south',
81
+ 'so' => 'south',
82
+ 'e' => 'east',
83
+ 'ea' => 'east',
84
+ 'w' => 'west',
85
+ 'we' => 'west',
86
+ 'ne' => 'northeast',
87
+ 'noea' => 'northeast',
88
+ 'se' => 'southeast',
89
+ 'soea' => 'southeast',
90
+ 'nw' => 'northwest',
91
+ 'nowe' => 'northwest',
92
+ 'sw' => 'southwest',
93
+ 'sowe' => 'southwest',
94
+ 'ce:sm' => 'attention'
95
+ }.freeze
96
+
97
+ module_function
98
+
99
+ def build_query(options)
100
+ normalized_options = normalize_top_level_keys(options)
101
+
102
+ RULES.each_with_object([]) do |(canonical, aliases), present|
103
+ matches = aliases.filter_map do |name|
104
+ key = name.to_sym
105
+ [key, normalized_options[key]] if normalized_options.key?(key)
106
+ end
107
+
108
+ next if matches.empty?
109
+ raise ArgumentError, "Duplicate transformation rule: #{canonical}" if matches.length > 1
110
+
111
+ value = normalize_rule(canonical, matches.first.last)
112
+ present << [canonical, value] unless value.nil?
113
+ end
114
+ end
115
+
116
+ def normalize_rule(canonical, value)
117
+ case canonical
118
+ when 'adjust'
119
+ normalize_adjust(value)
120
+ when 'background', 'colorize'
121
+ normalize_color(value)
122
+ when 'background_alpha'
123
+ normalize_number(value, min: 0, max: 1)
124
+ when 'blur'
125
+ normalize_true_or_number(value, min: 0.3, max: 100)
126
+ when 'brightness', 'lightness', 'saturation'
127
+ normalize_number(value, min: 0.01, max: 10)
128
+ when 'chroma_subsampling'
129
+ normalize_enum(value, CHROMA_SUBSAMPLING_VALUES)
130
+ when 'color_profile'
131
+ normalize_enum(value, COLOR_PROFILES)
132
+ when 'contrast'
133
+ normalize_contrast(value)
134
+ when 'crop'
135
+ normalize_crop(value)
136
+ when 'dpi'
137
+ normalize_integer(value, min: 1, max: 600)
138
+ when 'dpr'
139
+ normalize_number(value, min: 0.01, max: 8)
140
+ when 'duotone'
141
+ normalize_duotone(value)
142
+ when 'enlarge', 'keep_copyright', 'strip_color_profile', 'strip_metadata'
143
+ normalize_boolean_flag(value)
144
+ when 'extend'
145
+ normalize_extend(value)
146
+ when 'extend_aspect_ratio'
147
+ normalize_extend_aspect_ratio(value)
148
+ when 'flip'
149
+ normalize_flip(value)
150
+ when 'format'
151
+ normalize_enum(value, FORMATS)
152
+ when 'gradient'
153
+ normalize_gradient(value)
154
+ when 'gravity'
155
+ normalize_gravity(value)
156
+ when 'height', 'min-height', 'min-width', 'width'
157
+ normalize_integer(value, min: 1, max: 8192)
158
+ when 'hue'
159
+ normalize_number(value)
160
+ when 'monochrome'
161
+ normalize_monochrome(value)
162
+ when 'negate'
163
+ normalize_negate(value)
164
+ when 'normalize'
165
+ normalize_normalize(value)
166
+ when 'padding'
167
+ normalize_padding(value)
168
+ when 'pixelate'
169
+ normalize_integer(value, min: 2, max: 256)
170
+ when 'progressive'
171
+ normalize_boolean_or_auto(value)
172
+ when 'quality'
173
+ normalize_quality(value)
174
+ when 'resizing_algorithm'
175
+ normalize_enum(value, RESIZING_ALGORITHMS)
176
+ when 'resizing_type'
177
+ normalize_enum(value, RESIZING_TYPES, aliases: RESIZING_TYPE_ALIASES)
178
+ when 'rotate', 'watermark_rotate'
179
+ normalize_rotate(value)
180
+ when 'sharpen'
181
+ normalize_sharpen(value)
182
+ when 'watermark'
183
+ normalize_watermark(value)
184
+ when 'watermark_position'
185
+ normalize_watermark_position(value)
186
+ when 'watermark_shadow'
187
+ normalize_watermark_shadow(value)
188
+ when 'watermark_size'
189
+ normalize_watermark_size(value)
190
+ when 'watermark_text'
191
+ normalize_text_or_json_object(value)
192
+ when 'watermark_url'
193
+ normalize_watermark_url(value)
194
+ when 'zoom'
195
+ normalize_zoom(value)
196
+ end
197
+ rescue ArgumentError, TypeError
198
+ nil
199
+ end
200
+
201
+ def normalize_adjust(value)
202
+ return normalize_json_string(value) if json_object_string?(value)
203
+
204
+ hash = value_hash(value)
205
+ if hash
206
+ brightness = normalize_number(field(hash, 'brightness'))
207
+ saturation = normalize_number(field(hash, 'saturation'))
208
+ color = normalize_number(field(hash, 'color'))
209
+
210
+ return contiguous_segments([brightness], [saturation, color])
211
+ end
212
+
213
+ segments = split_segments(value, min: 1, max: 3)
214
+ return unless segments
215
+
216
+ brightness = normalize_number(segments[0])
217
+ saturation = normalize_number(segments[1]) if segments.length > 1
218
+ color = normalize_number(segments[2]) if segments.length > 2
219
+
220
+ contiguous_segments([brightness], [saturation, color].first(segments.length - 1))
221
+ end
222
+
223
+ def normalize_contrast(value)
224
+ return normalize_json_string(value) if json_object_string?(value)
225
+
226
+ hash = value_hash(value)
227
+ if hash
228
+ multiplier = normalize_number(field(hash, 'multiplier'))
229
+ pivot = normalize_number(field(hash, 'pivot'))
230
+
231
+ return contiguous_segments([multiplier], [pivot])
232
+ end
233
+
234
+ segments = split_segments(value, min: 1, max: 2)
235
+ return unless segments
236
+
237
+ multiplier = normalize_number(segments[0])
238
+ pivot = normalize_number(segments[1]) if segments.length > 1
239
+
240
+ contiguous_segments([multiplier], [pivot].first(segments.length - 1))
241
+ end
242
+
243
+ def normalize_crop(value)
244
+ return normalize_json_string(value) if json_object_string?(value)
245
+
246
+ hash = value_hash(value)
247
+ return normalize_crop_hash(hash) if hash
248
+
249
+ segments = split_segments(value, min: 2, max: 5)
250
+ return unless [2, 3, 4, 5].include?(segments&.length)
251
+
252
+ if segments.length <= 3
253
+ width = normalize_integer(segments[0], min: 1)
254
+ height = normalize_integer(segments[1], min: 1)
255
+ gravity = normalize_gravity(segments[2]) if segments.length == 3
256
+
257
+ return contiguous_segments([width, height], [gravity].first(segments.length - 2))
258
+ end
259
+
260
+ x = normalize_integer(segments[0])
261
+ y = normalize_integer(segments[1])
262
+ width = normalize_integer(segments[2], min: 1)
263
+ height = normalize_integer(segments[3], min: 1)
264
+ gravity = normalize_gravity(segments[4]) if segments.length == 5
265
+
266
+ contiguous_segments([x, y, width, height], [gravity].first(segments.length - 4))
267
+ end
268
+
269
+ def normalize_crop_hash(hash)
270
+ x = normalize_integer(field(hash, 'x'))
271
+ y = normalize_integer(field(hash, 'y'))
272
+ width = normalize_integer(field(hash, 'width', 'w'), min: 1)
273
+ height = normalize_integer(field(hash, 'height', 'h'), min: 1)
274
+ gravity = normalize_gravity(field(hash, 'gravity', 'g'))
275
+
276
+ if field?(hash, 'x') || field?(hash, 'y')
277
+ contiguous_segments([x, y, width, height], [gravity])
278
+ else
279
+ contiguous_segments([width, height], [gravity])
280
+ end
281
+ end
282
+
283
+ def normalize_duotone(value)
284
+ return normalize_json_string(value) if json_object_string?(value)
285
+
286
+ hash = value_hash(value)
287
+ if hash
288
+ shadow = normalize_color(field(hash, 'shadowColor', 'shadow_color', 'shadow'))
289
+ highlight = normalize_color(field(hash, 'highlightColor', 'highlight_color', 'highlight'))
290
+
291
+ return contiguous_segments([shadow, highlight], [])
292
+ end
293
+
294
+ segments = split_segments(value, min: 2, max: 2)
295
+ return unless segments
296
+
297
+ shadow = normalize_color(segments[0])
298
+ highlight = normalize_color(segments[1])
299
+
300
+ contiguous_segments([shadow, highlight], [])
301
+ end
302
+
303
+ def normalize_extend(value)
304
+ return normalize_json_string(value) if json_object_string?(value)
305
+
306
+ hash = value_hash(value)
307
+ return normalize_extend_hash(hash) if hash
308
+
309
+ segments = split_segments(value, min: 1, max: 5)
310
+ return unless segments
311
+
312
+ top = normalize_integer(segments[0])
313
+ right = normalize_integer(segments[1]) if segments.length > 1
314
+ bottom = normalize_integer(segments[2]) if segments.length > 2
315
+ left = normalize_integer(segments[3]) if segments.length > 3
316
+ background = normalize_color(segments[4]) if segments.length > 4
317
+
318
+ contiguous_segments([top], [right, bottom, left, background].first(segments.length - 1))
319
+ end
320
+
321
+ def normalize_extend_hash(hash)
322
+ top = normalize_integer(field(hash, 'top'))
323
+ right = normalize_integer(field(hash, 'right'))
324
+ bottom = normalize_integer(field(hash, 'bottom'))
325
+ left = normalize_integer(field(hash, 'left'))
326
+ background = normalize_color(field(hash, 'background', 'bg'))
327
+
328
+ compact_json = compact_json_object(
329
+ hash,
330
+ 'top' => top,
331
+ 'right' => right,
332
+ 'bottom' => bottom,
333
+ 'left' => left,
334
+ 'background' => background
335
+ )
336
+ return unless compact_json
337
+
338
+ contiguous_segments([top], [right, bottom, left, background]) || compact_json
339
+ end
340
+
341
+ def normalize_extend_aspect_ratio(value)
342
+ return normalize_json_string(value) if json_object_string?(value)
343
+
344
+ hash = value_hash(value)
345
+ if hash
346
+ ratio = normalize_number(field(hash, 'ratio'), min: 0.000001)
347
+ return ratio if ratio
348
+
349
+ width = normalize_integer(field(hash, 'width', 'w'), min: 1)
350
+ height = normalize_integer(field(hash, 'height', 'h'), min: 1)
351
+
352
+ return contiguous_segments([width, height], [])
353
+ end
354
+
355
+ segments = split_segments(value, min: 1, max: 2)
356
+ return unless segments
357
+
358
+ return normalize_number(segments[0], min: 0.000001) if segments.length == 1
359
+
360
+ width = normalize_integer(segments[0], min: 1)
361
+ height = normalize_integer(segments[1], min: 1)
362
+
363
+ contiguous_segments([width, height], [])
364
+ end
365
+
366
+ def normalize_flip(value)
367
+ hash = value_hash(value)
368
+ if hash
369
+ horizontal = parse_boolean(field(hash, 'horizontal'))
370
+ vertical = parse_boolean(field(hash, 'vertical'))
371
+ return if horizontal.nil? && vertical.nil?
372
+
373
+ return 'both' if horizontal && vertical
374
+ return 'horizontal' if horizontal
375
+ return 'vertical' if vertical
376
+
377
+ return
378
+ end
379
+
380
+ string = normalize_string(value)
381
+ return string if FLIP_VALUES.include?(string)
382
+
383
+ segments = split_segments(value, min: 2, max: 2)
384
+ return unless segments
385
+
386
+ horizontal = parse_boolean(segments[0])
387
+ vertical = parse_boolean(segments[1])
388
+ return if horizontal.nil? || vertical.nil?
389
+
390
+ "#{horizontal}:#{vertical}"
391
+ end
392
+
393
+ def normalize_gradient(value)
394
+ return normalize_json_string(value) if json_object_string?(value)
395
+
396
+ hash = value_hash(value)
397
+ return normalize_gradient_hash(hash) if hash
398
+
399
+ segments = split_segments(value, min: 1, max: 4)
400
+ return unless segments
401
+
402
+ colors = normalize_color_list(segments[0])
403
+ angle = normalize_number(segments[1]) if segments.length > 1
404
+ opacity = normalize_number(segments[2], min: 0, max: 1) if segments.length > 2
405
+ blend = normalize_non_empty_string(segments[3]) if segments.length > 3
406
+
407
+ contiguous_segments([colors], [angle, opacity, blend].first(segments.length - 1))
408
+ end
409
+
410
+ def normalize_gradient_hash(hash)
411
+ colors = normalize_color_list(field(hash, 'colors'))
412
+ angle = normalize_number(field(hash, 'angle'))
413
+ opacity = normalize_number(field(hash, 'opacity'), min: 0, max: 1)
414
+ blend = normalize_non_empty_string(field(hash, 'blend'))
415
+
416
+ contiguous_segments([colors], [angle, opacity, blend])
417
+ end
418
+
419
+ def normalize_monochrome(value)
420
+ boolean = parse_boolean(value)
421
+ return 'true' if boolean == true
422
+ return if boolean == false
423
+
424
+ normalize_color(value)
425
+ end
426
+
427
+ def normalize_negate(value)
428
+ hash = value_hash(value)
429
+ if hash
430
+ alpha = parse_boolean(field(hash, 'alpha'))
431
+ return unless [true, false].include?(alpha)
432
+
433
+ return "alpha:#{alpha}"
434
+ end
435
+
436
+ boolean = parse_boolean(value)
437
+ return 'true' if boolean == true
438
+ return if boolean == false
439
+
440
+ segments = split_segments(value, min: 2, max: 2)
441
+ return unless segments&.first == 'alpha'
442
+
443
+ alpha = parse_boolean(segments[1])
444
+ return unless [true, false].include?(alpha)
445
+
446
+ "alpha:#{alpha}"
447
+ end
448
+
449
+ def normalize_normalize(value)
450
+ hash = value_hash(value)
451
+ if hash
452
+ lower = normalize_number(field(hash, 'lower'))
453
+ upper = normalize_number(field(hash, 'upper'))
454
+
455
+ return contiguous_segments([lower, upper], [])
456
+ end
457
+
458
+ boolean = parse_boolean(value)
459
+ return 'true' if boolean == true
460
+ return if boolean == false
461
+
462
+ segments = split_segments(value, min: 2, max: 2)
463
+ return unless segments
464
+
465
+ lower = normalize_number(segments[0])
466
+ upper = normalize_number(segments[1])
467
+
468
+ contiguous_segments([lower, upper], [])
469
+ end
470
+
471
+ def normalize_padding(value)
472
+ return normalize_json_string(value) if json_object_string?(value)
473
+
474
+ hash = value_hash(value)
475
+ return normalize_padding_hash(hash) if hash
476
+
477
+ segments = split_segments(value, min: 1, max: 4)
478
+ return unless segments
479
+
480
+ normalized = segments.map { |segment| normalize_integer(segment) }
481
+ return if normalized.any?(&:nil?)
482
+
483
+ normalized.join(':')
484
+ end
485
+
486
+ def normalize_padding_hash(hash)
487
+ all = normalize_integer(field(hash, 'all'))
488
+ return all if all
489
+
490
+ x = normalize_integer(field(hash, 'x'))
491
+ y = normalize_integer(field(hash, 'y'))
492
+ return contiguous_segments([x, y], []) if x || y
493
+
494
+ top = normalize_integer(field(hash, 'top'))
495
+ right = normalize_integer(field(hash, 'right'))
496
+ bottom = normalize_integer(field(hash, 'bottom'))
497
+ left = normalize_integer(field(hash, 'left'))
498
+
499
+ compact_json = compact_json_object(
500
+ hash,
501
+ 'top' => top,
502
+ 'right' => right,
503
+ 'bottom' => bottom,
504
+ 'left' => left
505
+ )
506
+ return unless compact_json
507
+
508
+ contiguous_segments([top, right, bottom, left], []) || compact_json
509
+ end
510
+
511
+ def normalize_quality(value)
512
+ return 'auto' if normalize_string(value) == 'auto'
513
+
514
+ normalize_integer(value, min: 1, max: 100)
515
+ end
516
+
517
+ def normalize_rotate(value)
518
+ return normalize_json_string(value) if json_object_string?(value)
519
+
520
+ hash = value_hash(value)
521
+ if hash
522
+ angle = normalize_number(field(hash, 'angle'))
523
+ background = normalize_color(field(hash, 'background', 'bg'))
524
+
525
+ return contiguous_segments([angle], [background])
526
+ end
527
+
528
+ segments = split_segments(value, min: 1, max: 2)
529
+ return unless segments
530
+
531
+ angle = normalize_number(segments[0])
532
+ background = normalize_color(segments[1]) if segments.length > 1
533
+
534
+ contiguous_segments([angle], [background].first(segments.length - 1))
535
+ end
536
+
537
+ def normalize_sharpen(value)
538
+ return normalize_json_string(value) if json_object_string?(value)
539
+
540
+ hash = value_hash(value)
541
+ return json_object(hash) if hash
542
+
543
+ normalize_true_or_number(value)
544
+ end
545
+
546
+ def normalize_watermark(value)
547
+ return normalize_json_string(value) if json_object_string?(value)
548
+
549
+ hash = value_hash(value)
550
+ return normalize_watermark_hash(hash) if hash
551
+
552
+ segments = split_segments(value, min: 1, max: 4)
553
+ return unless segments
554
+
555
+ image_id = normalize_non_empty_string(segments[0])
556
+ gravity = normalize_gravity(segments[1]) if segments.length > 1
557
+ x = normalize_integer(segments[2]) if segments.length > 2
558
+ y = normalize_integer(segments[3]) if segments.length > 3
559
+
560
+ contiguous_segments([image_id], [gravity, x, y].first(segments.length - 1))
561
+ end
562
+
563
+ def normalize_watermark_hash(hash)
564
+ image_id = normalize_non_empty_string(field(hash, 'image_id', 'imageId', 'id'))
565
+ gravity = normalize_gravity(field(hash, 'gravity', 'g'))
566
+ x = normalize_integer(field(hash, 'x'))
567
+ y = normalize_integer(field(hash, 'y'))
568
+
569
+ contiguous_segments([image_id], [gravity, x, y])
570
+ end
571
+
572
+ def normalize_watermark_position(value)
573
+ return normalize_json_string(value) if json_object_string?(value)
574
+
575
+ hash = value_hash(value)
576
+ return normalize_watermark_position_hash(hash) if hash
577
+
578
+ segments = split_segments(value, min: 1, max: 5)
579
+ return unless segments
580
+
581
+ gravity = normalize_gravity(segments[0])
582
+ x = normalize_integer(segments[1]) if segments.length > 1
583
+ y = normalize_integer(segments[2]) if segments.length > 2
584
+ opacity = normalize_number(segments[3], min: 0, max: 1) if segments.length > 3
585
+ blend = normalize_non_empty_string(segments[4]) if segments.length > 4
586
+
587
+ contiguous_segments([gravity], [x, y, opacity, blend].first(segments.length - 1))
588
+ end
589
+
590
+ def normalize_watermark_position_hash(hash)
591
+ gravity = normalize_gravity(field(hash, 'gravity', 'g'))
592
+ x = normalize_integer(field(hash, 'x'))
593
+ y = normalize_integer(field(hash, 'y'))
594
+ opacity = normalize_number(field(hash, 'opacity'), min: 0, max: 1)
595
+ blend = normalize_non_empty_string(field(hash, 'blend'))
596
+
597
+ contiguous_segments([gravity], [x, y, opacity, blend])
598
+ end
599
+
600
+ def normalize_watermark_shadow(value)
601
+ return normalize_json_string(value) if json_object_string?(value)
602
+
603
+ boolean = parse_boolean(value)
604
+ return 'true' if boolean == true
605
+ return if boolean == false
606
+
607
+ hash = value_hash(value)
608
+ return normalize_watermark_shadow_hash(hash) if hash
609
+
610
+ segments = split_segments(value, min: 1, max: 4)
611
+ return unless segments
612
+
613
+ color = normalize_color(segments[0])
614
+ blur = normalize_number(segments[1]) if segments.length > 1
615
+ x = normalize_integer(segments[2]) if segments.length > 2
616
+ y = normalize_integer(segments[3]) if segments.length > 3
617
+
618
+ contiguous_segments([color], [blur, x, y].first(segments.length - 1))
619
+ end
620
+
621
+ def normalize_watermark_shadow_hash(hash)
622
+ color = normalize_color(field(hash, 'color'))
623
+ blur = normalize_number(field(hash, 'blur'))
624
+ x = normalize_integer(field(hash, 'x'))
625
+ y = normalize_integer(field(hash, 'y'))
626
+
627
+ contiguous_segments([color], [blur, x, y])
628
+ end
629
+
630
+ def normalize_watermark_size(value)
631
+ return normalize_json_string(value) if json_object_string?(value)
632
+
633
+ hash = value_hash(value)
634
+ return normalize_watermark_size_hash(hash) if hash
635
+
636
+ segments = split_segments(value, min: 1, max: 3)
637
+ return unless segments
638
+
639
+ width = normalize_integer(segments[0], min: 1)
640
+ height = normalize_integer(segments[1], min: 1) if segments.length > 1
641
+ scale = normalize_number(segments[2], min: 0.000001) if segments.length > 2
642
+
643
+ contiguous_segments([width], [height, scale].first(segments.length - 1))
644
+ end
645
+
646
+ def normalize_watermark_size_hash(hash)
647
+ width = normalize_integer(field(hash, 'width', 'w'), min: 1)
648
+ height = normalize_integer(field(hash, 'height', 'h'), min: 1)
649
+ scale = normalize_number(field(hash, 'scale'), min: 0.000001)
650
+
651
+ contiguous_segments([width], [height, scale])
652
+ end
653
+
654
+ def normalize_text_or_json_object(value)
655
+ return normalize_json_string(value) if json_object_string?(value)
656
+
657
+ hash = value_hash(value)
658
+ return json_object(hash) if hash
659
+
660
+ normalize_non_empty_string(value)
661
+ end
662
+
663
+ def normalize_watermark_url(value)
664
+ string = normalize_non_empty_string(value)
665
+ return unless string
666
+
667
+ return Base64.strict_encode64(string) if valid_https_url?(string)
668
+
669
+ string if valid_base64_https_url?(string)
670
+ end
671
+
672
+ def normalize_zoom(value)
673
+ hash = value_hash(value)
674
+ if hash
675
+ factor = normalize_number(field(hash, 'factor'), min: 0.000001)
676
+ gravity = normalize_gravity(field(hash, 'gravity', 'g'))
677
+
678
+ return contiguous_segments([factor], [gravity])
679
+ end
680
+
681
+ segments = split_segments(value, min: 1, max: 2)
682
+ return unless segments
683
+
684
+ factor = normalize_number(segments[0], min: 0.000001)
685
+ gravity = normalize_gravity(segments[1]) if segments.length > 1
686
+
687
+ contiguous_segments([factor], [gravity].first(segments.length - 1))
688
+ end
689
+
690
+ def normalize_top_level_keys(hash)
691
+ hash.to_h.transform_keys(&:to_sym)
692
+ end
693
+
694
+ def value_hash(value)
695
+ return unless value.respond_to?(:to_hash)
696
+
697
+ value.to_hash.transform_keys(&:to_s)
698
+ end
699
+
700
+ def field(hash, *names)
701
+ names.each do |name|
702
+ return hash[name] if hash.key?(name)
703
+ end
704
+
705
+ nil
706
+ end
707
+
708
+ def field?(hash, *names)
709
+ names.any? { |name| hash.key?(name) }
710
+ end
711
+
712
+ def split_segments(value, min:, max:)
713
+ string = normalize_non_empty_string(value)
714
+ return unless string
715
+
716
+ segments = string.split(':', -1)
717
+ return unless segments.length.between?(min, max)
718
+ return if segments.any?(&:empty?)
719
+
720
+ segments
721
+ end
722
+
723
+ def contiguous_segments(required, optional)
724
+ return if required.any?(&:nil?)
725
+
726
+ last_present = optional.rindex { |segment| !segment.nil? }
727
+ selected_optional = last_present.nil? ? [] : optional[0..last_present]
728
+ return if selected_optional.any?(&:nil?)
729
+
730
+ (required + selected_optional).join(':')
731
+ end
732
+
733
+ def normalize_boolean_flag(value)
734
+ parse_boolean(value) == true ? 'true' : nil
735
+ end
736
+
737
+ def normalize_boolean_or_auto(value)
738
+ return 'auto' if normalize_string(value) == 'auto'
739
+
740
+ boolean = parse_boolean(value)
741
+ return 'true' if boolean == true
742
+
743
+ 'false' if boolean == false
744
+ end
745
+
746
+ def parse_boolean(value)
747
+ return true if value == true
748
+ return false if value == false
749
+ return true if value == 1
750
+ return false if value.is_a?(Numeric) && value.zero?
751
+
752
+ case normalize_string(value)
753
+ when 'true', '1'
754
+ true
755
+ when 'false', '0'
756
+ false
757
+ end
758
+ end
759
+
760
+ def normalize_true_or_number(value, min: nil, max: nil)
761
+ boolean = parse_boolean(value)
762
+ return 'true' if boolean == true
763
+ return if boolean == false
764
+
765
+ normalize_number(value, min: min, max: max)
766
+ end
767
+
768
+ def normalize_integer(value, min: nil, max: nil)
769
+ integer = parse_integer(value)
770
+ return if integer.nil?
771
+ return if min && integer < min
772
+ return if max && integer > max
773
+
774
+ integer.to_s
775
+ end
776
+
777
+ def parse_integer(value)
778
+ return value if value.is_a?(Integer)
779
+
780
+ if value.is_a?(Float)
781
+ return unless value.finite? && value == value.to_i
782
+
783
+ return value.to_i
784
+ end
785
+
786
+ string = normalize_non_empty_string(value)
787
+ return unless string&.match?(/\A[+-]?\d+\z/)
788
+
789
+ Integer(string)
790
+ end
791
+
792
+ def normalize_number(value, min: nil, max: nil)
793
+ number = parse_number(value)
794
+ return if number.nil?
795
+ return if min && number < min
796
+ return if max && number > max
797
+
798
+ format_number(number)
799
+ end
800
+
801
+ def parse_number(value)
802
+ return value.to_f if value.is_a?(Numeric) && value.to_f.finite?
803
+
804
+ string = normalize_non_empty_string(value)
805
+ return unless string
806
+
807
+ number = Float(string)
808
+ return unless number.finite?
809
+
810
+ number
811
+ rescue ArgumentError, TypeError
812
+ nil
813
+ end
814
+
815
+ def format_number(number)
816
+ return number.to_i.to_s if number == number.to_i
817
+
818
+ number.to_s
819
+ end
820
+
821
+ def normalize_enum(value, values, aliases: {})
822
+ string = normalize_string(value)
823
+ return aliases[string] if aliases.key?(string)
824
+
825
+ string if values.include?(string)
826
+ end
827
+
828
+ def normalize_gravity(value)
829
+ string = normalize_string(value)
830
+ return GRAVITY_ALIASES[string] if GRAVITY_ALIASES.key?(string)
831
+
832
+ string if GRAVITY_VALUES.include?(string)
833
+ end
834
+
835
+ def normalize_color(value)
836
+ string = normalize_non_empty_string(value)
837
+ return unless string
838
+
839
+ string = string.delete_prefix('#')
840
+ return string.downcase if string.match?(/\A(?:[[:xdigit:]]{3}|[[:xdigit:]]{6}|[[:xdigit:]]{8})\z/)
841
+
842
+ string if rgb_color?(string)
843
+ end
844
+
845
+ def rgb_color?(string)
846
+ segments = string.split(':', -1)
847
+ return false unless [3, 4].include?(segments.length)
848
+
849
+ red, green, blue = segments.first(3).map { |segment| parse_integer(segment) }
850
+ return false unless [red, green, blue].all? { |part| part&.between?(0, 255) }
851
+ return true if segments.length == 3
852
+
853
+ alpha = parse_number(segments[3])
854
+ alpha&.between?(0, 1)
855
+ end
856
+
857
+ def normalize_color_list(value)
858
+ colors = value.is_a?(Array) ? value : normalize_non_empty_string(value)&.split(',', -1)
859
+ return unless colors&.length&.>= 2
860
+
861
+ normalized = colors.map { |color| normalize_color(color) }
862
+ return if normalized.any?(&:nil?)
863
+
864
+ normalized.join(',')
865
+ end
866
+
867
+ def normalize_non_empty_string(value)
868
+ string = value.to_s.strip
869
+ return if string.empty?
870
+
871
+ string
872
+ end
873
+
874
+ def normalize_string(value)
875
+ normalize_non_empty_string(value)&.downcase
876
+ end
877
+
878
+ def json_object_string?(value)
879
+ string = normalize_non_empty_string(value)
880
+ return false unless string&.start_with?('{')
881
+
882
+ JSON.parse(string).is_a?(Hash)
883
+ rescue JSON::ParserError
884
+ false
885
+ end
886
+
887
+ def normalize_json_string(value)
888
+ normalize_non_empty_string(value)
889
+ end
890
+
891
+ def compact_json_object(input_hash, fields)
892
+ return unless fields.values.any?
893
+ return if fields.any? { |key, item| field?(input_hash, key) && item.nil? }
894
+
895
+ JSON.generate(fields.compact)
896
+ end
897
+
898
+ def json_object(hash)
899
+ return if hash.empty?
900
+
901
+ JSON.generate(camelize_hash_keys(hash))
902
+ end
903
+
904
+ def camelize_hash_keys(hash)
905
+ hash.each_with_object({}) do |(key, value), result|
906
+ result[camelize_key(key)] = value.is_a?(Hash) ? camelize_hash_keys(value) : value
907
+ end
908
+ end
909
+
910
+ def camelize_key(key)
911
+ key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
912
+ end
913
+
914
+ def valid_https_url?(value)
915
+ uri = URI.parse(value)
916
+ uri.is_a?(URI::HTTPS) && uri.host && !uri.host.empty?
917
+ rescue URI::InvalidURIError
918
+ false
919
+ end
920
+
921
+ def valid_base64_https_url?(value)
922
+ decoded = Base64.strict_decode64(value)
923
+ valid_https_url?(decoded)
924
+ rescue ArgumentError
925
+ false
926
+ end
927
+ end
928
+ # rubocop:enable Metrics/ModuleLength
929
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imgwire
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: imgwire
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Blackhawk Software, LLC
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-18 00:00:00.000000000 Z
11
+ date: 2026-04-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: typhoeus
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: base64
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.2'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rubocop
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -130,6 +144,7 @@ files:
130
144
  - lib/imgwire/resources/images_resource.rb
131
145
  - lib/imgwire/resources/metrics_resource.rb
132
146
  - lib/imgwire/uploads.rb
147
+ - lib/imgwire/url_transformations.rb
133
148
  - lib/imgwire/version.rb
134
149
  homepage: https://github.com/Blackhawk-Software/imgwire-ruby
135
150
  licenses: