diff --git a/Gemfile.lock b/Gemfile.lock index 4e0d92a..5a4d05f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - fastimage (1.5.4) + fastimage (1.5.5) addressable (~> 2.3, >= 2.3.5) GEM diff --git a/lib/fastimage.rb b/lib/fastimage.rb index b276c22..d023f7a 100644 --- a/lib/fastimage.rb +++ b/lib/fastimage.rb @@ -5,7 +5,7 @@ # 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 +# 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 and PNG files. @@ -15,7 +15,7 @@ # it has enough. This is possibly a useful bandwidth-saving feature if the file is on a network # attached disk rather than truly local. # -# New in v1.2.9, FastImage will automatically read from any object that responds to :read - for +# New in v1.2.9, FastImage will automatically read from any object that responds to :read - for # instance an IO object if that is passed instead of a URI. # # New in v1.2.10 FastImage will follow up to 4 HTTP redirects to get the image. @@ -43,10 +43,11 @@ require 'net/https' require 'addressable/uri' require 'fastimage/fbr.rb' +require 'delegate' class FastImage attr_reader :size, :type - + attr_reader :bytes_read class FastImageException < StandardError # :nodoc: @@ -63,7 +64,7 @@ class FastImage end DefaultTimeout = 2 - + LocalFileChunkSize = 256 # Returns an array containing the width and height of the image. @@ -172,12 +173,12 @@ class FastImage end end end - + uri.rewind if uri.respond_to?(:rewind) - + raise SizeNotFound if options[:raise_on_failure] && @property == :size && !@size - - rescue Timeout::Error, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, + + rescue Timeout::Error, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, ImageFetchFailure, Net::HTTPBadResponse, EOFError, Errno::ENOENT raise ImageFetchFailure if options[:raise_on_failure] rescue NoMethodError # 1.8.7p248 can raise this due to a net/http bug @@ -192,7 +193,7 @@ class FastImage raise ImageFetchFailure end end - + end private @@ -202,7 +203,7 @@ class FastImage fetch_using_http_from_parsed_uri end - + def fetch_using_http_from_parsed_uri setup_http @http.request_get(@parsed_uri.request_uri, 'Accept-Encoding' => 'identity') do |res| @@ -225,14 +226,14 @@ class FastImage raise ImageFetchFailure unless res.is_a?(Net::HTTPSuccess) - @read_fiber = Fiber.new do + read_fiber = Fiber.new do res.read_body do |str| Fiber.yield str end end - parse_packets - + parse_packets FiberStream.new(read_fiber) + break # needed to actively quit out of the fetch end end @@ -261,13 +262,13 @@ class FastImage end def fetch_using_read(readable) - @read_fiber = Fiber.new do + read_fiber = Fiber.new do while str = readable.read(LocalFileChunkSize) Fiber.yield str end end - - parse_packets + + parse_packets FiberStream.new(read_fiber) end def fetch_using_open_uri @@ -276,16 +277,12 @@ class FastImage end end - def parse_packets - @str = "" - @str.force_encoding("ASCII-8BIT") if has_encoding? - @strpos = 0 - @bytes_read = 0 - @bytes_delivered = 0 + def parse_packets(stream) + @stream = stream begin result = send("parse_#{@property}") - if result + if result instance_variable_set("@#{@property}", result) else raise CannotParseImage @@ -297,53 +294,60 @@ class FastImage def parse_size @type = parse_type unless @type - @strpos = 0 - @bytes_delivered = 0 send("parse_size_for_#{@type}") end - def has_encoding? - if @has_encoding.nil? - @has_encoding = String.new.respond_to? :force_encoding - else - @has_encoding + module StreamUtil + def read_byte + read(1).ord + end + + def read_int + read(2).unpack('n')[0] end end - def get_chars(n) - while @strpos + n - 1 >= @str.size - unused_str = @str[@strpos..-1] - new_string = @read_fiber.resume - raise CannotParseImage if !new_string + class FiberStream + include StreamUtil + attr_reader :pos - # we are dealing with bytes here, so force the encoding - if has_encoding? - new_string.force_encoding("ASCII-8BIT") + def initialize(read_fiber) + @read_fiber = read_fiber + @pos = 0 + @strpos = 0 + @str = '' + end + + 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 - @bytes_read += new_string.size - - @str = unused_str + new_string - @strpos = 0 + result = @str[@strpos..(@strpos + n - 1)] + end + + def read(n) + result = peek(n) + @strpos += n + @pos += n + result end - - result = @str[@strpos..(@strpos + n - 1)] - @strpos += n - @bytes_delivered += n - result end - def get_byte - get_chars(1).unpack("C")[0] - end - - def read_int(str) - size_bytes = str.unpack("CC") - (size_bytes[0] << 8) + size_bytes[1] + class IOStream < SimpleDelegator + include StreamUtil end def parse_type - case get_chars(2) + case @stream.peek(2) when "BM" :bmp when "GI" @@ -352,41 +356,41 @@ class FastImage :jpeg when 0x89.chr + "P" :png - when "II" - :tiff - when "MM" + when "II", "MM" :tiff + when '8B' + :psd else raise UnknownImageType end end def parse_size_for_gif - get_chars(11)[6..10].unpack('SS') + @stream.read(11)[6..10].unpack('SS') end def parse_size_for_png - get_chars(25)[16..24].unpack('NN') + @stream.read(25)[16..24].unpack('NN') end def parse_size_for_jpeg loop do @state = case @state when nil - get_chars(2) + @stream.read(2) :started when :started - get_byte == 0xFF ? :sof : :started + @stream.read_byte == 0xFF ? :sof : :started when :sof - case get_byte + case @stream.read_byte when 0xe1 # APP1 - skip_chars = read_int(get_chars(2)) - 2 - skip_from = @bytes_delivered - if get_chars(4) == "Exif" - get_chars(2) - parse_exif + skip_chars = @stream.read_int - 2 + data = @stream.read(skip_chars) + io = StringIO.new(data) + if io.read(4) == "Exif" + io.read(2) + @exif = Exif.new(IOStream.new(io)) rescue nil end - get_chars(skip_chars - (@bytes_delivered - skip_from)) :started when 0xe0..0xef :skipframe @@ -398,24 +402,23 @@ class FastImage :skipframe end when :skipframe - skip_chars = read_int(get_chars(2)) - 2 - get_chars(skip_chars) + skip_chars = @stream.read_int - 2 + @stream.read(skip_chars) :started when :readsize - s = get_chars(7) - if @exif_orientation && @exif_orientation >= 5 - return [read_int(s[3..4]), read_int(s[5..6])] - else - return [read_int(s[5..6]), read_int(s[3..4])] - end + s = @stream.read(3) + height = @stream.read_int + width = @stream.read_int + width, height = height, width if @exif && @exif.rotated? + return [width, height] end end end def parse_size_for_bmp - d = get_chars(32)[14..28] + d = @stream.read(32)[14..28] header = d.unpack("C")[0] - + result = if header == 40 d[4..-1].unpack('l= 0 - get_chars(relative_offset) + def rotated? + @orientation && @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 + + next_offset = @stream.read(4).unpack(@long)[0] + relative_offset = next_offset - (@stream.pos - @start_byte) + if relative_offset >= 0 + @stream.read(relative_offset) + parse_exif_ifd + end + end + + def parse_exif + @start_byte = @stream.pos + + get_exif_byte_order + + @stream.read(2) # 42 + + offset = @stream.read(4).unpack(@long)[0] + @stream.read(offset - 8) + parse_exif_ifd end - end - def parse_exif - @exif_start_byte = @bytes_delivered - - get_exif_byte_order - - get_chars(2) # 42 - - offset = get_chars(4).unpack(@long)[0] - get_chars(offset - 8) - - parse_exif_ifd end def parse_size_for_tiff - parse_exif - - if @exif_orientation && @exif_orientation >= 5 - return [@exif_height, @exif_width] + exif = Exif.new(@stream) + if exif.rotated? + [exif.height, exif.width] else - return [@exif_width, @exif_height] + [exif.width, exif.height] end + end - raise CannotParseImage + def parse_size_for_psd + @stream.read(26).unpack("x14NN").reverse end end diff --git a/test/fixtures/test.psd b/test/fixtures/test.psd new file mode 100644 index 0000000..9daf0e4 Binary files /dev/null and b/test/fixtures/test.psd differ diff --git a/test/fixtures/test4.jpg b/test/fixtures/test4.jpg new file mode 100644 index 0000000..3e6047e Binary files /dev/null and b/test/fixtures/test4.jpg differ diff --git a/test/test.rb b/test/test.rb index eaa90b8..7db0863 100644 --- a/test/test.rb +++ b/test/test.rb @@ -18,8 +18,10 @@ GoodFixtures = { "test.png"=>[:png, [30, 20]], "test2.jpg"=>[:jpeg, [250, 188]], "test3.jpg"=>[:jpeg, [630, 367]], + "test4.jpg"=>[:jpeg, [1485, 1299]], "test.tiff"=>[:tiff, [85, 67]], "test2.tiff"=>[:tiff, [333, 225]], + "test.psd"=>[:psd, [17, 32]], "exif_orientation.jpg"=>[:jpeg, [2448, 3264]], "infinite.jpg"=>[:jpeg, [160,240]] } @@ -59,13 +61,13 @@ class FastImageTest < Test::Unit::TestCase GoodFixtures.each do |fn, info| assert_equal info[1], FastImage.size(TestUrl + fn) assert_equal info[1], FastImage.size(TestUrl + fn, :raise_on_failure=>true) - end + end end def test_should_return_nil_on_fetch_failure assert_nil FastImage.size(TestUrl + "does_not_exist") end - + def test_should_return_nil_for_faulty_jpeg_where_size_cannot_be_found assert_nil FastImage.size(TestUrl + "faulty.jpg") end @@ -73,11 +75,11 @@ class FastImageTest < Test::Unit::TestCase def test_should_return_nil_when_image_type_not_known assert_nil FastImage.size(TestUrl + "test.ico") end - + def test_should_return_nil_if_timeout_occurs assert_nil FastImage.size("http://example.com/does_not_exist", :timeout=>0.001) end - + def test_should_raise_when_asked_to_when_size_cannot_be_found assert_raises(FastImage::SizeNotFound) do FastImage.size(TestUrl + "faulty.jpg", :raise_on_failure=>true) @@ -101,17 +103,17 @@ class FastImageTest < Test::Unit::TestCase FastImage.size(TestUrl + "test.ico", :raise_on_failure=>true) end end - + def test_should_report_type_correctly_for_local_files GoodFixtures.each do |fn, info| assert_equal info[0], FastImage.type(File.join(FixturePath, fn)) - end + end end - + def test_should_report_size_correctly_for_local_files GoodFixtures.each do |fn, info| assert_equal info[1], FastImage.size(File.join(FixturePath, fn)) - end + end end def test_should_report_type_correctly_for_ios @@ -121,7 +123,7 @@ class FastImageTest < Test::Unit::TestCase end end end - + def test_should_report_size_correctly_for_ios GoodFixtures.each do |fn, info| File.open(File.join(FixturePath, fn), "r") do |io| @@ -129,7 +131,7 @@ class FastImageTest < Test::Unit::TestCase end end end - + def test_should_report_size_correctly_on_io_object_twice GoodFixtures.each do |fn, info| File.open(File.join(FixturePath, fn), "r") do |io| @@ -144,11 +146,11 @@ class FastImageTest < Test::Unit::TestCase assert_equal GoodFixtures["test.bmp"][1], FastImage.size(File.join("fixtures", "folder with spaces", "test.bmp")) end end - + def test_should_return_nil_on_fetch_failure_for_local_path assert_nil FastImage.size("does_not_exist") end - + def test_should_return_nil_for_faulty_jpeg_where_size_cannot_be_found_for_local_file assert_nil FastImage.size(File.join(FixturePath, "faulty.jpg")) end @@ -156,13 +158,13 @@ class FastImageTest < Test::Unit::TestCase def test_should_return_nil_when_image_type_not_known_for_local_file assert_nil FastImage.size(File.join(FixturePath, "test.ico")) end - + def test_should_raise_when_asked_to_when_size_cannot_be_found_for_local_file assert_raises(FastImage::SizeNotFound) do FastImage.size(File.join(FixturePath, "faulty.jpg"), :raise_on_failure=>true) end end - + def test_should_handle_permanent_redirect url = "http://example.com/foo.jpeg" register_redirect(url, TestUrl + GoodFixtures.keys.first) @@ -189,7 +191,7 @@ class FastImageTest < Test::Unit::TestCase FastImage.size(first_url, :raise_on_failure=>true) end end - + def test_should_handle_permanent_redirect_with_relative_url url = "http://example.nowhere/foo.jpeg" register_redirect(url, "/" + GoodFixtures.keys.first) @@ -201,7 +203,7 @@ class FastImageTest < Test::Unit::TestCase resp['Location'] = to FakeWeb.register_uri(:get, from, :response=>resp) end - + def test_should_fetch_info_of_large_image_faster_than_downloading_the_whole_thing time = Time.now size = FastImage.size(LargeImage) @@ -214,7 +216,7 @@ class FastImageTest < Test::Unit::TestCase assert type_time - time < LargeImageFetchLimit assert_equal LargeImageInfo[0], type end - + # This test doesn't actually test the proxy function, but at least # it excercises the code. You could put anything in the http_proxy and it would still pass. # Any ideas on how to actually test this? @@ -226,9 +228,9 @@ class FastImageTest < Test::Unit::TestCase ENV['http_proxy'] = nil assert_equal actual_size, size end - + def test_should_handle_https_image size = FastImage.size(HTTPSImage) - assert_equal HTTPSImageInfo[1], size + assert_equal HTTPSImageInfo[1], size end end