imgwire 0.1.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: 465414c80dd7d88016d6e156b188ed81c5c074836000087e4e47d11baed5a8ae
4
- data.tar.gz: 8de6fcd2da02b947376eb73d9021eeb35bb62109133acecee8ad8f361c992ab8
3
+ metadata.gz: 51b2aefb2be738f37e07af70399bd781d56bdeacc886b4fdd86335ac2c52539f
4
+ data.tar.gz: e60ce39c3b7825ab9cbcb632073839c5343cd667b3dbd6c5f3f6c6b25b522b44
5
5
  SHA512:
6
- metadata.gz: 6ee90396ef5802259a36e17740a2c5b7e7e103b029b3f7eae3bb5966d613dc1a546a00ba2cfe1c0ea564bcab0e43938d7643be9387727a6d2b296d194a26a209
7
- data.tar.gz: 169a27d84d24340b998692974e8d4ec9f1c8248def2c14d0bcee3475cebc7e321c4cff6f8583090dd2eaabf90dd7160e6ea0c2c16024b586eecf0bad0316e9bd
6
+ metadata.gz: 51e0d57435f845cf12f0b3b677e75ab32fae572d1c2ee5b347c35cee54f964bdae11d9a974dd1f210fa3b09cedc9057332afb77e2ee01cef62a0a60c6d11547d
7
+ data.tar.gz: 18bc491397f7fd0f0433abd0e9d645327ebd5437fe8365ac80eaf699f9c741a6403dd28a11b1731c6b408889eb6a157f7da20d83f3810c8dce10c379530fc720
data/CODEGEN_VERSION CHANGED
@@ -1 +1 @@
1
- 213554905f77b6ffb7a29c020c632196bf0d7567b10d10f40a0a59839008d248
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:
@@ -15,6 +15,8 @@ require 'time'
15
15
 
16
16
  module ImgwireGenerated
17
17
  class ImageSchema
18
+ attr_accessor :can_upload
19
+
18
20
  attr_accessor :cdn_url
19
21
 
20
22
  attr_accessor :created_at
@@ -37,6 +39,8 @@ module ImgwireGenerated
37
39
 
38
40
  attr_accessor :idempotency_key
39
41
 
42
+ attr_accessor :is_directly_deliverable
43
+
40
44
  attr_accessor :mime_type
41
45
 
42
46
  attr_accessor :original_filename
@@ -80,6 +84,7 @@ module ImgwireGenerated
80
84
  # Attribute mapping from ruby-style variable name to JSON key.
81
85
  def self.attribute_map
