Repository: sdsykes/fastimage (ruby)

Issue:
FastImage.size() breaks on some iPhone .DNG/.TIFF images. Possibly ProRaw related?
There are some iPhone-generated .DNGs that seem to be causing `FastImage.size()` to return [0,0].

Given these example images (not mine): https://www.dropbox.com/scl/fo/w76njtviyc2tipplny42k/AJuB_2lZhZyiyHgWgGgsyNA?dl=0&e=2&rlkey=fh7r7odywwj5s6cwvk6ebqwm9 or https://www.dropbox.com/scl/fo/hawcc5qf30wvspb7n18jv/AMWewSuf0WaUb1r0b9snXdo?rlkey=34csa6pwq9yyvug1poy1ujztg&e=4&dl=0

FastImage will fail to produce their sizes, even though other libs work:

```
FastImage: [0, 0]
ImageSize: [4032, 3024]
Dimensions: [4032, 3024]
ImageProcessing (libvips): [4032, 3024]
```

Tested with this simple script:
```ruby
require 'fastimage'
require 'image_size'
require 'image_processing/vips'
require 'dimensions'


# Function to get dimensions using FastImage
def dimensions_fastimage(file)
  FastImage.size(file)
rescue => e
  puts "FastImage error: #{e.message}"
  nil
end

# Function to get dimensions using ImageSize
def dimensions_image_size(file)
  size = ImageSize.path(file)
  [size.width, size.height]
rescue => e
  puts "ImageSize error: #{e.message}"
  nil
end

# Function to get dimensions using ImageProcessing with libvips
def dimensions_image_processing(file)
  vips_image = Vips::Image.new_from_file(file)
  [vips_image.width, vips_image.height]
rescue => e
  puts "ImageProcessing error: #{e.message}"
  nil
end

# Function to get dimensions using Dimensions
def dimensions_dimensions(file)
  Dimensions.dimensions(file)
rescue => e
  puts "Dimensions error: #{e.message}"
  nil
end


# Main program
if ARGV.empty?
  puts "Usage: ruby image-sizes.rb <image_file>"
  exit
end

image_file = ARGV[0]

puts "Comparing image dimensions for: #{image_file}"
puts "FastImage: #{dimensions_fastimage(image_file)}"
puts "ImageSize: #{dimensions_image_size(image_file)}"
puts "Dimensions: #{dimensions_dimensions(image_file)}"
puts "ImageProcessing (libvips): #{dimensions_image_processing(image_file)}"

```

