Skip to content

Commit 13edc3d

Browse files
committed
feat: add responsive image attributes generation for HTML <img> tags
1 parent 5af1833 commit 13edc3d

File tree

1 file changed

+140
-0
lines changed

1 file changed

+140
-0
lines changed

lib/imagekit/helpers/helper.rb

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,102 @@ def get_authentication_parameters(token: nil, expire: nil)
237237
get_authentication_parameters_internal(final_token, final_expire, @client.private_key)
238238
end
239239

240+
# Generates responsive image attributes for use in HTML <img> tags.
241+
#
242+
# This method creates optimized srcset and sizes attributes for responsive images,
243+
# enabling browsers to select the most appropriate image size based on the device's
244+
# screen width and resolution. Supports three strategies:
245+
# - Width-based (w descriptors): When sizes attribute is provided
246+
# - DPR-based (x descriptors): When width is provided without sizes
247+
# - Fallback (w descriptors): Uses device breakpoints when neither is provided
248+
#
249+
# @param options [Hash] Options for generating responsive image attributes
250+
# @option options [String] :src Required. The relative or absolute path of the image
251+
# @option options [String] :url_endpoint Required. Your ImageKit URL endpoint
252+
# @option options [Integer] :width The intended display width in pixels, used only when sizes is not provided.
253+
# Triggers a DPR-based strategy (1x and 2x variants) and generates x descriptors in srcSet. Ignored if sizes is present.
254+
# @option options [String] :sizes The value for the HTML sizes attribute (e.g., "100vw" or "(min-width:768px) 50vw, 100vw").
255+
# If it includes one or more vw units, breakpoints smaller than the corresponding percentage of the smallest device width are excluded.
256+
# If it contains no vw units, the full breakpoint list is used. Enables a width-based strategy and generates w descriptors in srcSet.
257+
# @option options [Array<Integer>] :device_breakpoints Custom list of device-width breakpoints in pixels.
258+
# These define common screen widths for responsive image generation. Defaults to [640, 750, 828, 1080, 1200, 1920, 2048, 3840]. Sorted automatically.
259+
# @option options [Array<Integer>] :image_breakpoints Custom list of image-specific breakpoints in pixels.
260+
# Useful for generating small variants (e.g., placeholders or thumbnails). Merged with device_breakpoints before calculating srcSet.
261+
# Defaults to [16, 32, 48, 64, 96, 128, 256, 384]. Sorted automatically.
262+
# @option options [Array<Hash>] :transformation Array of transformation objects to apply
263+
# @option options [Symbol] :transformation_position Where to add transformations (:path or :query)
264+
# @option options [Hash] :query_parameters Additional query parameters to add to URLs
265+
# @return [Hash] Hash containing responsive image attributes suitable for an HTML <img> element:
266+
# - :src - URL for the largest candidate (assigned to plain src)
267+
# - :src_set - Candidate set with w or x descriptors (if generated)
268+
# - :sizes - sizes attribute value (returned or synthesized as "100vw")
269+
# - :width - Width as a number (if width was provided)
270+
def get_responsive_image_attributes(options = {})
271+
# Default breakpoint pools
272+
default_device_breakpoints = [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
273+
default_image_breakpoints = [16, 32, 48, 64, 96, 128, 256, 384]
274+
275+
# Extract options
276+
src = options[:src]
277+
url_endpoint = options[:url_endpoint]
278+
width = options[:width]
279+
sizes = options[:sizes]
280+
device_breakpoints = options[:device_breakpoints] || default_device_breakpoints
281+
image_breakpoints = options[:image_breakpoints] || default_image_breakpoints
282+
transformation = options[:transformation] || []
283+
transformation_position = options[:transformation_position]
284+
query_parameters = options[:query_parameters]
285+
286+
# Sort and merge breakpoints
287+
sorted_device_breakpoints = device_breakpoints.sort
288+
sorted_image_breakpoints = image_breakpoints.sort
289+
all_breakpoints = (sorted_image_breakpoints + sorted_device_breakpoints).sort.uniq
290+
291+
# Compute candidate widths and descriptor kind
292+
result = compute_candidate_widths(
293+
all_breakpoints: all_breakpoints,
294+
device_breakpoints: sorted_device_breakpoints,
295+
explicit_width: width,
296+
sizes_attr: sizes
297+
)
298+
candidates = result[:candidates]
299+
descriptor_kind = result[:descriptor_kind]
300+
301+
# Helper to build a single ImageKit URL
302+
build_url_fn = lambda do |w|
303+
build_url(
304+
Imagekit::Models::SrcOptions.new(
305+
src: src,
306+
url_endpoint: url_endpoint,
307+
query_parameters: query_parameters,
308+
transformation_position: transformation_position,
309+
transformation: transformation + [
310+
Imagekit::Models::Transformation.new(width: w, crop: "at_max") # never upscale beyond original
311+
]
312+
)
313+
)
314+
end
315+
316+
# Build srcset
317+
src_set_entries = candidates.map.with_index do |w, i|
318+
descriptor = descriptor_kind == :w ? "#{w}w" : "#{i + 1}x"
319+
"#{build_url_fn.call(w)} #{descriptor}"
320+
end
321+
src_set = src_set_entries.empty? ? nil : src_set_entries.join(", ")
322+
323+
final_sizes = sizes || (descriptor_kind == :w ? "100vw" : nil)
324+
325+
# Build result - include only when defined
326+
result = {
327+
src: build_url_fn.call(candidates.last) # largest candidate
328+
}
329+
result[:src_set] = src_set if src_set
330+
result[:sizes] = final_sizes if final_sizes
331+
result[:width] = width if width
332+
333+
result
334+
end
335+
240336
# @api private
241337
#
242338
# @param client [Imagekit::Client]
@@ -246,6 +342,50 @@ def initialize(client:)
246342

247343
private
248344

345+
# Compute candidate widths for responsive images.
346+
# Implements three strategies:
347+
# 1. Width-based srcSet (w) when sizes attribute contains vw units
348+
# 2. Fallback to device breakpoints when no width or sizes provided
349+
# 3. DPR-based srcSet (x) with 1x and 2x variants when width is provided
350+
def compute_candidate_widths(
351+
all_breakpoints:,
352+
device_breakpoints:,
353+
explicit_width: nil,
354+
sizes_attr: nil
355+
)
356+
# Strategy 1: Width-based srcSet (w) using viewport vw hints
357+
if sizes_attr
358+
vw_tokens = sizes_attr.scan(/(?:^|\s)(1?\d{1,2})vw/).flatten.map(&:to_i)
359+
360+
if vw_tokens.any?
361+
# Find the smallest vw percentage
362+
smallest_ratio = vw_tokens.min / 100.0
363+
# Calculate minimum required pixels
364+
min_required_px = device_breakpoints.first * smallest_ratio
365+
# Filter breakpoints >= min_required_px
366+
candidates = all_breakpoints.select { |bp| bp >= min_required_px }
367+
return {candidates: candidates, descriptor_kind: :w}
368+
end
369+
370+
# No usable vw found: fallback to all breakpoints
371+
return {candidates: all_breakpoints, descriptor_kind: :w}
372+
end
373+
374+
# Strategy 2: Fallback using device breakpoints if no explicit width
375+
return {candidates: device_breakpoints, descriptor_kind: :w} unless explicit_width
376+
377+
# Strategy 3: Use 1x and 2x nearest breakpoints for x descriptor
378+
# Find the first breakpoint >= target (or use the largest)
379+
nearest = lambda do |target|
380+
all_breakpoints.find { |bp| bp >= target } || all_breakpoints.last
381+
end
382+
383+
# Generate unique 1x and 2x variants
384+
unique = [nearest.call(explicit_width), nearest.call(explicit_width * 2)].uniq
385+
386+
{candidates: unique, descriptor_kind: :x}
387+
end
388+
249389
# Generate a 32-character hex token
250390
def generate_token
251391
# Generate 16 random bytes and convert to hex (32 characters)

0 commit comments

Comments
 (0)