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, autorot: true, **options) if path_or_image.is_a?(::Vips::Image) image = path_or_image else source_path = path_or_image options = Utils.select_valid_loader_options(source_path, options) image = ::Vips::Image.new_from_file(source_path, **options) end image = image.autorot if autorot && !options.key?(:autorotate) image 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, destination_path, quality: nil, **options) options = options.merge(Q: quality) if quality options = Utils.select_valid_saver_options(destination_path, options) image.write_to_file(destination_path, **options) 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. Additional options can be # specified, such as background colors to fill in the gaps when rotating # with an angle which is not a multiple of 90 degrees. 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 Vips::Image#set, #set_type, #set_value, and #move 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) image = self.image image = image.thumbnail_image(width, height: height, **options) image = image.conv(sharpen) 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