pura-tiff 0.1.0 → 0.1.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b00dc30c05d30d3947e757c942c5f9c41d7039e8c5dd627b67b5f9d68fe0872b
4
- data.tar.gz: 3e0a469c58f5c8eed893b9d89d30febffe0b46c8e569ee5783d331ba1d442ca9
3
+ metadata.gz: a5de8bb460d185dadbd4303b95cb83002fabbfeb452b09e76cab6fa2902fc001
4
+ data.tar.gz: 2132a81943a9ca91aa6f8875f8d6013d603ab862d3ef3c7d96d559b5a37b9c00
5
5
  SHA512:
6
- metadata.gz: a972f156bb570f8ca7645c1b6fec3905fec8754d7c35d9dfb38cd10c13337c7e97b0f3468bac3e43f4126b5c22604afa9822c10e59a5b42a008302a364bf5961
7
- data.tar.gz: 80edb1fdb65459d42ce68fc827266461cdca99f0e679fb67ea5d15529ae5ca8a2b8501471d06d5b1ace29912d0a13b9f814ab5845d41eedd92fe07f80ec5eaea
6
+ metadata.gz: 2063bd023a49b5af798eb2965b91ee5648bd35207f7ab8351b421dde2258e61690d39695b65c9530a762d42bc21558ad3520f46759dbc67328ec3bd9b0856a6d
7
+ data.tar.gz: fcc4e7fda37d03106b6ccb43474c39b34bda1f5ac391af13ae5a8365cb194cee6e6dbd24bfcb78314ac611293c036beedc89149b21067fb57c9887bd28d21fe0
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pura-tiff
2
2
 
3
- A pure Ruby TIFF decoder/encoder with zero C extension dependencies.
3
+ A pure Ruby TIFF decoder/encoder without additional image-processing libraries.
4
4
 
5
5
  Part of the **pura-*** series — pure Ruby image codec gems.
6
6
 
@@ -8,7 +8,7 @@ Part of the **pura-*** series — pure Ruby image codec gems.
8
8
 
9
9
  - TIFF decoding and encoding (uncompressed)
10
10
  - Image resizing (bilinear / nearest-neighbor / fit / fill)
11
- - No native extensions, no FFI, no external dependencies
11
+ - No image-specific native extension or FFI dependency
12
12
  - CLI tool included
13
13
 
14
14
  ## Installation
@@ -46,6 +46,8 @@ pura-tiff resize input.tiff --width 200 --height 200 --out thumb.tiff
46
46
 
47
47
  ## Benchmark
48
48
 
49
+ These historical measurements include ffmpeg process startup. They do not compare against an in-process C codec or establish Rails pipeline throughput.
50
+
49
51
  400×400 image, Ruby 4.0.2 + YJIT.
50
52
 
51
53
  ### Decode
@@ -55,7 +57,6 @@ pura-tiff resize input.tiff --width 200 --height 200 --out thumb.tiff
55
57
  | **pura-tiff** | **14 ms** |
56
58
  | ffmpeg (C) | 59 ms |
57
59
 
58
- **pura-tiff is 4× faster than ffmpeg** for TIFF decoding. No other pure Ruby TIFF implementation exists.
59
60
 
60
61
  ### Encode
61
62
 
@@ -67,8 +68,6 @@ pura-tiff resize input.tiff --width 200 --height 200 --out thumb.tiff
67
68
  ## Why pure Ruby?
68
69
 
69
70
  - **`gem install` and go** — no `brew install`, no `apt install`, no C compiler needed
70
- - **4× faster than C** — uncompressed TIFF is pure data copying, and Ruby + YJIT handles it beautifully
71
- - **Works everywhere Ruby works** — CRuby, ruby.wasm, JRuby, TruffleRuby
72
71
  - **Part of pura-\*** — convert between JPEG, PNG, BMP, GIF, TIFF, WebP seamlessly
73
72
 
74
73
  ## Related gems
