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
-
.suggested_sources_for_select ⇒ Array<String>
Distinct image sources for populating a select field.
Instance Method Summary collapse
-
#aspect_ratio ⇒ Float?
Width / Height.
-
#aspect_ratio_label ⇒ String?
Human-readable aspect ratio (e.g. "16:9", "4:5", "1.91:1").
-
#human_size ⇒ String?
Attachment file size in human-readable form (e.g. "1.2 MB").
-
#ik_file_name ⇒ String
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).
-
#ik_file_name_with_extension ⇒ String
Returns the full filename with extension (for display/download purposes).
-
#ik_get_file_details ⇒ HashWithIndifferentAccess?
ImageKit file details for the image, looked up by file id when available, otherwise by searching for the image's tag.
-
#ik_get_metadata ⇒ Hash?
ImageKit metadata for the image, looked up by file id when available, otherwise by URL.
-
#ik_get_metadata_by_url ⇒ Hash?
ImageKit metadata fetched by the image's raw URL.
-
#ik_raw_url ⇒ String
Untransformed ImageKit URL for the image.
-
#ik_url(transformations: [], query_parameters: {}, alternate_path: nil, transformation_position: nil) ⇒ String
Builds an ImageKit URL with the given transformations and query parameters.
-
#image_info ⇒ String?
Raw image metadata as YAML, from the stored asset or fetched from ImageKit by URL.
-
#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.
-
#info ⇒ String
Multi-line, human-readable summary of the image (title, reference number, file name, format, dimensions, size, colorspace, DPI).
-
#presets_hash ⇒ Hash{String => String}
Named size presets for the image, from original dimensions up to XXL.
-
#sourceset(encode_format: nil) ⇒ String
New simplified form using image url.
-
#thumbnail_url(options = {}) ⇒ String?
URL for a thumbnail of the asset.
-
#to_s ⇒ String
Display string for the image: the first present of title, attachment name, or "Unknown", followed by the record id.
Class Method Details
.suggested_sources_for_select ⇒ Array<String>
Distinct image sources for populating a select field.
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_ratio ⇒ Float?
Width / Height
90 91 92 93 94 |
# File 'app/concerns/models/imageable.rb', line 90 def aspect_ratio return unless && .to_f / end |
#aspect_ratio_label ⇒ String?
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.
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 && w, h = , 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_size ⇒ String?
Attachment file size in human-readable form (e.g. "1.2 MB").
72 73 74 75 76 |
# File 'app/concerns/models/imageable.rb', line 72 def human_size return if .blank? ActionController::Base.helpers.number_to_human_size() end |
#ik_file_name ⇒ String
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)
340 341 342 |
# File 'app/concerns/models/imageable.rb', line 340 def ik_file_name slug end |
#ik_file_name_with_extension ⇒ String
Returns the full filename with extension (for display/download purposes)
348 349 350 |
# File 'app/concerns/models/imageable.rb', line 348 def ik_file_name_with_extension .present? ? "#{slug}.#{}" : slug end |
#ik_get_file_details ⇒ HashWithIndifferentAccess?
ImageKit file details for the image, looked up by file id when available,
otherwise by searching for the image's tag.
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_metadata ⇒ Hash?
ImageKit metadata for the image, looked up by file id when available,
otherwise by URL.
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_url ⇒ Hash?
ImageKit metadata fetched by the image's raw URL.
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_url ⇒ String
Untransformed ImageKit URL for the image.
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.
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_info ⇒ String?
Raw image metadata as YAML, from the stored asset or fetched from ImageKit
by URL.
120 121 122 123 |
# File 'app/concerns/models/imageable.rb', line 120 def image_info # attachment.identify('-verbose').gsub(" ", " ") 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
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 != 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 |
#info ⇒ String
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.
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: #{}" rescue StandardError nil end begin attrs << "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_hash ⇒ Hash{String => String}
Named size presets for the image, from original dimensions up to XXL.
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
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)} #{(( * p) / 100).round}w" end srcsets.join(',') end |
#thumbnail_url(options = {}) ⇒ String?
URL for a thumbnail of the asset.
26 27 28 29 30 31 32 33 |
# File 'app/concerns/models/imageable.rb', line 26 def thumbnail_url( = {}) return unless asset = .dup dimensions = .delete(:dimensions) || STANDARD_THUMBNAIL_SIZE image_url(size: dimensions.to_s, thumbnail: true, **) end |
#to_s ⇒ String
Display string for the image: the first present of title, attachment name,
or "Unknown", followed by the record id.
82 83 84 |
# File 'app/concerns/models/imageable.rb', line 82 def to_s [title, , 'Unknown'].find(&:present?) + " [#{id}]" end |