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 +4 -4
- data/README.md +19 -5
- data/lib/pura/tiff/decoder.rb +64 -15
- data/lib/pura/tiff/image.rb +5 -0
- data/lib/pura/tiff/version.rb +1 -1
- data/lib/pura-tiff.rb +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5de8bb460d185dadbd4303b95cb83002fabbfeb452b09e76cab6fa2902fc001
|
|
4
|
+
data.tar.gz: 2132a81943a9ca91aa6f8875f8d6013d603ab862d3ef3c7d96d559b5a37b9c00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
data/lib/pura/tiff/decoder.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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)
|
data/lib/pura/tiff/image.rb
CHANGED
|
@@ -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
|
data/lib/pura/tiff/version.rb
CHANGED
data/lib/pura-tiff.rb
CHANGED