fastimage 2.3.1 → 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,1079 +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
- nil
325
- end
326
-
327
- case res['content-encoding']
328
- when 'deflate', 'gzip', 'x-gzip'
329
- begin
330
- gzip = Zlib::GzipReader.new(FiberStream.new(read_fiber))
331
- rescue FiberError, Zlib::GzipFile::Error
332
- raise CannotParseImage
333
- end
334
-
335
- read_fiber = Fiber.new do
336
- while data = gzip.readline
337
- Fiber.yield data
338
- end
339
- nil
340
- end
341
- end
342
-
343
- parse_packets FiberStream.new(read_fiber)
344
-
345
- break # needed to actively quit out of the fetch
346
- end
347
- end
348
-
349
- def protocol_relative_url?(url)
350
- url.start_with?("//")
351
- end
352
-
353
- def proxy_uri
354
- begin
355
- if @options[:proxy]
356
- proxy = URI.parse(@options[:proxy])
357
- else
358
- proxy = ENV['http_proxy'] && ENV['http_proxy'] != "" ? URI.parse(ENV['http_proxy']) : nil
359
- end
360
- rescue URI::InvalidURIError
361
- proxy = nil
362
- end
363
- proxy
364
- end
365
-
366
- def setup_http
367
- proxy = proxy_uri
368
-
369
- if proxy
370
- @http = Net::HTTP::Proxy(proxy.host, proxy.port, proxy.user, proxy.password).new(@parsed_uri.host, @parsed_uri.port)
371
- else
372
- @http = Net::HTTP.new(@parsed_uri.host, @parsed_uri.port)
373
- end
374
- @http.use_ssl = (@parsed_uri.scheme == "https")
375
- @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
376
- @http.open_timeout = @options[:timeout]
377
- @http.read_timeout = @options[:timeout]
378
- end
379
-
380
- def fetch_using_read(readable)
381
- readable.rewind if readable.respond_to?(:rewind)
382
- # Pathnames respond to read, but always return the first
383
- # chunk of the file unlike an IO (even though the
384
- # docuementation for it refers to IO). Need to supply
385
- # an offset in this case.
386
- if readable.is_a?(Pathname)
387
- read_fiber = Fiber.new do
388
- offset = 0
389
- while str = readable.read(LocalFileChunkSize, offset)
390
- Fiber.yield str
391
- offset += LocalFileChunkSize
392
- end
393
- nil
394
- end
395
- else
396
- read_fiber = Fiber.new do
397
- while str = readable.read(LocalFileChunkSize)
398
- Fiber.yield str
399
- end
400
- nil
401
- end
402
- end
403
-
404
- parse_packets FiberStream.new(read_fiber)
405
- end
406
-
407
- def fetch_using_file_open
408
- @content_length = File.size?(@uri)
409
- File.open(@uri) do |s|
410
- fetch_using_read(s)
411
- end
412
- end
413
-
414
- def parse_packets(stream)
415
- @stream = stream
416
-
417
- begin
418
- result = send("parse_#{@property}")
419
- if result != nil
420
- # extract exif orientation if it was found
421
- if @property == :size && result.size == 3
422
- @orientation = result.pop
423
- else
424
- @orientation = 1
425
- end
426
-
427
- instance_variable_set("@#{@property}", result)
428
- else
429
- raise CannotParseImage
430
- end
431
- rescue FiberError
432
- raise CannotParseImage
433
- end
434
- end
435
-
436
- def parse_size
437
- @type = parse_type unless @type
438
- send("parse_size_for_#{@type}")
439
- end
440
-
441
- def parse_animated
442
- @type = parse_type unless @type
443
- %i(gif png webp avif).include?(@type) ? send("parse_animated_for_#{@type}") : nil
444
- end
445
-
446
- def fetch_using_base64(uri)
447
- decoded = begin
448
- Base64.decode64(uri.split(',')[1])
449
- rescue
450
- raise CannotParseImage
451
- end
452
- @content_length = decoded.size
453
- fetch_using_read StringIO.new(decoded)
454
- end
455
-
456
- module StreamUtil # :nodoc:
457
- def read_byte
458
- read(1)[0].ord
459
- end
460
-
461
- def read_int
462
- read(2).unpack('n')[0]
463
- end
464
-
465
- def read_string_int
466
- value = []
467
- while read(1) =~ /(\d)/
468
- value << $1
469
- end
470
- value.join.to_i
471
- end
472
- end
473
-
474
- class FiberStream # :nodoc:
475
- include StreamUtil
476
- attr_reader :pos
477
-
478
- # read_fiber should return nil if it no longer has anything to return when resumed
479
- # so the result of the whole Fiber block should be set to be nil in case yield is no
480
- # longer called
481
- def initialize(read_fiber)
482
- @read_fiber = read_fiber
483
- @pos = 0
484
- @strpos = 0
485
- @str = ''
486
- end
487
-
488
- # Peeking beyond the end of the input will raise
489
- def peek(n)
490
- while @strpos + n > @str.size
491
- unused_str = @str[@strpos..-1]
492
-
493
- new_string = @read_fiber.resume
494
- raise CannotParseImage if !new_string
495
- # we are dealing with bytes here, so force the encoding
496
- new_string.force_encoding("ASCII-8BIT") if new_string.respond_to? :force_encoding
497
-
498
- @str = unused_str + new_string
499
- @strpos = 0
500
- end
501
-
502
- @str[@strpos, n]
503
- end
504
-
505
- def read(n)
506
- result = peek(n)
507
- @strpos += n
508
- @pos += n
509
- result
510
- end
511
-
512
- def skip(n)
513
- discarded = 0
514
- fetched = @str[@strpos..-1].size
515
- while n > fetched
516
- discarded += @str[@strpos..-1].size
517
- new_string = @read_fiber.resume
518
- raise CannotParseImage if !new_string
519
-
520
- new_string.force_encoding("ASCII-8BIT") if new_string.respond_to? :force_encoding
521
-
522
- fetched += new_string.size
523
- @str = new_string
524
- @strpos = 0
525
- end
526
- @strpos = @strpos + n - discarded
527
- @pos += n
528
- end
529
- end
530
-
531
- class IOStream < SimpleDelegator # :nodoc:
532
- include StreamUtil
533
- end
534
-
535
- def parse_type
536
- parsed_type = case @stream.peek(2)
537
- when "BM"
538
- :bmp
539
- when "GI"
540
- :gif
541
- when 0xff.chr + 0xd8.chr
542
- :jpeg
543
- when 0x89.chr + "P"
544
- :png
545
- when "II", "MM"
546
- case @stream.peek(11)[8..10]
547
- when "APC", "CR\002"
548
- nil # do not recognise CRW or CR2 as tiff
549
- else
550
- :tiff
551
- end
552
- when '8B'
553
- :psd
554
- when "\0\0"
555
- case @stream.peek(3).bytes.to_a.last
556
- when 0
557
- # http://www.ftyps.com/what.html
558
- case @stream.peek(12)[4..-1]
559
- when "ftypavif"
560
- :avif
561
- when "ftypavis"
562
- :avif
563
- when "ftypheic"
564
- :heic
565
- when "ftypmif1"
566
- :heif
567
- end
568
- # ico has either a 1 (for ico format) or 2 (for cursor) at offset 3
569
- when 1 then :ico
570
- when 2 then :cur
571
- end
572
- when "RI"
573
- :webp if @stream.peek(12)[8..11] == "WEBP"
574
- when "<s"
575
- :svg if @stream.peek(4) == "<svg"
576
- when /\s\s|\s<|<[?!]/, 0xef.chr + 0xbb.chr
577
- # Peek 10 more chars each time, and if end of file is reached just raise
578
- # unknown. We assume the <svg tag cannot be within 10 chars of the end of
579
- # the file, and is within the first 1000 chars.
580
- begin
581
- :svg if (1..100).detect {|n| @stream.peek(10 * n).include?("<svg")}
582
- rescue FiberError, CannotParseImage
583
- nil
584
- end
585
- end
586
-
587
- parsed_type or raise UnknownImageType
588
- end
589
-
590
- def parse_size_for_ico
591
- icons = @stream.read(6)[4..5].unpack('v').first
592
- sizes = icons.times.map { @stream.read(16).unpack('C2').map { |x| x == 0 ? 256 : x } }.sort_by { |w,h| w * h }
593
- sizes.last
594
- end
595
- alias_method :parse_size_for_cur, :parse_size_for_ico
596
-
597
- # HEIC/AVIF are a special case of the general ISO_BMFF format, in which all data is encapsulated in typed boxes,
598
- # with a mandatory ftyp box that is used to indicate particular file types. Is composed of nested "boxes". Each
599
- # box has a header composed of
600
- # - Size (32 bit integer)
601
- # - Box type (4 chars)
602
- # - Extended size: only if size === 1, the type field is followed by 64 bit integer of extended size
603
- # - Payload: Type-dependent
604
- class IsoBmff # :nodoc:
605
- def initialize(stream)
606
- @stream = stream
607
- end
608
-
609
- def width_and_height
610
- @rotation = 0
611
- @max_size = nil
612
- @primary_box = nil
613
- @ipma_boxes = []
614
- @ispe_boxes = []
615
- @final_size = nil
616
-
617
- catch :finish do
618
- read_boxes!
619
- end
620
-
621
- if [90, 270].include?(@rotation)
622
- @final_size.reverse
623
- else
624
- @final_size
625
- end
626
- end
627
-
628
- private
629
-
630
- # Format specs: https://www.loc.gov/preservation/digital/formats/fdd/fdd000525.shtml
631
-
632
- # If you need to inspect a heic/heif file, use
633
- # https://gpac.github.io/mp4box.js/test/filereader.html
634
- def read_boxes!(max_read_bytes = nil)
635
- end_pos = max_read_bytes.nil? ? nil : @stream.pos + max_read_bytes
636
- index = 0
637
-
638
- loop do
639
- return if end_pos && @stream.pos >= end_pos
640
-
641
- box_type, box_size = read_box_header!
642
-
643
- case box_type
644
- when "meta"
645
- handle_meta_box(box_size)
646
- when "pitm"
647
- handle_pitm_box(box_size)
648
- when "ipma"
649
- handle_ipma_box(box_size)
650
- when "hdlr"
651
- handle_hdlr_box(box_size)
652
- when "iprp", "ipco"
653
- read_boxes!(box_size)
654
- when "irot"
655
- handle_irot_box
656
- when "ispe"
657
- handle_ispe_box(box_size, index)
658
- when "mdat"
659
- @stream.skip(box_size)
660
- else
661
- @stream.skip(box_size)
662
- end
663
-
664
- index += 1
665
- end
666
- end
667
-
668
- def handle_irot_box
669
- @rotation = (read_uint8! & 0x3) * 90
670
- end
671
-
672
- def handle_ispe_box(box_size, index)
673
- throw :finish if box_size < 12
674
-
675
- data = @stream.read(box_size)
676
- width, height = data[4...12].unpack("N2")
677
- @ispe_boxes << { index: index, size: [width, height] }
678
- end
679
-
680
- def handle_hdlr_box(box_size)
681
- throw :finish if box_size < 12
682
-
683
- data = @stream.read(box_size)
684
- throw :finish if data[8...12] != "pict"
685
- end
686
-
687
- def handle_ipma_box(box_size)
688
- @stream.read(3)
689
- flags3 = read_uint8!
690
- entries_count = read_uint32!
691
-
692
- entries_count.times do
693
- id = read_uint16!
694
- essen_count = read_uint8!
695
-
696
- essen_count.times do
697
- property_index = read_uint8! & 0x7F
698
-
699
- if flags3 & 1 == 1
700
- property_index = (property_index << 7) + read_uint8!
701
- end
702
-
703
- @ipma_boxes << { id: id, property_index: property_index - 1 }
704
- end
705
- end
706
- end
707
-
708
- def handle_pitm_box(box_size)
709
- data = @stream.read(box_size)
710
- @primary_box = data[4...6].unpack("S>")[0]
711
- end
712
-
713
- def handle_meta_box(box_size)
714
- throw :finish if box_size < 4
715
-
716
- @stream.read(4)
717
- read_boxes!(box_size - 4)
718
-
719
- throw :finish if !@primary_box
720
-
721
- primary_indices = @ipma_boxes
722
- .select { |box| box[:id] == @primary_box }
723
- .map { |box| box[:property_index] }
724
-
725
- ispe_box = @ispe_boxes.find do |box|
726
- primary_indices.include?(box[:index])
727
- end
728
-
729
- if ispe_box
730
- @final_size = ispe_box[:size]
731
- end
732
-
733
- throw :finish
734
- end
735
-
736
- def read_box_header!
737
- size = read_uint32!
738
- type = @stream.read(4)
739
- size = read_uint64! - 8 if size == 1
740
- [type, size - 8]
741
- end
742
-
743
- def read_uint8!
744
- @stream.read(1).unpack("C")[0]
745
- end
746
-
747
- def read_uint16!
748
- @stream.read(2).unpack("S>")[0]
749
- end
750
-
751
- def read_uint32!
752
- @stream.read(4).unpack("N")[0]
753
- end
754
-
755
- def read_uint64!
756
- @stream.read(8).unpack("Q>")[0]
757
- end
758
- end
759
-
760
- def parse_size_for_avif
761
- bmff = IsoBmff.new(@stream)
762
- bmff.width_and_height
763
- end
764
-
765
- def parse_size_for_heic
766
- bmff = IsoBmff.new(@stream)
767
- bmff.width_and_height
768
- end
769
-
770
- def parse_size_for_heif
771
- bmff = IsoBmff.new(@stream)
772
- bmff.width_and_height
773
- end
774
-
775
- class Gif # :nodoc:
776
- def initialize(stream)
777
- @stream = stream
778
- end
779
-
780
- def width_and_height
781
- @stream.read(11)[6..10].unpack('SS')
782
- end
783
-
784
- # Checks if a delay between frames exists and if it does, then the GIFs is
785
- # animated
786
- def animated?
787
- frames = 0
788
-
789
- # "GIF" + version (3) + width (2) + height (2)
790
- @stream.skip(10)
791
-
792
- # fields (1) + bg color (1) + pixel ratio (1)
793
- fields = @stream.read(3).unpack("CCC")[0]
794
- if fields & 0x80 != 0 # Global Color Table
795
- # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
796
- @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
797
- end
798
-
799
- loop do
800
- block_type = @stream.read(1).unpack("C")[0]
801
-
802
- if block_type == 0x21 # Graphic Control Extension
803
- # extension type (1) + size (1)
804
- size = @stream.read(2).unpack("CC")[1]
805
- @stream.skip(size)
806
- skip_sub_blocks
807
- elsif block_type == 0x2C # Image Descriptor
808
- frames += 1
809
- return true if frames > 1
810
-
811
- # left position (2) + top position (2) + width (2) + height (2) + fields (1)
812
- fields = @stream.read(9).unpack("SSSSC")[4]
813
- if fields & 0x80 != 0 # Local Color Table
814
- # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
815
- @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
816
- end
817
-
818
- @stream.skip(1) # LZW min code size (1)
819
- skip_sub_blocks
820
- else
821
- break # unrecognized block
822
- end
823
- end
824
-
825
- false
826
- end
827
-
828
- private
829
-
830
- def skip_sub_blocks
831
- loop do
832
- size = @stream.read(1).unpack("C")[0]
833
- if size == 0
834
- break
835
- else
836
- @stream.skip(size)
837
- end
838
- end
839
- end
840
- end
841
-
842
- def parse_size_for_gif
843
- gif = Gif.new(@stream)
844
- gif.width_and_height
845
- end
846
-
847
- def parse_size_for_png
848
- @stream.read(25)[16..24].unpack('NN')
849
- end
850
-
851
- def parse_size_for_jpeg
852
- exif = nil
853
- loop do
854
- @state = case @state
855
- when nil
856
- @stream.skip(2)
857
- :started
858
- when :started
859
- @stream.read_byte == 0xFF ? :sof : :started
860
- when :sof
861
- case @stream.read_byte
862
- when 0xe1 # APP1
863
- skip_chars = @stream.read_int - 2
864
- data = @stream.read(skip_chars)
865
- io = StringIO.new(data)
866
- if io.read(4) == "Exif"
867
- io.read(2)
868
- new_exif = Exif.new(IOStream.new(io)) rescue nil
869
- exif ||= new_exif # only use the first APP1 segment
870
- end
871
- :started
872
- when 0xe0..0xef
873
- :skipframe
874
- when 0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF
875
- :readsize
876
- when 0xFF
877
- :sof
878
- else
879
- :skipframe
880
- end
881
- when :skipframe
882
- skip_chars = @stream.read_int - 2
883
- @stream.skip(skip_chars)
884
- :started
885
- when :readsize
886
- @stream.skip(3)
887
- height = @stream.read_int
888
- width = @stream.read_int
889
- width, height = height, width if exif && exif.rotated?
890
- return [width, height, exif ? exif.orientation : 1]
891
- end
892
- end
893
- end
894
-
895
- def parse_size_for_bmp
896
- d = @stream.read(32)[14..28]
897
- header = d.unpack("C")[0]
898
-
899
- result = if header == 12
900
- d[4..8].unpack('SS')
901
- else
902
- d[4..-1].unpack('l<l<')
903
- end
904
-
905
- # ImageHeight is expressed in pixels. The absolute value is necessary because ImageHeight can be negative
906
- [result.first, result.last.abs]
907
- end
908
-
909
- def parse_size_for_webp
910
- vp8 = @stream.read(16)[12..15]
911
- _len = @stream.read(4).unpack("V")
912
- case vp8
913
- when "VP8 "
914
- parse_size_vp8
915
- when "VP8L"
916
- parse_size_vp8l
917
- when "VP8X"
918
- parse_size_vp8x
919
- else
920
- nil
921
- end
922
- end
923
-
924
- def parse_size_vp8
925
- w, h = @stream.read(10).unpack("@6vv")
926
- [w & 0x3fff, h & 0x3fff]
927
- end
928
-
929
- def parse_size_vp8l
930
- @stream.skip(1) # 0x2f
931
- b1, b2, b3, b4 = @stream.read(4).bytes.to_a
932
- [1 + (((b2 & 0x3f) << 8) | b1), 1 + (((b4 & 0xF) << 10) | (b3 << 2) | ((b2 & 0xC0) >> 6))]
933
- end
934
-
935
- def parse_size_vp8x
936
- flags = @stream.read(4).unpack("C")[0]
937
- b1, b2, b3, b4, b5, b6 = @stream.read(6).unpack("CCCCCC")
938
- width, height = 1 + b1 + (b2 << 8) + (b3 << 16), 1 + b4 + (b5 << 8) + (b6 << 16)
939
-
940
- if flags & 8 > 0 # exif
941
- # parse exif for orientation
942
- # TODO: find or create test images for this
943
- end
944
-
945
- return [width, height]
946
- end
947
-
948
- class Exif # :nodoc:
949
- attr_reader :width, :height, :orientation
950
-
951
- def initialize(stream)
952
- @stream = stream
953
- @width, @height, @orientation = nil
954
- parse_exif
955
- end
956
-
957
- def rotated?
958
- @orientation >= 5
959
- end
960
-
961
- private
962
-
963
- def get_exif_byte_order
964
- byte_order = @stream.read(2)
965
- case byte_order
966
- when 'II'
967
- @short, @long = 'v', 'V'
968
- when 'MM'
969
- @short, @long = 'n', 'N'
970
- else
971
- raise CannotParseImage
972
- end
973
- end
974
-
975
- def parse_exif_ifd
976
- tag_count = @stream.read(2).unpack(@short)[0]
977
- tag_count.downto(1) do
978
- type = @stream.read(2).unpack(@short)[0]
979
- @stream.read(6)
980
- data = @stream.read(2).unpack(@short)[0]
981
- case type
982
- when 0x0100 # image width
983
- @width = data
984
- when 0x0101 # image height
985
- @height = data
986
- when 0x0112 # orientation
987
- @orientation = data
988
- end
989
- if @width && @height && @orientation
990
- return # no need to parse more
991
- end
992
- @stream.read(2)
993
- end
994
- end
995
-
996
- def parse_exif
997
- @start_byte = @stream.pos
998
-
999
- get_exif_byte_order
1000
-
1001
- @stream.read(2) # 42
1002
-
1003
- offset = @stream.read(4).unpack(@long)[0]
1004
- if @stream.respond_to?(:skip)
1005
- @stream.skip(offset - 8)
1006
- else
1007
- @stream.read(offset - 8)
1008
- end
1009
-
1010
- parse_exif_ifd
1011
-
1012
- @orientation ||= 1
1013
- end
1014
-
1015
- end
1016
-
1017
- def parse_size_for_tiff
1018
- exif = Exif.new(@stream)
1019
- if exif.rotated?
1020
- [exif.height, exif.width, exif.orientation]
1021
- else
1022
- [exif.width, exif.height, exif.orientation]
1023
- end
1024
- end
1025
-
1026
- def parse_size_for_psd
1027
- @stream.read(26).unpack("x14NN").reverse
1028
- end
1029
-
1030
- class Svg # :nodoc:
1031
- def initialize(stream)
1032
- @stream = stream
1033
- @width, @height, @ratio, @viewbox_width, @viewbox_height = nil
1034
- parse_svg
1035
- end
1036
-
1037
- def width_and_height
1038
- if @width && @height
1039
- [@width, @height]
1040
- elsif @width && @ratio
1041
- [@width, @width / @ratio]
1042
- elsif @height && @ratio
1043
- [@height * @ratio, @height]
1044
- elsif @viewbox_width && @viewbox_height
1045
- [@viewbox_width, @viewbox_height]
1046
- else
1047
- nil
1048
- end
1049
- end
1050
-
1051
- private
1052
-
1053
- def parse_svg
1054
- attr_name = []
1055
- state = nil
1056
-
1057
- while (char = @stream.read(1)) && state != :stop do
1058
- case char
1059
- when "="
1060
- if attr_name.join =~ /width/i
1061
- @stream.read(1)
1062
- @width = @stream.read_string_int
1063
- return if @height
1064
- elsif attr_name.join =~ /height/i
1065
- @stream.read(1)
1066
- @height = @stream.read_string_int
1067
- return if @width
1068
- elsif attr_name.join =~ /viewbox/i
1069
- values = attr_value.split(/\s/)
1070
- if values[2].to_f > 0 && values[3].to_f > 0
1071
- @ratio = values[2].to_f / values[3].to_f
1072
- @viewbox_width = values[2].to_i
1073
- @viewbox_height = values[3].to_i
1074
- end
1075
- end
1076
- when /\w/
1077
- attr_name << char
1078
- when "<"
1079
- attr_name = [char]
1080
- when ">"
1081
- state = :stop if state == :started
1082
- else
1083
- state = :started if attr_name.join == "<svg"
1084
- attr_name.clear
1085
- end
1086
- end
1087
- end
1088
-
1089
- def attr_value
1090
- @stream.read(1)
1091
-
1092
- value = []
1093
- while @stream.read(1) =~ /([^"])/
1094
- value << $1
1095
- end
1096
- value.join
1097
- end
1098
- end
1099
-
1100
- def parse_size_for_svg
1101
- svg = Svg.new(@stream)
1102
- svg.width_and_height
1103
- end
1104
-
1105
- def parse_animated_for_gif
1106
- gif = Gif.new(@stream)
1107
- gif.animated?
1108
- end
1109
-
1110
- def parse_animated_for_png
1111
- # Signature (8) + IHDR chunk (4 + 4 + 13 + 4)
1112
- @stream.read(33)
1113
-
1114
- loop do
1115
- length = @stream.read(4).unpack("L>")[0]
1116
- type = @stream.read(4)
1117
-
1118
- case type
1119
- when "acTL"
1120
- return true
1121
- when "IDAT"
1122
- return false
1123
- end
1124
-
1125
- @stream.skip(length + 4)
1126
- end
1127
- end
1128
-
1129
- def parse_animated_for_webp
1130
- vp8 = @stream.read(16)[12..15]
1131
- _len = @stream.read(4).unpack("V")
1132
- case vp8
1133
- when "VP8 "
1134
- false
1135
- when "VP8L"
1136
- false
1137
- when "VP8X"
1138
- flags = @stream.read(4).unpack("C")[0]
1139
- flags & 2 > 0
1140
- else
1141
- nil
1142
- end
1143
- end
1144
-
1145
- def parse_animated_for_avif
1146
- @stream.peek(12)[4..-1] == "ftypavis"
1147
- end
1148
- end