# frozen_string_literal: true # coding: ASCII-8BIT # FastImage finds the size or type of an image given its uri. # It is careful to only fetch and parse as much of the image as is needed to determine the result. # It does this by using a feature of Net::HTTP that yields strings from the resource being fetched # as soon as the packets arrive. # # No external libraries such as ImageMagick are used here, this is a very lightweight solution to # finding image information. # # FastImage knows about GIF, JPEG, BMP, TIFF, ICO, CUR, PNG, PSD, SVG and WEBP files. # # FastImage can also read files from the local filesystem by supplying the path instead of a uri. # In this case FastImage reads the file in chunks of 256 bytes until # it has enough. This is possibly a useful bandwidth-saving feature if the file is on a network # attached disk rather than truly local. # # FastImage will automatically read from any object that responds to :read - for # instance an IO object if that is passed instead of a URI. # # FastImage will follow up to 4 HTTP redirects to get the image. # # FastImage also provides a reader for the content length header provided in HTTP. # This may be useful to assess the file size of an image, but do not rely on it exclusively - # it will not be present in chunked responses for instance. # # FastImage accepts additional HTTP headers. This can be used to set a user agent # or referrer which some servers require. Pass an :http_header argument to specify headers, # e.g., :http_header => {'User-Agent' => 'Fake Browser'}. # # FastImage can give you information about the parsed display orientation of an image with Exif # data (jpeg or tiff). # # === Examples # require 'fastimage' # # FastImage.size("http://stephensykes.com/images/ss.com_x.gif") # => [266, 56] # FastImage.type("http://stephensykes.com/images/pngimage") # => :png # FastImage.type("/some/local/file.gif") # => :gif # File.open("/some/local/file.gif", "r") {|io| FastImage.type(io)} # => :gif # FastImage.new("http://stephensykes.com/images/pngimage").content_length # => 432 # FastImage.new("http://stephensykes.com/images/ExifOrientation3.jpg").orientation # => 3 # # === References # * http://www.anttikupila.com/flash/getting-jpg-dimensions-with-as3-without-loading-the-entire-file/ # * http://pennysmalls.wordpress.com/2008/08/19/find-jpeg-dimensions-fast-in-pure-ruby-no-ima/ # * https://rubygems.org/gems/imagesize # * https://github.com/remvee/exifr # require 'net/https' require 'delegate' require 'pathname' require 'zlib' require 'base64' require 'uri' # see http://stackoverflow.com/questions/5208851/i/41048816#41048816 if RUBY_VERSION < "2.2" module URI DEFAULT_PARSER = Parser.new(:HOSTNAME => "(?:(?:[a-zA-Z\\d](?:[-\\_a-zA-Z\\d]*[a-zA-Z\\d])?)\\.)*(?:[a-zA-Z](?:[-\\_a-zA-Z\\d]*[a-zA-Z\\d])?)\\.?") end end class FastImage attr_reader :size, :type, :content_length, :orientation attr_reader :bytes_read class FastImageException < StandardError # :nodoc: end class UnknownImageType < FastImageException # :nodoc: end class ImageFetchFailure < FastImageException # :nodoc: end class SizeNotFound < FastImageException # :nodoc: end class CannotParseImage < FastImageException # :nodoc: end DefaultTimeout = 2 unless const_defined?(:DefaultTimeout) LocalFileChunkSize = 256 unless const_defined?(:LocalFileChunkSize) # Returns an array containing the width and height of the image. # It will return nil if the image could not be fetched, or if the image type was not recognised. # # By default there is a timeout of 2 seconds for opening and reading from a remote server. # This can be changed by passing a :timeout => number_of_seconds in the options. # # If you wish FastImage to raise if it cannot size the image for any reason, then pass # :raise_on_failure => true in the options. # # FastImage knows about GIF, JPEG, BMP, TIFF, ICO, CUR, PNG, PSD, SVG and WEBP files. # # === Example # # require 'fastimage' # # FastImage.size("http://stephensykes.com/images/ss.com_x.gif") # => [266, 56] # FastImage.size("http://stephensykes.com/images/pngimage") # => [16, 16] # FastImage.size("http://farm4.static.flickr.com/3023/3047236863_9dce98b836.jpg") # => [500, 375] # FastImage.size("http://www-ece.rice.edu/~wakin/images/lena512.bmp") # => [512, 512] # FastImage.size("test/fixtures/test.jpg") # => [882, 470] # FastImage.size("http://pennysmalls.com/does_not_exist") # => nil # FastImage.size("http://pennysmalls.com/does_not_exist", :raise_on_failure=>true) # => raises FastImage::ImageFetchFailure # FastImage.size("http://stephensykes.com/favicon.ico", :raise_on_failure=>true) # => [16, 16] # FastImage.size("http://stephensykes.com/images/squareBlue.icns", :raise_on_failure=>true) # => raises FastImage::UnknownImageType # FastImage.size("http://stephensykes.com/favicon.ico", :raise_on_failure=>true, :timeout=>0.01) # => raises FastImage::ImageFetchFailure # FastImage.size("http://stephensykes.com/images/faulty.jpg", :raise_on_failure=>true) # => raises FastImage::SizeNotFound # # === Supported options # [:timeout] # Overrides the default timeout of 2 seconds. Applies both to reading from and opening the http connection. # [:raise_on_failure] # If set to true causes an exception to be raised if the image size cannot be found for any reason. # def self.size(uri, options={}) new(uri, options).size end # Returns an symbol indicating the image type fetched from a uri. # It will return nil if the image could not be fetched, or if the image type was not recognised. # # By default there is a timeout of 2 seconds for opening and reading from a remote server. # This can be changed by passing a :timeout => number_of_seconds in the options. # # If you wish FastImage to raise if it cannot find the type of the image for any reason, then pass # :raise_on_failure => true in the options. # # === Example # # require 'fastimage' # # FastImage.type("http://stephensykes.com/images/ss.com_x.gif") # => :gif # FastImage.type("http://stephensykes.com/images/pngimage") # => :png # FastImage.type("http://farm4.static.flickr.com/3023/3047236863_9dce98b836.jpg") # => :jpeg # FastImage.type("http://www-ece.rice.edu/~wakin/images/lena512.bmp") # => :bmp # FastImage.type("test/fixtures/test.jpg") # => :jpeg # FastImage.type("http://stephensykes.com/does_not_exist") # => nil # File.open("/some/local/file.gif", "r") {|io| FastImage.type(io)} # => :gif # FastImage.type("test/fixtures/test.tiff") # => :tiff # FastImage.type("test/fixtures/test.psd") # => :psd # # === Supported options # [:timeout] # Overrides the default timeout of 2 seconds. Applies both to reading from and opening the http connection. # [:raise_on_failure] # If set to true causes an exception to be raised if the image type cannot be found for any reason. # def self.type(uri, options={}) new(uri, options.merge(:type_only=>true)).type end def initialize(uri, options={}) @uri = uri @options = { :type_only => false, :timeout => DefaultTimeout, :raise_on_failure => false, :proxy => nil, :http_header => {} }.merge(options) @property = @options[:type_only] ? :type : :size @type, @state = nil if uri.respond_to?(:read) fetch_using_read(uri) elsif uri.start_with?('data:') fetch_using_base64(uri) else begin @parsed_uri = URI.parse(uri) rescue URI::InvalidURIError fetch_using_file_open else if @parsed_uri.scheme == "http" || @parsed_uri.scheme == "https" fetch_using_http else fetch_using_file_open end end end raise SizeNotFound if @options[:raise_on_failure] && @property == :size && !@size rescue Timeout::Error, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, ImageFetchFailure, Net::HTTPBadResponse, EOFError, Errno::ENOENT, OpenSSL::SSL::SSLError raise ImageFetchFailure if @options[:raise_on_failure] rescue NoMethodError # 1.8.7p248 can raise this due to a net/http bug raise ImageFetchFailure if @options[:raise_on_failure] rescue UnknownImageType raise UnknownImageType if @options[:raise_on_failure] rescue CannotParseImage if @options[:raise_on_failure] if @property == :size raise SizeNotFound else raise ImageFetchFailure end end ensure uri.rewind if uri.respond_to?(:rewind) end private def fetch_using_http @redirect_count = 0 fetch_using_http_from_parsed_uri end def fetch_using_http_from_parsed_uri http_header = {'Accept-Encoding' => 'identity'}.merge(@options[:http_header]) setup_http @http.request_get(@parsed_uri.request_uri, http_header) do |res| if res.is_a?(Net::HTTPRedirection) && @redirect_count < 4 @redirect_count += 1 begin @parsed_uri = URI.join(@parsed_uri, URI.escape(res['Location'])) rescue URI::InvalidURIError else fetch_using_http_from_parsed_uri break end end raise ImageFetchFailure unless res.is_a?(Net::HTTPSuccess) @content_length = res.content_length read_fiber = Fiber.new do res.read_body do |str| Fiber.yield str end end case res['content-encoding'] when 'deflate', 'gzip', 'x-gzip' begin gzip = Zlib::GzipReader.new(FiberStream.new(read_fiber)) rescue FiberError, Zlib::GzipFile::Error raise CannotParseImage end read_fiber = Fiber.new do while data = gzip.readline Fiber.yield data end end end parse_packets FiberStream.new(read_fiber) break # needed to actively quit out of the fetch end end def protocol_relative_url?(url) url.start_with?("//") end def proxy_uri begin if @options[:proxy] proxy = URI.parse(@options[:proxy]) else proxy = ENV['http_proxy'] && ENV['http_proxy'] != "" ? URI.parse(ENV['http_proxy']) : nil end rescue URI::InvalidURIError proxy = nil end proxy end def setup_http proxy = proxy_uri if proxy @http = Net::HTTP::Proxy(proxy.host, proxy.port).new(@parsed_uri.host, @parsed_uri.port) else @http = Net::HTTP.new(@parsed_uri.host, @parsed_uri.port) end @http.use_ssl = (@parsed_uri.scheme == "https") @http.verify_mode = OpenSSL::SSL::VERIFY_NONE @http.open_timeout = @options[:timeout] @http.read_timeout = @options[:timeout] end def fetch_using_read(readable) # Pathnames respond to read, but always return the first # chunk of the file unlike an IO (even though the # docuementation for it refers to IO). Need to supply # an offset in this case. if readable.is_a?(Pathname) read_fiber = Fiber.new do offset = 0 while str = readable.read(LocalFileChunkSize, offset) Fiber.yield str offset += LocalFileChunkSize end end else read_fiber = Fiber.new do while str = readable.read(LocalFileChunkSize) Fiber.yield str end end end parse_packets FiberStream.new(read_fiber) end def fetch_using_file_open @content_length = File.size?(@uri) File.open(@uri) do |s| fetch_using_read(s) end end def parse_packets(stream) @stream = stream begin result = send("parse_#{@property}") if result # extract exif orientation if it was found if @property == :size && result.size == 3 @orientation = result.pop else @orientation = 1 end instance_variable_set("@#{@property}", result) else raise CannotParseImage end rescue FiberError raise CannotParseImage end end def parse_size @type = parse_type unless @type send("parse_size_for_#{@type}") end def fetch_using_base64(uri) data = uri.split(',')[1] decoded = Base64.decode64(data) @content_length = decoded.size fetch_using_read StringIO.new(decoded) end module StreamUtil # :nodoc: def read_byte read(1)[0].ord end def read_int read(2).unpack('n')[0] end def read_string_int value = [] while read(1) =~ /(\d)/ value << $1 end value.join.to_i end end class FiberStream # :nodoc: include StreamUtil attr_reader :pos def initialize(read_fiber) @read_fiber = read_fiber @pos = 0 @strpos = 0 @str = '' end # Peeking beyond the end of the input will raise def peek(n) while @strpos + n - 1 >= @str.size unused_str = @str[@strpos..-1] new_string = @read_fiber.resume raise CannotParseImage if !new_string # we are dealing with bytes here, so force the encoding new_string.force_encoding("ASCII-8BIT") if String.method_defined? :force_encoding @str = unused_str + new_string @strpos = 0 end @str[@strpos..(@strpos + n - 1)] end def read(n) result = peek(n) @strpos += n @pos += n result end def skip(n) discarded = 0 fetched = @str[@strpos..-1].size while n > fetched discarded += @str[@strpos..-1].size new_string = @read_fiber.resume raise CannotParseImage if !new_string new_string.force_encoding("ASCII-8BIT") if String.method_defined? :force_encoding fetched += new_string.size @str = new_string @strpos = 0 end @strpos = @strpos + n - discarded @pos += n end end class IOStream < SimpleDelegator # :nodoc: include StreamUtil end def parse_type parsed_type = case @stream.peek(2) when "BM" :bmp when "GI" :gif when 0xff.chr + 0xd8.chr :jpeg when 0x89.chr + "P" :png when "II", "MM" :tiff when '8B' :psd when "\0\0" # ico has either a 1 (for ico format) or 2 (for cursor) at offset 3 case @stream.peek(3).bytes.to_a.last when 1 then :ico when 2 then :cur end when "RI" :webp if @stream.peek(12)[8..11] == "WEBP" when "> 6))] end def parse_size_vp8x flags = @stream.read(4).unpack("C")[0] b1, b2, b3, b4, b5, b6 = @stream.read(6).unpack("CCCCCC") width, height = 1 + b1 + (b2 << 8) + (b3 << 16), 1 + b4 + (b5 << 8) + (b6 << 16) if flags & 8 > 0 # exif # parse exif for orientation # TODO: find or create test images for this end return [width, height] end class Exif # :nodoc: attr_reader :width, :height, :orientation def initialize(stream) @stream = stream @width, @height, @orientation = nil parse_exif end def rotated? @orientation >= 5 end private def get_exif_byte_order byte_order = @stream.read(2) case byte_order when 'II' @short, @long = 'v', 'V' when 'MM' @short, @long = 'n', 'N' else raise CannotParseImage end end def parse_exif_ifd tag_count = @stream.read(2).unpack(@short)[0] tag_count.downto(1) do type = @stream.read(2).unpack(@short)[0] @stream.read(6) data = @stream.read(2).unpack(@short)[0] case type when 0x0100 # image width @width = data when 0x0101 # image height @height = data when 0x0112 # orientation @orientation = data end if @width && @height && @orientation return # no need to parse more end @stream.read(2) end end def parse_exif @start_byte = @stream.pos get_exif_byte_order @stream.read(2) # 42 offset = @stream.read(4).unpack(@long)[0] if @stream.respond_to?(:skip) @stream.skip(offset - 8) else @stream.read(offset - 8) end parse_exif_ifd @orientation ||= 1 end end def parse_size_for_tiff exif = Exif.new(@stream) if exif.rotated? [exif.height, exif.width, exif.orientation] else [exif.width, exif.height, exif.orientation] end end def parse_size_for_psd @stream.read(26).unpack("x14NN").reverse end class Svg # :nodoc: def initialize(stream) @stream = stream @width, @height, @ratio, @viewbox_width, @viewbox_height = nil parse_svg end def width_and_height if @width && @height [@width, @height] elsif @width && @ratio [@width, @width / @ratio] elsif @height && @ratio [@height * @ratio, @height] elsif @viewbox_width && @viewbox_height [@viewbox_width, @viewbox_height] else nil end end private def parse_svg attr_name = [] state = nil while (char = @stream.read(1)) && state != :stop do case char when "=" if attr_name.join =~ /width/i @stream.read(1) @width = @stream.read_string_int return if @height elsif attr_name.join =~ /height/i @stream.read(1) @height = @stream.read_string_int return if @width elsif attr_name.join =~ /viewbox/i values = attr_value.split(/\s/) if values[2].to_f > 0 && values[3].to_f > 0 @ratio = values[2].to_f / values[3].to_f @viewbox_width = values[2].to_i @viewbox_height = values[3].to_i end end when /\w/ attr_name << char when "<" attr_name = [char] when ">" state = :stop if state == :started else state = :started if attr_name.join == "