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.
@@ -0,0 +1,63 @@
1
+ module FastImageParsing
2
+ class Gif < ImageBase # :nodoc:
3
+ def dimensions
4
+ @stream.read(11)[6..10].unpack('SS')
5
+ end
6
+
7
+ # Checks for multiple frames
8
+ def animated?
9
+ frames = 0
10
+
11
+ # "GIF" + version (3) + width (2) + height (2)
12
+ @stream.skip(10)
13
+
14
+ # fields (1) + bg color (1) + pixel ratio (1)
15
+ fields = @stream.read(3).unpack("CCC")[0]
16
+ if fields & 0x80 != 0 # Global Color Table
17
+ # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
18
+ @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
19
+ end
20
+
21
+ loop do
22
+ block_type = @stream.read(1).unpack("C")[0]
23
+
24
+ if block_type == 0x21 # Graphic Control Extension
25
+ # extension type (1) + size (1)
26
+ size = @stream.read(2).unpack("CC")[1]
27
+ @stream.skip(size)
28
+ skip_sub_blocks
29
+ elsif block_type == 0x2C # Image Descriptor
30
+ frames += 1
31
+ return true if frames > 1
32
+
33
+ # left position (2) + top position (2) + width (2) + height (2) + fields (1)
34
+ fields = @stream.read(9).unpack("SSSSC")[4]
35
+ if fields & 0x80 != 0 # Local Color Table
36
+ # 2 * (depth + 1) colors, each occupying 3 bytes (RGB)
37
+ @stream.skip(3 * 2 ** ((fields & 0x7) + 1))
38
+ end
39
+
40
+ @stream.skip(1) # LZW min code size (1)
41
+ skip_sub_blocks
42
+ else
43
+ break # unrecognized block
44
+ end
45
+ end
46
+
47
+ false
48
+ end
49
+
50
+ private
51
+
52
+ def skip_sub_blocks
53
+ loop do
54
+ size = @stream.read(1).unpack("C")[0]
55
+ if size == 0
56
+ break
57
+ else
58
+ @stream.skip(size)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,8 @@
1
+ module FastImageParsing
2
+ class Heic < ImageBase # :nodoc:
3
+ def dimensions
4
+ bmff = IsoBmff.new(@stream)
5
+ [bmff.width, bmff.height]
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ module FastImageParsing
2
+ class Ico < ImageBase
3
+ def dimensions
4
+ icons = @stream.read(6)[4..5].unpack('v').first
5
+ sizes = icons.times.map { @stream.read(16).unpack('C2').map { |x| x == 0 ? 256 : x } }.sort_by { |w,h| w * h }
6
+ sizes.last
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ module FastImageParsing
2
+ class ImageBase # :nodoc:
3
+ def initialize(stream)
4
+ @stream = stream
5
+ end
6
+
7
+ # Implement in subclasses
8
+ def dimensions
9
+ raise NotImplementedError
10
+ end
11
+
12
+ # Implement in subclasses if appropriate
13
+ def animated?
14
+ nil
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,176 @@
1
+ module FastImageParsing
2
+ # HEIC/AVIF are a special case of the general ISO_BMFF format, in which all data is encapsulated in typed boxes,
3
+ # with a mandatory ftyp box that is used to indicate particular file types. Is composed of nested "boxes". Each
4
+ # box has a header composed of
5
+ # - Size (32 bit integer)
6
+ # - Box type (4 chars)
7
+ # - Extended size: only if size === 1, the type field is followed by 64 bit integer of extended size
8
+ # - Payload: Type-dependent
9
+ class IsoBmff # :nodoc:
10
+ attr_reader :width, :height
11
+
12
+ def initialize(stream)
13
+ @stream = stream
14
+ @width, @height = nil
15
+ parse_isobmff
16
+ end
17
+
18
+ def parse_isobmff
19
+ @rotation = 0
20
+ @max_size = nil
21
+ @primary_box = nil
22
+ @ipma_boxes = []
23
+ @ispe_boxes = []
24
+ @final_size = nil
25
+
26
+ catch :finish do
27
+ read_boxes!
28
+ end
29
+
30
+ if [90, 270].include?(@rotation)
31
+ @final_size.reverse!
32
+ end
33
+
34
+ @width, @height = @final_size
35
+ end
36
+
37
+ private
38
+
39
+ # Format specs: https://www.loc.gov/preservation/digital/formats/fdd/fdd000525.shtml
40
+
41
+ # If you need to inspect a heic/heif file, use
42
+ # https://gpac.github.io/mp4box.js/test/filereader.html
43
+ def read_boxes!(max_read_bytes = nil)
44
+ end_pos = max_read_bytes.nil? ? nil : @stream.pos + max_read_bytes
45
+ index = 0
46
+
47
+ loop do
48
+ return if end_pos && @stream.pos >= end_pos
49
+
50
+ box_type, box_size = read_box_header!
51
+
52
+ case box_type
53
+ when "meta"
54
+ handle_meta_box(box_size)
55
+ when "pitm"
56
+ handle_pitm_box(box_size)
57
+ when "ipma"
58
+ handle_ipma_box(box_size)
59
+ when "hdlr"
60
+ handle_hdlr_box(box_size)
61
+ when "iprp", "ipco"
62
+ read_boxes!(box_size)
63
+ when "irot"
64
+ handle_irot_box
65
+ when "ispe"
66
+ handle_ispe_box(box_size, index)
67
+ when "mdat"
68
+ @stream.skip(box_size)
69
+ when "jxlc"
70
+ handle_jxlc_box(box_size)
71
+ else
72
+ @stream.skip(box_size)
73
+ end
74
+
75
+ index += 1
76
+ end
77
+ end
78
+
79
+ def handle_irot_box
80
+ @rotation = (read_uint8! & 0x3) * 90
81
+ end
82
+
83
+ def handle_ispe_box(box_size, index)
84
+ throw :finish if box_size < 12
85
+
86
+ data = @stream.read(box_size)
87
+ width, height = data[4...12].unpack("N2")
88
+ @ispe_boxes << { index: index, size: [width, height] }
89
+ end
90
+
91
+ def handle_hdlr_box(box_size)
92
+ throw :finish if box_size < 12
93
+
94
+ data = @stream.read(box_size)
95
+ throw :finish if data[8...12] != "pict"
96
+ end
97
+
98
+ def handle_ipma_box(box_size)
99
+ @stream.read(3)
100
+ flags3 = read_uint8!
101
+ entries_count = read_uint32!
102
+
103
+ entries_count.times do
104
+ id = read_uint16!
105
+ essen_count = read_uint8!
106
+
107
+ essen_count.times do
108
+ property_index = read_uint8! & 0x7F
109
+
110
+ if flags3 & 1 == 1
111
+ property_index = (property_index << 7) + read_uint8!
112
+ end
113
+
114
+ @ipma_boxes << { id: id, property_index: property_index - 1 }
115
+ end
116
+ end
117
+ end
118
+
119
+ def handle_pitm_box(box_size)
120
+ data = @stream.read(box_size)
121
+ @primary_box = data[4...6].unpack("S>")[0]
122
+ end
123
+
124
+ def handle_meta_box(box_size)
125
+ throw :finish if box_size < 4
126
+
127
+ @stream.read(4)
128
+ read_boxes!(box_size - 4)
129
+
130
+ throw :finish if !@primary_box
131
+
132
+ primary_indices = @ipma_boxes
133
+ .select { |box| box[:id] == @primary_box }
134
+ .map { |box| box[:property_index] }
135
+
136
+ ispe_box = @ispe_boxes.find do |box|
137
+ primary_indices.include?(box[:index])
138
+ end
139
+
140
+ if ispe_box
141
+ @final_size = ispe_box[:size]
142
+ end
143
+
144
+ throw :finish
145
+ end
146
+
147
+ def handle_jxlc_box(box_size)
148
+ jxlc = Jxlc.new(@stream)
149
+ @final_size = [jxlc.width, jxlc.height]
150
+ throw :finish
151
+ end
152
+
153
+ def read_box_header!
154
+ size = read_uint32!
155
+ type = @stream.read(4)
156
+ size = read_uint64! - 8 if size == 1
157
+ [type, size - 8]
158
+ end
159
+
160
+ def read_uint8!
161
+ @stream.read(1).unpack("C")[0]
162
+ end
163
+
164
+ def read_uint16!
165
+ @stream.read(2).unpack("S>")[0]
166
+ end
167
+
168
+ def read_uint32!
169
+ @stream.read(4).unpack("N")[0]
170
+ end
171
+
172
+ def read_uint64!
173
+ @stream.read(8).unpack("Q>")[0]
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,52 @@
1
+ module FastImageParsing
2
+ class IOStream < SimpleDelegator # :nodoc:
3
+ include StreamUtil
4
+ end
5
+
6
+ class Jpeg < ImageBase # :nodoc:
7
+ def dimensions
8
+ exif = nil
9
+ state = nil
10
+ loop do
11
+ state = case state
12
+ when nil
13
+ @stream.skip(2)
14
+ :started
15
+ when :started
16
+ @stream.read_byte == 0xFF ? :sof : :started
17
+ when :sof
18
+ case @stream.read_byte
19
+ when 0xe1 # APP1
20
+ skip_chars = @stream.read_int - 2
21
+ data = @stream.read(skip_chars)
22
+ io = StringIO.new(data)
23
+ if io.read(4) == "Exif"
24
+ io.read(2)
25
+ new_exif = Exif.new(IOStream.new(io)) rescue nil
26
+ exif ||= new_exif # only use the first APP1 segment
27
+ end
28
+ :started
29
+ when 0xe0..0xef
30
+ :skipframe
31
+ when 0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF
32
+ :readsize
33
+ when 0xFF
34
+ :sof
35
+ else
36
+ :skipframe
37
+ end
38
+ when :skipframe
39
+ skip_chars = @stream.read_int - 2
40
+ @stream.skip(skip_chars)
41
+ :started
42
+ when :readsize
43
+ @stream.skip(3)
44
+ height = @stream.read_int
45
+ width = @stream.read_int
46
+ width, height = height, width if exif && exif.rotated?
47
+ return [width, height, exif ? exif.orientation : 1]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,13 @@
1
+ module FastImageParsing
2
+ class Jxl < ImageBase # :nodoc:
3
+ def dimensions
4
+ if @stream.peek(2) == "\xFF\x0A".b
5
+ jxlc = Jxlc.new(@stream)
6
+ [jxlc.width, jxlc.height]
7
+ else
8
+ bmff = IsoBmff.new(@stream)
9
+ [bmff.width, bmff.height]
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,75 @@
1
+ module FastImageParsing
2
+ class Jxlc # :nodoc:
3
+ attr_reader :width, :height
4
+
5
+ LENGTHS = [9, 13, 18, 30]
6
+ MULTIPLIERS = [1, 1.2, Rational(4, 3), 1.5, Rational(16, 9), 1.25, 2]
7
+
8
+ def initialize(stream)
9
+ @stream = stream
10
+ @width, @height´ = nil
11
+ @bit_counter = 0
12
+ parse_jxlc
13
+ end
14
+
15
+ def parse_jxlc
16
+ @words = @stream.read(6)[2..5].unpack('vv')
17
+
18
+ # small mode allows for values <= 256 that are divisible by 8
19
+ small = get_bits(1)
20
+ if small == 1
21
+ y = (get_bits(5) + 1) * 8
22
+ x = x_from_ratio(y)
23
+ if !x
24
+ x = (get_bits(5) + 1) * 8
25
+ end
26
+ @width, @height = x, y
27
+ return
28
+ end
29
+
30
+ len = LENGTHS[get_bits(2)]
31
+ y = get_bits(len) + 1
32
+ x = x_from_ratio(y)
33
+ if !x
34
+ len = LENGTHS[get_bits(2)]
35
+ x = get_bits(len) + 1
36
+ end
37
+ @width, @height = x, y
38
+ end
39
+
40
+ def get_bits(size)
41
+ if @words.size < (@bit_counter + size) / 16 + 1
42
+ @words += @stream.read(4).unpack('vv')
43
+ end
44
+
45
+ dest_pos = 0
46
+ dest = 0
47
+ size.times do
48
+ word = @bit_counter / 16
49
+ source_pos = @bit_counter % 16
50
+ dest |= ((@words[word] & (1 << source_pos)) > 0 ? 1 : 0) << dest_pos
51
+ dest_pos += 1
52
+ @bit_counter += 1
53
+ end
54
+ dest
55
+ end
56
+
57
+ def x_from_ratio(y)
58
+ ratio = get_bits(3)
59
+ if ratio == 0
60
+ return nil
61
+ else
62
+ return (y * MULTIPLIERS[ratio - 1]).to_i
63
+ end
64
+ end
65
+ end
66
+
67
+ def parse_size_for_jxl
68
+ if @stream.peek(2) == "\xFF\x0A".b
69
+ JXL.new(@stream).read_size_header
70
+ else
71
+ bmff = IsoBmff.new(@stream)
72
+ bmff.width_and_height
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,26 @@
1
+ module FastImageParsing
2
+ class Png < ImageBase # :nodoc:
3
+ def dimensions
4
+ @stream.read(25)[16..24].unpack('NN')
5
+ end
6
+
7
+ def animated?
8
+ # Signature (8) + IHDR chunk (4 + 4 + 13 + 4)
9
+ @stream.read(33)
10
+
11
+ loop do
12
+ length = @stream.read(4).unpack("L>")[0]
13
+ type = @stream.read(4)
14
+
15
+ case type
16
+ when "acTL"
17
+ return true
18
+ when "IDAT"
19
+ return false
20
+ end
21
+
22
+ @stream.skip(length + 4)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ module FastImageParsing
2
+ class Psd < ImageBase # :nodoc:
3
+ def dimensions
4
+ @stream.read(26).unpack("x14NN").reverse
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ module FastImageParsing
2
+ module StreamUtil # :nodoc:
3
+ def read_byte
4
+ read(1)[0].ord
5
+ end
6
+
7
+ def read_int
8
+ read(2).unpack('n')[0]
9
+ end
10
+
11
+ def read_string_int
12
+ value = []
13
+ while read(1) =~ /(\d)/
14
+ value << $1
15
+ end
16
+ value.join.to_i
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,69 @@
1
+ module FastImageParsing
2
+ class Svg < ImageBase # :nodoc:
3
+ def dimensions
4
+ @width, @height, @ratio, @viewbox_width, @viewbox_height = nil
5
+
6
+ parse_svg
7
+
8
+ if @width && @height
9
+ [@width, @height]
10
+ elsif @width && @ratio
11
+ [@width, @width / @ratio]
12
+ elsif @height && @ratio
13
+ [@height * @ratio, @height]
14
+ elsif @viewbox_width && @viewbox_height
15
+ [@viewbox_width, @viewbox_height]
16
+ else
17
+ nil
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def parse_svg
24
+ attr_name = []
25
+ state = nil
26
+
27
+ while (char = @stream.read(1)) && state != :stop do
28
+ case char
29
+ when "="
30
+ if attr_name.join =~ /width/i
31
+ @stream.read(1)
32
+ @width = @stream.read_string_int
33
+ return if @height
34
+ elsif attr_name.join =~ /height/i
35
+ @stream.read(1)
36
+ @height = @stream.read_string_int
37
+ return if @width
38
+ elsif attr_name.join =~ /viewbox/i
39
+ values = attr_value.split(/\s/)
40
+ if values[2].to_f > 0 && values[3].to_f > 0
41
+ @ratio = values[2].to_f / values[3].to_f
42
+ @viewbox_width = values[2].to_i
43
+ @viewbox_height = values[3].to_i
44
+ end
45
+ end
46
+ when /\w/
47
+ attr_name << char
48
+ when "<"
49
+ attr_name = [char]
50
+ when ">"
51
+ state = :stop if state == :started
52
+ else
53
+ state = :started if attr_name.join == "<svg"
54
+ attr_name.clear
55
+ end
56
+ end
57
+ end
58
+
59
+ def attr_value
60
+ @stream.read(1)
61
+
62
+ value = []
63
+ while @stream.read(1) =~ /([^"])/
64
+ value << $1
65
+ end
66
+ value.join
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,16 @@
1
+ module FastImageParsing
2
+ class Tiff < ImageBase # :nodoc:
3
+ def initialize(stream)
4
+ @stream = stream
5
+ end
6
+
7
+ def dimensions
8
+ exif = Exif.new(@stream)
9
+ if exif.rotated?
10
+ [exif.height, exif.width, exif.orientation]
11
+ else
12
+ [exif.width, exif.height, exif.orientation]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,69 @@
1
+ module FastImageParsing
2
+ class TypeParser
3
+ def initialize(stream)
4
+ @stream = stream
5
+ end
6
+
7
+ # type will use peek to get enough bytes to determing the type of the image
8
+ def type
9
+ parsed_type = case @stream.peek(2)
10
+ when "BM"
11
+ :bmp
12
+ when "GI"
13
+ :gif
14
+ when 0xff.chr + 0xd8.chr
15
+ :jpeg
16
+ when 0x89.chr + "P"
17
+ :png
18
+ when "II", "MM"
19
+ case @stream.peek(11)[8..10]
20
+ when "APC", "CR\002"
21
+ nil # do not recognise CRW or CR2 as tiff
22
+ else
23
+ :tiff
24
+ end
25
+ when '8B'
26
+ :psd
27
+ when "\xFF\x0A".b
28
+ :jxl
29
+ when "\0\0"
30
+ case @stream.peek(3).bytes.to_a.last
31
+ when 0
32
+ # http://www.ftyps.com/what.html
33
+ case @stream.peek(12)[4..-1]
34
+ when "ftypavif"
35
+ :avif
36
+ when "ftypavis"
37
+ :avif
38
+ when "ftypheic"
39
+ :heic
40
+ when "ftypmif1"
41
+ :heif
42
+ else
43
+ if @stream.peek(7)[4..-1] == 'JXL'
44
+ :jxl
45
+ end
46
+ end
47
+ # ico has either a 1 (for ico format) or 2 (for cursor) at offset 3
48
+ when 1 then :ico
49
+ when 2 then :cur
50
+ end
51
+ when "RI"
52
+ :webp if @stream.peek(12)[8..11] == "WEBP"
53
+ when "<s"
54
+ :svg if @stream.peek(4) == "<svg"
55
+ when /\s\s|\s<|<[?!]/, 0xef.chr + 0xbb.chr
56
+ # Peek 10 more chars each time, and if end of file is reached just raise
57
+ # unknown. We assume the <svg tag cannot be within 10 chars of the end of
58
+ # the file, and is within the first 1000 chars.
59
+ begin
60
+ :svg if (1..100).detect {|n| @stream.peek(10 * n).include?("<svg")}
61
+ rescue FiberError, FastImage::CannotParseImage
62
+ nil
63
+ end
64
+ end
65
+
66
+ parsed_type or raise FastImage::UnknownImageType
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,60 @@
1
+ module FastImageParsing
2
+ class Webp < ImageBase # :nodoc:
3
+ def dimensions
4
+ vp8 = @stream.read(16)[12..15]
5
+ _len = @stream.read(4).unpack("V")
6
+ case vp8
7
+ when "VP8 "
8
+ parse_size_vp8
9
+ when "VP8L"
10
+ parse_size_vp8l
11
+ when "VP8X"
12
+ parse_size_vp8x
13
+ else
14
+ nil
15
+ end
16
+ end
17
+
18
+ def animated?
19
+ vp8 = @stream.read(16)[12..15]
20
+ _len = @stream.read(4).unpack("V")
21
+ case vp8
22
+ when "VP8 "
23
+ false
24
+ when "VP8L"
25
+ false
26
+ when "VP8X"
27
+ flags = @stream.read(4).unpack("C")[0]
28
+ flags & 2 > 0
29
+ else
30
+ nil
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def parse_size_vp8
37
+ w, h = @stream.read(10).unpack("@6vv")
38
+ [w & 0x3fff, h & 0x3fff]
39
+ end
40
+
41
+ def parse_size_vp8l
42
+ @stream.skip(1) # 0x2f
43
+ b1, b2, b3, b4 = @stream.read(4).bytes.to_a
44
+ [1 + (((b2 & 0x3f) << 8) | b1), 1 + (((b4 & 0xF) << 10) | (b3 << 2) | ((b2 & 0xC0) >> 6))]
45
+ end
46
+
47
+ def parse_size_vp8x
48
+ flags = @stream.read(4).unpack("C")[0]
49
+ b1, b2, b3, b4, b5, b6 = @stream.read(6).unpack("CCCCCC")
50
+ width, height = 1 + b1 + (b2 << 8) + (b3 << 16), 1 + b4 + (b5 << 8) + (b6 << 16)
51
+
52
+ if flags & 8 > 0 # exif
53
+ # parse exif for orientation
54
+ # TODO: find or create test images for this
55
+ end
56
+
57
+ [width, height]
58
+ end
59
+ end
60
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class FastImage
4
- VERSION = '2.3.1'
4
+ VERSION = '2.4.0'
5
5
  end