82
86
  {
87
+ :'can_upload' => :'can_upload',
83
88
  :'cdn_url' => :'cdn_url',
84
89
  :'created_at' => :'created_at',
85
90
  :'custom_metadata' => :'custom_metadata',
@@ -91,6 +96,7 @@ module ImgwireGenerated
91
96
  :'height' => :'height',
92
97
  :'id' => :'id',
93
98
  :'idempotency_key' => :'idempotency_key',
99
+ :'is_directly_deliverable' => :'is_directly_deliverable',
94
100
  :'mime_type' => :'mime_type',
95
101
  :'original_filename' => :'original_filename',
96
102
  :'processed_metadata_at' => :'processed_metadata_at',
@@ -116,6 +122,7 @@ module ImgwireGenerated
116
122
  # Attribute type mapping.
117
123
  def self.openapi_types
118
124
  {
125
+ :'can_upload' => :'Boolean',
119
126
  :'cdn_url' => :'String',
120
127
  :'created_at' => :'Time',
121
128
  :'custom_metadata' => :'Hash<String, CustomMetadataValue>',
@@ -127,6 +134,7 @@ module ImgwireGenerated
127
134
  :'height' => :'Integer',
128
135
  :'id' => :'String',
129
136
  :'idempotency_key' => :'String',
137
+ :'is_directly_deliverable' => :'Boolean',
130
138
  :'mime_type' => :'SupportedMimeType',
131
139
  :'original_filename' => :'String',
132
140
  :'processed_metadata_at' => :'Time',
@@ -168,6 +176,12 @@ module ImgwireGenerated
168
176
  h[k.to_sym] = v
169
177
  }
170
178
 
179
+ if attributes.key?(:'can_upload')
180
+ self.can_upload = attributes[:'can_upload']
181
+ else
182
+ self.can_upload = nil
183
+ end
184
+
171
185
  if attributes.key?(:'cdn_url')
172
186
  self.cdn_url = attributes[:'cdn_url']
173
187
  else
@@ -238,6 +252,12 @@ module ImgwireGenerated
238
252
  self.idempotency_key = nil
239
253
  end
240
254
 
255
+ if attributes.key?(:'is_directly_deliverable')
256
+ self.is_directly_deliverable = attributes[:'is_directly_deliverable']
257
+ else
258
+ self.is_directly_deliverable = nil
259
+ end
260
+
241
261
  if attributes.key?(:'mime_type')
242
262
  self.mime_type = attributes[:'mime_type']
243
263
  else
@@ -298,6 +318,10 @@ module ImgwireGenerated
298
318
  def list_invalid_properties
299
319
  warn '[DEPRECATED] the `list_invalid_properties` method is obsolete'
300
320
  invalid_properties = Array.new
321
+ if @can_upload.nil?
322
+ invalid_properties.push('invalid value for "can_upload", can_upload cannot be nil.')
323
+ end
324
+
301
325
  if @cdn_url.nil?
302
326
  invalid_properties.push('invalid value for "cdn_url", cdn_url cannot be nil.')
303
327
  end
@@ -326,6 +350,10 @@ module ImgwireGenerated
326
350
  invalid_properties.push('invalid value for "id", id cannot be nil.')
327
351
  end
328
352
 
353
+ if @is_directly_deliverable.nil?
354
+ invalid_properties.push('invalid value for "is_directly_deliverable", is_directly_deliverable cannot be nil.')
355
+ end
356
+
329
357
  if @mime_type.nil?
330
358
  invalid_properties.push('invalid value for "mime_type", mime_type cannot be nil.')
331
359
  end
@@ -357,6 +385,7 @@ module ImgwireGenerated
357
385
  # @return true if the model is valid
358
386
  def valid?
359
387
  warn '[DEPRECATED] the `valid?` method is obsolete'
388
+ return false if @can_upload.nil?
360
389
  return false if @cdn_url.nil?
361
390
  return false if @created_at.nil?
362
391
  return false if @custom_metadata.nil?
@@ -364,6 +393,7 @@ module ImgwireGenerated
364
393
  return false if @extension.nil?
365
394
  return false if @height.nil?
366
395
  return false if @id.nil?
396
+ return false if @is_directly_deliverable.nil?
367
397
  return false if @mime_type.nil?
368
398
  return false if @original_filename.nil?
369
399
  return false if @size_bytes.nil?
@@ -373,6 +403,16 @@ module ImgwireGenerated
373
403
  true
374
404
  end
375
405
 
406
+ # Custom attribute writer method with validation
407
+ # @param [Object] can_upload Value to be assigned
408
+ def can_upload=(can_upload)
409
+ if can_upload.nil?
410
+ fail ArgumentError, 'can_upload cannot be nil'
411
+ end
412
+
413
+ @can_upload = can_upload
414
+ end
415
+
376
416
  # Custom attribute writer method with validation
377
417
  # @param [Object] cdn_url Value to be assigned
378
418
  def cdn_url=(cdn_url)
@@ -443,6 +483,16 @@ module ImgwireGenerated
443
483
  @id = id
444
484
  end
445
485
 
486
+ # Custom attribute writer method with validation
487
+ # @param [Object] is_directly_deliverable Value to be assigned
488
+ def is_directly_deliverable=(is_directly_deliverable)
489
+ if is_directly_deliverable.nil?
490
+ fail ArgumentError, 'is_directly_deliverable cannot be nil'
491
+ end
492
+
493
+ @is_directly_deliverable = is_directly_deliverable
494
+ end
495
+
446
496
  # Custom attribute writer method with validation
447
497
  # @param [Object] mime_type Value to be assigned
448
498
  def mime_type=(mime_type)
@@ -508,6 +558,7 @@ module ImgwireGenerated
508
558
  def ==(o)
509
559
  return true if self.equal?(o)
510
560
  self.class == o.class &&
561
+ can_upload == o.can_upload &&
511
562
  cdn_url == o.cdn_url &&
512
563
  created_at == o.created_at &&
513
564
  custom_metadata == o.custom_metadata &&
@@ -519,6 +570,7 @@ module ImgwireGenerated
519
570
  height == o.height &&
520
571
  id == o.id &&
521
572
  idempotency_key == o.idempotency_key &&
573
+ is_directly_deliverable == o.is_directly_deliverable &&
522
574
  mime_type == o.mime_type &&
523
575
  original_filename == o.original_filename &&
524
576
  processed_metadata_at == o.processed_metadata_at &&
@@ -539,7 +591,7 @@ module ImgwireGenerated
539
591
  # Calculates hash code according to all attributes.
540
592
  # @return [Integer] Hash code
541
593
  def hash
542
- [cdn_url, created_at, custom_metadata, deleted_at, environment_id, exif_data, extension, hash_sha256, height, id, idempotency_key, mime_type, original_filename, processed_metadata_at, purpose, size_bytes, status, updated_at, upload_token_id, width].hash
594
+ [can_upload, cdn_url, created_at, custom_metadata, deleted_at, environment_id, exif_data, extension, hash_sha256, height, id, idempotency_key, is_directly_deliverable, mime_type, original_filename, processed_metadata_at, purpose, size_bytes, status, updated_at, upload_token_id, width].hash
543
595
  end
544
596
 
545
597
  # Builds the object from hash
@@ -16,13 +16,18 @@ require 'time'
16
16
  module ImgwireGenerated
17
17
  class SupportedMimeType
18
18
  IMAGE_JPEG = "image/jpeg".freeze
19
+ IMAGE_JXL = "image/jxl".freeze
19
20
  IMAGE_PNG = "image/png".freeze
20
21
  IMAGE_WEBP = "image/webp".freeze
21
22
  IMAGE_AVIF = "image/avif".freeze
22
23
  IMAGE_GIF = "image/gif".freeze
24
+ IMAGE_VND_MICROSOFT_ICON = "image/vnd.microsoft.icon".freeze
25
+ IMAGE_HEIC = "image/heic".freeze
26
+ IMAGE_BMP = "image/bmp".freeze
27
+ IMAGE_TIFF = "image/tiff".freeze
23
28
 
24
29
  def self.all_vars
25
- @all_vars ||= [IMAGE_JPEG, IMAGE_PNG, IMAGE_WEBP, IMAGE_AVIF, IMAGE_GIF].freeze
30
+ @all_vars ||= [IMAGE_JPEG, IMAGE_JXL, IMAGE_PNG, IMAGE_WEBP, IMAGE_AVIF, IMAGE_GIF, IMAGE_VND_MICROSOFT_ICON, IMAGE_HEIC, IMAGE_BMP, IMAGE_TIFF].freeze
26
31
  end
27
32
 
28
33
  # Builds the enum from string
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].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
@@ -61,77 +47,7 @@ module Imgwire
61
47
  raise ArgumentError, 'Invalid transformation rule value for preset' unless PRESETS.include?(preset)
