fastimage 2.3.0 → 2.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/fastimage.rb CHANGED
@@ -9,7 +9,7 @@
9
9
  # No external libraries such as ImageMagick are used here, this is a very lightweight solution to
10
10
  # finding image information.
11
11
  #
12
- # FastImage knows about GIF, JPEG, BMP, TIFF, ICO, CUR, PNG, PSD, SVG and WEBP files.
12
+ # FastImage knows about GIF, JPEG, BMP, TIFF, ICO, CUR, PNG, HEIC/HEIF, AVIF, PSD, SVG, WEBP and JXL files.
13
13
  #
14
14
  # FastImage can also read files from the local filesystem by supplying the path instead of a uri.
15
15
  # In this case FastImage reads the file in chunks of 256 bytes until
@@ -59,9 +59,10 @@ require 'net/https'
59
59
  require 'delegate'
60
60
  require 'pathname'
61
61
  require 'zlib'
62
- require 'base64'
63
62
  require 'uri'
64
63
  require 'stringio'
64
+
65
+ require_relative 'fastimage/fastimage'
65
66
  require_relative 'fastimage/version'
66
67
 
67
68
  # see http://stackoverflow.com/questions/5208851/i/41048816#41048816
@@ -70,1073 +71,3 @@ if RUBY_VERSION < "2.2"
70
71
  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])?)\\.?")
71
72
  end
72
73
  end
