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