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.
@@ -0,0 +1,76 @@
1
+ module FastImageParsing
2
+ class Exif # :nodoc:
3
+ attr_reader :width, :height, :orientation
4
+
5
+ def initialize(stream)
6
+ @stream = stream
7
+ @width, @height, @orientation = nil
8
+ parse_exif
9
+ end
10
+
11
+ def rotated?
12
+ @orientation >= 5
13
+ end
14
+
15
+ private
16
+
17
+ def get_exif_byte_order
18
+ byte_order = @stream.read(2)
19
+ case byte_order
20
+ when 'II'
21
+ @short, @long = 'v', 'V'
22
+ when 'MM'
23
+ @short, @long = 'n', 'N'
24
+ else
25
+ raise FastImage::CannotParseImage
26
+ end
27
+ end
28
+
29
+ def parse_exif_ifd
30
+ tag_count = @stream.read(2).unpack(@short)[0]
31
+ tag_count.downto(1) do
32
+ type = @stream.read(2).unpack(@short)[0]
33
+ data_type = @stream.read(2).unpack(@short)[0]
34
+ @stream.read(4)
35
+
36
+ if data_type == 4
37
+ data = @stream.read(4).unpack(@long)[0]
38
+ else
39
+ data = @stream.read(2).unpack(@short)[0]
40
+ @stream.read(2)
41
+ end
42
+
43
+ case type
44
+ when 0x0100 # image width
45
+ @width = data
46
+ when 0x0101 # image height
47
+ @height = data
48
+ when 0x0112 # orientation
49
+ @orientation = data
50
+ end
51
+ if @width && @height && @orientation
52
+ return # no need to parse more
53
+ end
54
+ end
55
+ end
56
+
57
+ def parse_exif
58
+ @start_byte = @stream.pos
59
+
60
+ get_exif_byte_order
61
+
62
+ @stream.read(2) # 42
63
+
64
+ offset = @stream.read(4).unpack(@long)[0]
65
+ if @stream.respond_to?(:skip)
66
+ @stream.skip(offset - 8)
67
+ else
68
+ @stream.read(offset - 8)
69
+ end
70
+
71
+ parse_exif_ifd
72
+
73
+ @orientation ||= 1
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,58 @@
1
+ module FastImageParsing
2
+ class FiberStream # :nodoc:
3
+ include StreamUtil
4
+ attr_reader :pos
5
+
6
+ # read_fiber should return nil if it no longer has anything to return when resumed
7
+ # so the result of the whole Fiber block should be set to be nil in case yield is no
8
+ # longer called
9
+ def initialize(read_fiber)
10
+ @read_fiber = read_fiber
11
+ @pos = 0
12
+ @strpos = 0
13
+ @str = ''
14
+ end
15
+
16
+ # Peeking beyond the end of the input will raise
17
+ def peek(n)
18
+ while @strpos + n > @str.size
19
+ unused_str = @str[@strpos..-1]
20
+
21
+ new_string = @read_fiber.resume
22
+ raise FastImage::CannotParseImage if !new_string
23
+ # we are dealing with bytes here, so force the encoding
24
+ new_string.force_encoding("ASCII-8BIT") if new_string.respond_to? :force_encoding
25
+
26
+ @str = unused_str + new_string
27
+ @strpos = 0
28
+ end
29
+
30
+ @str[@strpos, n]
31
+ end
32
+
33
+ def read(n)
34
+ result = peek(n)
35
+ @strpos += n
36
+ @pos += n
37
+ result
38
+ end
39
+
40
+ def skip(n)
41
+ discarded = 0
42
+ fetched = @str[@strpos..-1].size
43
+ while n > fetched
44
+ discarded += @str[@strpos..-1].size
45
+ new_string = @read_fiber.resume
46
+ raise FastImage::CannotParseImage if !new_string
47
+
48
+ new_string.force_encoding("ASCII-8BIT") if new_string.respond_to? :force_encoding
49
+
50
+ fetched += new_string.size
51
+ @str = new_string
52
+ @strpos = 0
53
+ end
54
+ @strpos = @strpos + n - discarded
55
+ @pos += n
56
+ end
57
+ end
58
+ end
@@ -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