@@ -84,6 +83,21 @@ pura-tiff resize input.tiff --width 200 --height 200 --out thumb.tiff
84
83
  | [pura-webp](https://github.com/komagata/pura-webp) | WebP | ✅ Available |
85
84
  | [pura-image](https://github.com/komagata/pura-image) | All formats | ✅ Available |
86
85
 
86
+ ## Pixel model and limitations
87
+
88
+ Images contain 8-bit RGB pixels. Only the first IFD is decoded. Supported input is 8-bit strip data with uncompressed, LZW, or PackBits compression; encoding is uncompressed. Alpha and extra samples are discarded.
89
+
90
+ `crop(x, y, width, height)` requires integer coordinates, positive dimensions, and a region entirely inside the image; invalid regions raise `ArgumentError`.
91
+
92
+ ## Decode limits
93
+
94
+ `Pura::Tiff.decode(input, max_input_bytes: 64 * 1024 * 1024,
95
+ max_pixels: 40_000_000, max_decoded_bytes: 256 * 1024 * 1024)` accepts a path or binary data.
96
+ All limits must be positive integers. These defaults bound input, pixel count, and decoded data size; they are not a cap on the Ruby process's total memory or CPU time.
97
+
98
+ Invalid image data raises `Pura::Tiff::DecodeError`; exceeding a configured limit raises its subclass `Pura::Tiff::LimitExceeded`.
99
+ TIFF decoding validates directory/tag bounds and rejects truncated or oversized strip output.
100
+
87
101
  ## License
88
102
 
89
103
  MIT
@@ -3,6 +3,7 @@
3
3
  module Pura
4
4
  module Tiff
5
5
  class DecodeError < StandardError; end
6
+ class LimitExceeded < DecodeError; end
6
7
 
7
8
  class Decoder
8
9
  # TIFF tag IDs
@@ -48,16 +49,32 @@ module Pura
48
49
  TYPE_RATIONAL => 8
49
50
  }.freeze
50
51
 
51
- def self.decode(input)
52
+ DEFAULT_MAX_INPUT_BYTES = 64 * 1024 * 1024
53
+ DEFAULT_MAX_PIXELS = 40_000_000
54
+ DEFAULT_MAX_DECODED_BYTES = 256 * 1024 * 1024
55
+
56
+ def self.decode(input, max_input_bytes: DEFAULT_MAX_INPUT_BYTES, **options)
57
+ unless max_input_bytes.is_a?(Integer) && max_input_bytes.positive?
58
+ raise ArgumentError, "max_input_bytes must be a positive integer"
59
+ end
60
+
52
61
  data = if input.is_a?(String) && input.bytesize < 4096 && !input.include?("\0") && File.exist?(input)
53
- File.binread(input)
62
+ File.binread(input, max_input_bytes + 1)
54
63
  else
55
64
  input.b
56
65
  end
57
- new(data).decode
66
+ raise LimitExceeded, "TIFF input limit exceeded" if data.bytesize > max_input_bytes
67
+
68
+ new(data, **options).decode
58
69
  end
59
70
 
60
- def initialize(data)
71
+ def initialize(data, max_pixels: DEFAULT_MAX_PIXELS, max_decoded_bytes: DEFAULT_MAX_DECODED_BYTES)
72
+ unless [max_pixels, max_decoded_bytes].all? { |value| value.is_a?(Integer) && value.positive? }
73
+ raise ArgumentError, "decode limits must be positive integers"
74
+ end
75
+
76
+ @max_pixels = max_pixels
77
+ @max_decoded_bytes = max_decoded_bytes
61
78
  @data = data
62
79
  @pos = 0
63
80
  @little = true
@@ -88,22 +105,36 @@ module Pura
88
105
  raise DecodeError, "unsupported compression: #{compression}"
89
106
  end
90
107
 
91
- # Decompress all strips
108
+ raise DecodeError, "invalid TIFF dimensions" unless width.positive? && height.positive?
109
+ raise LimitExceeded, "TIFF pixel limit exceeded" if width * height > @max_pixels
110
+ raise DecodeError, "invalid SamplesPerPixel" unless samples_per_pixel.positive?
111
+
112
+ expected = width * height * samples_per_pixel
113
+ if [expected, width * height * 3].max > @max_decoded_bytes
114
+ raise LimitExceeded, "TIFF decoded byte limit exceeded"
115
+ end
116
+ unless strip_offsets.size == strip_byte_counts.size
117
+ raise DecodeError, "strip offsets and byte counts must have equal sizes"
118
+ end
119
+
120
+ # Decompress all strips, bounding every append before allocating output.
92
121
  raw = String.new(encoding: Encoding::BINARY)
93
122
  strip_offsets.each_with_index do |offset, i|
94
123
  count = strip_byte_counts[i]
95
124
  strip_data = @data.byteslice(offset, count)
96
125
  raise DecodeError, "truncated strip data" unless strip_data && strip_data.bytesize == count
97
126
 
98
- case compression
99
- when COMPRESSION_NONE
100
- raw << strip_data
101
- when COMPRESSION_LZW
102
- raw << decompress_lzw(strip_data)
103
- when COMPRESSION_PACKBITS
104
- raw << decompress_packbits(strip_data)
105
- end
127
+ remaining = expected - raw.bytesize
128
+ decoded = case compression
129
+ when COMPRESSION_NONE then strip_data
130
+ when COMPRESSION_LZW then decompress_lzw(strip_data, remaining)
131
+ when COMPRESSION_PACKBITS then decompress_packbits(strip_data, remaining)
132
+ end
133
+ raise DecodeError, "excess TIFF strip data size" if decoded.bytesize > remaining
134
+
135
+ raw << decoded
106
136
  end
137
+ raise DecodeError, "incorrect TIFF strip data size" unless raw.bytesize == expected
107
138
 
108
139
  # Convert to RGB
109
140
  pixels = convert_to_rgb(raw, width, height, photometric, samples_per_pixel, color_map, extra_samples)
@@ -133,6 +164,7 @@ module Pura
133
164
 
134
165
  def parse_ifd(offset)
135
166
  count = read_u16(offset)
167
+ check_bounds(offset + 2, (count * 12) + 4)
136
168
  tags = {}
137
169
 
138
170
  count.times do |i|
@@ -151,6 +183,7 @@ module Pura
151
183
  read_u32(value_offset_field)
152
184
  end
153
185
 
186
+ check_bounds(data_offset, total_size)
154
187
  tags[tag_id] = { type: type, count: value_count, offset: data_offset }
155
188
  end
156
189
 
@@ -191,7 +224,14 @@ module Pura
191
224
  end
192
225
  end
193
226
 
227
+ def check_bounds(offset, size)
228
+ return if offset >= 0 && size >= 0 && offset + size <= @data.bytesize
229
+
230
+ raise DecodeError, "TIFF data out of bounds or truncated"
231
+ end
232
+
194
233
  def read_u16(offset)
234
+ check_bounds(offset, 2)
195
235
  if @little
196
236
  @data.getbyte(offset) | (@data.getbyte(offset + 1) << 8)
197
237
  else
@@ -200,6 +240,7 @@ module Pura
200
240
  end
201
241
 
202
242
  def read_u32(offset)
243
+ check_bounds(offset, 4)
203
244
  if @little
204
245
  @data.getbyte(offset) |
205
246
  (@data.getbyte(offset + 1) << 8) |
@@ -283,7 +324,7 @@ module Pura
283
324
  out
284
325
  end
285
326
 
286
- def decompress_lzw(data)
327
+ def decompress_lzw(data, limit)
287
328
  # TIFF LZW uses big-endian bit packing (MSB first)
288
329
  out = String.new(encoding: Encoding::BINARY)
289
330
  bit_buf = 0
@@ -335,6 +376,8 @@ module Pura
335
376
  raise DecodeError, "invalid LZW code: #{code} (next=#{next_code})"
336
377
  end
337
378
 
379
+ raise DecodeError, "excess TIFF strip data size" if out.bytesize + current.bytesize > limit
380
+
338
381
  out << current
339
382
 
340
383
  if prev_string && (next_code < 4096)
@@ -353,7 +396,7 @@ module Pura
353
396
  out
354
397
  end
355
398
 
356
- def decompress_packbits(data)
399
+ def decompress_packbits(data, limit)
357
400
  out = String.new(encoding: Encoding::BINARY)
358
401
  pos = 0
359
402
  size = data.bytesize
@@ -365,11 +408,17 @@ module Pura
365
408
  if n < 128
366
409
  # Copy next n+1 bytes literally
367
410
  count = n + 1
411
+ raise DecodeError, "truncated PackBits literal" if pos + count > size
412
+ raise DecodeError, "excess TIFF strip data size" if out.bytesize + count > limit
413
+
368
414
  out << data.byteslice(pos, count)
369
415
  pos += count
370
416
  elsif n > 128
371
417
  # Repeat next byte (257-n) times
372
418
  count = 257 - n
419
+ raise DecodeError, "truncated PackBits repeat" if pos >= size
420
+ raise DecodeError, "excess TIFF strip data size" if out.bytesize + count > limit
421
+
373
422
  byte = data.byteslice(pos, 1)
374
423
  pos += 1
375
424
  out << (byte * count)
@@ -81,6 +81,11 @@ module Pura
81
81
  end
82
82
 
83
83
  def crop(x, y, w, h)
84
+ unless [x, y, w, h].all?(Integer) &&
85
+ x >= 0 && y >= 0 && w.positive? && h.positive? && x + w <= @width && y + h <= @height
86
+ raise ArgumentError, "crop must be a positive integer region within the image"
87
+ end
88
+
84
89
  out = String.new(encoding: Encoding::BINARY, capacity: w * h * 3)
85
90
  h.times do |row|
86
91
  src_offset = (((y + row) * @width) + x) * 3
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Pura
4
4
  module Tiff
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
7
7
  end
data/lib/pura-tiff.rb CHANGED
@@ -7,8 +7,8 @@ require_relative "pura/tiff/encoder"
7
7
 
8
8
  module Pura
9
9
  module Tiff
10
- def self.decode(input)
11
- Decoder.decode(input)
10
+ def self.decode(input, **options)
11
+ Decoder.decode(input, **options)
12
12
  end
13
13
 
14
14
  def self.encode(image, output_path)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pura-tiff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - komagata