minimagick/minimagick

View on GitHub
lib/mini_magick/image.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'tempfile'
require 'stringio'
require 'pathname'
require 'uri'
require 'open-uri'

require 'mini_magick/image/info'
require 'mini_magick/utilities'

module MiniMagick
  class Image

    ##
    # This is the primary loading method used by all of the other class
    # methods.
    #
    # Use this to pass in a stream object. Must respond to #read(size) or be a
    # binary string object (BLOB)
    #
    # Probably easier to use the {.open} method if you want to open a file or a
    # URL.
    #
    # @param stream [#read, String] Some kind of stream object that needs
    #   to be read or is a binary String blob
    # @param ext [String] A manual extension to use for reading the file. Not
    #   required, but if you are having issues, give this a try.
    # @return [MiniMagick::Image]
    #
    def self.read(stream, ext = nil)
      if stream.is_a?(String)
        stream = StringIO.new(stream)
      end

      create(ext) { |file| IO.copy_stream(stream, file) }
    end

    ##
    # Creates an image object from a binary string blob which contains raw
    # pixel data (i.e. no header data).
    #
    # @param blob [String] Binary string blob containing raw pixel data.
    # @param columns [Integer] Number of columns.
    # @param rows [Integer] Number of rows.
    # @param depth [Integer] Bit depth of the encoded pixel data.
    # @param map [String] A code for the mapping of the pixel data. Example:
    #   'gray' or 'rgb'.
    # @param format [String] The file extension of the image format to be
    #   used when creating the image object.
    # Defaults to 'png'.
    # @return [MiniMagick::Image] The loaded image.
    #
    def self.import_pixels(blob, columns, rows, depth, map, format = 'png')
      # Create an image object with the raw pixel data string:
      create(".dat", false) { |f| f.write(blob) }.tap do |image|
        output_path = image.path.sub(/\.\w+$/, ".#{format}")
        # Use ImageMagick to convert the raw data file to an image file of the
        # desired format:
        MiniMagick::Tool::Convert.new do |convert|
          convert.size "#{columns}x#{rows}"
          convert.depth depth
          convert << "#{map}:#{image.path}"
          convert << output_path
        end

        image.path.replace output_path
      end
    end

    ##
    # Opens a specific image file either on the local file system or at a URI.
    # Use this if you don't want to overwrite the image file.
    #
    # Extension is either guessed from the path or you can specify it as a
    # second parameter.
    #
    # @param path_or_url [String] Either a local file path or a URL that
    #   open-uri can read
    # @param ext [String] Specify the extension you want to read it as
    # @param options [Hash] Specify options for the open method
    # @return [MiniMagick::Image] The loaded image
    #
    def self.open(path_or_url, ext = nil, options = {})
      options, ext = ext, nil if ext.is_a?(Hash)

      # Don't use Kernel#open, but reuse its logic
      openable =
        if path_or_url.respond_to?(:open)
          path_or_url
        elsif path_or_url.respond_to?(:to_str) &&
              %r{\A[A-Za-z][A-Za-z0-9+\-\.]*://} =~ path_or_url &&
              (uri = URI.parse(path_or_url)).respond_to?(:open)
          uri
        else
          options = { binmode: true }.merge(options)
          Pathname(path_or_url)
        end

      if openable.is_a?(URI::Generic)
        ext ||= File.extname(openable.path)
      else
        ext ||= File.extname(openable.to_s)
      end
      ext.sub!(/:.*/, '') # hack for filenames or URLs that include a colon

      if openable.is_a?(URI::Generic)
        openable.open(options) { |file| read(file, ext) }
      else
        openable.open(**options) { |file| read(file, ext) }
      end
    end

    ##
    # Used to create a new Image object data-copy. Not used to "paint" or
    # that kind of thing.
    #
    # Takes an extension in a block and can be used to build a new Image
    # object. Used by both {.open} and {.read} to create a new object. Ensures
    # we have a good tempfile.
    #
    # @param ext [String] Specify the extension you want to read it as
    # @param validate [Boolean] If false, skips validation of the created
    #   image. Defaults to true.
    # @yield [Tempfile] You can #write bits to this object to create the new
    #   Image
    # @return [MiniMagick::Image] The created image
    #
    def self.create(ext = nil, validate = MiniMagick.validate_on_create, &block)
      tempfile = MiniMagick::Utilities.tempfile(ext.to_s.downcase, &block)

      new(tempfile.path, tempfile).tap do |image|
        image.validate! if validate
      end
    end

    ##
    # @private
    # @!macro [attach] attribute
    #   @!attribute [r] $1
    #
    def self.attribute(name, key = name.to_s)
      define_method(name) do |*args|
        if args.any? && name != :resolution
          mogrify { |b| b.send(name, *args) }
        else
          @info[key, *args]
        end
      end
    end

    ##
    # @return [String] The location of the current working file
    #
    attr_reader :path
    ##
    # @return [Tempfile] The underlying temporary file
    #
    attr_reader :tempfile

    ##
    # Create a new {MiniMagick::Image} object.
    #
    # _DANGER_: The file location passed in here is the *working copy*. That
    # is, it gets *modified*. You can either copy it yourself or use {.open}
    # which creates a temporary file for you and protects your original.
    #
    # @param input_path [String, Pathname] The location of an image file
    # @yield [MiniMagick::Tool::Mogrify] If block is given, {#combine_options}
    #   is called.
    #
    def initialize(input_path, tempfile = nil, &block)
      @path = input_path.to_s
      @tempfile = tempfile
      @info = MiniMagick::Image::Info.new(@path)

      combine_options(&block) if block
    end

    def ==(other)
      self.class == other.class && signature == other.signature
    end
    alias eql? ==

    def hash
      signature.hash
    end

    ##
    # Returns raw image data.
    #
    # @return [String] Binary string
    #
    def to_blob
      File.binread(path)
    end

    ##
    # Checks to make sure that MiniMagick can read the file and understand it.
    #
    # This uses the 'identify' command line utility to check the file. If you
    # are having issues with this, then please work directly with the
    # 'identify' command and see if you can figure out what the issue is.
    #
    # @return [Boolean]
    #
    def valid?
      validate!
      true
    rescue MiniMagick::Invalid
      false
    end

    ##
    # Runs `identify` on the current image, and raises an error if it doesn't
    # pass.
    #
    # @raise [MiniMagick::Invalid]
    #
    def validate!
      identify
    rescue MiniMagick::Error => error
      raise MiniMagick::Invalid, error.message
    end

    ##
    # Returns the image format (e.g. "JPEG", "GIF").
    #
    # @return [String]
    #
    attribute :type, "format"
    ##
    # @return [String]
    #
    attribute :mime_type
    ##
    # @return [Integer]
    #
    attribute :width
    ##
    # @return [Integer]
    #
    attribute :height
    ##
    # @return [Array<Integer>]
    #
    attribute :dimensions
    ##
    # Returns the file size of the image (in bytes).
    #
    # @return [Integer]
    #
    attribute :size
    ##
    # Returns the file size in a human readable format.
    #
    # @return [String]
    #
    attribute :human_size
    ##
    # @return [String]
    #
    attribute :colorspace
    ##
    # @return [Hash]
    #
    attribute :exif
    ##
    # Returns the resolution of the photo. You can optionally specify the
    # units measurement.
    #
    # @example
    #   image.resolution("PixelsPerInch") #=> [250, 250]
    # @see http://www.imagemagick.org/script/command-line-options.php#units
    # @return [Array<Integer>]
    #
    attribute :resolution
    ##
    # Returns the message digest of this image as a SHA-256, hexidecimal
    # encoded string. This signature uniquely identifies the image and is
    # convenient for determining if an image has been modified or whether two
    # images are identical.
    #
    # @example
    #   image.signature #=> "60a7848c4ca6e36b8e2c5dea632ecdc29e9637791d2c59ebf7a54c0c6a74ef7e"
    # @see http://www.imagemagick.org/api/signature.php
    # @return [String]
    #
    attribute :signature
    ##
    # Returns the information from `identify -verbose` in a Hash format, for
    # ImageMagick.
    #
    # @return [Hash]
    attribute :data
    ##
    # Returns the information from `identify -verbose` in a Hash format, for
    # GraphicsMagick.
    #
    # @return [Hash]
    attribute :details

    ##
    # Use this method if you want to access raw Identify's format API.
    #
    # @example
    #    image["%w %h"]       #=> "250 450"
    #    image["%r"]          #=> "DirectClass sRGB"
    #
    # @param value [String]
    # @see http://www.imagemagick.org/script/escape.php
    # @return [String]
    #
    def [](value)
      @info[value.to_s]
    end
    alias info []

    ##
    # Returns layers of the image. For example, JPEGs are 1-layered, but
    # formats like PSDs, GIFs and PDFs can have multiple layers/frames/pages.
    #
    # @example
    #   image = MiniMagick::Image.new("document.pdf")
    #   image.pages.each_with_index do |page, idx|
    #     page.write("page#{idx}.pdf")
    #   end
    # @return [Array<MiniMagick::Image>]
    #
    def layers
      layers_count = identify.lines.count
      layers_count.times.map do |idx|
        MiniMagick::Image.new("#{path}[#{idx}]")
      end
    end
    alias pages layers
    alias frames layers

    ##
    # Returns a matrix of pixels from the image. The matrix is constructed as
    # an array (1) of arrays (2) of arrays (3) of unsigned integers:
    #
    # 1) one for each row of pixels
    # 2) one for each column of pixels
    # 3) three or four elements in the range 0-255, one for each of the RGB(A) color channels
    #
    # @example
    #   img = MiniMagick::Image.open 'image.jpg'
    #   pixels = img.get_pixels
    #   pixels[3][2][1] # the green channel value from the 4th-row, 3rd-column pixel
    #
    # @example
    #   img = MiniMagick::Image.open 'image.jpg'
    #   pixels = img.get_pixels("RGBA")
    #   pixels[3][2][3] # the alpha channel value from the 4th-row, 3rd-column pixel
    #
    # It can also be called after applying transformations:
    #
    # @example
    #   img = MiniMagick::Image.open 'image.jpg'
    #   img.crop '20x30+10+5'
    #   img.colorspace 'Gray'
    #   pixels = img.get_pixels
    #
    # In this example, all pixels in pix should now have equal R, G, and B values.
    #
    # @param map [String] A code for the mapping of the pixel data. Must be either
    #   'RGB' or 'RGBA'. Default to 'RGB'
    # @return [Array] Matrix of each color of each pixel
    def get_pixels(map="RGB")
      raise ArgumentError, "Invalid map value" unless ["RGB", "RGBA"].include?(map)
      convert = MiniMagick::Tool::Convert.new
      convert << path
      convert.depth(8)
      convert << "#{map}:-"

      # Do not use `convert.call` here. We need the whole binary (unstripped) output here.
      shell = MiniMagick::Shell.new
      output, * = shell.run(convert.command)

      pixels_array = output.unpack("C*")
      pixels = pixels_array.each_slice(map.length).each_slice(width).to_a

      # deallocate large intermediary objects
      output.clear
      pixels_array.clear

      pixels
    end

    ##
    # This is used to create image from pixels. This might be required if you
    # create pixels for some image processing reasons and you want to form 
    # image from those pixels.
    #
    # *DANGER*: This operation can be very expensive. Please try to use with
    # caution. 
    # 
    # @example
    #   # It is given in readme.md file
    ##
    def self.get_image_from_pixels(pixels, dimension, map, depth, mime_type)
      pixels = pixels.flatten
      blob = pixels.pack('C*')
      import_pixels(blob, *dimension, depth, map, mime_type)
    end

    ##
    # This is used to change the format of the image. That is, from "tiff to
    # jpg" or something like that. Once you run it, the instance is pointing to
    # a new file with a new extension!
    #
    # *DANGER*: This renames the file that the instance is pointing to. So, if
    # you manually opened the file with Image.new(file_path)... Then that file
    # is DELETED! If you used Image.open(file) then you are OK. The original
    # file will still be there. But, any changes to it might not be...
    #
    # Formatting an animation into a non-animated type will result in
    # ImageMagick creating multiple pages (starting with 0).  You can choose
    # which page you want to manipulate.  We default to the first page.
    #
    # If you would like to convert between animated formats, pass nil as your
    # page and ImageMagick will copy all of the pages.
    #
    # @param format [String] The target format... Like 'jpg', 'gif', 'tiff' etc.
    # @param page [Integer] If this is an animated gif, say which 'page' you
    #   want with an integer. Default 0 will convert only the first page; 'nil'
    #   will convert all pages.
    # @param read_opts [Hash] Any read options to be passed to ImageMagick
    #   for example: image.format('jpg', page, {density: '300'})
    # @yield [MiniMagick::Tool::Convert] It optionally yields the command,
    #   if you want to add something.
    # @return [self]
    #
    def format(format, page = 0, read_opts={})
      if @tempfile
        new_tempfile = MiniMagick::Utilities.tempfile(".#{format}")
        new_path = new_tempfile.path
      else
        new_path = Pathname(path).sub_ext(".#{format}").to_s
      end

      input_path = path.dup
      input_path << "[#{page}]" if page && !layer?

      MiniMagick::Tool::Convert.new do |convert|
        read_opts.each do |opt, val|
          convert.send(opt.to_s, val)
        end
        convert << input_path
        yield convert if block_given?
        convert << new_path
      end

      if @tempfile
        destroy!
        @tempfile = new_tempfile
      else
        File.delete(path) unless path == new_path || layer?
      end

      path.replace new_path
      @info.clear

      self
    rescue MiniMagick::Invalid, MiniMagick::Error => e
      new_tempfile.unlink if new_tempfile && @tempfile != new_tempfile
      raise e
    end

    ##
    # You can use multiple commands together using this method. Very easy to
    # use!
    #
    # @example
    #   image.combine_options do |c|
    #     c.draw "image Over 0,0 10,10 '#{MINUS_IMAGE_PATH}'"
    #     c.thumbnail "300x500>"
    #     c.background "blue"
    #   end
    #
    # @yield [MiniMagick::Tool::Mogrify]
    # @see http://www.imagemagick.org/script/mogrify.php
    # @return [self]
    #
    def combine_options(&block)
      mogrify(&block)
    end

    ##
    # If an unknown method is called then it is sent through the mogrify
    # program.
    #
    # @see http://www.imagemagick.org/script/mogrify.php
    # @return [self]
    #
    def method_missing(name, *args)
      mogrify do |builder|
        builder.send(name, *args)
      end
    end

    def respond_to_missing?(method_name, include_private = false)
      MiniMagick::Tool::Mogrify.option_methods.include?(method_name.to_s)
    end

    ##
    # Writes the temporary file out to either a file location (by passing in a
    # String) or by passing in a Stream that you can #write(chunk) to
    # repeatedly
    #
    # @param output_to [String, Pathname, #read] Some kind of stream object
    #   that needs to be read or a file path as a String
    #
    def write(output_to)
      case output_to
      when String, Pathname
        if layer?
          MiniMagick::Tool::Convert.new do |builder|
            builder << path
            builder << output_to
          end
        else
          FileUtils.copy_file path, output_to unless path == output_to.to_s
        end
      else
        IO.copy_stream File.open(path, "rb"), output_to
      end
    end

    ##
    # @example
    #  first_image = MiniMagick::Image.open "first.jpg"
    #  second_image = MiniMagick::Image.open "second.jpg"
    #  result = first_image.composite(second_image) do |c|
    #    c.compose "Over" # OverCompositeOp
    #    c.geometry "+20+20" # copy second_image onto first_image from (20, 20)
    #  end
    #  result.write "output.jpg"
    #
    # @see http://www.imagemagick.org/script/composite.php
    #
    def composite(other_image, output_extension = type.downcase, mask = nil)
      output_tempfile = MiniMagick::Utilities.tempfile(".#{output_extension}")

      MiniMagick::Tool::Composite.new do |composite|
        yield composite if block_given?
        composite << other_image.path
        composite << path
        composite << mask.path if mask
        composite << output_tempfile.path
      end

      Image.new(output_tempfile.path, output_tempfile)
    end

    ##
    # Collapse images with sequences to the first frame (i.e. animated gifs) and
    # preserve quality.
    #
    # @param frame [Integer] The frame to which to collapse to, defaults to `0`.
    # @return [self]
    #
    def collapse!(frame = 0)
      mogrify(frame) { |builder| builder.quality(100) }
    end

    ##
    # Destroys the tempfile (created by {.open}) if it exists.
    #
    def destroy!
      if @tempfile
        FileUtils.rm_f @tempfile.path.sub(/mpc$/, "cache") if @tempfile.path.end_with?(".mpc")
        @tempfile.unlink
      end
    end

    ##
    # Runs `identify` on itself. Accepts an optional block for adding more
    # options to `identify`.
    #
    # @example
    #   image = MiniMagick::Image.open("image.jpg")
    #   image.identify do |b|
    #     b.verbose
    #   end # runs `identify -verbose image.jpg`
    # @return [String] Output from `identify`
    # @yield [MiniMagick::Tool::Identify]
    #
    def identify
      MiniMagick::Tool::Identify.new do |builder|
        yield builder if block_given?
        builder << path
      end
    end

    # @private
    def run_command(tool_name, *args)
      MiniMagick::Tool.const_get(tool_name.capitalize).new do |builder|
        args.each do |arg|
          builder << arg
        end
      end
    end

    def mogrify(page = nil)
      MiniMagick::Tool::MogrifyRestricted.new do |builder|
        yield builder if block_given?
        builder << (page ? "#{path}[#{page}]" : path)
      end

      @info.clear

      self
    end

    def layer?
      path =~ /\[\d+\]$/
    end

    ##
    # Compares if image width
    # is greater than height
    # ============
    # |          |
    # |          |
    # ============
    # @return [Boolean]
    def landscape?
      width > height
    end

    ##
    # Compares if image height
    # is greater than width
    # ======
    # |    |
    # |    |
    # |    |
    # |    |
    # ======
    # @return [Boolean]
    def portrait?
      height > width
    end
  end
end