Module: Models::Imageable

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

Constant Summary collapse

STANDARD_THUMBNAIL_SIZE =
'400x400>'
STANDARD_SIZES =
{
  thumb: '30x30>',
  small: '100x100>',
  medium: '300x300>',
  large: '400x400>',
  extra_large: '600x600>'
}
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]

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.suggested_sources_for_selectObject



248
249
250
# File 'app/concerns/models/imageable.rb', line 248

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

Instance Method Details

#aspect_ratioObject

Width / Height



64
65
66
67
68
# File 'app/concerns/models/imageable.rb', line 64

def aspect_ratio
  return unless attachment_height && attachment_width

  attachment_width.to_f / attachment_height
end

#human_sizeObject



53
54
55
56
57
# File 'app/concerns/models/imageable.rb', line 53

def human_size
  return unless attachment_size.present?

  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)



263
264
265
# File 'app/concerns/models/imageable.rb', line 263

def ik_file_name
  slug
end

#ik_file_name_with_extensionObject

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



268
269
270
# File 'app/concerns/models/imageable.rb', line 268

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

#ik_get_file_detailsObject



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

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



286
287
288
289
290
291
292
293
294
295
296
# File 'app/concerns/models/imageable.rb', line 286

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



312
313
314
315
316
# File 'app/concerns/models/imageable.rb', line 312

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

#ik_raw_urlObject



272
273
274
# File 'app/concerns/models/imageable.rb', line 272

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

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



276
277
278
279
280
281
282
283
284
# File 'app/concerns/models/imageable.rb', line 276

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



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

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



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
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
# File 'app/concerns/models/imageable.rb', line 102

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.map(&:presence).compact
  ik_url(transformations: transformations, query_parameters: query_parameters, transformation_position: transformation_position)
end

#infoObject



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

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



75
76
77
78
79
80
81
82
83
84
85
# File 'app/concerns/models/imageable.rb', line 75

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



253
254
255
256
257
258
# File 'app/concerns/models/imageable.rb', line 253

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



15
16
17
18
19
20
21
22
# File 'app/concerns/models/imageable.rb', line 15

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



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

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