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