pura-jpeg 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/LICENSE +21 -0
- data/README.md +103 -0
- data/bin/pura-jpeg +222 -0
- data/lib/pura/jpeg/decoder.rb +646 -0
- data/lib/pura/jpeg/encoder.rb +655 -0
- data/lib/pura/jpeg/image.rb +164 -0
- data/lib/pura/jpeg/version.rb +7 -0
- data/lib/pura-jpeg.rb +18 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b497cc2d62e912f13269111752139cdede385a5a4f0fb0a020743c9270f969a6
|
|
4
|
+
data.tar.gz: 6712cb8189a06cfd4ea1fe4af6c0dbd713d286f79acd29e24f0e9123c4f09843
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 533b5753fb406eac2f2590b719cba7d1ab282ccbadf333fbb16b51bc35432b9e436552f85d9fd30336f6028b46f98fcfa879ce40d4957ee006591c60d99fa1d8
|
|
7
|
+
data.tar.gz: a4f52d7145e529abd3c12ea8363d5f36fc3853f3095c1f82f953dbe098eeabd4859422678a5bc68755beca89c4577e9c6571ee90ef8fd4d201fb0de4a422ed9b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jpeg_pure contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# pura-jpeg
|
|
2
|
+
|
|
3
|
+
A pure Ruby JPEG decoder/encoder with zero C extension dependencies.
|
|
4
|
+
|
|
5
|
+
Part of the **pura-*** series — pure Ruby image codec gems.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Baseline JPEG decoding and encoding (SOF0)
|
|
10
|
+
- Image resizing (bilinear / nearest-neighbor interpolation)
|
|
11
|
+
- Huffman coding, fast integer IDCT/FDCT, YCbCr ↔ RGB conversion
|
|
12
|
+
- 4:2:0, 4:2:2, and 4:4:4 chroma subsampling
|
|
13
|
+
- No native extensions, no FFI, no external dependencies
|
|
14
|
+
- CLI tool included
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install pura-jpeg
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
require "pura-jpeg"
|
|
26
|
+
|
|
27
|
+
# Decode
|
|
28
|
+
image = Pura::Jpeg.decode("photo.jpg")
|
|
29
|
+
image.width #=> 1920
|
|
30
|
+
image.height #=> 1080
|
|
31
|
+
image.pixels #=> Raw RGB byte string
|
|
32
|
+
|
|
33
|
+
# Resize
|
|
34
|
+
thumb = image.resize(200, 200)
|
|
35
|
+
fitted = image.resize_fit(800, 600) # maintain aspect ratio
|
|
36
|
+
|
|
37
|
+
# Encode
|
|
38
|
+
Pura::Jpeg.encode(thumb, "thumb.jpg", quality: 80)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## CLI
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pura-jpeg decode input.jpg --info
|
|
45
|
+
pura-jpeg resize input.jpg --width 200 --height 200 --out thumb.jpg
|
|
46
|
+
pura-jpeg resize input.jpg --fit 800x600 --out fitted.jpg
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Benchmark
|
|
50
|
+
|
|
51
|
+
400×400 image, Ruby 4.0.2 + YJIT.
|
|
52
|
+
|
|
53
|
+
### Decode
|
|
54
|
+
|
|
55
|
+
| Decoder | Time | Language |
|
|
56
|
+
|---------|------|----------|
|
|
57
|
+
| jpeg-js (V8 JIT) | 39 ms | Pure JavaScript |
|
|
58
|
+
| jpeg-js (`--jitless`) | 143 ms | Pure JavaScript (interpreter) |
|
|
59
|
+
| ffmpeg (C) | 55 ms | C |
|
|
60
|
+
| **pura-jpeg** | **304 ms** | **Pure Ruby** |
|
|
61
|
+
| ptjd | 5,448 ms | Pure Tcl |
|
|
62
|
+
|
|
63
|
+
### Encode
|
|
64
|
+
|
|
65
|
+
| Encoder | Time | vs ffmpeg |
|
|
66
|
+
|---------|------|-----------|
|
|
67
|
+
| ffmpeg (C) | 62 ms | — |
|
|
68
|
+
| **pura-jpeg** | **238 ms** | 3.8× slower |
|
|
69
|
+
|
|
70
|
+
### Full pipeline (decode → resize → encode)
|
|
71
|
+
|
|
72
|
+
| Operation | Time |
|
|
73
|
+
|-----------|------|
|
|
74
|
+
| Decode | 304 ms |
|
|
75
|
+
| Encode (quality 85) | 243 ms |
|
|
76
|
+
| Full pipeline | ~547 ms |
|
|
77
|
+
|
|
78
|
+
pura-jpeg is **2× faster than ptjd** (Tcl) and within **2× of jpeg-js running without JIT**. These are the only three pure scripting-language JPEG implementations that exist — Python, Perl, PHP, and Lua all rely on C extensions.
|
|
79
|
+
|
|
80
|
+
## Why pure Ruby?
|
|
81
|
+
|
|
82
|
+
- **`gem install` and go** — no `brew install`, no `apt install`, no C compiler needed
|
|
83
|
+
- **Works everywhere Ruby works** — CRuby, ruby.wasm, mruby, JRuby, TruffleRuby
|
|
84
|
+
- **Edge/Wasm ready** — runs in Cloudflare Workers, browsers (via ruby.wasm), sandboxed environments where you can't install system libraries
|
|
85
|
+
- **Perfect for dev/CI** — no ImageMagick or libvips setup. `rails new` → image upload → it just works
|
|
86
|
+
- **Unix philosophy** — one format, one gem, composable
|
|
87
|
+
|
|
88
|
+
## Related gems
|
|
89
|
+
|
|
90
|
+
| Gem | Format | Status |
|
|
91
|
+
|-----|--------|--------|
|
|
92
|
+
| **pura-jpeg** | JPEG | ✅ Available |
|
|
93
|
+
| [pura-png](https://github.com/komagata/pura-png) | PNG | ✅ Available |
|
|
94
|
+
| [pura-bmp](https://github.com/komagata/pura-bmp) | BMP | ✅ Available |
|
|
95
|
+
| [pura-gif](https://github.com/komagata/pura-gif) | GIF | ✅ Available |
|
|
96
|
+
| [pura-tiff](https://github.com/komagata/pura-tiff) | TIFF | ✅ Available |
|
|
97
|
+
| [pura-ico](https://github.com/komagata/pura-ico) | ICO | ✅ Available |
|
|
98
|
+
| [pura-webp](https://github.com/komagata/pura-webp) | WebP | ✅ Available |
|
|
99
|
+
| [pura-image](https://github.com/komagata/pura-image) | All formats | ✅ Available |
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
data/bin/pura-jpeg
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/pure-jpeg"
|
|
5
|
+
|
|
6
|
+
def usage
|
|
7
|
+
puts <<~USAGE
|
|
8
|
+
Usage: pure-jpeg <command> [options]
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
decode <input.jpg> [options] Decode a JPEG 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 JPEG
|
|
16
|
+
--width <n> Image width (required)
|
|
17
|
+
--height <n> Image height (required)
|
|
18
|
+
--quality <n> JPEG quality 1-100 (default: 85)
|
|
19
|
+
--subsampling <type> 420 or 444 (default: 420)
|
|
20
|
+
--out <file> Output JPEG file (required)
|
|
21
|
+
|
|
22
|
+
resize <input.jpg> [options] Resize a JPEG file
|
|
23
|
+
--width <n> Target width
|
|
24
|
+
--height <n> Target height
|
|
25
|
+
--fit <W>x<H> Fit within bounds (maintain aspect ratio)
|
|
26
|
+
--fill <W>x<H> Fill exact size (crop to fit)
|
|
27
|
+
--interpolation <type> bilinear or nearest (default: bilinear)
|
|
28
|
+
--quality <n> Output JPEG quality (default: 85)
|
|
29
|
+
--out <file> Output JPEG file (required)
|
|
30
|
+
|
|
31
|
+
benchmark <input.jpg> Benchmark decoding vs system tools
|
|
32
|
+
|
|
33
|
+
version Show version
|
|
34
|
+
USAGE
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cmd_decode(args)
|
|
39
|
+
input = nil
|
|
40
|
+
info_only = false
|
|
41
|
+
output = nil
|
|
42
|
+
|
|
43
|
+
i = 0
|
|
44
|
+
while i < args.size
|
|
45
|
+
case args[i]
|
|
46
|
+
when "--info"
|
|
47
|
+
info_only = true
|
|
48
|
+
when "--out"
|
|
49
|
+
i += 1
|
|
50
|
+
output = args[i]
|
|
51
|
+
else
|
|
52
|
+
input = args[i]
|
|
53
|
+
end
|
|
54
|
+
i += 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
usage unless input
|
|
58
|
+
|
|
59
|
+
unless File.exist?(input)
|
|
60
|
+
$stderr.puts "Error: file not found: #{input}"
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
65
|
+
image = Pure::Jpeg.decode(input)
|
|
66
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
67
|
+
|
|
68
|
+
if info_only
|
|
69
|
+
puts "File: #{input}"
|
|
70
|
+
puts "Width: #{image.width}"
|
|
71
|
+
puts "Height: #{image.height}"
|
|
72
|
+
puts "Pixels: #{image.width * image.height}"
|
|
73
|
+
puts "Size: #{File.size(input)} bytes"
|
|
74
|
+
puts "Decode: #{'%.3f' % elapsed}s"
|
|
75
|
+
elsif output
|
|
76
|
+
File.binwrite(output, image.pixels)
|
|
77
|
+
puts "Wrote #{image.pixels.bytesize} bytes of raw RGB to #{output}"
|
|
78
|
+
puts "Decode time: #{'%.3f' % elapsed}s"
|
|
79
|
+
else
|
|
80
|
+
puts "Decoded #{input}: #{image.width}x#{image.height} in #{'%.3f' % elapsed}s"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cmd_encode(args)
|
|
85
|
+
input = nil
|
|
86
|
+
width = nil
|
|
87
|
+
height = nil
|
|
88
|
+
quality = 85
|
|
89
|
+
subsampling = :s420
|
|
90
|
+
output = nil
|
|
91
|
+
|
|
92
|
+
i = 0
|
|
93
|
+
while i < args.size
|
|
94
|
+
case args[i]
|
|
95
|
+
when "--width"
|
|
96
|
+
i += 1; width = args[i].to_i
|
|
97
|
+
when "--height"
|
|
98
|
+
i += 1; height = args[i].to_i
|
|
99
|
+
when "--quality"
|
|
100
|
+
i += 1; quality = args[i].to_i
|
|
101
|
+
when "--subsampling"
|
|
102
|
+
i += 1
|
|
103
|
+
subsampling = args[i] == "444" ? :s444 : :s420
|
|
104
|
+
when "--out"
|
|
105
|
+
i += 1; output = args[i]
|
|
106
|
+
else
|
|
107
|
+
input = args[i]
|
|
108
|
+
end
|
|
109
|
+
i += 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
unless input && width && height && output
|
|
113
|
+
$stderr.puts "Error: encode requires input file, --width, --height, and --out"
|
|
114
|
+
usage
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
unless File.exist?(input)
|
|
118
|
+
$stderr.puts "Error: file not found: #{input}"
|
|
119
|
+
exit 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
raw = File.binread(input)
|
|
123
|
+
image = Pure::Jpeg::Image.new(width, height, raw)
|
|
124
|
+
|
|
125
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
126
|
+
size = Pure::Jpeg.encode(image, output, quality: quality, subsampling: subsampling)
|
|
127
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
128
|
+
|
|
129
|
+
puts "Encoded #{width}x#{height} to #{output} (#{size} bytes) in #{'%.3f' % elapsed}s"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def cmd_resize(args)
|
|
133
|
+
input = nil
|
|
134
|
+
width = nil
|
|
135
|
+
height = nil
|
|
136
|
+
fit = nil
|
|
137
|
+
fill = nil
|
|
138
|
+
interpolation = :bilinear
|
|
139
|
+
quality = 85
|
|
140
|
+
output = nil
|
|
141
|
+
|
|
142
|
+
i = 0
|
|
143
|
+
while i < args.size
|
|
144
|
+
case args[i]
|
|
145
|
+
when "--width"
|
|
146
|
+
i += 1; width = args[i].to_i
|
|
147
|
+
when "--height"
|
|
148
|
+
i += 1; height = args[i].to_i
|
|
149
|
+
when "--fit"
|
|
150
|
+
i += 1; fit = args[i]
|
|
151
|
+
when "--fill"
|
|
152
|
+
i += 1; fill = args[i]
|
|
153
|
+
when "--interpolation"
|
|
154
|
+
i += 1
|
|
155
|
+
interpolation = args[i] == "nearest" ? :nearest : :bilinear
|
|
156
|
+
when "--quality"
|
|
157
|
+
i += 1; quality = args[i].to_i
|
|
158
|
+
when "--out"
|
|
159
|
+
i += 1; output = args[i]
|
|
160
|
+
else
|
|
161
|
+
input = args[i]
|
|
162
|
+
end
|
|
163
|
+
i += 1
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
unless input && output
|
|
167
|
+
$stderr.puts "Error: resize requires input file and --out"
|
|
168
|
+
usage
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
unless File.exist?(input)
|
|
172
|
+
$stderr.puts "Error: file not found: #{input}"
|
|
173
|
+
exit 1
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
177
|
+
image = Pure::Jpeg.decode(input)
|
|
178
|
+
|
|
179
|
+
if fit
|
|
180
|
+
fw, fh = fit.split("x").map(&:to_i)
|
|
181
|
+
resized = image.resize_fit(fw, fh, interpolation: interpolation)
|
|
182
|
+
elsif fill
|
|
183
|
+
fw, fh = fill.split("x").map(&:to_i)
|
|
184
|
+
resized = image.resize_fill(fw, fh, interpolation: interpolation)
|
|
185
|
+
elsif width && height
|
|
186
|
+
resized = image.resize(width, height, interpolation: interpolation)
|
|
187
|
+
else
|
|
188
|
+
$stderr.puts "Error: specify --width/--height, --fit, or --fill"
|
|
189
|
+
usage
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
size = Pure::Jpeg.encode(resized, output, quality: quality)
|
|
193
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
194
|
+
|
|
195
|
+
puts "Resized #{image.width}x#{image.height} -> #{resized.width}x#{resized.height} to #{output} (#{size} bytes) in #{'%.3f' % elapsed}s"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def cmd_benchmark(args)
|
|
199
|
+
input = args[0]
|
|
200
|
+
usage unless input
|
|
201
|
+
|
|
202
|
+
require_relative "../benchmark/decode_benchmark"
|
|
203
|
+
DecodeBenchmark.run(input)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
case ARGV[0]
|
|
207
|
+
when "decode"
|
|
208
|
+
cmd_decode(ARGV[1..])
|
|
209
|
+
when "encode"
|
|
210
|
+
cmd_encode(ARGV[1..])
|
|
211
|
+
when "resize"
|
|
212
|
+
cmd_resize(ARGV[1..])
|
|
213
|
+
when "benchmark"
|
|
214
|
+
cmd_benchmark(ARGV[1..])
|
|
215
|
+
when "version", "--version", "-v"
|
|
216
|
+
puts "pure-jpeg #{Pure::Jpeg::VERSION}"
|
|
217
|
+
when nil, "help", "--help", "-h"
|
|
218
|
+
usage
|
|
219
|
+
else
|
|
220
|
+
$stderr.puts "Unknown command: #{ARGV[0]}"
|
|
221
|
+
usage
|
|
222
|
+
end
|