Merge pull request #28 from marcandre/psd

Gracefully fail on EXIF parse, + support PSD
This commit is contained in:
Stephen Sykes 2014-01-29 11:31:38 -08:00
commit c31bc71a52
5 changed files with 172 additions and 151 deletions

View File

@ -1,7 +1,7 @@
PATH
remote: .
specs:
fastimage (1.5.4)
fastimage (1.5.5)
addressable (~> 2.3, >= 2.3.5)
GEM

View File

@ -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<l<')
else
@ -426,68 +429,84 @@ class FastImage
[result.first, result.last.abs]
end
def get_exif_byte_order
byte_order = get_chars(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 = get_chars(2).unpack(@short)[0]
tag_count.downto(1) do
type = get_chars(2).unpack(@short)[0]
get_chars(6)
data = get_chars(2).unpack(@short)[0]
case type
when 0x0100 # image width
@exif_width = data
when 0x0101 # image height
@exif_height = data
when 0x0112 # orientation
@exif_orientation = data
end
if @type == :tiff && @exif_width && @exif_height && @exif_orientation
return # no need to parse more
end
get_chars(2)
class Exif
attr_reader :width, :height
def initialize(stream)
@stream = stream
parse_exif
end
next_offset = get_chars(4).unpack(@long)[0]
relative_offset = next_offset - (@bytes_delivered - @exif_start_byte)
if relative_offset >= 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

BIN
test/fixtures/test.psd vendored Normal file

Binary file not shown.

BIN
test/fixtures/test4.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -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