# 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