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_selectObject



270
271
272
# File 'app/concerns/models/imageable.rb', line 270

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

Instance Method Details

#aspect_ratioObject

Width / Height



68
69
70
71
72
# File 'app/concerns/models/imageable.rb', line 68

def aspect_ratio
  return unless attachment_height && attachment_width

  attachment_width.to_f / attachment_height
end

#aspect_ratio_labelObject

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.



77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'app/concerns/models/imageable.rb', line 77

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_sizeObject



57
58
59
60
61
# File 'app/concerns/models/imageable.rb', line 57

def human_size
  return if attachment_size.blank?

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

#ik_file_nameObject

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)



285
286
287
# File 'app/concerns/models/imageable.rb', line 285

def ik_file_name
  slug
end

#ik_file_name_with_extensionObject

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



290
291
292
# File 'app/concerns/models/imageable.rb', line 290

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

#ik_get_file_detailsObject



320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'app/concerns/models/imageable.rb', line 320

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_metadataObject



308
309
310
311
312
313
314
315
316
317
318
# File 'app/concerns/models/imageable.rb', line 308

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_urlObject



334
335
336
337
338
# File 'app/concerns/models/imageable.rb', line 334

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

#ik_raw_urlObject



294
295
296
# File 'app/concerns/models/imageable.rb', line 294

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

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



298
299
300
301
302
303
304
305
306
# File 'app/concerns/models/imageable.rb', line 298

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_infoObject



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

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

#image_url(args = nil) ⇒ Object

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



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
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
# File 'app/concerns/models/imageable.rb', line 124

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 = {}
  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

  if args[:dpr] != :ignore
    if args[:dpr].present?
      transformations << { dpr: args[:dpr] }
    elsif args[:dpr].nil? # Because if we do false, it will be removed, but by default having auto is a good idea
      transformations << { dpr: 'auto' }
    end
  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

#infoObject



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'app/concerns/models/imageable.rb', line 28

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_hashObject



96
97
98
99
100
101
102
103
104
105
106
# File 'app/concerns/models/imageable.rb', line 96

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) ⇒ Object

New simplified form using image url



275
276
277
278
279
280
# File 'app/concerns/models/imageable.rb', line 275

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 = {}) ⇒ Object



19
20
21
22
23
24
25
26
# File 'app/concerns/models/imageable.rb', line 19

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_sObject



63
64
65
# File 'app/concerns/models/imageable.rb', line 63

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