fastimage 2.3.0 → 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,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