fastimage 2.2.6 → 2.4.0

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