62
48
 
63
49
  uri = URI.parse(cdn_url)
64
- slash_index = uri.path.rindex('/')
65
- prefix = slash_index ? uri.path[0..slash_index] : ''
66
- file_name = slash_index ? uri.path[(slash_index + 1)..] : uri.path
67
- dot_index = file_name.rindex('.')
68
- raise ArgumentError, 'Cannot apply a preset to a CDN URL without a file extension.' if dot_index.nil?
69
-
70
- "#{prefix}#{file_name}@#{preset}"
71
- end
72
-
73
- def build_query(options)
74
- present = []
75
-
76
- RULES.each do |canonical, aliases|
77
- matches = aliases.filter_map do |name|
78
- symbol = name.to_sym
79
- [symbol, options[symbol]] if options.key?(symbol)
80
- end
81
-
82
- next if matches.empty?
83
- raise ArgumentError, "Duplicate transformation rule: #{canonical}" if matches.length > 1
84
-
85
- value = normalize_rule(canonical, matches.first.last)
86
- present << [canonical, value] unless value.nil?
87
- end
88
-
89
- present
90
- end
91
-
92
- def normalize_rule(canonical, value)
93
- case canonical
94
- when 'background'
95
- string = value.to_s.delete_prefix('#')
96
- unless string.match?(/\A[\da-fA-F]{6}\z/)
97
- raise ArgumentError,
98
- "Invalid transformation rule value for #{canonical}"
99
- end
100
-
101
- string.downcase
102
- when 'crop', 'gravity'
103
- value.to_s
104
- when 'enlarge', 'strip_metadata'
105
- value ? 'true' : nil
106
- when 'format'
107
- string = value.to_s
108
- unless FORMATS.include?(string)
109
- raise ArgumentError,
110
- "Invalid transformation rule value for #{canonical}"
111
- end
112
-
113
- string
114
- when 'height', 'quality', 'width'
115
- integer = Integer(value)
116
- unless integer.positive?
117
- raise ArgumentError,
118
- "Invalid transformation rule value for #{canonical}"
119
- end
120
-
121
- integer.to_s
122
- when 'rotate'
123
- integer = Integer(value)
124
- unless ROTATE_ANGLES.include?(integer)
125
- raise ArgumentError,
126
- "Invalid transformation rule value for #{canonical}"
127
- end
128
-
129
- integer.to_s
130
- else
131
- raise ArgumentError, "Unsupported transformation rule: #{canonical}"
132
- end
133
- rescue ArgumentError, TypeError
134
- raise ArgumentError, "Invalid transformation rule value for #{canonical}"
50
+ "#{uri.path}@#{preset}"
135
51
  end
136
52
  end
137
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.1.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.1.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-17 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: