Module: Models::Imageable

Extended by:
ActiveSupport::Concern
Included in:
Api::Image, Image
Defined in:
app/concerns/models/imageable.rb

Overview

ActiveSupport::Concern mixin: imageable.

Constant Summary collapse

STANDARD_THUMBNAIL_SIZE =

Standard thumbnail size.

'400x400>'.freeze
STANDARD_SIZES =

Standard sizes.

{
  thumb: '30x30>',
  small: '100x100>',
  medium: '300x300>',
  large: '400x400>',
  extra_large: '600x600>'
}.freeze
VALID_IMAGE_URL_OPTIONS =

Available valid image url options.

%i[encode_format size borderx bordery size_modifier percentage
width height crop_x crop_y crop_w crop_h crop_mode crop_last rotate relative
focus
hostname cache webp download optimize thumbnail background progressive_jpeg blur].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.suggested_sources_for_selectArray<String>

Distinct image sources for populating a select field.

Returns:

  • (Array<String>)

    sorted, unique, non-blank source values



319
320
321
# File 'app/concerns/models/imageable.rb', line 319

def self.suggested_sources_for_select
  Image.where.not(source: [nil, '']).order(:source).distinct.pluck(:source)
end

Instance Method Details

#aspect_ratioFloat?

Width / Height

Returns:

  • (Float, nil)

    width divided by height, or nil when either dimension
    is unknown



90
91
92
93
94
# File 'app/concerns/models/imageable.rb', line 90

def aspect_ratio
  return unless attachment_height && attachment_width

  attachment_width.to_f / attachment_height
end

#aspect_ratio_labelString?

Human-readable aspect ratio (e.g. "16:9", "4:5", "1.91:1"). Uses GCD to
reduce to integer form when both sides fit under 100; otherwise falls back
to a ":1" approximation for odd ratios like 699:157.

Returns:

  • (String, nil)

    the aspect ratio label, or nil when either dimension
    is unknown



102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'app/concerns/models/imageable.rb', line 102

def aspect_ratio_label
  return unless attachment_height && attachment_width

  w, h = attachment_width, attachment_height
  g    = w.gcd(h)
  w_r  = w / g
  h_r  = h / g
  if w_r < 100 && h_r < 100
    "#{w_r}:#{h_r}"
  else
    format('%.2f:1', w.to_f / h)
  end
end

#human_sizeString?

Attachment file size in human-readable form (e.g. "1.2 MB").

Returns:

  • (String, nil)

    the formatted size, or nil when the size is blank



72
73
74
75
76
# File 'app/concerns/models/imageable.rb', line 72

def human_size
  return if attachment_size.blank?

  ActionController::Base.helpers.number_to_human_size(attachment_size)
end

#ik_file_nameString

Returns the filename for ImageKit operations (without extension)
ImageKit files are stored without extensions to allow dynamic format negotiation
(ImageKit can serve optimal formats like WebP/AVIF based on browser support)

Returns:

  • (String)

    the image slug



340
341
342
# File 'app/concerns/models/imageable.rb', line 340

def ik_file_name
  slug
end

#ik_file_name_with_extensionString

Returns the full filename with extension (for display/download purposes)

Returns:

  • (String)

    the slug with extension when the format is known,
    otherwise the bare slug



348
349
350
# File 'app/concerns/models/imageable.rb', line 348

def ik_file_name_with_extension
  attachment_format.present? ? "#{slug}.#{attachment_format}" : slug
end

#ik_get_file_detailsHashWithIndifferentAccess?

ImageKit file details for the image, looked up by file id when available,
otherwise by searching for the image's tag.

Returns:

  • (HashWithIndifferentAccess, nil)

    the file details, or nil when
    unavailable



398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'app/concerns/models/imageable.rb', line 398

def ik_get_file_details
  # file id? use that
  # ImageKit 4.0 returns snake_case keys: file_id instead of fileId
  if (file_id = asset&.dig('file_id') || asset&.dig('fileId'))
    # Use ImageKitFactory helper method
    res = ImageKitFactory.get_file(file_id)
    res&.to_h&.deep_symbolize_keys&.with_indifferent_access
  else # Try a search by Image-<id>
    # Use ImageKitFactory helper method
    res = ImageKitFactory.list_assets(tags: ["image-#{id}", "Image-#{id}"])
    res&.items&.first&.to_h&.deep_symbolize_keys&.with_indifferent_access
  end
end

#ik_get_metadataHash?

ImageKit metadata for the image, looked up by file id when available,
otherwise by URL.

Returns:

  • (Hash, nil)

    symbolized metadata hash, or nil when unavailable



381
382
383
384
385
386
387
388
389
390
391
# File 'app/concerns/models/imageable.rb', line 381

def 
  # file id? use that
  # ImageKit 4.0 returns snake_case keys: file_id instead of fileId
  if (file_id = asset&.dig('file_id') || asset&.dig('fileId'))
    # Use ImageKitFactory helper method
    res = ImageKitFactory.(file_id)
    res&.to_h&.symbolize_keys
  else # try url
    
  end
end

#ik_get_metadata_by_urlHash?

ImageKit metadata fetched by the image's raw URL.

Returns:

  • (Hash, nil)

    symbolized metadata hash, or nil when unavailable



415
416
417
418
419
# File 'app/concerns/models/imageable.rb', line 415

def 
  # Use ImageKitFactory helper method
  res = ImageKitFactory.(ik_raw_url)
  res&.to_h&.symbolize_keys
end

#ik_raw_urlString

Untransformed ImageKit URL for the image.

Returns:

  • (String)

    the raw URL on IK_HOSTNAME



355
356
357
# File 'app/concerns/models/imageable.rb', line 355

def ik_raw_url
  "https://#{IK_HOSTNAME}#{ik_path}"
end

#ik_url(transformations: [], query_parameters: {}, alternate_path: nil, transformation_position: nil) ⇒ String

Builds an ImageKit URL with the given transformations and query parameters.

Parameters:

  • transformations (Array<Hash>) (defaults to: [])

    ImageKit transformation steps

  • query_parameters (Hash) (defaults to: {})

    extra query string parameters

  • alternate_path (String, nil) (defaults to: nil)

    path to use instead of #ik_path

  • transformation_position (String, nil) (defaults to: nil)

    'path' or 'query'
    (defaults to 'query')

Returns:

  • (String)

    the ImageKit URL



367
368
369
370
371
372
373
374
375
# File 'app/concerns/models/imageable.rb', line 367

def ik_url(transformations: [], query_parameters: {}, alternate_path: nil, transformation_position: nil)
  # Use ImageKitFactory helper method to build URL
  ImageKitFactory.build_url(
    src: alternate_path || ik_path,
    transformations: transformations,
    query_parameters: query_parameters,
    transformation_position: transformation_position || 'query'
  )
end

#image_infoString?

Raw image metadata as YAML, from the stored asset or fetched from ImageKit
by URL.

Returns:

  • (String, nil)

    YAML dump of the metadata, or nil when unavailable



120
121
122
123
# File 'app/concerns/models/imageable.rb', line 120

def image_info
  # attachment.identify('-verbose').gsub(" ", "&nbsp;") rescue nil
  (asset || )&.to_yaml
end

#image_url(args = nil) ⇒ String

encode_format: nil, webp: nil,
size: nil, size_modifier: nil,
percentage: nil,
width: nil, height: nil, borderx: nil, bordery: nil,
crop_x: nil, crop_y: nil, crop_w: nil, crop_h: nil, crop_mode: nil, rotate: nil,
relative: nil, hostname: nil, protocol: nil, cache: nil,
download: false, optimize: nil, thumbnail: false, background: nil
dpr: auto
For a full list of possible imagekit transformations see https://github.com/imagekit-developer/imagekit-ruby

Parameters:

  • args (Hash, nil) (defaults to: nil)

    transformation options (see VALID_IMAGE_URL_OPTIONS);
    recognized keys include :size, :width, :height, :percentage, :crop_x,
    :crop_y, :crop_w, :crop_h, :crop_mode, :crop_last, :focus, :thumbnail,
    :encode_format, :webp, :download, :quality, :background, :rotate, :blur,
    :zoom, :radius, :border, :borderx, :bordery, :border_color, :named, :dpr,
    :progressive_jpeg, :cache, and :transformation_position

Returns:

  • (String)

    the ImageKit URL for the image



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'app/concerns/models/imageable.rb', line 163

def image_url(args = nil)
  args = args&.dup # Copy to ensure we can modify without modifying the original
  args ||= {}
  args = args.symbolize_keys
  query_parameters = {}
  # A stable version token changes the browser/ImageKit cache key when an
  # asset is overwritten in place. Never pass a per-request value here.
  query_parameters['v'] = args[:cache] if args[:cache].present?
  transformations = []
  transformation_position = args[:transformation_position] # path or query, query is default
  # Cropping applies first
  # crop_x: nil, crop_y: nil, crop_w: nil, crop_h: nil
  crop_transform = {}
  crop_transform[:x] = args[:crop_x].presence
  crop_transform[:y] = args[:crop_y].presence
  crop_transform[:width] = args[:crop_w].presence
  crop_transform[:height] = args[:crop_h].presence
  crop_transform = crop_transform.compact
  # Only apply crop mode if we have a crop request, delete the crop mode so it doesn't get applied down the stream
  crop_transform[:crop_mode] = args.delete(:crop_mode).presence || 'force' if crop_transform.present?
  transformations << crop_transform unless args[:crop_last].to_b

  transformations << { named: args[:named] } if args[:named]

  # https://docs.imagekit.io/features/image-transformations/resize-crop-and-other-transformations
  # size does not exist so we have to extract width and height
  # sizing is done as a group, size will be taken as authoritative over width and height
  resize_operation = {}
  if args[:size].present?
    w, h = args[:size].split('x').map(&:presence)
    resize_operation[:width] = w.to_i if w
    resize_operation[:height] = h.to_i if h
  elsif args[:width].present? || args[:height].present?
    resize_operation[:width] = args[:width].to_i if args[:width].present?
    resize_operation[:height] = args[:height].to_i if args[:height].present?
  elsif args[:percentage] && args[:percentage].to_i > 0 && args[:percentage].to_i < 100 # In ik, this translates to a value between 0 and 1 for width and height
    pf = (args[:percentage].to_f / 100).round(2)
    resize_operation[:width] = pf
    resize_operation[:height] = pf
  end

  # if a width and height are specified, we need a padding technique if the image is not the 1:1 aspect ratio
  # we will assume a white background
  if resize_operation[:width] && resize_operation[:height]
    resize_operation[:cm] = args[:crop_mode].presence || 'pad_resize' unless args[:crop_mode] == :none
    # Strip leading # from background colors (e.g., '#ffffff' -> 'ffffff')
    # The # character breaks URLs as it starts a fragment identifier
    resize_operation[:bg] = (args[:background] || 'FFFFFF').to_s.delete_prefix('#')
  end

  transformations << resize_operation if resize_operation.present?

  # https://imagekit.io/blog/smart-crop-deliver-perfect-responsive-images/
  # fo (focus) MUST be in the same transformation step as w/h/cm for smart-crop to work.
  # We mutate resize_operation in-place — it's already referenced in transformations.
  if args[:focus].present?
    if resize_operation[:width] && resize_operation[:height]
      resize_operation[:focus] = args[:focus]
    else
      transformations << { focus: args[:focus] }
    end
  elsif args[:thumbnail]
    if resize_operation[:width] && resize_operation[:height]
      resize_operation[:focus] = 'auto'
    else
      transformations << { focus: 'auto' }
    end
  end

  if (zoom = args[:zoom].presence)
    transformations << { z: zoom }
  end

  if (radius = args[:radius].presence)
    transformations << { r: radius }
  end

  if (b = args[:border].presence || args[:borderx].presence || args[:bordery].presence)
    # there's no concept of x and y so we'll just take the first one
    # border color is possible but we didn't use this so we'll default to, gray?
    # If you pass true we'll use a nominal value of 3 px
    b = 3 if b == true
    # Strip leading # from colors (e.g., '#555555' -> '555555')
    bc = (args[:border_color] || args[:background] || '555555').to_s.delete_prefix('#')

    bs = "#{b.to_i}_#{bc}"
    transformations << { b: bs }
  end

  if (rt = args[:rotate].presence)
    transformations << { rt: rt }
  end

  # Blur transformation (1-100, higher = more blur)
  # Useful for LQIP (Low Quality Image Placeholder) poster images
  if (blur_value = args[:blur].presence)
    transformations << { bl: blur_value.to_i.clamp(1, 100) }
  end

  transformations << crop_transform if args[:crop_last].to_b

  # Only emit a DPR transform when one is explicitly requested. Our responsive
  # srcset already serves the right resolution per device — the browser multiplies
  # by devicePixelRatio when choosing a `w`-descriptor candidate — so a blanket
  # `dpr-auto` default is at best a no-op (the site sends no `Accept-CH`, and the
  # `DPR` image client-hint was removed from Chrome 90 anyway, so browsers never
  # send it → ImageKit resolves `dpr-auto` to 1) and at worst a 2× double-scale if
  # Client Hints are ever turned on. Explicit `dpr: 2` still works; `:ignore`/false
  # suppress it (unchanged for the marketplace image_profile callers).
  if args[:dpr].present? && args[:dpr] != :ignore
    transformations << { dpr: args[:dpr] }
  end

  if (format = args[:encode_format].presence&.to_s) && format != 'original'
    # Normalize
    format = 'jpeg' if format == 'jpg'
    t = {}
    # We only encode the format if the requested format is different
    # Or if webp (which is auto optimization or can be applied to avif) is false
    # Or if download is true
    if !args[:webp].to_b || args[:download].to_b || format != attachment_format
      t[:f] = format
      # if our requested format is jpeg, go progressive
      t[:pr] = (args[:progressive_jpeg].nil? || args[:progressive_jpeg] == true) if format == 'jpeg'
    end

    # A quality param supplied can be specified
    # to override the 85 default
    t[:q] = args[:quality].to_i if args[:quality].present?

    # JPEG doesn't support transparency, so always provide a background color
    # when converting to JPEG. This prevents black backgrounds on transparent PNGs,
    # even when attachment_format is not set.
    if format == 'jpeg'
      # Strip leading # from background colors (e.g., '#ffffff' -> 'ffffff')
      bc = (args[:background] || 'FFFFFF').to_s.delete_prefix('#')
      t[:bg] = bc
    end
    transformations << t
  end

  if args[:download].present?
    # Force download in native format
    # transformations.delete_if { |v| v[:f].present? }
    # transformations << { f: attachment_format }
    query_parameters['ik-attachment'] = true
  end

  # Remove empty transformations
  transformations = transformations.filter_map(&:presence)
  ik_url(transformations: transformations, query_parameters: query_parameters, transformation_position: transformation_position)
end

#infoString

Multi-line, human-readable summary of the image (title, reference number,
file name, format, dimensions, size, colorspace, DPI). Lines whose value
cannot be read are skipped.

Returns:

  • (String)

    newline-joined attribute lines



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'app/concerns/models/imageable.rb', line 40

def info
  attrs = []
  attrs << "Title: #{title}"
  attrs << "Ref: #{reference_number}"
  begin
    attrs << "File Name: #{attachment_name}"
  rescue StandardError
    nil
  end
  begin
    attrs << "Format: #{attachment_format}"
  rescue StandardError
    nil
  end
  begin
    attrs << "Dimensions (WxH): #{dimensions}"
  rescue StandardError
    nil
  end
  begin
    attrs << "Size: #{human_size}"
  rescue StandardError
    nil
  end
  attrs << "Colorspace: #{image_colorspace}"
  attrs << "DPI: #{image_dpi}"
  attrs.compact.join("\n")
end

#presets_hashHash{String => String}

Named size presets for the image, from original dimensions up to XXL.

Returns:

  • (Hash{String => String})

    preset label => ImageKit size string



128
129
130
131
132
133
134
135
136
137
138
# File 'app/concerns/models/imageable.rb', line 128

def presets_hash
  {
    'Original' => dimensions,
    'Thumb Image' => '30x30>',
    'Small Image' => '100x100>',
    'Medium Image' => '300x300>',
    'Large Image' => '400x400>',
    'Extra Large Image' => '600x600>',
    'XXL Image' => '900x900>'
  }
end

#sourceset(encode_format: nil) ⇒ String

New simplified form using image url

Parameters:

  • encode_format (String, Symbol, nil) (defaults to: nil)

    optional format (e.g. :webp) to
    encode the srcset URLs in

Returns:

  • (String)

    comma-joined srcset entries at 75/50/25 percent widths



328
329
330
331
332
333
# File 'app/concerns/models/imageable.rb', line 328

def sourceset(encode_format: nil)
  srcsets = [75, 50, 25].map do |p|
    "#{image_url(percentage: p, encode_format: encode_format)} #{((attachment_width * p) / 100).round}w"
  end
  srcsets.join(',')
end

#thumbnail_url(options = {}) ⇒ String?

URL for a thumbnail of the asset.

Parameters:

  • options (Hash) (defaults to: {})

    thumbnail options; remaining keys are forwarded to
    #image_url (see VALID_IMAGE_URL_OPTIONS)

Options Hash (options):

  • dimensions (String)

    ImageKit size string for the thumbnail
    (defaults to STANDARD_THUMBNAIL_SIZE)

Returns:

  • (String, nil)

    the thumbnail URL, or nil when there is no asset



26
27
28
29
30
31
32
33
# File 'app/concerns/models/imageable.rb', line 26

def thumbnail_url(options = {})
  return unless asset

  options = options.dup
  dimensions = options.delete(:dimensions) || STANDARD_THUMBNAIL_SIZE

  image_url(size: dimensions.to_s, thumbnail: true, **options)
end

#to_sString

Display string for the image: the first present of title, attachment name,
or "Unknown", followed by the record id.

Returns:

  • (String)

    e.g. "My Image [123]"



82
83
84
# File 'app/concerns/models/imageable.rb', line 82

def to_s
  [title, attachment_name, 'Unknown'].find(&:present?) + " [#{id}]"
end