require "mini_magick"
require "image_processing"
module ImageProcessing
module MiniMagick
extend Chainable
# Returns whether the given image file is processable.
def self.valid_image?(file)
::MiniMagick::Tool::Convert.new do |convert|
convert << file.path
convert << "null:"
end
true
rescue ::MiniMagick::Error
false
end
class Processor < ImageProcessing::Processor
accumulator :magick, ::MiniMagick::Tool
# Default sharpening parameters used on generated thumbnails.
SHARPEN_PARAMETERS = { radius: 0, sigma: 1 }
# Initializes the image on disk into a MiniMagick::Tool object. Accepts
# additional options related to loading the image (e.g. geometry).
# Additionally auto-orients the image to be upright.
def self.load_image(path_or_magick, loader: nil, page: nil, geometry: nil, auto_orient: true, **options)
if path_or_magick.is_a?(::MiniMagick::Tool)
magick = path_or_magick
else
source_path = path_or_magick
magick = ::MiniMagick::Tool::Convert.new
Utils.apply_options(magick, **options)
input = source_path
input = "#{loader}:#{input}" if loader
input += "[#{page}]" if page
input += "[#{geometry}]" if geometry
magick << input
end
magick.auto_orient if auto_orient
magick
end
# Calls the built ImageMagick command to perform processing and save
# the result to disk. Accepts additional options related to saving the
# image (e.g. quality).
def self.save_image(magick, destination_path, allow_splitting: false, **options)
Utils.apply_options(magick, **options)
magick << destination_path
magick.call
Utils.disallow_split_layers!(destination_path) unless allow_splitting
end
# Resizes the image to not be larger than the specified dimensions.
def resize_to_limit(width, height, **options)
thumbnail("#{width}x#{height}>", **options)
end
# Resizes the image to fit within the specified dimensions.
def resize_to_fit(width, height, **options)
thumbnail("#{width}x#{height}", **options)
end
# Resizes the image to fill the specified dimensions, applying any
# necessary cropping.
def resize_to_fill(width, height, gravity: "Center", **options)
thumbnail("#{width}x#{height}^", **options)
magick.gravity gravity
magick.background color(:transparent)
magick.extent "#{width}x#{height}"
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, background: :transparent, gravity: "Center", **options)
thumbnail("#{width}x#{height}", **options)
magick.background color(background)
magick.gravity gravity
magick.extent "#{width}x#{height}"
end
# Crops the image with the specified crop points.
def crop(*args)
case args.count
when 1 then magick.crop(*args)
when 4 then magick.crop("#{args[2]}x#{args[3]}+#{args[0]}+#{args[1]}")
else fail ArgumentError, "wrong number of arguments (expected 1 or 4, got #{args.count})"
end
end
# Rotates the image by an arbitrary angle. For angles that are not
# multiple of 90 degrees an optional background color can be specified to
# fill in the gaps.
def rotate(degrees, background: nil)
magick.background color(background) if background
magick.rotate(degrees)
end
# Overlays the specified image over the current one. Supports specifying
# an additional mask, composite mode, direction or offset of the overlay
# image.
def composite(overlay = :none, mask: nil, mode: nil, gravity: nil, offset: nil, args: nil, **options, &block)
return magick.composite if overlay == :none
if options.key?(:compose)
warn "[IMAGE_PROCESSING] The :compose parameter in #composite has been renamed to :mode, the :compose alias will be removed in ImageProcessing 2."
mode = options[:compose]
end
if options.key?(:geometry)
warn "[IMAGE_PROCESSING] The :geometry parameter in #composite has been deprecated and will be removed in ImageProcessing 2. Use :offset instead, e.g. `geometry: \"+10+15\"` should be replaced with `offset: [10, 15]`."
geometry = options[:geometry]
end
geometry = "%+d%+d" % offset if offset
overlay_path = convert_to_path(overlay, "overlay")
mask_path = convert_to_path(mask, "mask") if mask
magick << overlay_path
magick << mask_path if mask_path
magick.compose(mode) if mode
define(compose: { args: args }) if args
magick.gravity(gravity) if gravity
magick.geometry(geometry) if geometry
yield magick if block_given?
magick.composite
end
# Defines settings from the provided hash.
def define(options)
return magick.define(options) if options.is_a?(String)
Utils.apply_define(magick, options)
end
# Specifies resource limits from the provided hash.
def limits(options)
options.each { |type, value| magick.args.unshift("-limit", type.to_s, value.to_s) }
magick
end
# Appends a raw ImageMagick command-line argument to the command.
def append(*args)
magick.merge! args
end
private
# Converts the given color value into an identifier ImageMagick understands.
# This supports specifying RGB(A) values with arrays, which mainly exists
# for compatibility with the libvips implementation.
def color(value)
return "rgba(255,255,255,0.0)" if value.to_s == "transparent"
return "rgb(#{value.join(",")})" if value.is_a?(Array) && value.count == 3
return "rgba(#{value.join(",")})" if value.is_a?(Array) && value.count == 4
return value if value.is_a?(String)
raise ArgumentError, "unrecognized color format: #{value.inspect} (must be one of: string, 3-element RGB array, 4-element RGBA array)"
end
# Resizes the image using the specified geometry, and sharpens the
# resulting thumbnail.
def thumbnail(geometry, sharpen: nil)
magick.resize(geometry)
if sharpen
sharpen = SHARPEN_PARAMETERS.merge(sharpen)
magick.sharpen("#{sharpen[:radius]}x#{sharpen[:sigma]}")
end
magick
end
# Converts the image on disk in various forms into a path.
def convert_to_path(file, name)
if file.is_a?(String)
file
elsif file.respond_to?(:to_path)
file.to_path
elsif file.respond_to?(:path)
file.path
else
raise ArgumentError, "#{name} must be a String, Pathname, or respond to #path"
end
end
module Utils
module_function
# When a multi-layer format is being converted into a single-layer
# format, ImageMagick will create multiple images, one for each layer.
# We want to warn the user that this is probably not what they wanted.
def disallow_split_layers!(destination_path)
layers = Dir[destination_path.sub(/(\.\w+)?$/, '-*\0')]
if layers.any?
layers.each { |path| File.delete(path) }
raise Error, "Source format is multi-layer, but destination format is single-layer. If you care only about the first layer, add `.loader(page: 0)` to your pipeline. If you want to process each layer, see https://github.com/janko/image_processing/wiki/Splitting-a-PDF-into-multiple-images or use `.saver(allow_splitting: true)`."
end
end
# Applies options from the provided hash.
def apply_options(magick, define: {}, **options)
options.each do |option, value|
case value
when true, nil then magick.send(option)
when false then magick.send(option).+
else magick.send(option, *value)
end
end
apply_define(magick, define)
end
# Applies settings from the provided (nested) hash.
def apply_define(magick, options)
options.each do |namespace, settings|
namespace = namespace.to_s.tr("_", "-")
settings.each do |key, value|
key = key.to_s.tr("_", "-")
magick.define "#{namespace}:#{key}=#{value}"
end
end
magick
end
end
end
end
end