flipbook 0.3.0 → 0.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.
@@ -3,158 +3,148 @@
3
3
  module Flipbook
4
4
  module GIF
5
5
  class Reader
6
- attr_reader :width, :height
7
-
8
- def self.read(path)
9
- new(File.binread(path)).frames
6
+ def self.read(path, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
7
+ new(File.binread(path), max_pixels:, max_frames:, max_total_pixels:).read
10
8
  end
11
9
 
12
- def initialize(data, max_pixels: 16_777_216, max_frames: 10_000, max_total_pixels: 67_108_864)
13
- @data = String(data).b
14
- @cursor = 0
15
- @max_pixels = max_pixels
16
- @max_frames = max_frames
17
- @max_total_pixels = max_total_pixels
18
- @decoded_pixels = 0
19
- @frames = []
20
- @delays = []
21
- decode
10
+ def initialize(bytes, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
11
+ @bytes = String(bytes).b
12
+ @offset = 0
13
+ @max_pixels = Integer(max_pixels)
14
+ @max_frames = Integer(max_frames)
15
+ @max_total_pixels = Integer(max_total_pixels)
16
+ raise ArgumentError, "limits must be positive" unless @max_pixels.positive? && @max_frames.positive? && @max_total_pixels.positive?
22
17
  end
23
18
 
24
- def frames
25
- @frames.map(&:dup).freeze
26
- end
27
-
28
- def delays
29
- @delays.dup.freeze
30
- end
19
+ def read
20
+ signature = take(6)
21
+ raise Error, "invalid GIF signature" unless %w[GIF87a GIF89a].include?(signature)
31
22
 
32
- def each(&block)
33
- return enum_for(:each) unless block
34
-
35
- @frames.each(&block)
36
- end
37
-
38
- private
23
+ @width, @height, packed, background_index, = take(7).unpack("v2C3")
24
+ raise Error, "invalid GIF dimensions" if @width.zero? || @height.zero? || @width * @height > @max_pixels
39
25
 
40
- def decode
41
- raise Error, "invalid GIF signature" unless take(6).match?(/\AGIF8[79]a\z/)
42
-
43
- @width, @height, packed, @background_index, = take(7).unpack("v2C3")
44
- raise Error, "invalid GIF dimensions" unless @width.positive? && @height.positive?
45
- raise Error, "GIF exceeds pixel limit" if @width * @height > @max_pixels
46
-
47
- global = (packed & 0x80).zero? ? nil : read_palette(1 << ((packed & 7) + 1))
48
- background = global && global[@background_index]
49
- canvas = Tessel::Image.new(@width, @height)
50
- @current_control = { disposal: 1, delay: 0, transparent: nil }
51
- previous = nil
52
- previous_disposal = 0
53
- restore = nil
54
- control = { disposal: 1, delay: 0, transparent: nil }
26
+ @global_palette = read_palette(1 << ((packed & 7) + 1)) if (packed & 0x80).positive?
27
+ @background_index = background_index
28
+ @background = palette_color(@global_palette, background_index)
29
+ @frames = []
30
+ @canvas = Tessel::Image.new(@width, @height, fill: @background)
31
+ @control = default_control
32
+ @previous_disposal = nil
33
+ @finished = false
55
34
 
56
- until @cursor >= @data.bytesize
35
+ until @finished
57
36
  marker = byte
58
37
  case marker
59
- when 0x3B then break
60
- when 0x21 then read_extension(control)
61
- when 0x2C
62
- raise Error, "GIF exceeds frame limit" if @frames.length >= @max_frames
63
- @current_control = control
64
- frame = read_frame(global)
65
- if previous
66
- if previous_disposal == 2
67
- color = control_background(global, background, previous[:control][:transparent])
68
- canvas.fill_rect(previous[:left], previous[:top], previous[:width], previous[:height], color)
69
- elsif previous_disposal == 3 && restore
70
- canvas = restore.dup
71
- end
72
- elsif background && frame[:control][:transparent] != @background_index
73
- canvas.clear([*background, 255])
74
- end
75
- restore = canvas.dup if frame[:control][:disposal] == 3
76
- composite(canvas, frame)
77
- @frames << canvas.dup
78
- @delays << Rational(frame[:control][:delay], 100)
79
- previous = frame
80
- previous_disposal = frame[:control][:disposal]
81
- control = { disposal: 1, delay: 0, transparent: nil }
82
- @current_control = control
83
- else
84
- raise Error, "invalid GIF block marker"
38
+ when 0x21 then read_extension
39
+ when 0x2c then read_image
40
+ when 0x3b then @finished = true
41
+ else raise Error, "invalid GIF block marker 0x#{marker.to_s(16)}"
85
42
  end
86
43
  end
87
44
  raise Error, "GIF contains no frames" if @frames.empty?
45
+
46
+ @frames
47
+ end
48
+
49
+ private
50
+
51
+ def default_control
52
+ { delay: 0, disposal: 0, transparent: nil }
88
53
  end
89
54
 
90
- def read_extension(control)
55
+ def read_extension
91
56
  label = byte
92
- if label == 0xF9
93
- raise Error, "invalid GIF control extension" unless byte == 4
94
- packed, delay, transparent = take(4).unpack("CvC")
95
- raise Error, "invalid GIF control terminator" unless byte.zero?
96
- control.replace(disposal: (packed >> 2) & 7, delay: delay, transparent: (packed & 1).zero? ? nil : transparent)
57
+ case label
58
+ when 0xf9
59
+ size = byte
60
+ raise Error, "invalid GIF graphic control extension" unless size == 4
61
+
62
+ packed, delay, transparent_index = take(4).unpack("C v C")
63
+ raise Error, "invalid GIF graphic control terminator" unless byte.zero?
64
+ disposal = (packed >> 2) & 7
65
+ raise Error, "unsupported GIF disposal method" if disposal > 3
66
+
67
+ @control = { delay: delay, disposal:, transparent: (packed & 1).positive? ? transparent_index : nil }
68
+ when 0x01
69
+ skip_sub_blocks # fixed plain-text header and text data are both sub-blocks
97
70
  else
98
71
  skip_sub_blocks
99
72
  end
100
73
  end
101
74
 
102
- def read_frame(global)
75
+ def read_image
103
76
  left, top, width, height, packed = take(9).unpack("v4C")
104
- raise Error, "invalid GIF frame dimensions" unless width.positive? && height.positive?
105
- raise Error, "GIF frame exceeds pixel limit" if width * height > @max_pixels
106
- raise Error, "GIF exceeds total decoded pixel limit" if @decoded_pixels + width * height > @max_total_pixels
107
- raise Error, "GIF frame lies outside the logical screen" if left + width > @width || top + height > @height
77
+ raise Error, "invalid GIF image dimensions" if width.zero? || height.zero?
78
+ raise Error, "GIF image rectangle is outside the canvas" if left + width > @width || top + height > @height
108
79
 
109
- local = (packed & 0x80).zero? ? nil : read_palette(1 << ((packed & 7) + 1))
110
- palette = local || global
80
+ palette = (packed & 0x80).positive? ? read_palette(1 << ((packed & 7) + 1)) : @global_palette
111
81
  raise Error, "GIF frame has no color table" unless palette
112
- minimum = byte
113
- compressed = read_sub_blocks
114
- indices = LZW.decode(compressed, minimum, expected_size: width * height)
115
- indices = deinterlace(indices, width, height) unless (packed & 0x40).zero?
116
- @decoded_pixels += width * height
117
- { left:, top:, width:, height:, palette:, indices:, control: @current_control.dup }
118
- end
119
-
120
- def composite(canvas, frame)
121
- frame[:height].times do |row|
122
- frame[:width].times do |column|
123
- index = frame[:indices].getbyte(row * frame[:width] + column)
124
- next if index == frame[:control][:transparent]
125
-
126
- color = frame[:palette][index]
127
- raise Error, "GIF color index out of range" unless color
128
- canvas[frame[:left] + column, frame[:top] + row] = [*color, 255]
82
+ raise Error, "GIF transparent palette index is out of range" if @control[:transparent] && @control[:transparent] >= palette.length
83
+ minimum_code_size = byte
84
+ raise Error, "invalid GIF LZW code size" unless minimum_code_size.between?(2, 8)
85
+
86
+ indices = LZW.decode(read_sub_blocks, minimum_code_size, width * height)
87
+ indices = deinterlace(indices, width, height) if (packed & 0x40).positive?
88
+ draw_frame(indices, palette, left, top, width, height)
89
+ end
90
+
91
+ def draw_frame(indices, palette, left, top, width, height)
92
+ disposal = @control[:disposal]
93
+ if @previous_disposal
94
+ previous = @previous_disposal
95
+ case previous[:method]
96
+ when 2
97
+ @canvas.fill_rect(*previous[:rect], previous[:background])
98
+ when 3
99
+ @canvas = previous[:restore]
129
100
  end
130
101
  end
131
- end
132
102
 
133
- def control_background(global, background, transparent)
134
- return [0, 0, 0, 0] if transparent == @background_index
135
- return [0, 0, 0, 0] unless global && background
103
+ raise Error, "GIF frame limit exceeded" if @frames.length >= @max_frames
104
+ raise Error, "GIF total pixel limit exceeded" if (@frames.length + 1) * @width * @height > @max_total_pixels
105
+ restore = @canvas.dup if disposal == 3
106
+ transparent = @control[:transparent]
107
+ transparent_background = transparent && (!@global_palette || transparent == @background_index)
108
+ @canvas.clear([0, 0, 0, 0]) if @frames.empty? && transparent_background
109
+ indices.each_with_index do |palette_index, index|
110
+ next if palette_index == transparent
111
+
112
+ color = palette[palette_index]
113
+ raise Error, "GIF palette index is out of range" unless color
136
114
 
137
- [*background, 255]
115
+ x = left + index % width
116
+ y = top + index / width
117
+ @canvas[x, y] = [*color, 255]
118
+ end
119
+ @frames << @canvas.dup
120
+ background = if transparent_background
121
+ [0, 0, 0, 0]
122
+ else
123
+ [*@background, 255]
124
+ end
125
+ @previous_disposal = { method: disposal, rect: [left, top, width, height], background:, restore: }
126
+ @control = default_control
138
127
  end
139
128
 
140
129
  def deinterlace(indices, width, height)
141
- rows = [0, 4, 2, 1].flat_map do |start|
142
- step = { 0 => 8, 4 => 8, 2 => 4, 1 => 2 }.fetch(start)
143
- (start...height).step(step).to_a
144
- end
145
- output = "\0".b * indices.bytesize
146
- rows.each_with_index do |row, source_row|
147
- output[row * width, width] = indices.byteslice(source_row * width, width)
130
+ rows = Array.new(height)
131
+ offset = 0
132
+ [[0, 8], [4, 8], [2, 4], [1, 2]].each do |start, step|
133
+ (start...height).step(step) do |y|
134
+ rows[y] = indices.slice(offset, width)
135
+ offset += width
136
+ end
148
137
  end
149
- output
138
+ rows.flatten
150
139
  end
151
140
 
152
141
  def read_palette(count)
153
- take(count * 3).bytes.each_slice(3).map(&:freeze)
142
+ take(count * 3).unpack("C*").each_slice(3).map(&:freeze)
154
143
  end
155
144
 
156
- def skip_sub_blocks
157
- read_sub_blocks
145
+ def palette_color(palette, index)
146
+ color = palette&.[](index)
147
+ color || [0, 0, 0]
158
148
  end
159
149
 
160
150
  def read_sub_blocks
@@ -162,28 +152,117 @@ module Flipbook
162
152
  loop do
163
153
  length = byte
164
154
  break if length.zero?
165
- raise Error, "GIF sub-block exceeds input" if @cursor + length > @data.bytesize
166
155
 
167
156
  output << take(length)
168
157
  end
169
158
  output
170
159
  end
171
160
 
172
- def byte
173
- raise Error, "truncated GIF" if @cursor >= @data.bytesize
161
+ def skip_sub_blocks
162
+ loop do
163
+ length = byte
164
+ break if length.zero?
174
165
 
175
- value = @data.getbyte(@cursor)
176
- @cursor += 1
177
- value
166
+ take(length)
167
+ end
178
168
  end
179
169
 
180
170
  def take(length)
181
- raise Error, "truncated GIF" if length.negative? || @cursor + length > @data.bytesize
171
+ raise Error, "truncated GIF data" if length.negative? || @offset + length > @bytes.bytesize
182
172
 
183
- value = @data.byteslice(@cursor, length)
184
- @cursor += length
185
- value
173
+ result = @bytes.byteslice(@offset, length)
174
+ @offset += length
175
+ result
176
+ end
177
+
178
+ def byte
179
+ take(1).getbyte(0)
180
+ end
181
+ end
182
+
183
+ module LZW
184
+ module_function
185
+
186
+ def decode(bytes, minimum_code_size, expected_size)
187
+ clear = 1 << minimum_code_size
188
+ ending = clear + 1
189
+ prefix = Array.new(4096)
190
+ suffix = Array.new(4096)
191
+ clear.times { |index| suffix[index] = index }
192
+ next_code = ending + 1
193
+ width = minimum_code_size + 1
194
+ bit_offset = 0
195
+ previous = nil
196
+ first = nil
197
+ output = String.new(capacity: expected_size, encoding: Encoding::BINARY)
198
+ ended = false
199
+
200
+ loop do
201
+ code = read_code(bytes, bit_offset, width)
202
+ break unless code
203
+
204
+ bit_offset += width
205
+ if code == clear
206
+ clear.times { |index| suffix[index] = index }
207
+ next_code = ending + 1
208
+ width = minimum_code_size + 1
209
+ previous = nil
210
+ next
211
+ end
212
+ if code == ending
213
+ ended = true
214
+ break
215
+ end
216
+
217
+ if code < next_code && suffix[code]
218
+ entry, first = expand(code, clear, prefix, suffix)
219
+ elsif code == next_code && previous
220
+ entry, first = expand(previous, clear, prefix, suffix)
221
+ entry << first
222
+ else
223
+ raise Error, "invalid GIF LZW code"
224
+ end
225
+ output << entry.pack("C*")
226
+ raise Error, "GIF frame data exceeds its dimensions" if output.bytesize > expected_size
227
+
228
+ if previous && next_code < 4096
229
+ prefix[next_code] = previous
230
+ suffix[next_code] = first
231
+ next_code += 1
232
+ width += 1 if next_code == (1 << width) && width < 12
233
+ end
234
+ previous = code
235
+ end
236
+ raise Error, "truncated GIF LZW data" unless ended && output.bytesize == expected_size
237
+
238
+ output.bytes
239
+ end
240
+
241
+ def expand(code, clear, prefix, suffix)
242
+ stack = []
243
+ current = code
244
+ while current >= clear
245
+ raise Error, "invalid GIF LZW dictionary" unless current < 4096 && suffix[current] && prefix[current]
246
+
247
+ stack << suffix[current]
248
+ current = prefix[current]
249
+ end
250
+ stack << current
251
+ first = current
252
+ [stack.reverse, first]
253
+ end
254
+ private_class_method :expand
255
+
256
+ def read_code(bytes, bit_offset, width)
257
+ return nil if bit_offset + width > bytes.bytesize * 8
258
+
259
+ code = 0
260
+ width.times do |bit|
261
+ code |= ((bytes.getbyte((bit_offset + bit) / 8) >> ((bit_offset + bit) % 8)) & 1) << bit
262
+ end
263
+ code
186
264
  end
265
+ private_class_method :read_code
187
266
  end
188
267
  end
189
268
  end
@@ -20,38 +20,35 @@ module Flipbook
20
20
  raise
21
21
  end
22
22
 
23
- def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg, optimize: true, transparent_index: nil, clear_background: false)
23
+ def initialize(io, width:, height:, loop: true, palette: :per_frame, colors: 256, dither: :floyd_steinberg, transparent_index: nil, optimize: true)
24
24
  @io = io
25
25
  @width = Integer(width)
26
26
  @height = Integer(height)
27
27
  raise ArgumentError, "GIF dimensions must be between 1 and 65535" unless @width.between?(1, 65_535) && @height.between?(1, 65_535)
28
28
  raise ArgumentError, "colors must be between 2 and 256" unless colors.is_a?(Integer) && colors.between?(2, 256)
29
29
  raise ArgumentError, "unknown dither: #{dither}" unless %i[none ordered floyd_steinberg].include?(dither)
30
+ raise TypeError, "optimize must be true or false" unless [true, false].include?(optimize)
30
31
 
31
32
  @loop = loop_value(loop)
32
33
  @colors = colors
33
34
  @dither = dither
34
- @clear_background = clear_background
35
- @optimize = optimize && !clear_background
36
- @previous = nil
35
+ @optimize = optimize
36
+ @previous_image = nil
37
37
  @elapsed = Rational(0)
38
38
  @rounded_delay = 0
39
39
  @closed = false
40
40
  @warned_short_delay = false
41
41
  @frame_count = 0
42
+ @pending_frame = nil
43
+ @canvas_cleared = false
42
44
  @palette_mode = palette
43
45
  if palette == :per_frame
44
- raise ArgumentError, "clear_background requires a global palette" if clear_background
45
46
  raise ArgumentError, "transparent_index is only valid with a global palette" unless transparent_index.nil?
46
47
  @global_palette = nil
48
+ @global_transparent_index = 0
47
49
  else
48
50
  raise TypeError, "palette must be :per_frame or an RGB array" unless palette.is_a?(Array)
49
51
  @global_palette = validate_palette(palette)
50
- if (@optimize || clear_background) && transparent_index.nil?
51
- raise ArgumentError, "global palettes need room for a transparent color when optimize is enabled" if @global_palette.length == 256
52
- @global_palette = @global_palette + [[0, 0, 0]]
53
- transparent_index = @global_palette.length - 1
54
- end
55
52
  if transparent_index
56
53
  raise ArgumentError, "transparent_index must be the last palette entry" unless transparent_index == @global_palette.length - 1
57
54
  raise ArgumentError, "transparent_index is out of range" unless transparent_index.between?(0, @global_palette.length - 1)
@@ -73,41 +70,26 @@ module Flipbook
73
70
  @warned_short_delay = true
74
71
  end
75
72
  transparent_pixels = image.bytes.bytes.each_slice(4).any? { |_, _, _, alpha| alpha.zero? }
76
- if @palette_mode == :per_frame && @frame_count.positive? && (@previous_transparency || transparent_pixels)
77
- raise ArgumentError, "transparent multi-frame GIFs require a global palette"
73
+ if @pending_frame
74
+ disposal = (@pending_frame[:transparent] || transparent_pixels) ? 2 : 1
75
+ write_pending(disposal)
76
+ @canvas_cleared = disposal == 2
78
77
  end
78
+
79
79
  elapsed = @elapsed + seconds
80
80
  target_delay = (elapsed * 100).floor
81
81
  frame_delay = target_delay - @rounded_delay
82
82
  raise ArgumentError, "GIF frame delays cannot exceed 655.35 seconds" if frame_delay > 65_535
83
- palette, opaque_palette, transparent_index = frame_palette(image, transparent_pixels)
84
- indices, = Tessel::Quantize.quantize(image, palette: opaque_palette, dither: @dither)
85
- if transparent_pixels
86
- raise ArgumentError, "palette has no transparent entry" unless transparent_index
87
- image.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
88
- indices.setbyte(index, transparent_index) if alpha.zero?
89
- end
90
- end
91
-
92
- left, top, width, height = @optimize ? Diff.bounds(@previous, image) : [0, 0, @width, @height]
93
- if @previous && @optimize && transparent_index
94
- image_bytes = image.bytes
95
- old_bytes = @previous.bytes
96
- (top...(top + height)).each do |y|
97
- (left...(left + width)).each do |x|
98
- offset = (y * @width + x) * 4
99
- indices.setbyte(y * @width + x, transparent_index) if image_bytes.byteslice(offset, 4) == old_bytes.byteslice(offset, 4)
100
- end
101
- end
83
+ rectangle = if @optimize && @previous_image && !transparent_pixels && !@canvas_cleared
84
+ Diff.bounds(@previous_image, image) || [0, 0, 1, 1]
85
+ else
86
+ [0, 0, @width, @height]
102
87
  end
103
- frame_indices = crop_indices(indices, left, top, width, height)
104
- write_control(frame_delay, transparent_index)
105
- write_image(left, top, width, height, frame_indices, palette)
88
+ @pending_frame = { image: image.dup, delay: frame_delay, rectangle:, transparent: transparent_pixels }
106
89
  @elapsed = elapsed
107
90
  @rounded_delay = target_delay
108
91
  @frame_count += 1
109
- @previous_transparency = transparent_pixels
110
- @previous = image.dup
92
+ @previous_image = image.dup
111
93
  self
112
94
  end
113
95
 
@@ -115,6 +97,7 @@ module Flipbook
115
97
  return if @closed
116
98
  raise Error, "GIF contains no frames" if @frame_count.zero?
117
99
 
100
+ write_pending(@pending_frame[:transparent] ? 2 : 1)
118
101
  @io << ";".b
119
102
  @closed = true
120
103
  @io.close if @owns_io && !@io.closed?
@@ -148,30 +131,52 @@ module Flipbook
148
131
  return [@global_palette, opaque, @global_transparent_index]
149
132
  end
150
133
 
151
- reserve = @optimize || transparent_pixels
152
- opaque_count = reserve ? [@colors - 1, 1].max : @colors
134
+ opaque_count = [@colors - 1, 1].max
153
135
  opaque = Tessel::Quantize.palette_for(image, colors: [opaque_count, 2].max).first(opaque_count)
154
- palette = reserve ? opaque + [[0, 0, 0]] : opaque
155
- [palette, opaque, reserve ? opaque.length : nil]
136
+ [[[0, 0, 0]] + opaque, opaque, 0]
137
+ end
138
+
139
+ def write_pending(disposal)
140
+ pending = @pending_frame
141
+ image = pending[:image]
142
+ left, top, width, height = pending[:rectangle]
143
+ frame = if left.zero? && top.zero? && width == @width && height == @height
144
+ image
145
+ else
146
+ image.crop(left, top, width, height)
147
+ end
148
+ palette, opaque_palette, transparent_index = frame_palette(image, pending[:transparent])
149
+ indices, = Tessel::Quantize.quantize(frame, palette: opaque_palette, dither: @dither)
150
+ indices.each_byte.with_index { |index, offset| indices.setbyte(offset, index + 1) } if @palette_mode == :per_frame
151
+ if pending[:transparent]
152
+ raise ArgumentError, "palette has no transparent entry" unless transparent_index
153
+ frame.bytes.bytes.each_slice(4).with_index do |(_, _, _, alpha), index|
154
+ indices.setbyte(index, transparent_index) if alpha.zero?
155
+ end
156
+ end
157
+
158
+ write_control(pending[:delay], transparent_index, disposal:)
159
+ write_image(left, top, width, height, indices, palette)
160
+ @pending_frame = nil
156
161
  end
157
162
 
158
163
  def write_header
159
164
  @io << "GIF89a".b << [@width, @height].pack("v2")
160
165
  if @global_palette
161
166
  bits, size = table_size(@global_palette.length)
162
- background = @clear_background ? @global_transparent_index : 0
163
- @io << [0x80 | 0x70 | (bits - 1), background, 0].pack("C3")
167
+ @io << [0x80 | 0x70 | (bits - 1), @global_transparent_index || 0, 0].pack("C3")
164
168
  write_palette(@global_palette, size)
165
169
  else
166
- @io << "\0\0\0".b
170
+ @io << [0x80 | 0x70, 0, 0].pack("C3")
171
+ write_palette([[0, 0, 0]], 2)
167
172
  end
168
173
  if @loop
169
174
  @io << "!\xFF\x0BNETSCAPE2.0\x03\x01".b << [@loop].pack("v") << "\0".b
170
175
  end
171
176
  end
172
177
 
173
- def write_control(delay, transparent_index)
174
- packed = @clear_background ? 0x08 : (@optimize ? 0x04 : 0)
178
+ def write_control(delay, transparent_index, disposal:)
179
+ packed = disposal << 2
175
180
  packed |= 0x01 if transparent_index
176
181
  @io << "!\xF9\x04".b << [packed, delay, transparent_index || 0, 0].pack("CvCC")
177
182
  end
@@ -196,13 +201,6 @@ module Flipbook
196
201
  (size - palette.length).times { @io << "\0\0\0".b }
197
202
  end
198
203
 
199
- def crop_indices(indices, left, top, width, height)
200
- output = String.new(capacity: width * height, encoding: Encoding::BINARY)
201
- height.times do |row|
202
- output << indices.byteslice((top + row) * @width + left, width)
203
- end
204
- output
205
- end
206
204
  end
207
205
  end
208
206
  end
@@ -4,7 +4,7 @@ module Flipbook
4
4
  module Timing
5
5
  module_function
6
6
 
7
- def delays(count, fps: nil, delay: nil)
7
+ def delays(count, fps: nil, delay: nil, warn_short: true)
8
8
  raise ArgumentError, "frame count must be a positive integer" unless count.is_a?(Integer) && count.positive?
9
9
  raise ArgumentError, "provide fps or delay, not both" if !fps.nil? && !delay.nil?
10
10
  raise ArgumentError, "fps or delay is required" if fps.nil? && delay.nil?
@@ -19,7 +19,7 @@ module Flipbook
19
19
  else
20
20
  Array.new(count) { positive_delay(delay) }
21
21
  end
22
- warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers") if values.any? { |value| value < Rational(1, 50) }
22
+ warn("Flipbook: delays below 1/50 second may be clamped by GIF viewers") if warn_short && values.any? { |value| value < Rational(1, 50) }
23
23
  values
24
24
  end
25
25
 
@@ -39,17 +39,6 @@ module Flipbook
39
39
  end
40
40
  end
41
41
 
42
- def apng_fraction(delay)
43
- value = rational(delay)
44
- raise ArgumentError, "APNG delays cannot exceed 65535 seconds" if value > 65_535
45
- max_denominator = value > 1 ? (65_535 / value).floor : 65_535
46
- denominator = [value.denominator, max_denominator, 65_535].min
47
- denominator = 1 if denominator.zero?
48
- numerator = [(value * denominator).round, 1].max
49
- divisor = numerator.gcd(denominator)
50
- [numerator / divisor, denominator / divisor]
51
- end
52
-
53
42
  def rational(value)
54
43
  raise TypeError, "delay and fps must be numeric" unless value.is_a?(Numeric)
55
44
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flipbook
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end