# frozen_string_literal: true
module DynamicImage
# = DynamicImage Image Sizing
#
# Calculates cropping and fitting for image sizes.
class ImageSizing
def initialize(record, options = {})
@record = record
@uncropped = options[:uncropped] ? true : false
end
# Calculates crop geometry. The given vector is scaled
# to match the image size, since DynamicImage performs
# cropping before resizing.
#
# ==== Example
#
# image = Image.find(params[:id]) # 320x200 image
# sizing = DynamicImage::ImageSizing.new(image)
#
# sizing.crop_geometry(Vector2d(100, 100))
# # => [Vector2d(200, 200), Vector2d(60, 0)]
#
# Returns a tuple with crop size and crop start vectors.
def crop_geometry(ratio_vector)
# Maximize the crop area to fit the image size
crop_size = ratio_vector.fit(size).round
# Ignore pixels outside the pre-cropped area for now
center = crop_gravity - crop_start
start = center - (crop_size / 2).floor
start = clamp(start, crop_size, size)
[crop_size, (start + crop_start)]
end
# Returns crop geometry as an ImageMagick compatible string.
#
# ==== Example
#
# image = Image.find(params[:id]) # 320x200 image
# sizing = DynamicImage::ImageSizing.new(image)
#
# sizing.crop_geometry(Vector2d(100, 100)) # => "200x200+60+0"
def crop_geometry_string(ratio_vector)
crop_size, start = crop_geometry(ratio_vector)
crop_size.floor.to_s + "+#{start.x.to_i}+#{start.y.to_i}!"
end
# Adjusts +fit_size+ to fit the image dimensions.
# Any dimension set to zero will be ignored.
#
# ==== Options
#
# * :crop - Don't keep aspect ratio. This will allow
# the image to be cropped to the requested size.
# * :upscale - Don't limit to the size of the image.
# Images smaller than the given size will be scaled up.
#
# ==== Examples
#
# image = Image.find(params[:id]) # 320x200 image
# sizing = DynamicImage::ImageSizing.new(image)
#
# sizing.fit(Vector2d(0, 100))
# # => Vector2d(160.0, 100.0)
#
# sizing.fit(Vector2d(500, 500))
# # => Vector2d(320.0, 200.0)
#
# sizing.fit(Vector2d(500, 500), crop: true)
# # => Vector2d(200.0, 200.0)
#
# sizing.fit(Vector2d(500, 500), upscale: true)
# # => Vector2d(500.0, 312.5)
#
def fit(fit_size, options = {})
fit_size = parse_vector(fit_size)
require_dimensions!(fit_size) if options[:crop]
fit_size = size.fit(fit_size) unless options[:crop]
fit_size = size.contain(fit_size) unless options[:upscale]
fit_size
end
private
def crop_gravity
if uncropped? && !record.crop_gravity?
size / 2
else
record.crop_gravity
end
end
def crop_start
if uncropped?
Vector2d.new(0, 0)
else
record.crop_start
end
end
def size
if uncropped?
record.real_size
else
record.size
end
end
# Clamps the rectangle defined by +start+ and +size+
# to fit inside 0, 0 and +max_size+. It is assumed
# that +size+ will always be smaller than +max_size+.
#
# Returns the start vector.
def clamp(start, size, max_size)
start += shift_vector(start)
start -= shift_vector(max_size - (start + size))
start
end
def parse_vector(vector)
vector.is_a?(String) ? str_to_vector(vector) : vector
end
attr_reader :record
def require_dimensions!(vector)
return if vector.x.positive? && vector.y.positive?
raise DynamicImage::Errors::InvalidSizeOptions
end
def shift_vector(vect)
vector(
vect.x.negative? ? vect.x.abs : 0,
vect.y.negative? ? vect.y.abs : 0
)
end
def str_to_vector(str)
x, y = str.match(/(\d*)x(\d*)/)[1, 2].map(&:to_i)
Vector2d.new(x, y)
end
def uncropped?
@uncropped
end
def vector(width, height)
Vector2d.new(width, height)
end
end
end