From b50c834fad6081ff0776e2d14028abdfd00191f9 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Mon, 1 Apr 2024 21:20:53 +0300 Subject: [PATCH 01/13] Split and refactor --- lib/fastimage.rb | 1162 +---------------- lib/fastimage/fastimage.rb | 435 ++++++ lib/fastimage/fastimage_parsing/avif.rb | 12 + lib/fastimage/fastimage_parsing/bmp.rb | 17 + lib/fastimage/fastimage_parsing/exif.rb | 69 + .../fastimage_parsing/fiber_stream.rb | 58 + lib/fastimage/fastimage_parsing/gif.rb | 63 + lib/fastimage/fastimage_parsing/heic.rb | 8 + lib/fastimage/fastimage_parsing/ico.rb | 9 + lib/fastimage/fastimage_parsing/image_base.rb | 17 + lib/fastimage/fastimage_parsing/iso_bmff.rb | 176 +++ lib/fastimage/fastimage_parsing/jpeg.rb | 51 + lib/fastimage/fastimage_parsing/jxl.rb | 13 + lib/fastimage/fastimage_parsing/jxlc.rb | 75 ++ lib/fastimage/fastimage_parsing/png.rb | 26 + lib/fastimage/fastimage_parsing/psd.rb | 7 + .../fastimage_parsing/stream_util.rb | 19 + lib/fastimage/fastimage_parsing/svg.rb | 69 + lib/fastimage/fastimage_parsing/tiff.rb | 16 + .../fastimage_parsing/type_parser.rb | 68 + lib/fastimage/fastimage_parsing/webp.rb | 60 + 21 files changed, 1271 insertions(+), 1159 deletions(-) create mode 100644 lib/fastimage/fastimage.rb create mode 100644 lib/fastimage/fastimage_parsing/avif.rb create mode 100644 lib/fastimage/fastimage_parsing/bmp.rb create mode 100644 lib/fastimage/fastimage_parsing/exif.rb create mode 100644 lib/fastimage/fastimage_parsing/fiber_stream.rb create mode 100644 lib/fastimage/fastimage_parsing/gif.rb create mode 100644 lib/fastimage/fastimage_parsing/heic.rb create mode 100644 lib/fastimage/fastimage_parsing/ico.rb create mode 100644 lib/fastimage/fastimage_parsing/image_base.rb create mode 100644 lib/fastimage/fastimage_parsing/iso_bmff.rb create mode 100644 lib/fastimage/fastimage_parsing/jpeg.rb create mode 100644 lib/fastimage/fastimage_parsing/jxl.rb create mode 100644 lib/fastimage/fastimage_parsing/jxlc.rb create mode 100644 lib/fastimage/fastimage_parsing/png.rb create mode 100644 lib/fastimage/fastimage_parsing/psd.rb create mode 100644 lib/fastimage/fastimage_parsing/stream_util.rb create mode 100644 lib/fastimage/fastimage_parsing/svg.rb create mode 100644 lib/fastimage/fastimage_parsing/tiff.rb create mode 100644 lib/fastimage/fastimage_parsing/type_parser.rb create mode 100644 lib/fastimage/fastimage_parsing/webp.rb diff --git a/lib/fastimage.rb b/lib/fastimage.rb index e2a2c7a..30bcc82 100644 --- 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 @@ -62,6 +62,8 @@ 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 +72,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 "= 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> 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 == "")[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 index 0000000..20c40a3 --- /dev/null +++ b/lib/fastimage/fastimage.rb @@ -0,0 +1,435 @@ +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 :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) + + 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.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 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 + + 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_type + TypeParser.new(@stream).type + end + + def parser_class + @type ||= parse_type + 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 index 0000000..572fb38 --- /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 index 0000000..2121db8 --- /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= 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] + @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 +end diff --git a/lib/fastimage/fastimage_parsing/fiber_stream.rb b/lib/fastimage/fastimage_parsing/fiber_stream.rb new file mode 100644 index 0000000..7d8880d --- /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 index 0000000..2336665 --- /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 index 0000000..666b3f4 --- /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 index 0000000..9349d75 --- /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 index 0000000..d08834a --- /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 index 0000000..79905c0 --- /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 index 0000000..ef79f24 --- /dev/null +++ b/lib/fastimage/fastimage_parsing/jpeg.rb @@ -0,0 +1,51 @@ +module FastImageParsing + class IOStream < SimpleDelegator # :nodoc: + include StreamUtil + end + + class Jpeg < ImageBase # :nodoc: + def dimensions + 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 + end +end diff --git a/lib/fastimage/fastimage_parsing/jxl.rb b/lib/fastimage/fastimage_parsing/jxl.rb new file mode 100644 index 0000000..328e2e6 --- /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 index 0000000..56d651e --- /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 index 0000000..02e1e32 --- /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 index 0000000..56c2387 --- /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 index 0000000..6583b55 --- /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 index 0000000..e3c9318 --- /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 == " 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 From a5e4052dd59c7d43e3676e0552e4c9292342b5a9 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Sat, 6 Apr 2024 15:52:46 +0300 Subject: [PATCH 02/13] Incorporate lazy property fetching --- lib/fastimage/fastimage.rb | 108 ++++++++++++------ lib/fastimage/fastimage_parsing/jpeg.rb | 3 +- .../fastimage_parsing/type_parser.rb | 3 +- test/test.rb | 33 +++++- 4 files changed, 107 insertions(+), 40 deletions(-) diff --git a/lib/fastimage/fastimage.rb b/lib/fastimage/fastimage.rb index 20c40a3..61f8b41 100644 --- a/lib/fastimage/fastimage.rb +++ b/lib/fastimage/fastimage.rb @@ -21,8 +21,6 @@ class FastImage include FastImageParsing - - attr_reader :size, :type, :content_length, :orientation, :animated attr_reader :bytes_read @@ -153,7 +151,7 @@ def self.size(uri, options={}) # 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 + new(uri, options).type end # Returns a boolean value indicating the image is animated. @@ -181,38 +179,73 @@ def self.type(uri, options={}) # 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 + new(uri, options).animated end def initialize(uri, options={}) @uri = uri @options = { - :type_only => false, :timeout => DefaultTimeout, :raise_on_failure => false, :proxy => nil, :http_header => {} }.merge(options) + end - @property = if @options[:animated_only] - :animated - elsif @options[:type_only] - :type - else - :size + def type + @property = :type + fetch unless defined?(@type) + @type + end + + def size + @property = :size + begin + fetch unless defined?(@size) + rescue CannotParseImage end - raise BadImageURI if uri.nil? + 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 - @type, @state = nil + # 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) + 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) + @parsed_uri = URI.parse(@uri) rescue URI::InvalidURIError fetch_using_file_open else @@ -230,19 +263,11 @@ def initialize(uri, options={}) Errno::ENETUNREACH, ImageFetchFailure, Net::HTTPBadResponse, EOFError, Errno::ENOENT, OpenSSL::SSL::SSLError raise ImageFetchFailure if @options[:raise_on_failure] - rescue UnknownImageType, BadImageURI + rescue UnknownImageType, BadImageURI, CannotParseImage 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) + @uri.rewind if @uri.respond_to?(:rewind) end @@ -287,6 +312,7 @@ def fetch_using_http_from_parsed_uri 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| @@ -349,6 +375,8 @@ def setup_http 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 @@ -376,7 +404,8 @@ def fetch_using_read(readable) end def fetch_using_file_open - @content_length = File.size?(@uri) + return @content_length = File.size?(@uri) if @property == :content_length + File.open(@uri) do |s| fetch_using_read(s) end @@ -388,7 +417,7 @@ def fetch_using_base64(uri) rescue raise CannotParseImage end - @content_length = decoded.size + fetch_using_read StringIO.new(decoded) end @@ -396,7 +425,17 @@ def parse_packets(stream) @stream = stream begin - result = send("parse_#{@property}") + @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 @@ -414,12 +453,7 @@ def parse_packets(stream) end end - def parse_type - TypeParser.new(@stream).type - end - def parser_class - @type ||= parse_type klass = Parsers[@type] raise UnknownImageType unless klass klass diff --git a/lib/fastimage/fastimage_parsing/jpeg.rb b/lib/fastimage/fastimage_parsing/jpeg.rb index ef79f24..fb2eeb5 100644 --- a/lib/fastimage/fastimage_parsing/jpeg.rb +++ b/lib/fastimage/fastimage_parsing/jpeg.rb @@ -6,8 +6,9 @@ class IOStream < SimpleDelegator # :nodoc: class Jpeg < ImageBase # :nodoc: def dimensions exif = nil + state = nil loop do - @state = case @state + state = case state when nil @stream.skip(2) :started diff --git a/lib/fastimage/fastimage_parsing/type_parser.rb b/lib/fastimage/fastimage_parsing/type_parser.rb index be854be..2b6b21b 100644 --- a/lib/fastimage/fastimage_parsing/type_parser.rb +++ b/lib/fastimage/fastimage_parsing/type_parser.rb @@ -4,6 +4,7 @@ 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" @@ -65,4 +66,4 @@ def type parsed_type or raise FastImage::UnknownImageType end end -end \ No newline at end of file +end diff --git a/test/test.rb b/test/test.rb index 9de064c..40d2144 100644 --- a/test/test.rb +++ b/test/test.rb @@ -139,6 +139,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 +445,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 +488,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 +519,20 @@ 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 end From ae60942b73f81349a93696f85c3163a047623362 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Sat, 6 Apr 2024 16:37:56 +0300 Subject: [PATCH 03/13] Upgrade actions steps, add v3.3 --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ffa18ae..998efc4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,9 @@ jobs: - '3.0' - '3.1' - '3.2' + - '3.3' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} From e7d811178410aaf4e01fe4132fd7ae573ec50574 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Sat, 6 Apr 2024 16:41:04 +0300 Subject: [PATCH 04/13] Update checkout step --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 998efc4..ef10ff8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,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 }} From 2c8539d5039a3ab99b3256aedeac89d2ce547d50 Mon Sep 17 00:00:00 2001 From: Takuma Homma Date: Thu, 7 Nov 2024 15:07:17 +0900 Subject: [PATCH 05/13] Add base64 as a dependency explicitly for Ruby 3.4.0 support ref. https://www.ruby-lang.org/en/news/2023/12/25/ruby-3-3-0-released/ --- fastimage.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/fastimage.gemspec b/fastimage.gemspec index 2441e0f..1570d73 100644 --- a/fastimage.gemspec +++ b/fastimage.gemspec @@ -22,6 +22,7 @@ Gem::Specification.new do |s| s.require_paths = ["lib"] s.rubygems_version = %q{1.3.6} s.summary = %q{FastImage - Image info fast} + s.add_dependency 'base64', '~> 0.2.0' s.add_development_dependency 'fakeweb-fi', '~> 1.3' # Note rake 11 drops support for ruby 1.9.2 s.add_development_dependency('rake', ">= 10.5") From a891d8f43a738cd7194183493fc9857017f5046b Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Thu, 2 Jan 2025 19:44:19 +0000 Subject: [PATCH 06/13] Raise error correctly on bad url scheme, fixes #156 --- lib/fastimage/fastimage.rb | 2 ++ test/test.rb | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/lib/fastimage/fastimage.rb b/lib/fastimage/fastimage.rb index 61f8b41..90d13c8 100644 --- a/lib/fastimage/fastimage.rb +++ b/lib/fastimage/fastimage.rb @@ -291,6 +291,8 @@ def escaped_location(location) 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 diff --git a/test/test.rb b/test/test.rb index 40d2144..169f49b 100644 --- a/test/test.rb +++ b/test/test.rb @@ -535,4 +535,9 @@ def test_content_length_after_size 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 From cad7175e0628b239f760c837719cf1dc5acb5c30 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Thu, 2 Jan 2025 20:25:48 +0000 Subject: [PATCH 07/13] Handle tiff format with long dimension values, fixes #158 --- lib/fastimage/fastimage_parsing/exif.rb | 13 ++++++++++--- test/fixtures/test.dng | Bin 0 -> 512 bytes test/test.rb | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/test.dng diff --git a/lib/fastimage/fastimage_parsing/exif.rb b/lib/fastimage/fastimage_parsing/exif.rb index 8761a1b..ecf22e4 100644 --- a/lib/fastimage/fastimage_parsing/exif.rb +++ b/lib/fastimage/fastimage_parsing/exif.rb @@ -30,8 +30,16 @@ 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] + 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 @@ -43,7 +51,6 @@ def parse_exif_ifd if @width && @height && @orientation return # no need to parse more end - @stream.read(2) end end diff --git a/test/fixtures/test.dng b/test/fixtures/test.dng new file mode 100644 index 0000000000000000000000000000000000000000..fc9bb77b2bf6d508e1d7a31e81a8c41f577ffbba GIT binary patch literal 512 zcmX|-u}i~16vp4BwQ392rmarJR1l;T>80tC$$}z?mEa=iR7<4AYC`GYAlOZZjvYI? z2p#V+8;!#NfkvY28AhL{9k>;qxSll*G*Q z;7_p{vEhhyLZ+P0xDq9`kBNjHLc~<#&zid)h>O`xp*n{W^Rf`~l-RL;e5& literal 0 HcmV?d00001 diff --git a/test/test.rb b/test/test.rb index 169f49b..ecdbe96 100644 --- 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 = [ From 499865e47adf59d7d7283d6b4fa2467d53ad74be Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Thu, 2 Jan 2025 20:45:43 +0000 Subject: [PATCH 08/13] Test on ruby 3.4 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef10ff8..1d97f36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: - '3.1' - '3.2' - '3.3' + - '3.4' steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 From d69288b50474d2cfb599962ee6db72c2762c4f64 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Thu, 2 Jan 2025 21:03:19 +0000 Subject: [PATCH 09/13] Reduce base64 version requirement to support earlier rubies --- fastimage.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastimage.gemspec b/fastimage.gemspec index 1570d73..94e6438 100644 --- a/fastimage.gemspec +++ b/fastimage.gemspec @@ -22,7 +22,7 @@ Gem::Specification.new do |s| s.require_paths = ["lib"] s.rubygems_version = %q{1.3.6} s.summary = %q{FastImage - Image info fast} - s.add_dependency 'base64', '~> 0.2.0' + s.add_dependency 'base64', '~> 0.1' s.add_development_dependency 'fakeweb-fi', '~> 1.3' # Note rake 11 drops support for ruby 1.9.2 s.add_development_dependency('rake', ">= 10.5") From 729438e55bb755ce5a25e0b03cb53a6b9b845061 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Thu, 2 Jan 2025 21:21:18 +0000 Subject: [PATCH 10/13] Remove the base64 version altogether for compat --- fastimage.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastimage.gemspec b/fastimage.gemspec index 94e6438..4e3823d 100644 --- a/fastimage.gemspec +++ b/fastimage.gemspec @@ -22,7 +22,7 @@ Gem::Specification.new do |s| s.require_paths = ["lib"] s.rubygems_version = %q{1.3.6} s.summary = %q{FastImage - Image info fast} - s.add_dependency 'base64', '~> 0.1' + s.add_dependency 'base64' s.add_development_dependency 'fakeweb-fi', '~> 1.3' # Note rake 11 drops support for ruby 1.9.2 s.add_development_dependency('rake', ">= 10.5") From ba61a28d14bb757d1068e4751b852674370a33e7 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Fri, 3 Jan 2025 10:00:17 +0000 Subject: [PATCH 11/13] Remove entirely base64 dependency --- fastimage.gemspec | 1 - lib/fastimage.rb | 1 - lib/fastimage/fastimage.rb | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/fastimage.gemspec b/fastimage.gemspec index 4e3823d..2441e0f 100644 --- a/fastimage.gemspec +++ b/fastimage.gemspec @@ -22,7 +22,6 @@ Gem::Specification.new do |s| s.require_paths = ["lib"] s.rubygems_version = %q{1.3.6} s.summary = %q{FastImage - Image info fast} - s.add_dependency 'base64' s.add_development_dependency 'fakeweb-fi', '~> 1.3' # Note rake 11 drops support for ruby 1.9.2 s.add_development_dependency('rake', ">= 10.5") diff --git a/lib/fastimage.rb b/lib/fastimage.rb index 30bcc82..39dd80b 100644 --- a/lib/fastimage.rb +++ b/lib/fastimage.rb @@ -59,7 +59,6 @@ require 'delegate' require 'pathname' require 'zlib' -require 'base64' require 'uri' require 'stringio' diff --git a/lib/fastimage/fastimage.rb b/lib/fastimage/fastimage.rb index 90d13c8..88faae7 100644 --- a/lib/fastimage/fastimage.rb +++ b/lib/fastimage/fastimage.rb @@ -415,7 +415,7 @@ def fetch_using_file_open def fetch_using_base64(uri) decoded = begin - Base64.decode64(uri.split(',')[1]) + uri.split(',')[1].unpack1("m") rescue raise CannotParseImage end From 7f6a50c6a4110120dff4415cdfac6138ec50da70 Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Fri, 3 Jan 2025 10:05:02 +0000 Subject: [PATCH 12/13] Use more backward compatible unpack --- lib/fastimage/fastimage.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fastimage/fastimage.rb b/lib/fastimage/fastimage.rb index 88faae7..41c4a2c 100644 --- a/lib/fastimage/fastimage.rb +++ b/lib/fastimage/fastimage.rb @@ -415,7 +415,7 @@ def fetch_using_file_open def fetch_using_base64(uri) decoded = begin - uri.split(',')[1].unpack1("m") + uri.split(',')[1].unpack("m").first rescue raise CannotParseImage end From 4c06b15fa8f9ce704e40f43053f070fd6a48531a Mon Sep 17 00:00:00 2001 From: Stephen Sykes Date: Fri, 3 Jan 2025 10:26:28 +0000 Subject: [PATCH 13/13] Update changelog and version --- CHANGELOG | 13 +++++++++++++ lib/fastimage/version.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index b251b20..ae366fa 100644 --- 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/version.rb b/lib/fastimage/version.rb index e306e8f..fbd7cae 100644 --- 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