lib/image_processing/vips.rb



require "vips"
require "image_processing"

fail "image_processing/vips requires libvips 8.6+" unless Vips.at_least_libvips?(8, 6)

module ImageProcessing
  module Vips
    extend Chainable

    # Returns whether the given image file is processable.
    def self.valid_image?(file)
      ::Vips::Image.new_from_file(file.path, access: :sequential).avg
      true
    rescue ::Vips::Error
      false
    end

    class Processor < ImageProcessing::Processor
      accumulator :image, ::Vips::Image

      # Default sharpening mask that provides a fast and mild sharpen.
      SHARPEN_MASK = ::Vips::Image.new_from_array [[-1, -1, -1],
                                                   [-1, 32, -1],
                                                   [-1, -1, -1]], 24


      # Loads the image on disk into a Vips::Image object. Accepts additional
      # loader-specific options (e.g. interlacing). Afterwards auto-rotates the
      # image to be upright.
      def self.load_image(path_or_image, loader: nil, autorot: true, **options)
        if path_or_image.is_a?(::Vips::Image)
          image = path_or_image
        else
          path = path_or_image

          if loader
            image = ::Vips::Image.public_send(:"#{loader}load", path, **options)
          else
            options = Utils.select_valid_loader_options(path, options)
            image = ::Vips::Image.new_from_file(path, **options)
          end
        end

        image = image.autorot if autorot && !options.key?(:autorotate)
        image
      end

      # See #thumbnail.
      def self.supports_resize_on_load?
        true
      end

      # Writes the Vips::Image object to disk. This starts the processing
      # pipeline defined in the Vips::Image object. Accepts additional
      # saver-specific options (e.g. quality).
      def self.save_image(image, path, saver: nil, quality: nil, **options)
        options = options.merge(Q: quality) if quality

        if saver
          image.public_send(:"#{saver}save", path, **options)
        else
          options = Utils.select_valid_saver_options(path, options)
          image.write_to_file(path, **options)
        end
      end

      # Resizes the image to not be larger than the specified dimensions.
      def resize_to_limit(width, height, **options)
        width, height = default_dimensions(width, height)
        thumbnail(width, height, size: :down, **options)
      end

      # Resizes the image to fit within the specified dimensions.
      def resize_to_fit(width, height, **options)
        width, height = default_dimensions(width, height)
        thumbnail(width, height, **options)
      end

      # Resizes the image to fill the specified dimensions, applying any
      # necessary cropping.
      def resize_to_fill(width, height, **options)
        thumbnail(width, height, crop: :centre, **options)
      end

      # Resizes the image to fit within the specified dimensions and fills
      # the remaining area with the specified background color.
      def resize_and_pad(width, height, gravity: "centre", extend: nil, background: nil, alpha: nil, **options)
        image = thumbnail(width, height, **options)
        image = image.add_alpha if alpha && !image.has_alpha?
        image.gravity(gravity, width, height, extend: extend, background: background)
      end

      # Rotates the image by an arbitrary angle.
      def rotate(degrees, **options)
        image.similarity(angle: degrees, **options)
      end

      # Overlays the specified image over the current one. Supports specifying
      # composite mode, direction or offset of the overlay image.
      def composite(overlay, _mode = nil, mode: "over", gravity: "north-west", offset: nil, **options)
        # if the mode argument is given, call the original Vips::Image#composite
        if _mode
          overlay = [overlay] unless overlay.is_a?(Array)
          overlay = overlay.map { |object| convert_to_image(object, "overlay") }

          return image.composite(overlay, _mode, **options)
        end

        overlay = convert_to_image(overlay, "overlay")
        # add alpha channel so that #gravity can use a transparent background
        overlay = overlay.add_alpha unless overlay.has_alpha?

        # apply offset with correct gravity and make remainder transparent
        if offset
          opposite_gravity = gravity.to_s.gsub(/\w+/, "north"=>"south", "south"=>"north", "east"=>"west", "west"=>"east")
          overlay = overlay.gravity(opposite_gravity, overlay.width + offset.first, overlay.height + offset.last)
        end

        # create image-sized transparent background and apply specified gravity
        overlay = overlay.gravity(gravity, image.width, image.height)

        # apply the composition
        image.composite(overlay, mode, **options)
      end

      # make metadata setter methods chainable
      def set(*args)       image.tap { |img| img.set(*args) }       end
      def set_type(*args)  image.tap { |img| img.set_type(*args) }  end
      def set_value(*args) image.tap { |img| img.set_value(*args) } end
      def remove(*args)    image.tap { |img| img.remove(*args) }    end

      private

      # Resizes the image according to the specified parameters, and sharpens
      # the resulting thumbnail.
      def thumbnail(width, height, sharpen: SHARPEN_MASK, **options)
        if self.image.is_a?(String) # path
          # resize on load
          image = ::Vips::Image.thumbnail(self.image, width, height: height, **options)
        else
          image = self.image.thumbnail_image(width, height: height, **options)
        end
        image = image.conv(sharpen, precision: :integer) if sharpen
        image
      end

      # Hack to allow omitting one dimension.
      def default_dimensions(width, height)
        raise Error, "either width or height must be specified" unless width || height

        [width || ::Vips::MAX_COORD, height || ::Vips::MAX_COORD]
      end

      # Converts the image on disk in various forms into a Vips::Image object.
      def convert_to_image(object, name)
        return object if object.is_a?(::Vips::Image)

        if object.is_a?(String)
          path = object
        elsif object.respond_to?(:to_path)
          path = object.to_path
        elsif object.respond_to?(:path)
          path = object.path
        else
          raise ArgumentError, "#{name} must be a Vips::Image, String, Pathname, or respond to #path"
        end

        ::Vips::Image.new_from_file(path)
      end

      module Utils
        module_function

        # libvips uses various loaders depending on the input format.
        def select_valid_loader_options(source_path, options)
          loader = ::Vips.vips_foreign_find_load(source_path)
          loader ? select_valid_options(loader, options) : options
        end

        # Filters out unknown options for saving images.
        def select_valid_saver_options(destination_path, options)
          saver = ::Vips.vips_foreign_find_save(destination_path)
          saver ? select_valid_options(saver, options) : options
        end

        # libvips uses various loaders and savers depending on the input and
        # output image format. Each of these loaders and savers accept slightly
        # different options, so to allow the user to be able to specify options
        # for a specific loader/saver and have it ignored for other
        # loaders/savers, we do a little bit of introspection and filter out
        # options that don't exist for a particular loader or saver.
        def select_valid_options(operation_name, options)
          operation = ::Vips::Operation.new(operation_name)

          operation_options = operation.get_construct_args
            .select { |name, flags| (flags & ::Vips::ARGUMENT_INPUT)    != 0 }
            .select { |name, flags| (flags & ::Vips::ARGUMENT_REQUIRED) == 0 }
            .map(&:first).map(&:to_sym)

          options.select { |name, value| operation_options.include?(name) }
        end
      end
    end
  end
end