fastimage 2.3.1 → 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
@@ -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