pura-bmp 0.1.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.
- checksums.yaml +7 -0
- data/README.md +89 -0
- data/bin/pura-bmp +209 -0
- data/lib/pura/bmp/decoder.rb +413 -0
- data/lib/pura/bmp/encoder.rb +67 -0
- data/lib/pura/bmp/image.rb +164 -0
- data/lib/pura/bmp/version.rb +7 -0
- data/lib/pura-bmp.rb +18 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c04675c1f6dbc0d7a847e8bfdea1b0e3bf8775cb7f3dd9bd716221bd5c4d32fd
|
|
4
|
+
data.tar.gz: 7aa89ef86fdebafc1eaf57d7970476a00933484ef80106a82a724c80a8252baf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 253f58ee2de6e9548ead611ee4ff28ef208bee445a771432ef2f87f7e582199a50aff016c420b1f6de975a5168401681a339a3c061ecf3f624bffe7ce4a288a5
|
|
7
|
+
data.tar.gz: 63627ded0a9f7cfb98a89c0a0694d4b22563ef2741069e42841bc3d791214657e6893d7ea8f51d5f45eaccdfc79739bd1bcd65bf14c76809725cb242894f352b
|
data/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# pura-bmp
|
|
2
|
+
|
|
3
|
+
A pure Ruby BMP decoder/encoder with zero C extension dependencies.
|
|
4
|
+
|
|
5
|
+
Part of the **pura-*** series — pure Ruby image codec gems.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- BMP decoding and encoding (24-bit RGB)
|
|
10
|
+
- Image resizing (bilinear / nearest-neighbor / fit / fill)
|
|
11
|
+
- No native extensions, no FFI, no external dependencies
|
|
12
|
+
- CLI tool included
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
gem install pura-bmp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require "pura-bmp"
|
|
24
|
+
|
|
25
|
+
# Decode
|
|
26
|
+
image = Pura::Bmp.decode("photo.bmp")
|
|
27
|
+
image.width #=> 400
|
|
28
|
+
image.height #=> 400
|
|
29
|
+
image.pixels #=> Raw RGB byte string
|
|
30
|
+
image.pixel_at(x, y) #=> [r, g, b]
|
|
31
|
+
|
|
32
|
+
# Encode
|
|
33
|
+
Pura::Bmp.encode(image, "output.bmp")
|
|
34
|
+
|
|
35
|
+
# Resize
|
|
36
|
+
thumb = image.resize(200, 200)
|
|
37
|
+
fitted = image.resize_fit(800, 600)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## CLI
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pura-bmp decode input.bmp --info
|
|
44
|
+
pura-bmp resize input.bmp --width 200 --height 200 --out thumb.bmp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Benchmark
|
|
48
|
+
|
|
49
|
+
400×400 image, Ruby 4.0.2 + YJIT.
|
|
50
|
+
|
|
51
|
+
### Decode
|
|
52
|
+
|
|
53
|
+
| Decoder | Time |
|
|
54
|
+
|---------|------|
|
|
55
|
+
| **pura-bmp** | **39 ms** |
|
|
56
|
+
| ffmpeg (C) | 59 ms |
|
|
57
|
+
|
|
58
|
+
**pura-bmp is faster than ffmpeg** for BMP decoding. No other pure Ruby BMP implementation exists.
|
|
59
|
+
|
|
60
|
+
### Encode
|
|
61
|
+
|
|
62
|
+
| Encoder | Time | vs ffmpeg |
|
|
63
|
+
|---------|------|-----------|
|
|
64
|
+
| **pura-bmp** | **35 ms** | **0.6× — faster than ffmpeg!** |
|
|
65
|
+
| ffmpeg (C) | 58 ms | — |
|
|
66
|
+
|
|
67
|
+
## Why pure Ruby?
|
|
68
|
+
|
|
69
|
+
- **`gem install` and go** — no `brew install`, no `apt install`, no C compiler needed
|
|
70
|
+
- **Faster than C** — pure Ruby BMP decode beats ffmpeg on this benchmark
|
|
71
|
+
- **Works everywhere Ruby works** — CRuby, ruby.wasm, JRuby, TruffleRuby
|
|
72
|
+
- **Part of pura-\*** — convert between JPEG, PNG, BMP, GIF, TIFF, WebP seamlessly
|
|
73
|
+
|
|
74
|
+
## Related gems
|
|
75
|
+
|
|
76
|
+
| Gem | Format | Status |
|
|
77
|
+
|-----|--------|--------|
|
|
78
|
+
| [pura-jpeg](https://github.com/komagata/pura-jpeg) | JPEG | ✅ Available |
|
|
79
|
+
| [pura-png](https://github.com/komagata/pura-png) | PNG | ✅ Available |
|
|
80
|
+
| **pura-bmp** | BMP | ✅ Available |
|
|
81
|
+
| [pura-gif](https://github.com/komagata/pura-gif) | GIF | ✅ Available |
|
|
82
|
+
| [pura-tiff](https://github.com/komagata/pura-tiff) | TIFF | ✅ Available |
|
|
83
|
+
| [pura-ico](https://github.com/komagata/pura-ico) | ICO | ✅ Available |
|
|
84
|
+
| [pura-webp](https://github.com/komagata/pura-webp) | WebP | ✅ Available |
|
|
85
|
+
| [pura-image](https://github.com/komagata/pura-image) | All formats | ✅ Available |
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
data/bin/pura-bmp
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/pure-bmp"
|
|
5
|
+
|
|
6
|
+
def usage
|
|
7
|
+
puts <<~USAGE
|
|
8
|
+
Usage: pure-bmp <command> [options]
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
decode <input.bmp> [options] Decode a BMP file
|
|
12
|
+
--info Show image metadata
|
|
13
|
+
--out <file> Write raw RGB data to file
|
|
14
|
+
|
|
15
|
+
encode <input.rgb> [options] Encode raw RGB data to BMP
|
|
16
|
+
--width <n> Image width (required)
|
|
17
|
+
--height <n> Image height (required)
|
|
18
|
+
--out <file> Output BMP file (required)
|
|
19
|
+
|
|
20
|
+
resize <input.bmp> [options] Resize a BMP file
|
|
21
|
+
--width <n> Target width
|
|
22
|
+
--height <n> Target height
|
|
23
|
+
--fit <W>x<H> Fit within bounds (maintain aspect ratio)
|
|
24
|
+
--fill <W>x<H> Fill exact size (crop to fit)
|
|
25
|
+
--interpolation <type> bilinear or nearest (default: bilinear)
|
|
26
|
+
--out <file> Output BMP file (required)
|
|
27
|
+
|
|
28
|
+
benchmark <input.bmp> Benchmark decoding vs system tools
|
|
29
|
+
|
|
30
|
+
version Show version
|
|
31
|
+
USAGE
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cmd_decode(args)
|
|
36
|
+
input = nil
|
|
37
|
+
info_only = false
|
|
38
|
+
output = nil
|
|
39
|
+
|
|
40
|
+
i = 0
|
|
41
|
+
while i < args.size
|
|
42
|
+
case args[i]
|
|
43
|
+
when "--info"
|
|
44
|
+
info_only = true
|
|
45
|
+
when "--out"
|
|
46
|
+
i += 1
|
|
47
|
+
output = args[i]
|
|
48
|
+
else
|
|
49
|
+
input = args[i]
|
|
50
|
+
end
|
|
51
|
+
i += 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
usage unless input
|
|
55
|
+
|
|
56
|
+
unless File.exist?(input)
|
|
57
|
+
$stderr.puts "Error: file not found: #{input}"
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
62
|
+
image = Pure::Bmp.decode(input)
|
|
63
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
64
|
+
|
|
65
|
+
if info_only
|
|
66
|
+
puts "File: #{input}"
|
|
67
|
+
puts "Width: #{image.width}"
|
|
68
|
+
puts "Height: #{image.height}"
|
|
69
|
+
puts "Pixels: #{image.width * image.height}"
|
|
70
|
+
puts "Size: #{File.size(input)} bytes"
|
|
71
|
+
puts "Decode: #{'%.3f' % elapsed}s"
|
|
72
|
+
elsif output
|
|
73
|
+
File.binwrite(output, image.pixels)
|
|
74
|
+
puts "Wrote #{image.pixels.bytesize} bytes of raw RGB to #{output}"
|
|
75
|
+
puts "Decode time: #{'%.3f' % elapsed}s"
|
|
76
|
+
else
|
|
77
|
+
puts "Decoded #{input}: #{image.width}x#{image.height} in #{'%.3f' % elapsed}s"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cmd_encode(args)
|
|
82
|
+
input = nil
|
|
83
|
+
width = nil
|
|
84
|
+
height = nil
|
|
85
|
+
output = nil
|
|
86
|
+
|
|
87
|
+
i = 0
|
|
88
|
+
while i < args.size
|
|
89
|
+
case args[i]
|
|
90
|
+
when "--width"
|
|
91
|
+
i += 1; width = args[i].to_i
|
|
92
|
+
when "--height"
|
|
93
|
+
i += 1; height = args[i].to_i
|
|
94
|
+
when "--out"
|
|
95
|
+
i += 1; output = args[i]
|
|
96
|
+
else
|
|
97
|
+
input = args[i]
|
|
98
|
+
end
|
|
99
|
+
i += 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
unless input && width && height && output
|
|
103
|
+
$stderr.puts "Error: encode requires input file, --width, --height, and --out"
|
|
104
|
+
usage
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
unless File.exist?(input)
|
|
108
|
+
$stderr.puts "Error: file not found: #{input}"
|
|
109
|
+
exit 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
raw = File.binread(input)
|
|
113
|
+
image = Pure::Bmp::Image.new(width, height, raw)
|
|
114
|
+
|
|
115
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
116
|
+
size = Pure::Bmp.encode(image, output)
|
|
117
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
118
|
+
|
|
119
|
+
puts "Encoded #{width}x#{height} to #{output} (#{size} bytes) in #{'%.3f' % elapsed}s"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def cmd_resize(args)
|
|
123
|
+
input = nil
|
|
124
|
+
width = nil
|
|
125
|
+
height = nil
|
|
126
|
+
fit = nil
|
|
127
|
+
fill = nil
|
|
128
|
+
interpolation = :bilinear
|
|
129
|
+
output = nil
|
|
130
|
+
|
|
131
|
+
i = 0
|
|
132
|
+
while i < args.size
|
|
133
|
+
case args[i]
|
|
134
|
+
when "--width"
|
|
135
|
+
i += 1; width = args[i].to_i
|
|
136
|
+
when "--height"
|
|
137
|
+
i += 1; height = args[i].to_i
|
|
138
|
+
when "--fit"
|
|
139
|
+
i += 1; fit = args[i]
|
|
140
|
+
when "--fill"
|
|
141
|
+
i += 1; fill = args[i]
|
|
142
|
+
when "--interpolation"
|
|
143
|
+
i += 1
|
|
144
|
+
interpolation = args[i] == "nearest" ? :nearest : :bilinear
|
|
145
|
+
when "--out"
|
|
146
|
+
i += 1; output = args[i]
|
|
147
|
+
else
|
|
148
|
+
input = args[i]
|
|
149
|
+
end
|
|
150
|
+
i += 1
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
unless input && output
|
|
154
|
+
$stderr.puts "Error: resize requires input file and --out"
|
|
155
|
+
usage
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
unless File.exist?(input)
|
|
159
|
+
$stderr.puts "Error: file not found: #{input}"
|
|
160
|
+
exit 1
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
164
|
+
image = Pure::Bmp.decode(input)
|
|
165
|
+
|
|
166
|
+
if fit
|
|
167
|
+
fw, fh = fit.split("x").map(&:to_i)
|
|
168
|
+
resized = image.resize_fit(fw, fh, interpolation: interpolation)
|
|
169
|
+
elsif fill
|
|
170
|
+
fw, fh = fill.split("x").map(&:to_i)
|
|
171
|
+
resized = image.resize_fill(fw, fh, interpolation: interpolation)
|
|
172
|
+
elsif width && height
|
|
173
|
+
resized = image.resize(width, height, interpolation: interpolation)
|
|
174
|
+
else
|
|
175
|
+
$stderr.puts "Error: specify --width/--height, --fit, or --fill"
|
|
176
|
+
usage
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
size = Pure::Bmp.encode(resized, output)
|
|
180
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
181
|
+
|
|
182
|
+
puts "Resized #{image.width}x#{image.height} -> #{resized.width}x#{resized.height} to #{output} (#{size} bytes) in #{'%.3f' % elapsed}s"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def cmd_benchmark(args)
|
|
186
|
+
input = args[0]
|
|
187
|
+
usage unless input
|
|
188
|
+
|
|
189
|
+
require_relative "../benchmark/decode_benchmark"
|
|
190
|
+
DecodeBenchmark.run(input)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
case ARGV[0]
|
|
194
|
+
when "decode"
|
|
195
|
+
cmd_decode(ARGV[1..])
|
|
196
|
+
when "encode"
|
|
197
|
+
cmd_encode(ARGV[1..])
|
|
198
|
+
when "resize"
|
|
199
|
+
cmd_resize(ARGV[1..])
|
|
200
|
+
when "benchmark"
|
|
201
|
+
cmd_benchmark(ARGV[1..])
|
|
202
|
+
when "version", "--version", "-v"
|
|
203
|
+
puts "pure-bmp #{Pure::Bmp::VERSION}"
|
|
204
|
+
when nil, "help", "--help", "-h"
|
|
205
|
+
usage
|
|
206
|
+
else
|
|
207
|
+
$stderr.puts "Unknown command: #{ARGV[0]}"
|
|
208
|
+
usage
|
|
209
|
+
end
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pura
|
|
4
|
+
module Bmp
|
|
5
|
+
class DecodeError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class Decoder
|
|
8
|
+
# Compression types
|
|
9
|
+
BI_RGB = 0
|
|
10
|
+
BI_RLE8 = 1
|
|
11
|
+
BI_RLE4 = 2
|
|
12
|
+
BI_BITFIELDS = 3
|
|
13
|
+
|
|
14
|
+
def self.decode(input)
|
|
15
|
+
data = if input.is_a?(String) && !input.include?("\x00") && input.bytesize < 4096 && File.exist?(input)
|
|
16
|
+
File.binread(input)
|
|
17
|
+
else
|
|
18
|
+
input.b
|
|
19
|
+
end
|
|
20
|
+
new(data).decode
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(data)
|
|
24
|
+
@data = data
|
|
25
|
+
@pos = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def decode
|
|
29
|
+
# File header (14 bytes)
|
|
30
|
+
magic = read_bytes(2)
|
|
31
|
+
raise DecodeError, "Not a BMP file (missing BM signature)" unless magic == "BM"
|
|
32
|
+
|
|
33
|
+
_file_size = read_uint32_le
|
|
34
|
+
_reserved1 = read_uint16_le
|
|
35
|
+
_reserved2 = read_uint16_le
|
|
36
|
+
pixel_offset = read_uint32_le
|
|
37
|
+
|
|
38
|
+
# Info header
|
|
39
|
+
header_size = read_uint32_le
|
|
40
|
+
raise DecodeError, "Unsupported BMP header size: #{header_size}" unless header_size >= 40
|
|
41
|
+
|
|
42
|
+
width = read_int32_le
|
|
43
|
+
height = read_int32_le
|
|
44
|
+
_planes = read_uint16_le
|
|
45
|
+
bit_depth = read_uint16_le
|
|
46
|
+
compression = read_uint32_le
|
|
47
|
+
_image_size = read_uint32_le
|
|
48
|
+
_x_ppm = read_int32_le
|
|
49
|
+
_y_ppm = read_int32_le
|
|
50
|
+
colors_used = read_uint32_le
|
|
51
|
+
_colors_important = read_uint32_le
|
|
52
|
+
|
|
53
|
+
# Handle negative height (top-down storage)
|
|
54
|
+
top_down = height.negative?
|
|
55
|
+
height = height.abs
|
|
56
|
+
|
|
57
|
+
raise DecodeError, "Invalid dimensions: #{width}x#{height}" if width <= 0 || height <= 0
|
|
58
|
+
|
|
59
|
+
# Read additional header bytes if header is larger than 40
|
|
60
|
+
extra_header = header_size - 40
|
|
61
|
+
bitfield_masks = nil
|
|
62
|
+
|
|
63
|
+
if compression == BI_BITFIELDS && extra_header >= 12
|
|
64
|
+
r_mask = read_uint32_le
|
|
65
|
+
g_mask = read_uint32_le
|
|
66
|
+
b_mask = read_uint32_le
|
|
67
|
+
extra_header -= 12
|
|
68
|
+
bitfield_masks = { r: r_mask, g: g_mask, b: b_mask }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
skip_bytes(extra_header) if extra_header.positive?
|
|
72
|
+
|
|
73
|
+
# Read color palette for indexed images
|
|
74
|
+
palette = nil
|
|
75
|
+
if bit_depth <= 8
|
|
76
|
+
num_colors = colors_used.positive? ? colors_used : (1 << bit_depth)
|
|
77
|
+
palette = read_palette(num_colors)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Seek to pixel data
|
|
81
|
+
@pos = pixel_offset
|
|
82
|
+
|
|
83
|
+
# Decode pixel data
|
|
84
|
+
pixels = case bit_depth
|
|
85
|
+
when 24
|
|
86
|
+
decode_24bit(width, height, top_down)
|
|
87
|
+
when 32
|
|
88
|
+
decode_32bit(width, height, top_down, bitfield_masks)
|
|
89
|
+
when 8
|
|
90
|
+
if compression == BI_RLE8
|
|
91
|
+
decode_rle8(width, height, top_down, palette)
|
|
92
|
+
else
|
|
93
|
+
decode_8bit(width, height, top_down, palette)
|
|
94
|
+
end
|
|
95
|
+
when 4
|
|
96
|
+
decode_4bit(width, height, top_down, palette)
|
|
97
|
+
when 1
|
|
98
|
+
decode_1bit(width, height, top_down, palette)
|
|
99
|
+
else
|
|
100
|
+
raise DecodeError, "Unsupported bit depth: #{bit_depth}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Image.new(width, height, pixels)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def read_bytes(n)
|
|
109
|
+
raise DecodeError, "Unexpected end of data" if @pos + n > @data.bytesize
|
|
110
|
+
|
|
111
|
+
result = @data.byteslice(@pos, n)
|
|
112
|
+
@pos += n
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def read_uint16_le
|
|
117
|
+
bytes = read_bytes(2)
|
|
118
|
+
bytes.unpack1("v")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def read_uint32_le
|
|
122
|
+
bytes = read_bytes(4)
|
|
123
|
+
bytes.unpack1("V")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def read_int32_le
|
|
127
|
+
bytes = read_bytes(4)
|
|
128
|
+
bytes.unpack1("l<")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def skip_bytes(n)
|
|
132
|
+
@pos += n
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_palette(num_colors)
|
|
136
|
+
palette = Array.new(num_colors)
|
|
137
|
+
num_colors.times do |i|
|
|
138
|
+
b = @data.getbyte(@pos)
|
|
139
|
+
g = @data.getbyte(@pos + 1)
|
|
140
|
+
r = @data.getbyte(@pos + 2)
|
|
141
|
+
_a = @data.getbyte(@pos + 3)
|
|
142
|
+
@pos += 4
|
|
143
|
+
palette[i] = [r, g, b]
|
|
144
|
+
end
|
|
145
|
+
palette
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def decode_24bit(width, height, top_down)
|
|
149
|
+
stride = ((width * 3) + 3) & ~3 # Row size padded to 4-byte boundary
|
|
150
|
+
out = String.new(encoding: Encoding::BINARY, capacity: width * height * 3)
|
|
151
|
+
rows = []
|
|
152
|
+
|
|
153
|
+
height.times do
|
|
154
|
+
row = String.new(encoding: Encoding::BINARY, capacity: width * 3)
|
|
155
|
+
width.times do
|
|
156
|
+
b = @data.getbyte(@pos)
|
|
157
|
+
g = @data.getbyte(@pos + 1)
|
|
158
|
+
r = @data.getbyte(@pos + 2)
|
|
159
|
+
@pos += 3
|
|
160
|
+
row << r.chr << g.chr << b.chr
|
|
161
|
+
end
|
|
162
|
+
# Skip padding bytes
|
|
163
|
+
padding = stride - (width * 3)
|
|
164
|
+
@pos += padding
|
|
165
|
+
rows << row
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# BMP stores rows bottom-to-top (unless top-down)
|
|
169
|
+
rows.reverse! unless top_down
|
|
170
|
+
rows.each { |r| out << r }
|
|
171
|
+
out
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def decode_32bit(width, height, top_down, bitfield_masks)
|
|
175
|
+
out = String.new(encoding: Encoding::BINARY, capacity: width * height * 3)
|
|
176
|
+
rows = []
|
|
177
|
+
|
|
178
|
+
# Default masks for standard 32-bit BGRA
|
|
179
|
+
if bitfield_masks
|
|
180
|
+
r_mask = bitfield_masks[:r]
|
|
181
|
+
g_mask = bitfield_masks[:g]
|
|
182
|
+
b_mask = bitfield_masks[:b]
|
|
183
|
+
r_shift = mask_shift(r_mask)
|
|
184
|
+
g_shift = mask_shift(g_mask)
|
|
185
|
+
b_shift = mask_shift(b_mask)
|
|
186
|
+
r_max = mask_max(r_mask)
|
|
187
|
+
g_max = mask_max(g_mask)
|
|
188
|
+
b_max = mask_max(b_mask)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
height.times do
|
|
192
|
+
row = String.new(encoding: Encoding::BINARY, capacity: width * 3)
|
|
193
|
+
width.times do
|
|
194
|
+
if bitfield_masks
|
|
195
|
+
val = @data.byteslice(@pos, 4).unpack1("V")
|
|
196
|
+
@pos += 4
|
|
197
|
+
r = r_max.positive? ? ((val & r_mask) >> r_shift) * 255 / r_max : 0
|
|
198
|
+
g = g_max.positive? ? ((val & g_mask) >> g_shift) * 255 / g_max : 0
|
|
199
|
+
b = b_max.positive? ? ((val & b_mask) >> b_shift) * 255 / b_max : 0
|
|
200
|
+
else
|
|
201
|
+
b = @data.getbyte(@pos)
|
|
202
|
+
g = @data.getbyte(@pos + 1)
|
|
203
|
+
r = @data.getbyte(@pos + 2)
|
|
204
|
+
# skip alpha byte
|
|
205
|
+
@pos += 4
|
|
206
|
+
end
|
|
207
|
+
row << r.chr << g.chr << b.chr
|
|
208
|
+
end
|
|
209
|
+
rows << row
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
rows.reverse! unless top_down
|
|
213
|
+
rows.each { |r| out << r }
|
|
214
|
+
out
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def decode_8bit(width, height, top_down, palette)
|
|
218
|
+
raise DecodeError, "Missing palette for 8-bit image" unless palette
|
|
219
|
+
|
|
220
|
+
stride = (width + 3) & ~3 # Row size padded to 4-byte boundary
|
|
221
|
+
out = String.new(encoding: Encoding::BINARY, capacity: width * height * 3)
|
|
222
|
+
rows = []
|
|
223
|
+
|
|
224
|
+
height.times do
|
|
225
|
+
row = String.new(encoding: Encoding::BINARY, capacity: width * 3)
|
|
226
|
+
width.times do
|
|
227
|
+
idx = @data.getbyte(@pos)
|
|
228
|
+
@pos += 1
|
|
229
|
+
r, g, b = palette[idx] || [0, 0, 0]
|
|
230
|
+
row << r.chr << g.chr << b.chr
|
|
231
|
+
end
|
|
232
|
+
padding = stride - width
|
|
233
|
+
@pos += padding
|
|
234
|
+
rows << row
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
rows.reverse! unless top_down
|
|
238
|
+
rows.each { |r| out << r }
|
|
239
|
+
out
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def decode_rle8(width, height, top_down, palette)
|
|
243
|
+
raise DecodeError, "Missing palette for RLE8 image" unless palette
|
|
244
|
+
|
|
245
|
+
# Initialize pixel buffer to black
|
|
246
|
+
pixel_buf = Array.new(width * height * 3, 0)
|
|
247
|
+
x = 0
|
|
248
|
+
y = 0
|
|
249
|
+
|
|
250
|
+
while @pos < @data.bytesize
|
|
251
|
+
count = @data.getbyte(@pos)
|
|
252
|
+
value = @data.getbyte(@pos + 1)
|
|
253
|
+
@pos += 2
|
|
254
|
+
|
|
255
|
+
if count.positive?
|
|
256
|
+
# Encoded run: repeat value count times
|
|
257
|
+
count.times do
|
|
258
|
+
if x < width && y < height
|
|
259
|
+
r, g, b = palette[value] || [0, 0, 0]
|
|
260
|
+
offset = ((y * width) + x) * 3
|
|
261
|
+
pixel_buf[offset] = r
|
|
262
|
+
pixel_buf[offset + 1] = g
|
|
263
|
+
pixel_buf[offset + 2] = b
|
|
264
|
+
end
|
|
265
|
+
x += 1
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
case value
|
|
269
|
+
when 0 # End of line
|
|
270
|
+
x = 0
|
|
271
|
+
y += 1
|
|
272
|
+
when 1 # End of bitmap
|
|
273
|
+
break
|
|
274
|
+
when 2 # Delta
|
|
275
|
+
dx = @data.getbyte(@pos)
|
|
276
|
+
dy = @data.getbyte(@pos + 1)
|
|
277
|
+
@pos += 2
|
|
278
|
+
x += dx
|
|
279
|
+
y += dy
|
|
280
|
+
else
|
|
281
|
+
# Absolute mode: read 'value' literal pixels
|
|
282
|
+
value.times do
|
|
283
|
+
idx = @data.getbyte(@pos)
|
|
284
|
+
@pos += 1
|
|
285
|
+
if x < width && y < height
|
|
286
|
+
r, g, b = palette[idx] || [0, 0, 0]
|
|
287
|
+
offset = ((y * width) + x) * 3
|
|
288
|
+
pixel_buf[offset] = r
|
|
289
|
+
pixel_buf[offset + 1] = g
|
|
290
|
+
pixel_buf[offset + 2] = b
|
|
291
|
+
end
|
|
292
|
+
x += 1
|
|
293
|
+
end
|
|
294
|
+
# Absolute runs are padded to word boundary
|
|
295
|
+
@pos += 1 if value.odd?
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Build output: RLE stores rows bottom-to-top by default
|
|
301
|
+
out = String.new(encoding: Encoding::BINARY, capacity: width * height * 3)
|
|
302
|
+
if top_down
|
|
303
|
+
height.times do |row|
|
|
304
|
+
offset = row * width * 3
|
|
305
|
+
width.times do |col|
|
|
306
|
+
off = offset + (col * 3)
|
|
307
|
+
out << pixel_buf[off].chr << pixel_buf[off + 1].chr << pixel_buf[off + 2].chr
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
else
|
|
311
|
+
(height - 1).downto(0) do |row|
|
|
312
|
+
offset = row * width * 3
|
|
313
|
+
width.times do |col|
|
|
314
|
+
off = offset + (col * 3)
|
|
315
|
+
out << pixel_buf[off].chr << pixel_buf[off + 1].chr << pixel_buf[off + 2].chr
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
out
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def decode_4bit(width, height, top_down, palette)
|
|
324
|
+
raise DecodeError, "Missing palette for 4-bit image" unless palette
|
|
325
|
+
|
|
326
|
+
row_bytes = (width + 1) / 2
|
|
327
|
+
stride = (row_bytes + 3) & ~3
|
|
328
|
+
out = String.new(encoding: Encoding::BINARY, capacity: width * height * 3)
|
|
329
|
+
rows = []
|
|
330
|
+
|
|
331
|
+
height.times do
|
|
332
|
+
row = String.new(encoding: Encoding::BINARY, capacity: width * 3)
|
|
333
|
+
x = 0
|
|
334
|
+
row_bytes.times do
|
|
335
|
+
byte = @data.getbyte(@pos)
|
|
336
|
+
@pos += 1
|
|
337
|
+
|
|
338
|
+
# High nibble first
|
|
339
|
+
if x < width
|
|
340
|
+
idx = (byte >> 4) & 0x0F
|
|
341
|
+
r, g, b = palette[idx] || [0, 0, 0]
|
|
342
|
+
row << r.chr << g.chr << b.chr
|
|
343
|
+
x += 1
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Low nibble
|
|
347
|
+
next unless x < width
|
|
348
|
+
|
|
349
|
+
idx = byte & 0x0F
|
|
350
|
+
r, g, b = palette[idx] || [0, 0, 0]
|
|
351
|
+
row << r.chr << g.chr << b.chr
|
|
352
|
+
x += 1
|
|
353
|
+
end
|
|
354
|
+
padding = stride - row_bytes
|
|
355
|
+
@pos += padding
|
|
356
|
+
rows << row
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
rows.reverse! unless top_down
|
|
360
|
+
rows.each { |r| out << r }
|
|
361
|
+
out
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def decode_1bit(width, height, top_down, palette)
|
|
365
|
+
raise DecodeError, "Missing palette for 1-bit image" unless palette
|
|
366
|
+
|
|
367
|
+
row_bytes = (width + 7) / 8
|
|
368
|
+
stride = (row_bytes + 3) & ~3
|
|
369
|
+
out = String.new(encoding: Encoding::BINARY, capacity: width * height * 3)
|
|
370
|
+
rows = []
|
|
371
|
+
|
|
372
|
+
height.times do
|
|
373
|
+
row = String.new(encoding: Encoding::BINARY, capacity: width * 3)
|
|
374
|
+
x = 0
|
|
375
|
+
row_bytes.times do
|
|
376
|
+
byte = @data.getbyte(@pos)
|
|
377
|
+
@pos += 1
|
|
378
|
+
|
|
379
|
+
8.times do |bit|
|
|
380
|
+
break if x >= width
|
|
381
|
+
|
|
382
|
+
idx = (byte >> (7 - bit)) & 1
|
|
383
|
+
r, g, b = palette[idx] || [0, 0, 0]
|
|
384
|
+
row << r.chr << g.chr << b.chr
|
|
385
|
+
x += 1
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
padding = stride - row_bytes
|
|
389
|
+
@pos += padding
|
|
390
|
+
rows << row
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
rows.reverse! unless top_down
|
|
394
|
+
rows.each { |r| out << r }
|
|
395
|
+
out
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def mask_shift(mask)
|
|
399
|
+
return 0 if mask.zero?
|
|
400
|
+
|
|
401
|
+
shift = 0
|
|
402
|
+
shift += 1 while (mask >> shift).nobits?(1)
|
|
403
|
+
shift
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def mask_max(mask)
|
|
407
|
+
return 0 if mask.zero?
|
|
408
|
+
|
|
409
|
+
mask >> mask_shift(mask)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pura
|
|
4
|
+
module Bmp
|
|
5
|
+
class Encoder
|
|
6
|
+
def self.encode(image, output_path)
|
|
7
|
+
encoder = new(image)
|
|
8
|
+
data = encoder.encode
|
|
9
|
+
File.binwrite(output_path, data)
|
|
10
|
+
data.bytesize
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(image)
|
|
14
|
+
@image = image
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def encode
|
|
18
|
+
width = @image.width
|
|
19
|
+
height = @image.height
|
|
20
|
+
pixels = @image.pixels
|
|
21
|
+
|
|
22
|
+
stride = ((width * 3) + 3) & ~3 # Row size padded to 4-byte boundary
|
|
23
|
+
padding = stride - (width * 3)
|
|
24
|
+
pixel_data_size = stride * height
|
|
25
|
+
file_size = 14 + 40 + pixel_data_size # File header + info header + pixel data
|
|
26
|
+
pixel_offset = 14 + 40
|
|
27
|
+
|
|
28
|
+
out = String.new(encoding: Encoding::BINARY, capacity: file_size)
|
|
29
|
+
|
|
30
|
+
# File header (14 bytes)
|
|
31
|
+
out << "BM"
|
|
32
|
+
out << [file_size].pack("V")
|
|
33
|
+
out << [0, 0].pack("vv") # Reserved
|
|
34
|
+
out << [pixel_offset].pack("V")
|
|
35
|
+
|
|
36
|
+
# Info header (BITMAPINFOHEADER, 40 bytes)
|
|
37
|
+
out << [40].pack("V") # Header size
|
|
38
|
+
out << [width].pack("V") # Width
|
|
39
|
+
out << [height].pack("l<") # Height (positive = bottom-up)
|
|
40
|
+
out << [1].pack("v") # Planes
|
|
41
|
+
out << [24].pack("v") # Bit depth
|
|
42
|
+
out << [0].pack("V") # Compression (BI_RGB)
|
|
43
|
+
out << [pixel_data_size].pack("V") # Image size
|
|
44
|
+
out << [2835].pack("l<") # X pixels per meter (~72 DPI)
|
|
45
|
+
out << [2835].pack("l<") # Y pixels per meter (~72 DPI)
|
|
46
|
+
out << [0].pack("V") # Colors used
|
|
47
|
+
out << [0].pack("V") # Colors important
|
|
48
|
+
|
|
49
|
+
# Pixel data (bottom-to-top, BGR order)
|
|
50
|
+
pad_bytes = "\x00".b * padding
|
|
51
|
+
(height - 1).downto(0) do |y|
|
|
52
|
+
row_offset = y * width * 3
|
|
53
|
+
width.times do |x|
|
|
54
|
+
off = row_offset + (x * 3)
|
|
55
|
+
r = pixels.getbyte(off)
|
|
56
|
+
g = pixels.getbyte(off + 1)
|
|
57
|
+
b = pixels.getbyte(off + 2)
|
|
58
|
+
out << b.chr << g.chr << r.chr # BGR order
|
|
59
|
+
end
|
|
60
|
+
out << pad_bytes if padding.positive?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
out
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pura
|
|
4
|
+
module Bmp
|
|
5
|
+
class Image
|
|
6
|
+
attr_reader :width, :height, :pixels
|
|
7
|
+
|
|
8
|
+
def initialize(width, height, pixels)
|
|
9
|
+
@width = width
|
|
10
|
+
@height = height
|
|
11
|
+
@pixels = pixels.b
|
|
12
|
+
expected = width * height * 3
|
|
13
|
+
return if @pixels.bytesize == expected
|
|
14
|
+
|
|
15
|
+
raise ArgumentError, "pixel data size #{@pixels.bytesize} != expected #{expected} (#{width}x#{height}x3)"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_rgb_array
|
|
19
|
+
result = Array.new(width * height)
|
|
20
|
+
i = 0
|
|
21
|
+
offset = 0
|
|
22
|
+
while offset < @pixels.bytesize
|
|
23
|
+
result[i] = [@pixels.getbyte(offset), @pixels.getbyte(offset + 1), @pixels.getbyte(offset + 2)]
|
|
24
|
+
i += 1
|
|
25
|
+
offset += 3
|
|
26
|
+
end
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def pixel_at(x, y)
|
|
31
|
+
raise IndexError, "coordinates out of bounds" if x.negative? || x >= @width || y.negative? || y >= @height
|
|
32
|
+
|
|
33
|
+
offset = ((y * @width) + x) * 3
|
|
34
|
+
[@pixels.getbyte(offset), @pixels.getbyte(offset + 1), @pixels.getbyte(offset + 2)]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_ppm
|
|
38
|
+
header = "P6\n#{@width} #{@height}\n255\n"
|
|
39
|
+
header.b + @pixels
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resize to exact dimensions
|
|
43
|
+
# interpolation: :bilinear (default) or :nearest
|
|
44
|
+
def resize(new_width, new_height, interpolation: :bilinear)
|
|
45
|
+
raise ArgumentError, "width must be positive" unless new_width.positive?
|
|
46
|
+
raise ArgumentError, "height must be positive" unless new_height.positive?
|
|
47
|
+
|
|
48
|
+
if interpolation == :nearest
|
|
49
|
+
resize_nearest(new_width, new_height)
|
|
50
|
+
else
|
|
51
|
+
resize_bilinear(new_width, new_height)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Resize to fit within bounds, maintaining aspect ratio
|
|
56
|
+
def resize_fit(max_width, max_height, interpolation: :bilinear)
|
|
57
|
+
raise ArgumentError, "max_width must be positive" unless max_width.positive?
|
|
58
|
+
raise ArgumentError, "max_height must be positive" unless max_height.positive?
|
|
59
|
+
|
|
60
|
+
scale = [max_width.to_f / @width, max_height.to_f / @height].min
|
|
61
|
+
scale = [scale, 1.0].min # Don't upscale
|
|
62
|
+
new_width = (@width * scale).round
|
|
63
|
+
new_height = (@height * scale).round
|
|
64
|
+
new_width = 1 if new_width < 1
|
|
65
|
+
new_height = 1 if new_height < 1
|
|
66
|
+
resize(new_width, new_height, interpolation: interpolation)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resize to fill exact dimensions, cropping excess (center crop)
|
|
70
|
+
def resize_fill(fill_width, fill_height, interpolation: :bilinear)
|
|
71
|
+
raise ArgumentError, "width must be positive" unless fill_width.positive?
|
|
72
|
+
raise ArgumentError, "height must be positive" unless fill_height.positive?
|
|
73
|
+
|
|
74
|
+
scale = [fill_width.to_f / @width, fill_height.to_f / @height].max
|
|
75
|
+
scaled_w = (@width * scale).round
|
|
76
|
+
scaled_h = (@height * scale).round
|
|
77
|
+
scaled_w = 1 if scaled_w < 1
|
|
78
|
+
scaled_h = 1 if scaled_h < 1
|
|
79
|
+
|
|
80
|
+
scaled = resize(scaled_w, scaled_h, interpolation: interpolation)
|
|
81
|
+
|
|
82
|
+
# Center crop
|
|
83
|
+
crop_x = (scaled_w - fill_width) / 2
|
|
84
|
+
crop_y = (scaled_h - fill_height) / 2
|
|
85
|
+
scaled.crop(crop_x, crop_y, fill_width, fill_height)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Crop a region from the image
|
|
89
|
+
def crop(x, y, w, h)
|
|
90
|
+
out = String.new(encoding: Encoding::BINARY, capacity: w * h * 3)
|
|
91
|
+
h.times do |row|
|
|
92
|
+
src_offset = (((y + row) * @width) + x) * 3
|
|
93
|
+
out << @pixels.byteslice(src_offset, w * 3)
|
|
94
|
+
end
|
|
95
|
+
Image.new(w, h, out)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def resize_nearest(new_width, new_height)
|
|
101
|
+
out = String.new(encoding: Encoding::BINARY, capacity: new_width * new_height * 3)
|
|
102
|
+
x_ratio = @width.to_f / new_width
|
|
103
|
+
y_ratio = @height.to_f / new_height
|
|
104
|
+
|
|
105
|
+
new_height.times do |y|
|
|
106
|
+
src_y = (y * y_ratio).to_i
|
|
107
|
+
src_y = @height - 1 if src_y >= @height
|
|
108
|
+
new_width.times do |x|
|
|
109
|
+
src_x = (x * x_ratio).to_i
|
|
110
|
+
src_x = @width - 1 if src_x >= @width
|
|
111
|
+
offset = ((src_y * @width) + src_x) * 3
|
|
112
|
+
out << @pixels.byteslice(offset, 3)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Image.new(new_width, new_height, out)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resize_bilinear(new_width, new_height)
|
|
120
|
+
out = String.new(encoding: Encoding::BINARY, capacity: new_width * new_height * 3)
|
|
121
|
+
x_ratio = (@width - 1).to_f / [new_width - 1, 1].max
|
|
122
|
+
y_ratio = (@height - 1).to_f / [new_height - 1, 1].max
|
|
123
|
+
|
|
124
|
+
new_height.times do |y|
|
|
125
|
+
src_y = y * y_ratio
|
|
126
|
+
y0 = src_y.to_i
|
|
127
|
+
y1 = [y0 + 1, @height - 1].min
|
|
128
|
+
y_frac = src_y - y0
|
|
129
|
+
|
|
130
|
+
new_width.times do |x|
|
|
131
|
+
src_x = x * x_ratio
|
|
132
|
+
x0 = src_x.to_i
|
|
133
|
+
x1 = [x0 + 1, @width - 1].min
|
|
134
|
+
x_frac = src_x - x0
|
|
135
|
+
|
|
136
|
+
off00 = ((y0 * @width) + x0) * 3
|
|
137
|
+
off10 = ((y0 * @width) + x1) * 3
|
|
138
|
+
off01 = ((y1 * @width) + x0) * 3
|
|
139
|
+
off11 = ((y1 * @width) + x1) * 3
|
|
140
|
+
|
|
141
|
+
3.times do |c|
|
|
142
|
+
v00 = @pixels.getbyte(off00 + c)
|
|
143
|
+
v10 = @pixels.getbyte(off10 + c)
|
|
144
|
+
v01 = @pixels.getbyte(off01 + c)
|
|
145
|
+
v11 = @pixels.getbyte(off11 + c)
|
|
146
|
+
|
|
147
|
+
val = (v00 * (1 - x_frac) * (1 - y_frac)) +
|
|
148
|
+
(v10 * x_frac * (1 - y_frac)) +
|
|
149
|
+
(v01 * (1 - x_frac) * y_frac) +
|
|
150
|
+
(v11 * x_frac * y_frac)
|
|
151
|
+
|
|
152
|
+
val = val.round
|
|
153
|
+
val = 0 if val.negative?
|
|
154
|
+
val = 255 if val > 255
|
|
155
|
+
out << val.chr
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Image.new(new_width, new_height, out)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
data/lib/pura-bmp.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pura/bmp/version"
|
|
4
|
+
require_relative "pura/bmp/image"
|
|
5
|
+
require_relative "pura/bmp/decoder"
|
|
6
|
+
require_relative "pura/bmp/encoder"
|
|
7
|
+
|
|
8
|
+
module Pura
|
|
9
|
+
module Bmp
|
|
10
|
+
def self.decode(input)
|
|
11
|
+
Decoder.decode(input)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.encode(image, output_path)
|
|
15
|
+
Encoder.encode(image, output_path)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pura-bmp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- komagata
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: A pure Ruby BMP decoder and encoder with zero C extension dependencies.
|
|
41
|
+
Supports 1/4/8/24/32-bit color depths, RLE8 compression, and both bottom-up and
|
|
42
|
+
top-down storage.
|
|
43
|
+
executables:
|
|
44
|
+
- pura-bmp
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- README.md
|
|
49
|
+
- bin/pura-bmp
|
|
50
|
+
- lib/pura-bmp.rb
|
|
51
|
+
- lib/pura/bmp/decoder.rb
|
|
52
|
+
- lib/pura/bmp/encoder.rb
|
|
53
|
+
- lib/pura/bmp/image.rb
|
|
54
|
+
- lib/pura/bmp/version.rb
|
|
55
|
+
homepage: https://github.com/komagata/pure-bmp
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata: {}
|
|
59
|
+
rdoc_options: []
|
|
60
|
+
require_paths:
|
|
61
|
+
- lib
|
|
62
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: 3.0.0
|
|
67
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '0'
|
|
72
|
+
requirements: []
|
|
73
|
+
rubygems_version: 3.6.9
|
|
74
|
+
specification_version: 4
|
|
75
|
+
summary: Pure Ruby BMP decoder/encoder
|
|
76
|
+
test_files: []
|