73
-
74
- class FastImage
75
- attr_reader :size, :type, :content_length, :orientation, :animated
76
-
77
- attr_reader :bytes_read
78
-
79
- class FastImageException < StandardError # :nodoc:
80
- end
81
- class UnknownImageType < FastImageException # :nodoc:
82
- end
83
- class ImageFetchFailure < FastImageException # :nodoc:
84
- end
85
- class SizeNotFound < FastImageException # :nodoc:
86
- end
87
- class CannotParseImage < FastImageException # :nodoc:
88
- end
89
- class BadImageURI < FastImageException # :nodoc:
90
- end
91
-
92
- DefaultTimeout = 2 unless const_defined?(:DefaultTimeout)
93
-
94
- LocalFileChunkSize = 256 unless const_defined?(:LocalFileChunkSize)
95
-
96
- SUPPORTED_IMAGE_TYPES = [:bmp, :gif, :jpeg, :png, :tiff, :psd, :heic, :heif, :webp, :svg, :ico, :cur].freeze
97
-
98
- # Returns an array containing the width and height of the image.
99
- # It will return nil if the image could not be fetched, or if the image type was not recognised.
100
- #
101
- # By default there is a timeout of 2 seconds for opening and reading from a remote server.
102
- # This can be changed by passing a :timeout => number_of_seconds in the options.
103
- #
104
- # If you wish FastImage to raise if it cannot size the image for any reason, then pass
105
- # :raise_on_failure => true in the options.
106
- #
107
- # FastImage knows about GIF, JPEG, BMP, TIFF, ICO, CUR, PNG, PSD, SVG and WEBP files.
108
- #
109
- # === Example
110
- #
111
- # require 'fastimage'
112
- #
113
- # FastImage.size("https://switchstep.com/images/ios.gif")
114
- # => [196, 283]
115
- # FastImage.size("http://switchstep.com/images/ss_logo.png")
116
- # => [300, 300]
117
- # FastImage.size("https://upload.wikimedia.org/wikipedia/commons/0/09/Jpeg_thumb_artifacts_test.jpg")
118
- # => [1280, 800]
119
- # FastImage.size("https://eeweb.engineering.nyu.edu/~yao/EL5123/image/lena_gray.bmp")
120
- # => [512, 512]
121
- # FastImage.size("test/fixtures/test.jpg")
122
- # => [882, 470]
123
- # FastImage.size("http://switchstep.com/does_not_exist")
124
- # => nil
125
- # FastImage.size("http://switchstep.com/does_not_exist", :raise_on_failure=>true)
126
- # => raises FastImage::ImageFetchFailure
127
- # FastImage.size("http://switchstep.com/images/favicon.ico", :raise_on_failure=>true)
128
- # => [16, 16]
129
- # FastImage.size("http://switchstep.com/foo.ics", :raise_on_failure=>true)
130
- # => raises FastImage::UnknownImageType
131
- # FastImage.size("http://switchstep.com/images/favicon.ico", :raise_on_failure=>true, :timeout=>0.01)
132
- # => raises FastImage::ImageFetchFailure
133
- # FastImage.size("http://switchstep.com/images/faulty.jpg", :raise_on_failure=>true)
134
- # => raises FastImage::SizeNotFound
135
- #
136
- # === Supported options
137
- # [:timeout]
138
- # Overrides the default timeout of 2 seconds. Applies both to reading from and opening the http connection.
139
- # [:raise_on_failure]
140
- # If set to true causes an exception to be raised if the image size cannot be found for any reason.
141
- #
142
- def self.size(uri, options={})
143
- new(uri, options).size
144
- end
145
-
146
- # Returns an symbol indicating the image type fetched from a uri.
147
- # It will return nil if the image could not be fetched, or if the image type was not recognised.
148
- #
149
- # By default there is a timeout of 2 seconds for opening and reading from a remote server.
150
- # This can be changed by passing a :timeout => number_of_seconds in the options.
151
- #
152
- # If you wish FastImage to raise if it cannot find the type of the image for any reason, then pass
153
- # :raise_on_failure => true in the options.
154
- #
155
- # === Example
156
- #
157
- # require 'fastimage'
158
- #
159
- # FastImage.type("https://switchstep.com/images/ios.gif")
160
- # => :gif
161
- # FastImage.type("http://switchstep.com/images/ss_logo.png")
162
- # => :png
163
- # FastImage.type("https://upload.wikimedia.org/wikipedia/commons/0/09/Jpeg_thumb_artifacts_test.jpg")
164
- # => :jpeg
165
- # FastImage.type("https://eeweb.engineering.nyu.edu/~yao/EL5123/image/lena_gray.bmp")
166
- # => :bmp
167
- # FastImage.type("test/fixtures/test.jpg")
168
- # => :jpeg
169
- # FastImage.type("http://switchstep.com/does_not_exist")
170
- # => nil
171
- # File.open("/some/local/file.gif", "r") {|io| FastImage.type(io)}
172
- # => :gif
173
- # FastImage.type("test/fixtures/test.tiff")
174
- # => :tiff
175
- # FastImage.type("test/fixtures/test.psd")
176
- # => :psd
177
- #
178
- # === Supported options
179
- # [:timeout]
180
- # Overrides the default timeout of 2 seconds. Applies both to reading from and opening the http connection.
181
- # [:raise_on_failure]
182
- # If set to true causes an exception to be raised if the image type cannot be found for any reason.
183
- #
184
- def self.type(uri, options={})
185
- new(uri, options.merge(:type_only=>true)).type
186
- end
187
-
188
- # Returns a boolean value indicating the image is animated.
189
- # It will return nil if the image could not be fetched, or if the image type was not recognised.
190
- #
191
- # By default there is a timeout of 2 seconds for opening and reading from a remote server.
192
- # This can be changed by passing a :timeout => number_of_seconds in the options.
193
- #
194
- # If you wish FastImage to raise if it cannot find the type of the image for any reason, then pass
195
- # :raise_on_failure => true in the options.
196
- #
197
- # === Example
198
- #
199
- # require 'fastimage'
200
- #
201
- # FastImage.animated?("test/fixtures/test.gif")
202
- # => false
203
- # FastImage.animated?("test/fixtures/animated.gif")
204
- # => true
205
- #
206
- # === Supported options
207
- # [:timeout]
208
- # Overrides the default timeout of 2 seconds. Applies both to reading from and opening the http connection.
209
- # [:raise_on_failure]
210
- # If set to true causes an exception to be raised if the image type cannot be found for any reason.
211
- #
212
- def self.animated?(uri, options={})
213
- new(uri, options.merge(:animated_only=>true)).animated
214
- end
215
-
216
- def initialize(uri, options={})
217
- @uri = uri
218
- @options = {
219
- :type_only => false,
220
- :timeout => DefaultTimeout,
221
- :raise_on_failure => false,
222
- :proxy => nil,
223
- :http_header => {}
224
- }.merge(options)
225
-
226
- @property = if @options[:animated_only]
227
- :animated
228
- elsif @options[:type_only]
229
- :type
230
- else
231
- :size
232
- end
233
-
234
- raise BadImageURI if uri.nil?
235
-
236
- @type, @state = nil
237
-
238
- if uri.respond_to?(:read)
239
- fetch_using_read(uri)
240
- elsif uri.start_with?('data:')
241
- fetch_using_base64(uri)
242
- else
243
- begin
244
- @parsed_uri = URI.parse(uri)
245
- rescue URI::InvalidURIError
246
- fetch_using_file_open
247
- else
248
- if @parsed_uri.scheme == "http" || @parsed_uri.scheme == "https"
249
- fetch_using_http
250
- else
251
- fetch_using_file_open
252
- end
253
- end
254
- end
255
-
256
- raise SizeNotFound if @options[:raise_on_failure] && @property == :size && !@size
257
-
258
- rescue Timeout::Error, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET,
259
- Errno::ENETUNREACH, ImageFetchFailure, Net::HTTPBadResponse, EOFError, Errno::ENOENT,
260
- OpenSSL::SSL::SSLError
261
- raise ImageFetchFailure if @options[:raise_on_failure]
262
- rescue UnknownImageType, BadImageURI
263
- raise if @options[:raise_on_failure]
264
- rescue CannotParseImage
265
- if @options[:raise_on_failure]
266
- if @property == :size
267
- raise SizeNotFound
268
- else
269
- raise ImageFetchFailure
270
- end
271
- end
272
-
273
- ensure
274
- uri.rewind if uri.respond_to?(:rewind)
275
-
276
- end
277
-
278
- private
279
-
280
- def fetch_using_http
281
- @redirect_count = 0
282
-
283
- fetch_using_http_from_parsed_uri
284
- end
285
-
286
- # Some invalid locations need escaping
287
- def escaped_location(location)
288
- begin
289
- URI(location)
290
- rescue URI::InvalidURIError
291
- ::URI::DEFAULT_PARSER.escape(location)
292
- else
293
- location
294
- end
295
- end
296
-
297
- def fetch_using_http_from_parsed_uri
298
- http_header = {'Accept-Encoding' => 'identity'}.merge(@options[:http_header])
299
-
300
- setup_http
301
- @http.request_get(@parsed_uri.request_uri, http_header) do |res|
302
- if res.is_a?(Net::HTTPRedirection) && @redirect_count < 4
303
- @redirect_count += 1
304
- begin
305
- location = res['Location']
306
- raise ImageFetchFailure if location.nil? || location.empty?
307
-
308
- @parsed_uri = URI.join(@parsed_uri, escaped_location(location))
309
- rescue URI::InvalidURIError
310
- else
311
- fetch_using_http_from_parsed_uri
312
- break
313
- end
314
- end
315
-
316
- raise ImageFetchFailure unless res.is_a?(Net::HTTPSuccess)
317
-
318
- @content_length = res.content_length
319
-
320
- read_fiber = Fiber.new do
321
- res.read_body do |str|
322
- Fiber.yield str
323
- end
324
- end
325
-
326
- case res['content-encoding']
327
- when 'deflate', 'gzip', 'x-gzip'
328
- begin
329
- gzip = Zlib::GzipReader.new(FiberStream.new(read_fiber))
330
- rescue FiberError, Zlib::GzipFile::Error
331
- raise CannotParseImage
332
- end
333
-
334
- read_fiber = Fiber.new do
335
- while data = gzip.readline
336
- Fiber.yield data
337
- end
338
- end
339
- end
340
-
341
- parse_packets FiberStream.new(read_fiber)
342
-
343
- break # needed to actively quit out of the fetch
344
- end
345
- end
346
-
347
- def protocol_relative_url?(url)
348
- url.start_with?("//")
349
- end
350
-
351
- def proxy_uri
352
- begin
353
- if @options[:proxy]
354
- proxy = URI.parse(@options[:proxy])
355
- else
356
- proxy = ENV['http_proxy'] && ENV['http_proxy'] != "" ? URI.parse(ENV['http_proxy']) : nil
357
- end
358
- rescue URI::InvalidURIError
359
- proxy = nil
360
- end
361
- proxy
362
- end
363
-
364
- def setup_http
365
- proxy = proxy_uri
366
-
367
- if proxy
368
- @http = Net::HTTP::Proxy(proxy.host, proxy.port, proxy.user, proxy.password).new(@parsed_uri.host, @parsed_uri.port)
369
- else
370
- @http = Net::HTTP.new(@parsed_uri.host, @parsed_uri.port)
371
- end
372
- @http.use_ssl = (@parsed_uri.scheme == "https")
373
- @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
374
- @http.open_timeout = @options[:timeout]
375
- @http.read_timeout = @options[:timeout]
376
- end
377
-
378
- def fetch_using_read(readable)
379
- readable.rewind if readable.respond_to?(:rewind)
380
- # Pathnames respond to read, but always return the first
381
- # chunk of the file unlike an IO (even though the
382
- # docuementation for it refers to IO). Need to supply
383
- # an offset in this case.
384
- if readable.is_a?(Pathname)
385
- read_fiber = Fiber.new do
386
- offset = 0
387
- while str = readable.read(LocalFileChunkSize, offset)
388
- Fiber.yield str
389
- offset += LocalFileChunkSize
390
- end
391
- end
392
- else
393
- read_fiber = Fiber.new do
394
- while str = readable.read(LocalFileChunkSize)
395
- Fiber.yield str
396
- end
397
- end
398
- end
399
-
400
- parse_packets FiberStream.new(read_fiber)
401
- end
402
-
403
- def fetch_using_file_open
404
- @content_length = File.size?(@uri)
405
- File.open(@uri) do |s|
406
- fetch_using_read(s)
407
- end
408
- end
409
-
410
- def parse_packets(stream)
411
- @stream = stream
412
-
413
- begin
414
- result = send("parse_#{@property}")
415
- if result != nil
416
- # extract exif orientation if it was found
417
- if @property == :size && result.size == 3
418
- @orientation = result.pop
419
- else
420
- @orientation = 1
421
- end
422
-
423
- instance_variable_set("@#{@property}", result)
424
- else
425
- raise CannotParseImage
426
- end
427
- rescue FiberError
428
- raise CannotParseImage
429
- end
430
- end
431
-
432
- def parse_size
433
- @type = parse_type unless @type
434
- send("parse_size_for_#{@type}")
435
- end
436
-
437
- def parse_animated
438
- @type = parse_type unless @type
439
- %i(gif png webp avif).include?(@type) ? send("parse_animated_for_#{@type}") : nil
440
- end
441
-
442
- def fetch_using_base64(uri)
443
- decoded = begin
444
- Base64.decode64(uri.split(',')[1])
445
- rescue
446
- raise CannotParseImage
447
- end
448
- @content_length = decoded.size
449
- fetch_using_read StringIO.new(decoded)
450
- end
451
-
452
- module StreamUtil # :nodoc:
453
- def read_byte
454
- read(1)[0].ord
455
- end
456
-
457
- def read_int
458
- read(2).unpack('n')[0]
459
- end
460
-
461
- def read_string_int
462
- value = []
463
- while read(1) =~ /(\d)/
464
- value << $1
465
- end
466
- value.join.to_i
467
- end
468
- end
469
-
470
- class FiberStream # :nodoc:
471
- include StreamUtil
472
- attr_reader :pos
473
-
474
- def initialize(read_fiber)
475
- @read_fiber = read_fiber
476
- @pos = 0
477
- @strpos = 0
478
- @str = ''
479
- end
480
-
481
- # Peeking beyond the end of the input will raise
482
- def peek(n)
483
- while @strpos + n > @str.size
484
- unused_str = @str[@strpos..-1]
485
-
486
- new_string = @read_fiber.resume
487
- new_string = @read_fiber.resume if new_string.is_a? Net::ReadAdapter
488
- raise CannotParseImage if !new_string
489
- # we are dealing with bytes here, so force the encoding
490
- new_string.force_encoding("ASCII-8BIT") if new_string.respond_to? :force_encoding
491
-
492
- @str = unused_str + new_string
493
- @strpos = 0
494
- end
495
-
496
- @str[@strpos, n]
497
- end
498
-
499
- def read(n)
500
- result = peek(n)
501
- @strpos += n
502
- @pos += n
503
- result
504
- end
505
-
506
- def skip(n)
507
- discarded = 0
508
- fetched = @str[@strpos..-1].size
509
- while n > fetched
510
- discarded += @str[@strpos..-1].size
511
- new_string = @read_fiber.resume
512
- raise CannotParseImage if !new_string
513
-
514
- new_string.force_encoding("ASCII-8BIT") if new_string.respond_to? :force_encoding
515
-
516
- fetched += new_string.size
517
- @str = new_string
518
- @strpos = 0
519
- end
520
- @strpos = @strpos + n - discarded
521
- @pos += n
522
- end
523
- end
524
-
525
- class IOStream < SimpleDelegator # :nodoc:
526
- include StreamUtil
527
- end
528
-
529
- def parse_type
530
- parsed_type = case @stream.peek(2)
531
- when "BM"
532
- :bmp
533
- when "GI"
534
- :gif
535
- when 0xff.chr + 0xd8.chr
536
- :jpeg
537
- when 0x89.chr + "P"
538
- :png
539
- when "II", "MM"
540
- case @stream.peek(11)[8..10]
541
- when "APC", "CR\002"
542
- nil # do not recognise CRW or CR2 as tiff
543
- else
544
- :tiff
545
- end
546
- when '8B'
547
- :psd
548
- when "\0\0"
549
- case @stream.peek(3).bytes.to_a.last
550
- when 0
551
- # http://www.ftyps.com/what.html
552
- case @stream.peek(12)[4..-1]
553
- when "ftypavif"
554
- :avif
555
- when "ftypavis"
556
- :avif
557
- when "ftypheic"
558
- :heic
559
- when "ftypmif1"
560
- :heif
561
- end
562
- # ico has either a 1 (for ico format) or 2 (for cursor) at offset 3
563
- when 1 then :ico
564
- when 2 then :cur
565
- end
566
- when "RI"
567
- :webp if @stream.peek(12)[8..11] == "WEBP"
568
- when "<s"
569
- :svg if @stream.peek(4) == "<svg"
570
- when /\s\s|\s<|<[?!]/, 0xef.chr + 0xbb.chr
571
- # Peek 10 more chars each time, and if end of file is reached just raise
572
- # unknown. We assume the <svg tag cannot be within 10 chars of the end of
573
- # the file, and is within the first 1000 chars.
574
- begin
575
- :svg if (1..100).detect {|n| @stream.peek(10 * n).include?("<svg")}
576
- rescue FiberError
577
- nil
578
- end
579
- end
580
-
581
- parsed_type or raise UnknownImageType
582
- end
583
-
584
- def parse_size_for_ico
585
- icons = @stream.read(6)[4..5].unpack('v').first
586
- sizes = icons.times.map { @stream.read(16).unpack('C2').map { |x| x == 0 ? 256 : x } }.sort_by { |w,h| w * h }
587
- sizes.last
588
- end
589
- alias_method :parse_size_for_cur, :parse_size_for_ico
590
-
591
- # HEIC/AVIF are a special case of the general ISO_BMFF format, in which all data is encapsulated in typed boxes,
592
- # with a mandatory ftyp box that is used to indicate particular file types. Is composed of nested "boxes". Each
593
- # box has a header composed of
594
- # - Size (32 bit integer)
595
- # - Box type (4 chars)
596
- # - Extended size: only if size === 1, the type field is followed by 64 bit integer of extended size
597
- # - Payload: Type-dependent
598
- class IsoBmff # :nodoc:
599
- def initialize(stream)
600
- @stream = stream
601
- end
602
-
603
- def width_and_height
604
- @rotation = 0
605
- @max_size = nil
606
- @primary_box = nil
607
- @ipma_boxes = []
608
- @ispe_boxes = []
609
- @final_size = nil
610
-
611
- catch :finish do
612
- read_boxes!
613
- end
614
-
615
- if [90, 270].include?(@rotation)
616
- @final_size.reverse
617
- else
618
- @final_size
619
- end
620
- end
621
-
622
- private
623
-
624
- # Format specs: https://www.loc.gov/preservation/digital/formats/fdd/fdd000525.shtml
625
-
626
- # If you need to inspect a heic/heif file, use
627
- # https://gpac.github.io/mp4box.js/test/filereader.html
628
- def read_boxes!(max_read_bytes = nil)
629
- end_pos = max_read_bytes.nil? ? nil : @stream.pos + max_read_bytes
630
- index = 0
631
-
632
- loop do
633
- return if end_pos && @stream.pos >= end_pos
634
-
635
- box_type, box_size = read_box_header!
636
-
637
- case box_type
638
- when "meta"
639
- handle_meta_box(box_size)
640
- when "pitm"
641
- handle_pitm_box(box_size)
642
- when "ipma"
643
- handle_ipma_box(box_size)
644
- when "hdlr"
645
- handle_hdlr_box(box_size)
646
- when "iprp", "ipco"
647
- read_boxes!(box_size)
648
- when "irot"
649
- handle_irot_box
650
- when "ispe"
651
- handle_ispe_box(box_size, index)
652
- when "mdat"
653
- @stream.skip(box_size)
654
- else
655
- @stream.skip(box_size)
656
- end
657
-
658
- index += 1
659
- end
660
- end
661
-
662
- def handle_irot_box
663
- @rotation = (read_uint8! & 0x3) * 90
664
- end
665
-
666
- def handle_ispe_box(box_size, index)
667
- throw :finish if box_size < 12
668
-
669
- data = @stream.read(box_size)
670
- width, height = data[4...12].unpack("N2")
671
- @ispe_boxes << { index: index, size: [width, height] }
672
- end
673
-
674
- def handle_hdlr_box(box_size)
675
- throw :finish if box_size < 12
676
-
677
- data = @stream.read(box_size)
678
- throw :finish if data[8...12] != "pict"
679
- end
680
-
681
- def handle_ipma_box(box_size)
682
- @stream.read(3)
683
- flags3 = read_uint8!
684
- entries_count = read_uint32!
685
-
686
- entries_count.times do
687
- id = read_uint16!
688
- essen_count = read_uint8!
689
-
690
- essen_count.times do
691
- property_index = read_uint8! & 0x7F
692
-
693
- if flags3 & 1 == 1
694
- property_index = (property_index << 7) + read_uint8!
695
- end
696
-
697
- @ipma_boxes << { id: id, property_index: property_index - 1 }
698
- end
699
- end
700
- end
701
-
702
- def handle_pitm_box(box_size)
703
- data = @stream.read(box_size)
704
- @primary_box = data[4...6].unpack("S>")[0]
705
- end
706
-
707
- def handle_meta_box(box_size)
708
- throw :finish if box_size < 4
709
-
710
- @stream.read(4)
711
- read_boxes!(box_size - 4)
712
-
713
- throw :finish if !@primary_box
714
-
715
- primary_indices = @ipma_boxes
716
- .select { |box| box[:id] == @primary_box }
717
- .map { |box| box[:property_index] }
718
-
719
- ispe_box = @ispe_boxes.find do |box|
720
- primary_indices.include?(box[:index])
721
- end
722
-
723
- if ispe_box
724
- @final_size = ispe_box[:size]
725
- end
726
-
727
- throw :finish
728
- end
729
-
730
- def read_box_header!
731
- size = read_uint32!
732
- type = @stream.read(4)
733
- size = read_uint64! - 8 if size == 1
734
- [type, size - 8]
735
- end
736
-
737
- def read_uint8!
738
- @stream.read(1).unpack("C")[0]
739
- end
740
-
741
- def read_uint16!
742
- @stream.read(2).unpack("S>")[0]
743
- end
744
-
745
- def read_uint32!
746
- @stream.read(4).unpack("N")[0]
747
- end
748
-
749
- def read_uint64!
750
- @stream.read(8).unpack("Q>")[0]
751
- end
752
- end
753
-
754
- def parse_size_for_avif
755
- bmff = IsoBmff.new(@stream)
756
- bmff.width_and_height
757
- end
758
-
759
- def parse_size_for_heic
760
- bmff = IsoBmff.new(@stream)
761
- bmff.width_and_height
762
- end
763
-
764
- def parse_size_for_heif
765
- bmff = IsoBmff.new(@stream)
766
- bmff.width_and_height
767
- end
768
-
769
- class Gif # :nodoc:
770
- def initialize(stream)
771
- @stream = stream
772
- end
773
-
774
- def width_and_height
775
- @stream.read(11)[6..10].unpack('SS')
776
- end
777
-
778
- # Checks if a delay between frames exists and if it does, then the GIFs is
779
- # animated
780
- def animated?
781
- frames = 0
782
-
783
- # "GIF" + version (3) + width (2) + height (2)
784
- @stream.skip(10)
785
-
786
- # fields (1) + bg color (1) + pixel ratio (1)
787
- fields = @stream.read(3).unpack("CCC")[0]
788
- if fields & 0x80 != 0 # Global Color Table
789
- # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
790
- @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
791
- end
792
-
793
- loop do
794
- block_type = @stream.read(1).unpack("C")[0]
795
-
796
- if block_type == 0x21 # Graphic Control Extension
797
- # extension type (1) + size (1)
798
- size = @stream.read(2).unpack("CC")[1]
799
- @stream.skip(size)
800
- skip_sub_blocks
801
- elsif block_type == 0x2C # Image Descriptor
802
- frames += 1
803
- return true if frames > 1
804
-
805
- # left position (2) + top position (2) + width (2) + height (2) + fields (1)
806
- fields = @stream.read(9).unpack("SSSSC")[4]
807
- if fields & 0x80 != 0 # Local Color Table
808
- # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
809
- @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
810
- end
811
-
812
- @stream.skip(1) # LZW min code size (1)
813
- skip_sub_blocks
814
- else
815
- break # unrecognized block
816
- end
817
- end
818
-
819
- false
820
- end
821
-
822
- private
823
-
824
- def skip_sub_blocks
825
- loop do
826
- size = @stream.read(1).unpack("C")[0]
827
- if size == 0
828
- break
829
- else
830
- @stream.skip(size)
831
- end
832
- end
833
- end
834
- end
835
-
836
- def parse_size_for_gif
837
- gif = Gif.new(@stream)
838
- gif.width_and_height
839
- end
840
-
841
- def parse_size_for_png
842
- @stream.read(25)[16..24].unpack('NN')
843
- end
844
-
845
- def parse_size_for_jpeg
846
- exif = nil
847
- loop do
848
- @state = case @state
849
- when nil
850
- @stream.skip(2)
851
- :started
852
- when :started
853
- @stream.read_byte == 0xFF ? :sof : :started
854
- when :sof
855
- case @stream.read_byte
856
- when 0xe1 # APP1
857
- skip_chars = @stream.read_int - 2
858
- data = @stream.read(skip_chars)
859
- io = StringIO.new(data)
860
- if io.read(4) == "Exif"
861
- io.read(2)
862
- new_exif = Exif.new(IOStream.new(io)) rescue nil
863
- exif ||= new_exif # only use the first APP1 segment
864
- end
865
- :started
866
- when 0xe0..0xef
867
- :skipframe
868
- when 0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF
869
- :readsize
870
- when 0xFF
871
- :sof
872
- else
873
- :skipframe
874
- end
875
- when :skipframe
876
- skip_chars = @stream.read_int - 2
877
- @stream.skip(skip_chars)
878
- :started
879
- when :readsize
880
- @stream.skip(3)
881
- height = @stream.read_int
882
- width = @stream.read_int
883
- width, height = height, width if exif && exif.rotated?
884
- return [width, height, exif ? exif.orientation : 1]
885
- end
886
- end
887
- end
888
-
889
- def parse_size_for_bmp
890
- d = @stream.read(32)[14..28]
891
- header = d.unpack("C")[0]
892
-
893
- result = if header == 12
894
- d[4..8].unpack('SS')
895
- else
896
- d[4..-1].unpack('l<l<')
897
- end
898
-
899
- # ImageHeight is expressed in pixels. The absolute value is necessary because ImageHeight can be negative
900
- [result.first, result.last.abs]
901
- end
902
-
903
- def parse_size_for_webp
904
- vp8 = @stream.read(16)[12..15]
905
- _len = @stream.read(4).unpack("V")
906
- case vp8
907
- when "VP8 "
908
- parse_size_vp8
909
- when "VP8L"
910
- parse_size_vp8l
911
- when "VP8X"
912
- parse_size_vp8x
913
- else
914
- nil
915
- end
916
- end
917
-
918
- def parse_size_vp8
919
- w, h = @stream.read(10).unpack("@6vv")
920
- [w & 0x3fff, h & 0x3fff]
921
- end
922
-
923
- def parse_size_vp8l
924
- @stream.skip(1) # 0x2f
925
- b1, b2, b3, b4 = @stream.read(4).bytes.to_a
926
- [1 + (((b2 & 0x3f) << 8) | b1), 1 + (((b4 & 0xF) << 10) | (b3 << 2) | ((b2 & 0xC0) >> 6))]
927
- end
928
-
929
- def parse_size_vp8x
930
- flags = @stream.read(4).unpack("C")[0]
931
- b1, b2, b3, b4, b5, b6 = @stream.read(6).unpack("CCCCCC")
932
- width, height = 1 + b1 + (b2 << 8) + (b3 << 16), 1 + b4 + (b5 << 8) + (b6 << 16)
933
-
934
- if flags & 8 > 0 # exif
935
- # parse exif for orientation
936
- # TODO: find or create test images for this
937
- end
938
-
939
- return [width, height]
940
- end
941
-
942
- class Exif # :nodoc:
943
- attr_reader :width, :height, :orientation
944
-
945
- def initialize(stream)
946
- @stream = stream
947
- @width, @height, @orientation = nil
948
- parse_exif
949
- end
950
-
951
- def rotated?
952
- @orientation >= 5
953
- end
954
-
955
- private
956
-
957
- def get_exif_byte_order
958
- byte_order = @stream.read(2)
959
- case byte_order
960
- when 'II'
961
- @short, @long = 'v', 'V'
962
- when 'MM'
963
- @short, @long = 'n', 'N'
964
- else
965
- raise CannotParseImage
966
- end
967
- end
968
-
969
- def parse_exif_ifd
970
- tag_count = @stream.read(2).unpack(@short)[0]
971
- tag_count.downto(1) do
972
- type = @stream.read(2).unpack(@short)[0]
973
- @stream.read(6)
974
- data = @stream.read(2).unpack(@short)[0]
975
- case type
976
- when 0x0100 # image width
977
- @width = data
978
- when 0x0101 # image height
979
- @height = data
980
- when 0x0112 # orientation
981
- @orientation = data
982
- end
983
- if @width && @height && @orientation
984
- return # no need to parse more
985
- end
986
- @stream.read(2)
987
- end
988
- end
989
-
990
- def parse_exif
991
- @start_byte = @stream.pos
992
-
993
- get_exif_byte_order
994
-
995
- @stream.read(2) # 42
996
-
997
- offset = @stream.read(4).unpack(@long)[0]
998
- if @stream.respond_to?(:skip)
999
- @stream.skip(offset - 8)
1000
- else
1001
- @stream.read(offset - 8)
1002
- end
1003
-
1004
- parse_exif_ifd
1005
-
1006
- @orientation ||= 1
1007
- end
1008
-
1009
- end
1010
-
1011
- def parse_size_for_tiff
1012
- exif = Exif.new(@stream)
1013
- if exif.rotated?
1014
- [exif.height, exif.width, exif.orientation]
1015
- else
1016
- [exif.width, exif.height, exif.orientation]
1017
- end
1018
- end
1019
-
1020
- def parse_size_for_psd
1021
- @stream.read(26).unpack("x14NN").reverse
1022
- end
1023
-
1024
- class Svg # :nodoc:
1025
- def initialize(stream)
1026
- @stream = stream
1027
- @width, @height, @ratio, @viewbox_width, @viewbox_height = nil
1028
- parse_svg
1029
- end
1030
-
1031
- def width_and_height
1032
- if @width && @height
1033
- [@width, @height]
1034
- elsif @width && @ratio
1035
- [@width, @width / @ratio]
1036
- elsif @height && @ratio
1037
- [@height * @ratio, @height]
1038
- elsif @viewbox_width && @viewbox_height
1039
- [@viewbox_width, @viewbox_height]
1040
- else
1041
- nil
1042
- end
1043
- end
1044
-
1045
- private
1046
-
1047
- def parse_svg
1048
- attr_name = []
1049
- state = nil
1050
-
1051
- while (char = @stream.read(1)) && state != :stop do
1052
- case char
1053
- when "="
1054
- if attr_name.join =~ /width/i
1055
- @stream.read(1)
1056
- @width = @stream.read_string_int
1057
- return if @height
1058
- elsif attr_name.join =~ /height/i
1059
- @stream.read(1)
1060
- @height = @stream.read_string_int
1061
- return if @width
1062
- elsif attr_name.join =~ /viewbox/i
1063
- values = attr_value.split(/\s/)
1064
- if values[2].to_f > 0 && values[3].to_f > 0
1065
- @ratio = values[2].to_f / values[3].to_f
1066
- @viewbox_width = values[2].to_i
1067
- @viewbox_height = values[3].to_i
1068
- end
1069
- end
1070
- when /\w/
1071
- attr_name << char
1072
- when "<"
1073
- attr_name = [char]
1074
- when ">"
1075
- state = :stop if state == :started
1076
- else
1077
- state = :started if attr_name.join == "<svg"
1078
- attr_name.clear
1079
- end
1080
- end
1081
- end
1082
-
1083
- def attr_value
1084
- @stream.read(1)
1085
-
1086
- value = []
1087
- while @stream.read(1) =~ /([^"])/
1088
- value << $1
1089
- end
1090
- value.join
1091
- end
1092
- end
1093
-
1094
- def parse_size_for_svg
1095
- svg = Svg.new(@stream)
1096
- svg.width_and_height
1097
- end
1098
-
1099
- def parse_animated_for_gif
1100
- gif = Gif.new(@stream)
1101
- gif.animated?
1102
- end
1103
-
1104
- def parse_animated_for_png
1105
- # Signature (8) + IHDR chunk (4 + 4 + 13 + 4)
1106
- @stream.read(33)
1107
-
1108
- loop do
1109
- length = @stream.read(4).unpack("L>")[0]
1110
- type = @stream.read(4)
1111
-
1112
- case type
1113
- when "acTL"
1114
- return true
1115
- when "IDAT"
1116
- return false
1117
- end
1118
-
1119
- @stream.skip(length + 4)
1120
- end
1121
- end
1122
-
1123
- def parse_animated_for_webp
1124
- vp8 = @stream.read(16)[12..15]
1125
- _len = @stream.read(4).unpack("V")
1126
- case vp8
1127
- when "VP8 "
1128
- false
1129
- when "VP8L"
1130
- false
1131
- when "VP8X"
1132
- flags = @stream.read(4).unpack("C")[0]
1133
- flags & 2 > 0
1134
- else
1135
- nil
1136
- end
1137
- end
1138
-
1139
- def parse_animated_for_avif
1140
- @stream.peek(12)[4..-1] == "ftypavis"
1141
- end
1142
- end