AlchemyCMS/alchemy_cms

View on GitHub
app/models/concerns/alchemy/picture_thumbnails.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
# frozen_string_literal: true

module Alchemy
  # Picture thumbnails and cropping concerns
  module PictureThumbnails
    extend ActiveSupport::Concern

    included do
      before_save :fix_crop_values

      delegate :image_file_width, :image_file_height, :image_file, to: :picture, allow_nil: true
    end

    # The url to show the picture.
    #
    # Takes all values like +name+ and crop sizes (+crop_from+, +crop_size+ from the build in graphical image cropper)
    # and also adds the security token.
    #
    # You typically want to set the size the picture should be resized to.
    #
    # === Example:
    #
    #   picture_view.picture_url(size: '200x300', crop: true, format: 'gif')
    #   # '/pictures/1/show/200x300/crop/cats.gif?sh=765rfghj'
    #
    # @option options size [String]
    #   The size the picture should be resized to.
    #
    # @option options format [String]
    #   The format the picture should be rendered in.
    #   Defaults to the +image_output_format+ from the +Alchemy::Config+.
    #
    # @option options crop [Boolean]
    #   If set to true the picture will be cropped to fit the size value.
    #
    # @return [String]
    def picture_url(options = {})
      return if picture.nil?

      picture.url(picture_url_options.merge(options)) || "missing-image.png"
    end

    # Picture rendering options
    #
    # Returns the +default_render_format+ of the associated +Alchemy::Picture+
    # together with the +crop_from+ and +crop_size+ values
    #
    # @return [HashWithIndifferentAccess]
    def picture_url_options
      return {} if picture.nil?

      crop = !!settings[:crop]

      {
        format: picture.default_render_format,
        crop: crop,
        crop_from: crop && crop_from.presence || nil,
        crop_size: crop && crop_size.presence || nil,
        size: settings[:size]
      }.with_indifferent_access
    end

    # Returns an url for the thumbnail representation of the assigned picture
    #
    # It takes cropping values into account, so it always represents the current
    # image displayed in the frontend.
    #
    # @return [String]
    def thumbnail_url
      return if picture.nil?

      picture.url(thumbnail_url_options) || "alchemy/missing-image.svg"
    end

    # Thumbnail rendering options
    #
    # @return [HashWithIndifferentAccess]
    def thumbnail_url_options
      crop = !!settings[:crop]

      {
        size: "160x120",
        crop: crop,
        crop_from: crop && crop_from.presence || default_crop_from&.join("x"),
        crop_size: crop && crop_size.presence || default_crop_size&.join("x"),
        flatten: true,
        format: picture&.image_file_format || "jpg"
      }
    end

    # Settings for the graphical JS image cropper
    def image_cropper_settings
      Alchemy::ImageCropperSettings.new(
        render_size: dimensions_from_string(render_size.presence || settings[:size]),
        default_crop_from: default_crop_from,
        default_crop_size: default_crop_size,
        fixed_ratio: settings[:fixed_ratio],
        image_width: picture&.image_file_width,
        image_height: picture&.image_file_height
      ).to_h
    end

    # Show image cropping link for ingredient
    def allow_image_cropping?
      settings[:crop] && picture &&
        picture.can_be_cropped_to?(
          settings[:size],
          settings[:upsample]
        ) && !!picture.image_file
    end

    private

    def default_crop_size
      return nil unless settings[:crop] && settings[:size]

      mask = inferred_dimensions_from_string(settings[:size])
      return if mask.nil?

      zoom = thumbnail_zoom_factor(mask)
      return nil if zoom.zero?

      [(mask[0] / zoom), (mask[1] / zoom)].map(&:round)
    end

    def thumbnail_zoom_factor(mask)
      [
        mask[0].to_f / (image_file_width || 1),
        mask[1].to_f / (image_file_height || 1)
      ].max
    end

    def default_crop_from
      return nil unless settings[:crop]
      return nil if default_crop_size.nil?

      [
        ((image_file_width || 0) - default_crop_size[0]) / 2,
        ((image_file_height || 0) - default_crop_size[1]) / 2
      ].map(&:round)
    end

    def dimensions_from_string(string)
      return if string.nil?

      string.split("x", 2).map(&:to_i)
    end

    def inferred_dimensions_from_string(string)
      return if string.nil?

      width, height = dimensions_from_string(string)
      ratio = image_file_width.to_f / image_file_height.to_i

      return if ratio.nan?

      if width.zero? && ratio.is_a?(Float)
        width = height * ratio
      end

      if height.zero? && ratio.is_a?(Float)
        height = width / ratio
      end

      [width.to_i, height.to_i]
    end

    def fix_crop_values
      %i[crop_from crop_size].each do |crop_value|
        if public_send(crop_value).is_a?(String)
          public_send(:"#{crop_value}=", normalize_crop_value(crop_value))
        end
      end
    end

    def normalize_crop_value(crop_value)
      public_send(crop_value).split("x").map { |n| normalize_number(n) }.join("x")
    end

    def normalize_number(number)
      number = number.to_f.round
      number.negative? ? 0 : number
    end
  end
end