This also seems to affect .TIFF files that have embedded Apple .DNGs, such as the ones outputted by the [ProCamera app](https://apps.apple.com/us/app/procamera-professional-camera/id694647259).


Discussion:

Patch:
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,4 +1,17 @@
+Version 2.4.0
+
+03-Jan-2025
+
+- IMPROVED: Refactor code into multiple files
+- FIX: error rising from redirects to unknown url scheme
+- FIX: Handle tiff format with long dimensions values
+- FIX: Remove problematic dependency on base64 gem
+- IMPROVED: properties including content_length are fetched more lazily improving performance
+
 Version 2.3.1
+
+01-Apr-2024
+
 - FIX: avoid bug where a NoMethodError exception is raised on faulty images
 
 Version 2.3.0
diff --git a/lib/fastimage.rb b/lib/fastimage.rb
--- a/lib/fastimage.rb
+++ b/lib/fastimage.rb
@@ -9,7 +9,7 @@
 # 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, WEBP and JXL files.
+# FastImage knows about GIF, JPEG, BMP, TIFF, ICO, CUR, PNG, HEIC/HEIF, AVIF, PSD, SVG, WEBP and JXL 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
@@ -59,9 +59,10 @@
 require 'delegate'
 require 'pathname'
 require 'zlib'
-require 'base64'
 require 'uri'
 require 'stringio'
+
+require_relative 'fastimage/fastimage'
 require_relative 'fastimage/version'
 
 # see http://stackoverflow.com/questions/5208851/i/41048816#41048816
@@ -70,1161 +71,3 @@ 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, :animated
-
-  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
-  class BadImageURI < FastImageException # :nodoc:
-  end
-
-  DefaultTimeout = 2 unless const_defined?(:DefaultTimeout)
-
-  LocalFileChunkSize = 256 unless const_defined?(:LocalFileChunkSize)
-
-  SUPPORTED_IMAGE_TYPES = [:bmp, :gif, :jpeg, :png, :tiff, :psd, :heic, :heif, :webp, :svg, :ico, :cur, :jxl].freeze
-
-  # 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, WEBP and JXL files.
-  #
-  # === Example
-  #
-  #   require 'fastimage'
-  #
-  #   FastImage.size("https://switchstep.com/images/ios.gif")
-  #   => [196, 283]
-  #   FastImage.size("http://switchstep.com/images/ss_logo.png")
-  #   => [300, 300]
-  #   FastImage.size("https://upload.wikimedia.org/wikipedia/commons/0/09/Jpeg_thumb_artifacts_test.jpg")
-  #   => [1280, 800]
-  #   FastImage.size("https://eeweb.engineering.nyu.edu/~yao/EL5123/image/lena_gray.bmp")
-  #   => [512, 512]
-  #   FastImage.size("test/fixtures/test.jpg")
-  #   => [882, 470]
-  #   FastImage.size("http://switchstep.com/does_not_exist")
-  #   => nil
-  #   FastImage.size("http://switchstep.com/does_not_exist", :raise_on_failure=>true)
-  #   => raises FastImage::ImageFetchFailure
-  #   FastImage.size("http://switchstep.com/images/favicon.ico", :raise_on_failure=>true)
-  #   => [16, 16]
-  #   FastImage.size("http://switchstep.com/foo.ics", :raise_on_failure=>true)
-  #   => raises FastImage::UnknownImageType
-  #   FastImage.size("http://switchstep.com/images/favicon.ico", :raise_on_failure=>true, :timeout=>0.01)
-  #   => raises FastImage::ImageFetchFailure
-  #   FastImage.size("http://switchstep.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("https://switchstep.com/images/ios.gif")
-  #   => :gif
-  #   FastImage.type("http://switchstep.com/images/ss_logo.png")
-  #   => :png
-  #   FastImage.type("https://upload.wikimedia.org/wikipedia/commons/0/09/Jpeg_thumb_artifacts_test.jpg")
-  #   => :jpeg
-  #   FastImage.type("https://eeweb.engineering.nyu.edu/~yao/EL5123/image/lena_gray.bmp")
-  #   => :bmp
-  #   FastImage.type("test/fixtures/test.jpg")
-  #   => :jpeg
-  #   FastImage.type("http://switchstep.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
-
-  # Returns a boolean value indicating the image is animated.
-  # 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.animated?("test/fixtures/test.gif")
-  #   => false
-  #   FastImage.animated?("test/fixtures/animated.gif")
-  #   => true
-  #
-  # === 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.animated?(uri, options={})
-    new(uri, options.merge(:animated_only=>true)).animated
-  end
-
-  def initialize(uri, options={})
-    @uri = uri
-    @options = {
-      :type_only        => false,
-      :timeout          => DefaultTimeout,
-      :raise_on_failure => false,
-      :proxy            => nil,
-      :http_header      => {}
-    }.merge(options)
-
-    @property = if @options[:animated_only]
-      :animated
-    elsif @options[:type_only]
-      :type
-    else
-      :size
-    end
-
-    raise BadImageURI if uri.nil?
-
-    @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,
-    Errno::ENETUNREACH, ImageFetchFailure, Net::HTTPBadResponse, EOFError, Errno::ENOENT,
-    OpenSSL::SSL::SSLError
-    raise ImageFetchFailure if @options[:raise_on_failure]
-  rescue UnknownImageType, BadImageURI
-    raise 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
-
-  # Some invalid locations need escaping
-  def escaped_location(location)
-    begin
-      URI(location)
-    rescue URI::InvalidURIError
-      ::URI::DEFAULT_PARSER.escape(location)
-    else
-      location
-    end
-  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
-          location = res['Location']
-          raise ImageFetchFailure if location.nil? || location.empty?
-
-          @parsed_uri = URI.join(@parsed_uri, escaped_location(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
-        nil
-      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
-          nil
-        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, proxy.user, proxy.password).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)
-    readable.rewind if readable.respond_to?(:rewind)
-    # 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
-        nil
-      end
-    else
-      read_fiber = Fiber.new do
-        while str = readable.read(LocalFileChunkSize)
-          Fiber.yield str
-        end
-        nil
-      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 != nil
-        # 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 parse_animated
-    @type = parse_type unless @type
-    %i(gif png webp avif).include?(@type) ? send("parse_animated_for_#{@type}") : nil
-  end
-
-  def fetch_using_base64(uri)
-    decoded = begin
-      Base64.decode64(uri.split(',')[1])
-    rescue
-      raise CannotParseImage
-    end
-    @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
-
-    # read_fiber should return nil if it no longer has anything to return when resumed
-    # so the result of the whole Fiber block should be set to be nil in case yield is no
-    # longer called
-    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 > @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 new_string.respond_to? :force_encoding
-
-        @str = unused_str + new_string
-        @strpos = 0
-      end
-
-      @str[@strpos, n]
-    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 new_string.respond_to? :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"
-      case @stream.peek(11)[8..10]
-      when "APC", "CR\002"
-        nil  # do not recognise CRW or CR2 as tiff
-      else
-        :tiff
-      end
-    when '8B'
-      :psd
-    when "\xFF\x0A".b
-      :jxl
-    when "\0\0"
-      case @stream.peek(3).bytes.to_a.last
-      when 0
-        # http://www.ftyps.com/what.html
-        case @stream.peek(12)[4..-1]
-        when "ftypavif"
-          :avif
-        when "ftypavis"
-          :avif
-        when "ftypheic"
-          :heic
-        when "ftypmif1"
-          :heif
-        else
-          if @stream.peek(7)[4..-1] == 'JXL'
-            :jxl
-          end
-        end
-      # ico has either a 1 (for ico format) or 2 (for cursor) at offset 3
-      when 1 then :ico
-      when 2 then :cur
-      end
-    when "RI"
-      :webp if @stream.peek(12)[8..11] == "WEBP"
-    when "<s"
-      :svg if @stream.peek(4) == "<svg"
-    when /\s\s|\s<|<[?!]/, 0xef.chr + 0xbb.chr
-      # Peek 10 more chars each time, and if end of file is reached just raise
-      # unknown. We assume the <svg tag cannot be within 10 chars of the end of
-      # the file, and is within the first 1000 chars.
-      begin
-        :svg if (1..100).detect {|n| @stream.peek(10 * n).include?("<svg")}
-      rescue FiberError, CannotParseImage
-        nil
-      end
-    end
-
-    parsed_type or raise UnknownImageType
-  end
-
-  def parse_size_for_ico
-    icons = @stream.read(6)[4..5].unpack('v').first
-    sizes = icons.times.map { @stream.read(16).unpack('C2').map { |x| x == 0 ? 256 : x } }.sort_by { |w,h| w * h }
-    sizes.last
-  end
-  alias_method :parse_size_for_cur, :parse_size_for_ico
-
-  # HEIC/AVIF are a special case of the general ISO_BMFF format, in which all data is encapsulated in typed boxes,
-  # with a mandatory ftyp box that is used to indicate particular file types. Is composed of nested "boxes". Each
-  # box has a header composed of
-  # - Size (32 bit integer)
-  # - Box type (4 chars)
-  # - Extended size: only if size === 1, the type field is followed by 64 bit integer of extended size
-  # - Payload: Type-dependent
-  class IsoBmff # :nodoc:
-    def initialize(stream)
-      @stream = stream
-    end
-
-    def width_and_height
-      @rotation = 0
-      @max_size = nil
-      @primary_box = nil
-      @ipma_boxes = []
-      @ispe_boxes = []
-      @final_size = nil
-
-      catch :finish do
-        read_boxes!
-      end
-
-      if [90, 270].include?(@rotation)
-        @final_size.reverse
-      else
-        @final_size
-      end
-    end
-
-    private
-
-    # Format specs: https://www.loc.gov/preservation/digital/formats/fdd/fdd000525.shtml
-
-    # If you need to inspect a heic/heif file, use
-    # https://gpac.github.io/mp4box.js/test/filereader.html
-    def read_boxes!(max_read_bytes = nil)
-      end_pos = max_read_bytes.nil? ? nil : @stream.pos + max_read_bytes
-      index = 0
-
-      loop do
-        return if end_pos && @stream.pos >= end_pos
-
-        box_type, box_size = read_box_header!
-
-        case box_type
-        when "meta"
-          handle_meta_box(box_size)
-        when "pitm"
-          handle_pitm_box(box_size)
-        when "ipma"
-          handle_ipma_box(box_size)
-        when "hdlr"
-          handle_hdlr_box(box_size)
-        when "iprp", "ipco"
-          read_boxes!(box_size)
-        when "irot"
-          handle_irot_box
-        when "ispe"
-          handle_ispe_box(box_size, index)
-        when "mdat"
-          @stream.skip(box_size)
-        when "jxlc"
-          handle_jxlc_box(box_size)
-        else
-          @stream.skip(box_size)
-        end
-
-        index += 1
-      end
-    end
-
-    def handle_irot_box
-      @rotation = (read_uint8! & 0x3) * 90
-    end
-
-    def handle_ispe_box(box_size, index)
-      throw :finish if box_size < 12
-
-      data = @stream.read(box_size)
-      width, height = data[4...12].unpack("N2")
-      @ispe_boxes << { index: index, size: [width, height] }
-    end
-
-    def handle_hdlr_box(box_size)
-      throw :finish if box_size < 12
-
-      data = @stream.read(box_size)
-      throw :finish if data[8...12] != "pict"
-    end
-
-    def handle_ipma_box(box_size)
-      @stream.read(3)
-      flags3 = read_uint8!
-      entries_count = read_uint32!
-
-      entries_count.times do
-        id = read_uint16!
-        essen_count = read_uint8!
-
-        essen_count.times do
-          property_index = read_uint8! & 0x7F
-
-          if flags3 & 1 == 1
-            property_index = (property_index << 7) + read_uint8!
-          end
-
-          @ipma_boxes << { id: id, property_index: property_index - 1 }
-        end
-      end
-    end
-
-    def handle_pitm_box(box_size)
-      data = @stream.read(box_size)
-      @primary_box = data[4...6].unpack("S>")[0]
-    end
-
-    def handle_meta_box(box_size)
-      throw :finish if box_size < 4
-
-      @stream.read(4)
-      read_boxes!(box_size - 4)
-
-      throw :finish if !@primary_box
-
-      primary_indices = @ipma_boxes
-                        .select { |box| box[:id] == @primary_box }
-                        .map { |box| box[:property_index] }
-
-      ispe_box = @ispe_boxes.find do |box|
-        primary_indices.include?(box[:index])
-      end
-
-      if ispe_box
-        @final_size = ispe_box[:size]
-      end
-
-      throw :finish
-    end
-
-    def handle_jxlc_box(box_size)
-      @final_size = JXL.new(@stream).read_size_header
-      throw :finish
-    end
-
-    def read_box_header!
-      size = read_uint32!
-      type = @stream.read(4)
-      size = read_uint64! - 8 if size == 1
-      [type, size - 8]
-    end
-
-    def read_uint8!
-      @stream.read(1).unpack("C")[0]
-    end
-
-    def read_uint16!
-      @stream.read(2).unpack("S>")[0]
-    end
-
-    def read_uint32!
-      @stream.read(4).unpack("N")[0]
-    end
-
-    def read_uint64!
-      @stream.read(8).unpack("Q>")[0]
-    end
-  end
-
-  def parse_size_for_avif
-    bmff = IsoBmff.new(@stream)
-    bmff.width_and_height
-  end
-
-  def parse_size_for_heic
-    bmff = IsoBmff.new(@stream)
-    bmff.width_and_height
-  end
-
-  def parse_size_for_heif
-    bmff = IsoBmff.new(@stream)
-    bmff.width_and_height
-  end
-
-  class JXL
-    LENGTHS = [9, 13, 18, 30]
-    MULTIPLIERS = [1, 1.2, Rational(4, 3), 1.5, Rational(16, 9), 1.25, 2]
-
-    def initialize(stream)
-      @stream = stream
-      @bit_counter = 0
-    end
-
-    def read_size_header
-      @words = @stream.read(6)[2..5].unpack('vv')
-
-      # small mode allows for values <= 256 that are divisible by 8
-      small = get_bits(1)
-      if small == 1
-        y = (get_bits(5) + 1) * 8
-        x = x_from_ratio(y)
-        if !x
-          x = (get_bits(5) + 1) * 8
-        end
-        return [x, y]
-      end
-
-      len = LENGTHS[get_bits(2)]
-      y = get_bits(len) + 1
-      x = x_from_ratio(y)
-      if !x
-        len = LENGTHS[get_bits(2)]
-        x = get_bits(len) + 1
-      end
-      [x, y]
-    end
-
-    def get_bits(size)
-      if @words.size < (@bit_counter + size) / 16 + 1
-        @words += @stream.read(4).unpack('vv')
-      end
-
-      dest_pos = 0
-      dest = 0
-      size.times do
-        word = @bit_counter / 16
-        source_pos = @bit_counter % 16
-        dest |= ((@words[word] & (1 << source_pos)) > 0 ? 1 : 0) << dest_pos
-        dest_pos += 1
-        @bit_counter += 1
-      end
-      dest
-    end
-
-    def x_from_ratio(y)
-      ratio = get_bits(3)
-      if ratio == 0
-        return nil
-      else
-        return (y * MULTIPLIERS[ratio - 1]).to_i
-      end
-    end
-  end
-
-  def parse_size_for_jxl
-    if @stream.peek(2) == "\xFF\x0A".b
-      JXL.new(@stream).read_size_header
-    else
-      bmff = IsoBmff.new(@stream)
-      bmff.width_and_height
-    end
-  end
-
-  class Gif # :nodoc:
-    def initialize(stream)
-      @stream = stream
-    end
-
-    def width_and_height
-      @stream.read(11)[6..10].unpack('SS')
-    end
-
-    # Checks if a delay between frames exists and if it does, then the GIFs is
-    # animated
-    def animated?
-      frames = 0
-
-      # "GIF" + version (3) + width (2) + height (2)
-      @stream.skip(10)
-
-      # fields (1) + bg color (1) + pixel ratio (1)
-      fields = @stream.read(3).unpack("CCC")[0]
-      if fields & 0x80 != 0 # Global Color Table
-        # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
-        @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
-      end
-
-      loop do
-        block_type = @stream.read(1).unpack("C")[0]
-
-        if block_type == 0x21 # Graphic Control Extension
-          # extension type (1) + size (1)
-          size = @stream.read(2).unpack("CC")[1]
-          @stream.skip(size)
-          skip_sub_blocks
-        elsif block_type == 0x2C # Image Descriptor
-          frames += 1
-          return true if frames > 1
-
-          # left position (2) + top position (2) + width (2) + height (2) + fields (1)
-          fields = @stream.read(9).unpack("SSSSC")[4]
-          if fields & 0x80 != 0 # Local Color Table
-            # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
-            @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
-          end
-
-          @stream.skip(1) # LZW min code size (1)
-          skip_sub_blocks
-        else
-          break # unrecognized block
-        end
-      end
-
-      false
-    end
-
-    private
-
-    def skip_sub_blocks
-      loop do
-        size = @stream.read(1).unpack("C")[0]
-        if size == 0
-          break
-        else
-          @stream.skip(size)
-        end
-      end
-    end
-  end
-
-  def parse_size_for_gif
-    gif = Gif.new(@stream)
-    gif.width_and_height
-  end
-
-  def parse_size_for_png
-    @stream.read(25)[16..24].unpack('NN')
-  end
-
-  def parse_size_for_jpeg
-    exif = nil
-    loop do
-      @state = case @state
-      when nil
-        @stream.skip(2)
-        :started
-      when :started
-        @stream.read_byte == 0xFF ? :sof : :started
-      when :sof
-        case @stream.read_byte
-        when 0xe1 # APP1
-          skip_chars = @stream.read_int - 2
-          data = @stream.read(skip_chars)
-          io = StringIO.new(data)
-          if io.read(4) == "Exif"
-            io.read(2)
-            new_exif = Exif.new(IOStream.new(io)) rescue nil
-            exif ||= new_exif # only use the first APP1 segment
-          end
-          :started
-        when 0xe0..0xef
-          :skipframe
-        when 0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF
-          :readsize
-        when 0xFF
-          :sof
-        else
-          :skipframe
-        end
-      when :skipframe
-        skip_chars = @stream.read_int - 2
-        @stream.skip(skip_chars)
-        :started
-      when :readsize
-        @stream.skip(3)
-        height = @stream.read_int
-        width = @stream.read_int
-        width, height = height, width if exif && exif.rotated?
-        return [width, height, exif ? exif.orientation : 1]
-      end
-    end
-  end
-
-  def parse_size_for_bmp
-    d = @stream.read(32)[14..28]
-    header = d.unpack("C")[0]
-
-    result = if header == 12
-               d[4..8].unpack('SS')
-             else
-               d[4..-1].unpack('l<l<')
-             end
-
-    # ImageHeight is expressed in pixels. The absolute value is necessary because ImageHeight can be negative
-    [result.first, result.last.abs]
-  end
-
-  def parse_size_for_webp
-    vp8 = @stream.read(16)[12..15]
-    _len = @stream.read(4).unpack("V")
-    case vp8
-    when "VP8 "
-      parse_size_vp8
-    when "VP8L"
-      parse_size_vp8l
-    when "VP8X"
-      parse_size_vp8x
-    else
-      nil
-    end
-  end
-
-  def parse_size_vp8
-    w, h = @stream.read(10).unpack("@6vv")
-    [w & 0x3fff, h & 0x3fff]
-  end
-
-  def parse_size_vp8l
-    @stream.skip(1) # 0x2f
-    b1, b2, b3, b4 = @stream.read(4).bytes.to_a
-    [1 + (((b2 & 0x3f) << 8) | b1), 1 + (((b4 & 0xF) << 10) | (b3 << 2) | ((b2 & 0xC0) >> 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 == "<svg"
-          attr_name.clear
-        end
-      end
-    end
-
-    def attr_value
-      @stream.read(1)
-
-      value = []
-      while @stream.read(1) =~ /([^"])/
-        value << $1
-      end
-      value.join
-    end
-  end
-
-  def parse_size_for_svg
-    svg = Svg.new(@stream)
-    svg.width_and_height
-  end
-
-  def parse_animated_for_gif
-    gif = Gif.new(@stream)
-    gif.animated?
-  end
-
-  def parse_animated_for_png
-    # Signature (8) + IHDR chunk (4 + 4 + 13 + 4)
-    @stream.read(33)
-
-    loop do
-      length = @stream.read(4).unpack("L>")[0]
-      type = @stream.read(4)
-
-      case type
-      when "acTL"
-        return true
-      when "IDAT"
-        return false
-      end
-
-      @stream.skip(length + 4)
-    end
-  end
-
-  def parse_animated_for_webp
-    vp8 = @stream.read(16)[12..15]
-    _len = @stream.read(4).unpack("V")
-    case vp8
-    when "VP8 "
-      false
-    when "VP8L"
-      false
-    when "VP8X"
-      flags = @stream.read(4).unpack("C")[0]
-      flags & 2 > 0
-    else
-      nil
-    end
-  end
-
-  def parse_animated_for_avif
-    @stream.peek(12)[4..-1] == "ftypavis"
-  end
-end
diff --git a/lib/fastimage/fastimage.rb b/lib/fastimage/fastimage.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage.rb
@@ -0,0 +1,471 @@
+require_relative 'fastimage_parsing/image_base'
+require_relative 'fastimage_parsing/stream_util'
+
+require_relative 'fastimage_parsing/avif'
+require_relative 'fastimage_parsing/bmp'
+require_relative 'fastimage_parsing/exif'
+require_relative 'fastimage_parsing/fiber_stream'
+require_relative 'fastimage_parsing/gif'
+require_relative 'fastimage_parsing/heic'
+require_relative 'fastimage_parsing/ico'
+require_relative 'fastimage_parsing/iso_bmff'
+require_relative 'fastimage_parsing/jpeg'
+require_relative 'fastimage_parsing/jxl'
+require_relative 'fastimage_parsing/jxlc'
+require_relative 'fastimage_parsing/png'
+require_relative 'fastimage_parsing/psd'
+require_relative 'fastimage_parsing/svg'
+require_relative 'fastimage_parsing/tiff'
+require_relative 'fastimage_parsing/type_parser'
+require_relative 'fastimage_parsing/webp'
+
+class FastImage
+  include FastImageParsing
+
+  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
+  class BadImageURI < FastImageException # :nodoc:
+  end
+
+  DefaultTimeout = 2 unless const_defined?(:DefaultTimeout)
+
+  LocalFileChunkSize = 256 unless const_defined?(:LocalFileChunkSize)
+
+  private
+
+  Parsers = {
+    :bmp => Bmp,
+    :gif => Gif,
+    :jpeg => Jpeg,
+    :png => Png,
+    :tiff => Tiff,
+    :psd => Psd,
+    :heic => Heic,
+    :heif => Heic,
+    :webp => Webp,
+    :svg => Svg,
+    :ico => Ico,
+    :cur => Ico,
+    :jxl => Jxl,
+    :avif => Avif
+  }.freeze
+
+  public
+
+  SUPPORTED_IMAGE_TYPES = Parsers.keys.freeze
+  
+  # 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, HEIC/HEIF, AVIF, PSD, SVG, WEBP and JXL files.
+  #
+  # === Example
+  #
+  #   require 'fastimage'
+  #
+  #   FastImage.size("https://switchstep.com/images/ios.gif")
+  #   => [196, 283]
+  #   FastImage.size("http://switchstep.com/images/ss_logo.png")
+  #   => [300, 300]
+  #   FastImage.size("https://upload.wikimedia.org/wikipedia/commons/0/09/Jpeg_thumb_artifacts_test.jpg")
+  #   => [1280, 800]
+  #   FastImage.size("https://eeweb.engineering.nyu.edu/~yao/EL5123/image/lena_gray.bmp")
+  #   => [512, 512]
+  #   FastImage.size("test/fixtures/test.jpg")
+  #   => [882, 470]
+  #   FastImage.size("http://switchstep.com/does_not_exist")
+  #   => nil
+  #   FastImage.size("http://switchstep.com/does_not_exist", :raise_on_failure=>true)
+  #   => raises FastImage::ImageFetchFailure
+  #   FastImage.size("http://switchstep.com/images/favicon.ico", :raise_on_failure=>true)
+  #   => [16, 16]
+  #   FastImage.size("http://switchstep.com/foo.ics", :raise_on_failure=>true)
+  #   => raises FastImage::UnknownImageType
+  #   FastImage.size("http://switchstep.com/images/favicon.ico", :raise_on_failure=>true, :timeout=>0.01)
+  #   => raises FastImage::ImageFetchFailure
+  #   FastImage.size("http://switchstep.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("https://switchstep.com/images/ios.gif")
+  #   => :gif
+  #   FastImage.type("http://switchstep.com/images/ss_logo.png")
+  #   => :png
+  #   FastImage.type("https://upload.wikimedia.org/wikipedia/commons/0/09/Jpeg_thumb_artifacts_test.jpg")
+  #   => :jpeg
+  #   FastImage.type("https://eeweb.engineering.nyu.edu/~yao/EL5123/image/lena_gray.bmp")
+  #   => :bmp
+  #   FastImage.type("test/fixtures/test.jpg")
+  #   => :jpeg
+  #   FastImage.type("http://switchstep.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).type
+  end
+
+  # Returns a boolean value indicating the image is animated.
+  # 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.animated?("test/fixtures/test.gif")
+  #   => false
+  #   FastImage.animated?("test/fixtures/animated.gif")
+  #   => true
+  #
+  # === 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.animated?(uri, options={})
+    new(uri, options).animated
+  end
+  
+  def initialize(uri, options={})
+    @uri = uri
+    @options = {
+      :timeout          => DefaultTimeout,
+      :raise_on_failure => false,
+      :proxy            => nil,
+      :http_header      => {}
+    }.merge(options)
+  end
+
+  def type
+    @property = :type
+    fetch unless defined?(@type)
+    @type
+  end
+
+  def size
+    @property = :size
+    begin
+      fetch unless defined?(@size)
+    rescue CannotParseImage
+    end
+
+    raise SizeNotFound if @options[:raise_on_failure] && !@size
+
+    @size
+  end
+
+  def orientation
+    size unless defined?(@size)
+    @orientation ||= 1 if @size
+  end
+
+  def width
+    size && @size[0]
+  end
+
+  def height
+    size && @size[1]
+  end
+
+  def animated
+    @property = :animated
+    fetch unless defined?(@animated)
+    @animated
+  end
+  
+  def content_length
+    @property = :content_length
+    fetch unless defined?(@content_length)
+    @content_length
+  end
+
+  # find an appropriate method to fetch the image according to the passed parameter
+  def fetch
+    raise BadImageURI if @uri.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,
+    Errno::ENETUNREACH, ImageFetchFailure, Net::HTTPBadResponse, EOFError, Errno::ENOENT,
+    OpenSSL::SSL::SSLError
+    raise ImageFetchFailure if @options[:raise_on_failure]
+  rescue UnknownImageType, BadImageURI, CannotParseImage
+    raise if @options[:raise_on_failure]
+  
+  ensure
+    @uri.rewind if @uri.respond_to?(:rewind)
+
+  end
+
+  private
+
+  def fetch_using_http
+    @redirect_count = 0
+
+    fetch_using_http_from_parsed_uri
+  end
+
+  # Some invalid locations need escaping
+  def escaped_location(location)
+    begin
+      URI(location)
+    rescue URI::InvalidURIError
+      ::URI::DEFAULT_PARSER.escape(location)
+    else
+      location
+    end
+  end
+
+  def fetch_using_http_from_parsed_uri
+    raise ImageFetchFailure unless @parsed_uri.is_a?(URI::HTTP)
+
+    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
+          location = res['Location']
+          raise ImageFetchFailure if location.nil? || location.empty?
+
+          @parsed_uri = URI.join(@parsed_uri, escaped_location(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
+      break if @property == :content_length
+
+      read_fiber = Fiber.new do
+        res.read_body do |str|
+          Fiber.yield str
+        end
+        nil
+      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
+          nil
+        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, proxy.user, proxy.password).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)
+    return @content_length = readable.size if @property == :content_length && readable.respond_to?(:size)
+
+    readable.rewind if readable.respond_to?(:rewind)
+    # 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
+        nil
+      end
+    else
+      read_fiber = Fiber.new do
+        while str = readable.read(LocalFileChunkSize)
+          Fiber.yield str
+        end
+        nil
+      end
+    end
+
+    parse_packets FiberStream.new(read_fiber)
+  end
+
+  def fetch_using_file_open
+    return @content_length = File.size?(@uri) if @property == :content_length
+
+    File.open(@uri) do |s|
+      fetch_using_read(s)
+    end
+  end
+
+  def fetch_using_base64(uri)
+    decoded = begin
+      uri.split(',')[1].unpack("m").first
+    rescue
+      raise CannotParseImage
+    end
+
+    fetch_using_read StringIO.new(decoded)
+  end
+  
+  def parse_packets(stream)
+    @stream = stream
+
+    begin
+      @type = TypeParser.new(@stream).type unless defined?(@type)
+
+      result = case @property
+      when :type
+        @type
+      when :size
+        parse_size
+      when :animated
+        parse_animated
+      end
+
+      if result != nil
+        # 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 parser_class
+    klass = Parsers[@type]
+    raise UnknownImageType unless klass
+    klass
+  end
+
+  def parse_size
+    parser_class.new(@stream).dimensions
+  end
+
+  def parse_animated
+    parser_class.new(@stream).animated?
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/avif.rb b/lib/fastimage/fastimage_parsing/avif.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/avif.rb
@@ -0,0 +1,12 @@
+module FastImageParsing
+  class Avif < ImageBase # :nodoc:  
+    def dimensions
+      bmff = IsoBmff.new(@stream)
+      [bmff.width, bmff.height]
+    end
+  
+    def animated?
+      @stream.peek(12)[4..-1] == "ftypavis"
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/bmp.rb b/lib/fastimage/fastimage_parsing/bmp.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/bmp.rb
@@ -0,0 +1,17 @@
+module FastImageParsing
+  class Bmp < ImageBase # :nodoc:
+    def dimensions
+      d = @stream.read(32)[14..28]
+      header = d.unpack("C")[0]
+
+      result = if header == 12
+                 d[4..8].unpack('SS')
+               else
+                 d[4..-1].unpack('l<l<')
+               end
+
+      # ImageHeight is expressed in pixels. The absolute value is necessary because ImageHeight can be negative
+      [result.first, result.last.abs]
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/exif.rb b/lib/fastimage/fastimage_parsing/exif.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/exif.rb
@@ -0,0 +1,76 @@
+module FastImageParsing
+  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 FastImage::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]
+        data_type = @stream.read(2).unpack(@short)[0]
+        @stream.read(4)
+
+        if data_type == 4
+          data = @stream.read(4).unpack(@long)[0]
+        else
+          data = @stream.read(2).unpack(@short)[0]
+          @stream.read(2)
+        end
+
+        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
+      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
+end
diff --git a/lib/fastimage/fastimage_parsing/fiber_stream.rb b/lib/fastimage/fastimage_parsing/fiber_stream.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/fiber_stream.rb
@@ -0,0 +1,58 @@
+module FastImageParsing
+  class FiberStream # :nodoc:
+    include StreamUtil
+    attr_reader :pos
+  
+    # read_fiber should return nil if it no longer has anything to return when resumed
+    # so the result of the whole Fiber block should be set to be nil in case yield is no
+    # longer called
+    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 > @str.size
+        unused_str = @str[@strpos..-1]
+  
+        new_string = @read_fiber.resume
+        raise FastImage::CannotParseImage if !new_string
+        # we are dealing with bytes here, so force the encoding
+        new_string.force_encoding("ASCII-8BIT") if new_string.respond_to? :force_encoding
+  
+        @str = unused_str + new_string
+        @strpos = 0
+      end
+  
+      @str[@strpos, n]
+    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 FastImage::CannotParseImage if !new_string
+  
+        new_string.force_encoding("ASCII-8BIT") if new_string.respond_to? :force_encoding
+  
+        fetched += new_string.size
+        @str = new_string
+        @strpos = 0
+      end
+      @strpos = @strpos + n - discarded
+      @pos += n
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/gif.rb b/lib/fastimage/fastimage_parsing/gif.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/gif.rb
@@ -0,0 +1,63 @@
+module FastImageParsing
+  class Gif < ImageBase # :nodoc:  
+    def dimensions
+      @stream.read(11)[6..10].unpack('SS')
+    end
+  
+    # Checks for multiple frames
+    def animated?
+      frames = 0
+  
+      # "GIF" + version (3) + width (2) + height (2)
+      @stream.skip(10)
+  
+      # fields (1) + bg color (1) + pixel ratio (1)
+      fields = @stream.read(3).unpack("CCC")[0]
+      if fields & 0x80 != 0 # Global Color Table
+        # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
+        @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
+      end
+  
+      loop do
+        block_type = @stream.read(1).unpack("C")[0]
+  
+        if block_type == 0x21 # Graphic Control Extension
+          # extension type (1) + size (1)
+          size = @stream.read(2).unpack("CC")[1]
+          @stream.skip(size)
+          skip_sub_blocks
+        elsif block_type == 0x2C # Image Descriptor
+          frames += 1
+          return true if frames > 1
+  
+          # left position (2) + top position (2) + width (2) + height (2) + fields (1)
+          fields = @stream.read(9).unpack("SSSSC")[4]
+          if fields & 0x80 != 0 # Local Color Table
+            # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
+            @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
+          end
+  
+          @stream.skip(1) # LZW min code size (1)
+          skip_sub_blocks
+        else
+          break # unrecognized block
+        end
+      end
+  
+      false
+    end
+  
+    private
+  
+    def skip_sub_blocks
+      loop do
+        size = @stream.read(1).unpack("C")[0]
+        if size == 0
+          break
+        else
+          @stream.skip(size)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/heic.rb b/lib/fastimage/fastimage_parsing/heic.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/heic.rb
@@ -0,0 +1,8 @@
+module FastImageParsing
+  class Heic < ImageBase # :nodoc:
+    def dimensions
+      bmff = IsoBmff.new(@stream)
+      [bmff.width, bmff.height]
+    end
+  end
+end
\ No newline at end of file
diff --git a/lib/fastimage/fastimage_parsing/ico.rb b/lib/fastimage/fastimage_parsing/ico.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/ico.rb
@@ -0,0 +1,9 @@
+module FastImageParsing
+  class Ico < ImageBase
+    def dimensions
+      icons = @stream.read(6)[4..5].unpack('v').first
+      sizes = icons.times.map { @stream.read(16).unpack('C2').map { |x| x == 0 ? 256 : x } }.sort_by { |w,h| w * h }
+      sizes.last
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/image_base.rb b/lib/fastimage/fastimage_parsing/image_base.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/image_base.rb
@@ -0,0 +1,17 @@
+module FastImageParsing
+  class ImageBase # :nodoc:
+    def initialize(stream)
+      @stream = stream
+    end
+  
+    # Implement in subclasses
+    def dimensions
+      raise NotImplementedError
+    end
+    
+    # Implement in subclasses if appropriate
+    def animated?
+      nil
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/iso_bmff.rb b/lib/fastimage/fastimage_parsing/iso_bmff.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/iso_bmff.rb
@@ -0,0 +1,176 @@
+module FastImageParsing
+  # HEIC/AVIF are a special case of the general ISO_BMFF format, in which all data is encapsulated in typed boxes,
+  # with a mandatory ftyp box that is used to indicate particular file types. Is composed of nested "boxes". Each
+  # box has a header composed of
+  # - Size (32 bit integer)
+  # - Box type (4 chars)
+  # - Extended size: only if size === 1, the type field is followed by 64 bit integer of extended size
+  # - Payload: Type-dependent
+  class IsoBmff # :nodoc:
+    attr_reader :width, :height
+  
+    def initialize(stream)
+      @stream = stream
+      @width, @height = nil
+      parse_isobmff
+    end
+    
+    def parse_isobmff
+      @rotation = 0
+      @max_size = nil
+      @primary_box = nil
+      @ipma_boxes = []
+      @ispe_boxes = []
+      @final_size = nil
+
+      catch :finish do
+        read_boxes!
+      end
+
+      if [90, 270].include?(@rotation)
+        @final_size.reverse!
+      end
+      
+      @width, @height = @final_size
+    end
+
+    private
+
+    # Format specs: https://www.loc.gov/preservation/digital/formats/fdd/fdd000525.shtml
+
+    # If you need to inspect a heic/heif file, use
+    # https://gpac.github.io/mp4box.js/test/filereader.html
+    def read_boxes!(max_read_bytes = nil)
+      end_pos = max_read_bytes.nil? ? nil : @stream.pos + max_read_bytes
+      index = 0
+
+      loop do
+        return if end_pos && @stream.pos >= end_pos
+
+        box_type, box_size = read_box_header!
+
+        case box_type
+        when "meta"
+          handle_meta_box(box_size)
+        when "pitm"
+          handle_pitm_box(box_size)
+        when "ipma"
+          handle_ipma_box(box_size)
+        when "hdlr"
+          handle_hdlr_box(box_size)
+        when "iprp", "ipco"
+          read_boxes!(box_size)
+        when "irot"
+          handle_irot_box
+        when "ispe"
+          handle_ispe_box(box_size, index)
+        when "mdat"
+          @stream.skip(box_size)
+        when "jxlc"
+          handle_jxlc_box(box_size)
+        else
+          @stream.skip(box_size)
+        end
+
+        index += 1
+      end
+    end
+
+    def handle_irot_box
+      @rotation = (read_uint8! & 0x3) * 90
+    end
+
+    def handle_ispe_box(box_size, index)
+      throw :finish if box_size < 12
+
+      data = @stream.read(box_size)
+      width, height = data[4...12].unpack("N2")
+      @ispe_boxes << { index: index, size: [width, height] }
+    end
+
+    def handle_hdlr_box(box_size)
+      throw :finish if box_size < 12
+
+      data = @stream.read(box_size)
+      throw :finish if data[8...12] != "pict"
+    end
+
+    def handle_ipma_box(box_size)
+      @stream.read(3)
+      flags3 = read_uint8!
+      entries_count = read_uint32!
+
+      entries_count.times do
+        id = read_uint16!
+        essen_count = read_uint8!
+
+        essen_count.times do
+          property_index = read_uint8! & 0x7F
+
+          if flags3 & 1 == 1
+            property_index = (property_index << 7) + read_uint8!
+          end
+
+          @ipma_boxes << { id: id, property_index: property_index - 1 }
+        end
+      end
+    end
+
+    def handle_pitm_box(box_size)
+      data = @stream.read(box_size)
+      @primary_box = data[4...6].unpack("S>")[0]
+    end
+
+    def handle_meta_box(box_size)
+      throw :finish if box_size < 4
+
+      @stream.read(4)
+      read_boxes!(box_size - 4)
+
+      throw :finish if !@primary_box
+
+      primary_indices = @ipma_boxes
+                        .select { |box| box[:id] == @primary_box }
+                        .map { |box| box[:property_index] }
+
+      ispe_box = @ispe_boxes.find do |box|
+        primary_indices.include?(box[:index])
+      end
+
+      if ispe_box
+        @final_size = ispe_box[:size]
+      end
+
+      throw :finish
+    end
+
+    def handle_jxlc_box(box_size)
+      jxlc = Jxlc.new(@stream)
+      @final_size = [jxlc.width, jxlc.height]
+      throw :finish
+    end
+
+    def read_box_header!
+      size = read_uint32!
+      type = @stream.read(4)
+      size = read_uint64! - 8 if size == 1
+      [type, size - 8]
+    end
+
+    def read_uint8!
+      @stream.read(1).unpack("C")[0]
+    end
+
+    def read_uint16!
+      @stream.read(2).unpack("S>")[0]
+    end
+
+    def read_uint32!
+      @stream.read(4).unpack("N")[0]
+    end
+
+    def read_uint64!
+      @stream.read(8).unpack("Q>")[0]
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/jpeg.rb b/lib/fastimage/fastimage_parsing/jpeg.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/jpeg.rb
@@ -0,0 +1,52 @@
+module FastImageParsing
+  class IOStream < SimpleDelegator # :nodoc:
+    include StreamUtil
+  end
+  
+  class Jpeg < ImageBase # :nodoc:
+    def dimensions
+      exif = nil
+      state = nil
+      loop do
+        state = case state
+        when nil
+          @stream.skip(2)
+          :started
+        when :started
+          @stream.read_byte == 0xFF ? :sof : :started
+        when :sof
+          case @stream.read_byte
+          when 0xe1 # APP1
+            skip_chars = @stream.read_int - 2
+            data = @stream.read(skip_chars)
+            io = StringIO.new(data)
+            if io.read(4) == "Exif"
+              io.read(2)
+              new_exif = Exif.new(IOStream.new(io)) rescue nil
+              exif ||= new_exif # only use the first APP1 segment
+            end
+            :started
+          when 0xe0..0xef
+            :skipframe
+          when 0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF
+            :readsize
+          when 0xFF
+            :sof
+          else
+            :skipframe
+          end
+        when :skipframe
+          skip_chars = @stream.read_int - 2
+          @stream.skip(skip_chars)
+          :started
+        when :readsize
+          @stream.skip(3)
+          height = @stream.read_int
+          width = @stream.read_int
+          width, height = height, width if exif && exif.rotated?
+          return [width, height, exif ? exif.orientation : 1]
+        end
+      end
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/jxl.rb b/lib/fastimage/fastimage_parsing/jxl.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/jxl.rb
@@ -0,0 +1,13 @@
+module FastImageParsing
+  class Jxl < ImageBase # :nodoc:
+    def dimensions
+      if @stream.peek(2) == "\xFF\x0A".b
+        jxlc = Jxlc.new(@stream)
+        [jxlc.width, jxlc.height]
+      else
+        bmff = IsoBmff.new(@stream)
+        [bmff.width, bmff.height]
+      end
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/jxlc.rb b/lib/fastimage/fastimage_parsing/jxlc.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/jxlc.rb
@@ -0,0 +1,75 @@
+module FastImageParsing
+  class Jxlc # :nodoc:
+    attr_reader :width, :height
+
+    LENGTHS = [9, 13, 18, 30]
+    MULTIPLIERS = [1, 1.2, Rational(4, 3), 1.5, Rational(16, 9), 1.25, 2]
+  
+    def initialize(stream)
+      @stream = stream
+      @width, @height´ = nil
+      @bit_counter = 0
+      parse_jxlc
+    end
+    
+    def parse_jxlc
+      @words = @stream.read(6)[2..5].unpack('vv')
+
+      # small mode allows for values <= 256 that are divisible by 8
+      small = get_bits(1)
+      if small == 1
+        y = (get_bits(5) + 1) * 8
+        x = x_from_ratio(y)
+        if !x
+          x = (get_bits(5) + 1) * 8
+        end
+        @width, @height = x, y
+        return
+      end
+
+      len = LENGTHS[get_bits(2)]
+      y = get_bits(len) + 1
+      x = x_from_ratio(y)
+      if !x
+        len = LENGTHS[get_bits(2)]
+        x = get_bits(len) + 1
+      end
+      @width, @height = x, y
+    end
+
+    def get_bits(size)
+      if @words.size < (@bit_counter + size) / 16 + 1
+        @words += @stream.read(4).unpack('vv')
+      end
+
+      dest_pos = 0
+      dest = 0
+      size.times do
+        word = @bit_counter / 16
+        source_pos = @bit_counter % 16
+        dest |= ((@words[word] & (1 << source_pos)) > 0 ? 1 : 0) << dest_pos
+        dest_pos += 1
+        @bit_counter += 1
+      end
+      dest
+    end
+
+    def x_from_ratio(y)
+      ratio = get_bits(3)
+      if ratio == 0
+        return nil
+      else
+        return (y * MULTIPLIERS[ratio - 1]).to_i
+      end
+    end
+  end
+
+  def parse_size_for_jxl
+    if @stream.peek(2) == "\xFF\x0A".b
+      JXL.new(@stream).read_size_header
+    else
+      bmff = IsoBmff.new(@stream)
+      bmff.width_and_height
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/png.rb b/lib/fastimage/fastimage_parsing/png.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/png.rb
@@ -0,0 +1,26 @@
+module FastImageParsing
+  class Png < ImageBase # :nodoc:
+    def dimensions
+      @stream.read(25)[16..24].unpack('NN')
+    end
+    
+    def animated?
+      # Signature (8) + IHDR chunk (4 + 4 + 13 + 4)
+      @stream.read(33)
+
+      loop do
+        length = @stream.read(4).unpack("L>")[0]
+        type = @stream.read(4)
+
+        case type
+        when "acTL"
+          return true
+        when "IDAT"
+          return false
+        end
+
+        @stream.skip(length + 4)
+      end
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/psd.rb b/lib/fastimage/fastimage_parsing/psd.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/psd.rb
@@ -0,0 +1,7 @@
+module FastImageParsing
+  class Psd < ImageBase # :nodoc:
+    def dimensions
+      @stream.read(26).unpack("x14NN").reverse
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/stream_util.rb b/lib/fastimage/fastimage_parsing/stream_util.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/stream_util.rb
@@ -0,0 +1,19 @@
+module FastImageParsing
+  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
+end
diff --git a/lib/fastimage/fastimage_parsing/svg.rb b/lib/fastimage/fastimage_parsing/svg.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/svg.rb
@@ -0,0 +1,69 @@
+module FastImageParsing
+  class Svg < ImageBase # :nodoc:
+    def dimensions
+      @width, @height, @ratio, @viewbox_width, @viewbox_height = nil
+
+      parse_svg
+      
+      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 == "<svg"
+          attr_name.clear
+        end
+      end
+    end
+  
+    def attr_value
+      @stream.read(1)
+  
+      value = []
+      while @stream.read(1) =~ /([^"])/
+        value << $1
+      end
+      value.join
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/tiff.rb b/lib/fastimage/fastimage_parsing/tiff.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/tiff.rb
@@ -0,0 +1,16 @@
+module FastImageParsing
+  class Tiff < ImageBase # :nodoc:
+    def initialize(stream)
+      @stream = stream
+    end
+  
+    def dimensions
+      exif = Exif.new(@stream)
+      if exif.rotated?
+        [exif.height, exif.width, exif.orientation]
+      else
+        [exif.width, exif.height, exif.orientation]
+      end
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/type_parser.rb b/lib/fastimage/fastimage_parsing/type_parser.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/type_parser.rb
@@ -0,0 +1,69 @@
+module FastImageParsing
+  class TypeParser
+    def initialize(stream)
+      @stream = stream
+    end
+    
+    # type will use peek to get enough bytes to determing the type of the image
+    def 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"
+        case @stream.peek(11)[8..10]
+        when "APC", "CR\002"
+          nil  # do not recognise CRW or CR2 as tiff
+        else
+          :tiff
+        end
+      when '8B'
+        :psd
+      when "\xFF\x0A".b
+        :jxl
+      when "\0\0"
+        case @stream.peek(3).bytes.to_a.last
+        when 0
+          # http://www.ftyps.com/what.html
+          case @stream.peek(12)[4..-1]
+          when "ftypavif"
+            :avif
+          when "ftypavis"
+            :avif
+          when "ftypheic"
+            :heic
+          when "ftypmif1"
+            :heif
+          else
+            if @stream.peek(7)[4..-1] == 'JXL'
+              :jxl
+            end
+          end
+        # ico has either a 1 (for ico format) or 2 (for cursor) at offset 3
+        when 1 then :ico
+        when 2 then :cur
+        end
+      when "RI"
+        :webp if @stream.peek(12)[8..11] == "WEBP"
+      when "<s"
+        :svg if @stream.peek(4) == "<svg"
+      when /\s\s|\s<|<[?!]/, 0xef.chr + 0xbb.chr
+        # Peek 10 more chars each time, and if end of file is reached just raise
+        # unknown. We assume the <svg tag cannot be within 10 chars of the end of
+        # the file, and is within the first 1000 chars.
+        begin
+          :svg if (1..100).detect {|n| @stream.peek(10 * n).include?("<svg")}
+        rescue FiberError, FastImage::CannotParseImage
+          nil
+        end
+      end
+
+      parsed_type or raise FastImage::UnknownImageType
+    end
+  end
+end
diff --git a/lib/fastimage/fastimage_parsing/webp.rb b/lib/fastimage/fastimage_parsing/webp.rb
new file mode 100644
--- /dev/null
+++ b/lib/fastimage/fastimage_parsing/webp.rb
@@ -0,0 +1,60 @@
+module FastImageParsing
+  class Webp < ImageBase # :nodoc:
+    def dimensions
+      vp8 = @stream.read(16)[12..15]
+      _len = @stream.read(4).unpack("V")
+      case vp8
+      when "VP8 "
+        parse_size_vp8
+      when "VP8L"
+        parse_size_vp8l
+      when "VP8X"
+        parse_size_vp8x
+      else
+        nil
+      end
+    end
+    
+    def animated?
+      vp8 = @stream.read(16)[12..15]
+      _len = @stream.read(4).unpack("V")
+      case vp8
+      when "VP8 "
+        false
+      when "VP8L"
+        false
+      when "VP8X"
+        flags = @stream.read(4).unpack("C")[0]
+        flags & 2 > 0
+      else
+        nil
+      end
+    end
+    
+    private
+  
+    def parse_size_vp8
+      w, h = @stream.read(10).unpack("@6vv")
+      [w & 0x3fff, h & 0x3fff]
+    end
+
+    def parse_size_vp8l
+      @stream.skip(1) # 0x2f
+      b1, b2, b3, b4 = @stream.read(4).bytes.to_a
+      [1 + (((b2 & 0x3f) << 8) | b1), 1 + (((b4 & 0xF) << 10) | (b3 << 2) | ((b2 & 0xC0) >> 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
+
+      [width, height]
+    end
+  end
+end
diff --git a/lib/fastimage/version.rb b/lib/fastimage/version.rb
--- a/lib/fastimage/version.rb
+++ b/lib/fastimage/version.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 class FastImage
-  VERSION = '2.3.1'
+  VERSION = '2.4.0'
 end


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -17,8 +17,10 @@ jobs:
           - '3.0'
           - '3.1'
           - '3.2'
+          - '3.3'
+          - '3.4'
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: ruby/setup-ruby@v1
         with:
           ruby-version: ${{ matrix.ruby }}
@@ -35,7 +37,7 @@ jobs:
           - '2.1'
           - '2.2'
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: ruby/setup-ruby@v1
         with:
           ruby-version: ${{ matrix.ruby }}
diff --git a/test/fixtures/test.dng b/test/fixtures/test.dng
new file mode 100644
Binary files /dev/null and b/test/fixtures/test.dng differ
diff --git a/test/test.rb b/test/test.rb
--- a/test/test.rb
+++ b/test/test.rb
@@ -60,6 +60,7 @@
   "avif/red_green_flash.avif" => [:avif, [256, 256]],
   "isobmff.jxl" => [:jxl, [1280,1600]],
   "naked.jxl" => [:jxl, [1000,1000]],
+  "test.dng" => [:tiff, [4032, 3024]]
 }
 
 BadFixtures = [
@@ -139,6 +140,14 @@ def test_should_report_animated_correctly
     assert_equal true, FastImage.animated?(TestUrl + "avif/red_green_flash.avif")
   end
 
+  def test_should_report_multiple_properties
+    fi = FastImage.new(File.join(FixturePath, "animated.gif"))
+    assert_equal :gif, fi.type
+    assert_equal [400, 400], fi.size
+    assert_equal true, fi.animated
+    assert_equal 1001718, fi.content_length
+  end
+
   def test_should_return_nil_on_fetch_failure
     assert_nil FastImage.size(TestUrl + "does_not_exist")
   end
@@ -437,6 +446,13 @@ def test_content_length
     FakeWeb.register_uri(:get, url, :body => File.join(FixturePath, "test.jpg"), :content_length => 52)
 
     assert_equal 52, FastImage.new(url).content_length
+    
+    assert_equal 322, FastImage.new(File.join(FixturePath, "test.png")).content_length
+    assert_equal 322, FastImage.new(Pathname.new(File.join(FixturePath, "test.png"))).content_length
+
+    string = File.read(File.join(FixturePath, "test.png"))
+    stringio = StringIO.new(string)
+    assert_equal 322, FastImage.new(stringio).content_length
   end
 
   def test_content_length_not_provided
@@ -473,7 +489,7 @@ def test_should_raise_when_handling_invalid_ico_files
   def test_should_support_data_uri_scheme_images
     assert_equal DataUriImageInfo[0], FastImage.type(DataUriImage)
     assert_equal DataUriImageInfo[1], FastImage.size(DataUriImage)
-    assert_raises(FastImage::ImageFetchFailure) do
+    assert_raises(FastImage::CannotParseImage) do
       FastImage.type("data:", :raise_on_failure => true)
     end
   end
@@ -504,4 +520,25 @@ def test_raises_when_uri_is_nil_and_raise_on_failure_is_set
       FastImage.size(nil, :raise_on_failure => true)
     end
   end
+  
+  def test_width
+    assert_equal 30, FastImage.new(TestUrl + "test.png").width
+    assert_equal nil, FastImage.new(TestUrl + "does_not_exist").width
+  end
+
+  def test_height
+    assert_equal 20, FastImage.new(TestUrl + "test.png").height
+    assert_equal nil, FastImage.new(TestUrl + "does_not_exist").height
+  end
+  
+  def test_content_length_after_size
+    fi = FastImage.new(File.join(FixturePath, "test.png"))
+    fi.size
+    assert_equal 322, fi.content_length
+  end
+  
+  def test_unknown_protocol
+    FakeWeb.register_uri(:get, "http://example.com/test", body: "", location: "hhttp://example.com", :status => 301)
+    assert_nil FastImage.size("http://example.com/test")
+  end
 end