pura-image 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/Gemfile +8 -0
- data/LICENSE +21 -0
- data/README.md +160 -0
- data/Rakefile +8 -0
- data/lib/image_processing/pura.rb +103 -0
- data/lib/pura/image/operations.rb +140 -0
- data/lib/pura/image/processor.rb +187 -0
- data/lib/pura/image/version.rb +7 -0
- data/lib/pura-image.rb +45 -0
- data/pura-image.gemspec +32 -0
- metadata +165 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6eedafcbd9dfa1e520c478e6d48e6c370f05671f92af380e199f0532d52d7cd1
|
|
4
|
+
data.tar.gz: fa2410f3e26b984d8365abffa266e33fa8d98bca75d04fbc2e022b895cdf3705
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b27e72e142ce3d4ab4ad618bb23e3c48393a6bb44fcbb723cf5fb2d28d6e07107a6985111116733f45bfd6d9d559d65276089684061903d81515c63351d243e5
|
|
7
|
+
data.tar.gz: 1ec0b0dd2e08635e7d9f96ff15832a730cef32250aac59026bc543ace8a65c9db6c0ae3ce2c957330a9e75d68affd5d75d0614528852e89fef0bf6ed1ccbffb1
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 komagata
|
|
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,160 @@
|
|
|
1
|
+
# pura-image
|
|
2
|
+
|
|
3
|
+
Pure Ruby image processing library with **zero C extension dependencies**. Bundles all **pura-*** format gems and provides an `ImageProcessing` adapter for Rails Active Storage.
|
|
4
|
+
|
|
5
|
+
## Supported Formats
|
|
6
|
+
|
|
7
|
+
| Format | Decode | Encode | Gem |
|
|
8
|
+
|--------|--------|--------|-----|
|
|
9
|
+
| JPEG | ✅ | ✅ | [pura-jpeg](https://github.com/komagata/pura-jpeg) |
|
|
10
|
+
| PNG | ✅ | ✅ | [pura-png](https://github.com/komagata/pura-png) |
|
|
11
|
+
| BMP | ✅ | ✅ | [pura-bmp](https://github.com/komagata/pura-bmp) |
|
|
12
|
+
| GIF | ✅ | ✅ | [pura-gif](https://github.com/komagata/pura-gif) |
|
|
13
|
+
| TIFF | ✅ | ✅ | [pura-tiff](https://github.com/komagata/pura-tiff) |
|
|
14
|
+
| ICO/CUR | ✅ | ✅ | [pura-ico](https://github.com/komagata/pura-ico) |
|
|
15
|
+
| WebP | ✅ | ✅ | [pura-webp](https://github.com/komagata/pura-webp) |
|
|
16
|
+
|
|
17
|
+
Format auto-detection by magic bytes — no extension guessing needed for decode.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gem install pura-image
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "pura-image"
|
|
29
|
+
|
|
30
|
+
# Load any format (auto-detected from magic bytes)
|
|
31
|
+
image = Pura::Image.load("photo.jpg")
|
|
32
|
+
image = Pura::Image.load("icon.webp")
|
|
33
|
+
image.width #=> 800
|
|
34
|
+
image.height #=> 600
|
|
35
|
+
|
|
36
|
+
# Save to any format (detected from extension)
|
|
37
|
+
Pura::Image.save(image, "output.png")
|
|
38
|
+
|
|
39
|
+
# Convert between formats
|
|
40
|
+
Pura::Image.convert("input.bmp", "output.jpg", quality: 85)
|
|
41
|
+
Pura::Image.convert("photo.tiff", "photo.png")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Image Operations
|
|
45
|
+
|
|
46
|
+
All operations from the `image_processing` gem are supported:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
image = Pura::Image.load("photo.jpg")
|
|
50
|
+
|
|
51
|
+
# Resize
|
|
52
|
+
image.resize_to_limit(800, 600) # downsize only, keep aspect ratio
|
|
53
|
+
image.resize_to_fit(400, 400) # resize to fit, keep aspect ratio
|
|
54
|
+
image.resize_to_fill(400, 400) # fill exact size, center crop excess
|
|
55
|
+
image.resize_and_pad(400, 400) # fit within bounds, pad with black
|
|
56
|
+
image.resize_to_cover(400, 400) # cover bounds, no crop
|
|
57
|
+
|
|
58
|
+
# Transform
|
|
59
|
+
image.crop(10, 10, 200, 200) # crop region
|
|
60
|
+
image.rotate(90) # rotate 90/180/270 degrees
|
|
61
|
+
image.grayscale # convert to grayscale
|
|
62
|
+
|
|
63
|
+
# Chain operations
|
|
64
|
+
result = Pura::Image.load("photo.jpg")
|
|
65
|
+
.resize_to_limit(800, 600)
|
|
66
|
+
.rotate(90)
|
|
67
|
+
.grayscale
|
|
68
|
+
|
|
69
|
+
Pura::Image.save(result, "thumb.jpg", quality: 80)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Rails Active Storage Integration
|
|
73
|
+
|
|
74
|
+
Drop-in replacement for libvips/ImageMagick:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# Gemfile
|
|
78
|
+
gem "image_processing"
|
|
79
|
+
gem "pura-image"
|
|
80
|
+
|
|
81
|
+
# config/application.rb
|
|
82
|
+
config.active_storage.variant_processor = :pura
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Models and views stay exactly the same:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class User < ApplicationRecord
|
|
89
|
+
has_one_attached :avatar do |attachable|
|
|
90
|
+
attachable.variant :thumb, resize_to_limit: [200, 200]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```erb
|
|
96
|
+
<%= image_tag user.avatar.variant(:thumb) %>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
No `brew install vips`. No `apt install imagemagick`. Just `gem install` and go.
|
|
100
|
+
|
|
101
|
+
### ImageProcessing::Pura API
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
require "image_processing/pura"
|
|
105
|
+
|
|
106
|
+
# Same API as ImageProcessing::Vips
|
|
107
|
+
processed = ImageProcessing::Pura
|
|
108
|
+
.source("photo.jpg")
|
|
109
|
+
.resize_to_limit(400, 400)
|
|
110
|
+
.convert("png")
|
|
111
|
+
.call(destination: "output.png")
|
|
112
|
+
|
|
113
|
+
# Pipeline branching
|
|
114
|
+
pipeline = ImageProcessing::Pura.source("photo.jpg")
|
|
115
|
+
large = pipeline.resize_to_limit(800, 800).call(destination: "large.jpg")
|
|
116
|
+
medium = pipeline.resize_to_limit(500, 500).call(destination: "medium.jpg")
|
|
117
|
+
small = pipeline.resize_to_limit(300, 300).call(destination: "small.jpg")
|
|
118
|
+
|
|
119
|
+
# Validation
|
|
120
|
+
ImageProcessing::Pura.valid_image?("photo.jpg") #=> true
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Benchmark
|
|
124
|
+
|
|
125
|
+
400×400 image, Ruby 4.0.2 + YJIT vs ffmpeg (C + SIMD).
|
|
126
|
+
|
|
127
|
+
### Decode
|
|
128
|
+
|
|
129
|
+
| Format | pura-* | ffmpeg | vs ffmpeg |
|
|
130
|
+
|--------|--------|--------|-----------|
|
|
131
|
+
| TIFF | **14 ms** | 59 ms | 🚀 **4× faster** |
|
|
132
|
+
| BMP | **39 ms** | 59 ms | 🚀 **1.5× faster** |
|
|
133
|
+
| GIF | 77 ms | 65 ms | ~1× (comparable) |
|
|
134
|
+
| PNG | 111 ms | 60 ms | 1.9× slower |
|
|
135
|
+
| WebP | 207 ms | 66 ms | 3.1× slower |
|
|
136
|
+
| JPEG | 304 ms | 55 ms | 5.5× slower |
|
|
137
|
+
|
|
138
|
+
### Encode
|
|
139
|
+
|
|
140
|
+
| Format | pura-* | ffmpeg | vs ffmpeg |
|
|
141
|
+
|--------|--------|--------|-----------|
|
|
142
|
+
| TIFF | **0.8 ms** | 58 ms | 🚀 **73× faster** |
|
|
143
|
+
| BMP | **35 ms** | 58 ms | 🚀 **1.7× faster** |
|
|
144
|
+
| PNG | **52 ms** | 61 ms | 🚀 **faster** |
|
|
145
|
+
| JPEG | 238 ms | 62 ms | 3.8× slower |
|
|
146
|
+
| GIF | 377 ms | 59 ms | 6.4× slower |
|
|
147
|
+
|
|
148
|
+
5 out of 11 operations are **faster than C** (ffmpeg process-spawn overhead).
|
|
149
|
+
|
|
150
|
+
## Why pure Ruby?
|
|
151
|
+
|
|
152
|
+
- **`gem install` and go** — no `brew install`, no `apt install`, no C compiler
|
|
153
|
+
- **Works everywhere Ruby works** — CRuby, ruby.wasm, mruby, JRuby, TruffleRuby
|
|
154
|
+
- **Edge/Wasm ready** — browsers (ruby.wasm), sandboxed environments, no system libraries needed
|
|
155
|
+
- **Perfect for dev/CI** — no ImageMagick/libvips setup. `rails new` → image upload → it just works
|
|
156
|
+
- **7 formats, 1 interface** — unified API across JPEG, PNG, BMP, GIF, TIFF, ICO, WebP
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "image_processing"
|
|
4
|
+
|
|
5
|
+
require_relative "../pura-image"
|
|
6
|
+
|
|
7
|
+
module ImageProcessing
|
|
8
|
+
module Pura
|
|
9
|
+
extend Chainable
|
|
10
|
+
|
|
11
|
+
def self.valid_image?(file)
|
|
12
|
+
path = file.respond_to?(:path) ? file.path : file.to_s
|
|
13
|
+
data = File.binread(path, 8)
|
|
14
|
+
format = ::Pura::Image::Processor.detect_format(data)
|
|
15
|
+
return false unless format
|
|
16
|
+
|
|
17
|
+
::Pura::Image::Processor.load(path)
|
|
18
|
+
true
|
|
19
|
+
rescue StandardError
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Processor < ImageProcessing::Processor
|
|
24
|
+
accumulator :image, ::Pura::Image::Wrapper
|
|
25
|
+
|
|
26
|
+
def self.supports_resize_on_load?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.load_image(path_or_image, **_options)
|
|
31
|
+
if path_or_image.is_a?(::Pura::Image::Wrapper)
|
|
32
|
+
path_or_image
|
|
33
|
+
elsif path_or_image.is_a?(String)
|
|
34
|
+
::Pura::Image::Processor.load(path_or_image)
|
|
35
|
+
elsif path_or_image.respond_to?(:path)
|
|
36
|
+
::Pura::Image::Processor.load(path_or_image.path)
|
|
37
|
+
else
|
|
38
|
+
raise ImageProcessing::Error, "unsupported source: #{path_or_image.inspect}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.save_image(wrapper, destination, **options)
|
|
43
|
+
::Pura::Image::Processor.save(wrapper, destination.to_s, **options)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resize_to_limit(width, height, **_options)
|
|
47
|
+
w = image.width
|
|
48
|
+
h = image.height
|
|
49
|
+
|
|
50
|
+
return image if w <= (width || w) && h <= (height || h)
|
|
51
|
+
|
|
52
|
+
width ||= w
|
|
53
|
+
height ||= h
|
|
54
|
+
image.resize_to_fit(width, height)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resize_to_fit(width, height, **_options)
|
|
58
|
+
width ||= image.width
|
|
59
|
+
height ||= image.height
|
|
60
|
+
image.resize_to_fit(width, height)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resize_to_fill(width, height, **_options)
|
|
64
|
+
image.resize_to_fill(width, height)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resize_and_pad(width, height, background: nil, **_options)
|
|
68
|
+
bg = background || [0, 0, 0]
|
|
69
|
+
image.resize_and_pad(width, height, background: bg)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resize_to_cover(width, height, **_options)
|
|
73
|
+
image.resize_to_cover(width, height)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def crop(left, top, width, height, **_options)
|
|
77
|
+
image.crop(left, top, width, height)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rotate(degrees, **_options)
|
|
81
|
+
image.rotate(degrees)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def colourspace(space, **_options)
|
|
85
|
+
if %w[b-w grey16].include?(space.to_s)
|
|
86
|
+
image.grayscale
|
|
87
|
+
else
|
|
88
|
+
image
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def strip(**_options)
|
|
93
|
+
image.strip
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def convert(format, **_options)
|
|
97
|
+
# Store desired format for save_image
|
|
98
|
+
@format = format.to_s.delete(".")
|
|
99
|
+
image
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pura
|
|
4
|
+
module Image
|
|
5
|
+
module Operations
|
|
6
|
+
def rotate(degrees)
|
|
7
|
+
case degrees % 360
|
|
8
|
+
when 0
|
|
9
|
+
dup_image
|
|
10
|
+
when 90
|
|
11
|
+
rotate90
|
|
12
|
+
when 180
|
|
13
|
+
rotate180
|
|
14
|
+
when 270
|
|
15
|
+
rotate270
|
|
16
|
+
else
|
|
17
|
+
raise ArgumentError, "Only 90, 180, 270 degree rotations are supported"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def grayscale
|
|
22
|
+
new_pixels = String.new(encoding: Encoding::BINARY, capacity: @pixels.bytesize)
|
|
23
|
+
i = 0
|
|
24
|
+
size = @pixels.bytesize
|
|
25
|
+
while i < size
|
|
26
|
+
r = @pixels.getbyte(i)
|
|
27
|
+
g = @pixels.getbyte(i + 1)
|
|
28
|
+
b = @pixels.getbyte(i + 2)
|
|
29
|
+
gray = ((r + g + b) / 3.0).round
|
|
30
|
+
new_pixels << gray << gray << gray
|
|
31
|
+
i += 3
|
|
32
|
+
end
|
|
33
|
+
self.class.new(@width, @height, new_pixels)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resize_to_limit(max_width, max_height)
|
|
37
|
+
return dup_image if @width <= max_width && @height <= max_height
|
|
38
|
+
|
|
39
|
+
scale = [@width.to_f / max_width, @height.to_f / max_height].max
|
|
40
|
+
new_w = [(@width / scale).round, 1].max
|
|
41
|
+
new_h = [(@height / scale).round, 1].max
|
|
42
|
+
resize(new_w, new_h)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resize_to_fit(max_width, max_height)
|
|
46
|
+
scale = [@width.to_f / max_width, @height.to_f / max_height].max
|
|
47
|
+
new_w = [(@width / scale).round, 1].max
|
|
48
|
+
new_h = [(@height / scale).round, 1].max
|
|
49
|
+
resize(new_w, new_h)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resize_to_fill(fill_width, fill_height)
|
|
53
|
+
resize_fill(fill_width, fill_height)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resize_and_pad(target_width, target_height, background: [0, 0, 0])
|
|
57
|
+
# First resize_to_fit
|
|
58
|
+
scale = [@width.to_f / target_width, @height.to_f / target_height].max
|
|
59
|
+
new_w = [(@width / scale).round, 1].max
|
|
60
|
+
new_h = [(@height / scale).round, 1].max
|
|
61
|
+
resized = resize(new_w, new_h)
|
|
62
|
+
|
|
63
|
+
# Create padded canvas
|
|
64
|
+
r, g, b = background
|
|
65
|
+
bg_row = (String.new(encoding: Encoding::BINARY) << r << g << b) * target_width
|
|
66
|
+
canvas = bg_row * target_height
|
|
67
|
+
|
|
68
|
+
# Center the resized image on the canvas
|
|
69
|
+
offset_x = (target_width - resized.width) / 2
|
|
70
|
+
offset_y = (target_height - resized.height) / 2
|
|
71
|
+
|
|
72
|
+
resized.height.times do |y|
|
|
73
|
+
src_start = y * resized.width * 3
|
|
74
|
+
dst_start = (((offset_y + y) * target_width) + offset_x) * 3
|
|
75
|
+
canvas[dst_start, resized.width * 3] = resized.pixels[src_start, resized.width * 3]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
self.class.new(target_width, target_height, canvas)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def resize_to_cover(cover_width, cover_height)
|
|
82
|
+
scale = [@width.to_f / cover_width, @height.to_f / cover_height].min
|
|
83
|
+
new_w = [(@width / scale).round, 1].max
|
|
84
|
+
new_h = [(@height / scale).round, 1].max
|
|
85
|
+
resize(new_w, new_h)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def strip
|
|
89
|
+
dup_image
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def dup_image
|
|
95
|
+
self.class.new(@width, @height, @pixels.dup)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rotate90
|
|
99
|
+
new_w = @height
|
|
100
|
+
new_h = @width
|
|
101
|
+
new_pixels = String.new(encoding: Encoding::BINARY, capacity: new_w * new_h * 3)
|
|
102
|
+
|
|
103
|
+
new_h.times do |y|
|
|
104
|
+
new_w.times do |x|
|
|
105
|
+
# (x, y) in new image comes from (y, height-1-x) in old image
|
|
106
|
+
src_offset = (((@height - 1 - x) * @width) + y) * 3
|
|
107
|
+
new_pixels << @pixels[src_offset, 3]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
self.class.new(new_w, new_h, new_pixels)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def rotate180
|
|
114
|
+
new_pixels = String.new(encoding: Encoding::BINARY, capacity: @pixels.bytesize)
|
|
115
|
+
(@height - 1).downto(0) do |y|
|
|
116
|
+
(@width - 1).downto(0) do |x|
|
|
117
|
+
offset = ((y * @width) + x) * 3
|
|
118
|
+
new_pixels << @pixels[offset, 3]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
self.class.new(@width, @height, new_pixels)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def rotate270
|
|
125
|
+
new_w = @height
|
|
126
|
+
new_h = @width
|
|
127
|
+
new_pixels = String.new(encoding: Encoding::BINARY, capacity: new_w * new_h * 3)
|
|
128
|
+
|
|
129
|
+
new_h.times do |y|
|
|
130
|
+
new_w.times do |x|
|
|
131
|
+
# (x, y) in new image comes from (width-1-y, x) in old image
|
|
132
|
+
src_offset = ((x * @width) + (@width - 1 - y)) * 3
|
|
133
|
+
new_pixels << @pixels[src_offset, 3]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
self.class.new(new_w, new_h, new_pixels)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pura
|
|
4
|
+
module Image
|
|
5
|
+
class Processor
|
|
6
|
+
MAGIC_BYTES = {
|
|
7
|
+
jpeg: [0xFF, 0xD8],
|
|
8
|
+
png: [0x89, 0x50, 0x4E, 0x47],
|
|
9
|
+
gif: [0x47, 0x49, 0x46], # "GIF"
|
|
10
|
+
bmp: [0x42, 0x4D], # "BM"
|
|
11
|
+
tiff_le: [0x49, 0x49, 0x2A, 0x00], # "II*\0" little-endian
|
|
12
|
+
tiff_be: [0x4D, 0x4D, 0x00, 0x2A], # "MM\0*" big-endian
|
|
13
|
+
webp: [0x52, 0x49, 0x46, 0x46], # "RIFF" (+ "WEBP" at offset 8)
|
|
14
|
+
ico: [0x00, 0x00, 0x01, 0x00], # ICO
|
|
15
|
+
cur: [0x00, 0x00, 0x02, 0x00] # CUR
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
EXTENSION_MAP = {
|
|
19
|
+
".jpg" => :jpeg, ".jpeg" => :jpeg,
|
|
20
|
+
".png" => :png,
|
|
21
|
+
".bmp" => :bmp,
|
|
22
|
+
".gif" => :gif,
|
|
23
|
+
".tif" => :tiff, ".tiff" => :tiff,
|
|
24
|
+
".webp" => :webp,
|
|
25
|
+
".ico" => :ico,
|
|
26
|
+
".cur" => :ico
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def detect_format(data)
|
|
31
|
+
bytes = data.bytes
|
|
32
|
+
|
|
33
|
+
return :jpeg if bytes[0] == 0xFF && bytes[1] == 0xD8
|
|
34
|
+
return :png if bytes[0..3] == MAGIC_BYTES[:png]
|
|
35
|
+
return :gif if bytes[0..2] == MAGIC_BYTES[:gif]
|
|
36
|
+
return :bmp if bytes[0..1] == MAGIC_BYTES[:bmp]
|
|
37
|
+
return :tiff if bytes[0..3] == MAGIC_BYTES[:tiff_le] || bytes[0..3] == MAGIC_BYTES[:tiff_be]
|
|
38
|
+
return :ico if bytes[0..3] == MAGIC_BYTES[:ico] || bytes[0..3] == MAGIC_BYTES[:cur]
|
|
39
|
+
|
|
40
|
+
if bytes[0..3] == MAGIC_BYTES[:webp] && bytes.length >= 12 && (bytes[8..11] == [0x57, 0x45, 0x42, 0x50])
|
|
41
|
+
return :webp # "WEBP"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
raise ArgumentError, "Unsupported image format"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def detect_format_by_extension(path)
|
|
48
|
+
ext = File.extname(path).downcase
|
|
49
|
+
EXTENSION_MAP[ext] || raise(ArgumentError, "Unsupported file extension: #{ext}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load(path)
|
|
53
|
+
data = File.binread(path, 16)
|
|
54
|
+
format = detect_format(data)
|
|
55
|
+
image = decode_with_format(path, format)
|
|
56
|
+
wrap(image)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def save(image, path, **options)
|
|
60
|
+
format = detect_format_by_extension(path)
|
|
61
|
+
raw_image = unwrap(image)
|
|
62
|
+
encode_with_format(raw_image, path, format, **options)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def convert(input_path, output_path, **options)
|
|
66
|
+
image = load(input_path)
|
|
67
|
+
save(image, output_path, **options)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def wrap(raw_image)
|
|
71
|
+
Wrapper.new(raw_image.width, raw_image.height, raw_image.pixels.dup)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def unwrap(wrapper)
|
|
75
|
+
Pura::Jpeg::Image.new(wrapper.width, wrapper.height, wrapper.pixels)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def decode_with_format(path, format)
|
|
81
|
+
case format
|
|
82
|
+
when :jpeg then Pura::Jpeg.decode(path)
|
|
83
|
+
when :png then Pura::Png.decode(path)
|
|
84
|
+
when :bmp then Pura::Bmp.decode(path)
|
|
85
|
+
when :gif then Pura::Gif.decode(path)
|
|
86
|
+
when :tiff then Pura::Tiff.decode(path)
|
|
87
|
+
when :ico then Pura::Ico.decode(path)
|
|
88
|
+
when :webp
|
|
89
|
+
raise ArgumentError, "WebP support not available" unless defined?(Pura::Webp)
|
|
90
|
+
|
|
91
|
+
Pura::Webp.decode(path)
|
|
92
|
+
else
|
|
93
|
+
raise ArgumentError, "Unsupported format: #{format}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def encode_with_format(image, path, format, **options)
|
|
98
|
+
case format
|
|
99
|
+
when :jpeg
|
|
100
|
+
Pura::Jpeg.encode(image, path, quality: options.fetch(:quality, 85))
|
|
101
|
+
when :png
|
|
102
|
+
png_img = Pura::Png::Image.new(image.width, image.height, image.pixels)
|
|
103
|
+
Pura::Png.encode(png_img, path, compression: options.fetch(:compression, 6))
|
|
104
|
+
when :bmp
|
|
105
|
+
bmp_img = Pura::Bmp::Image.new(image.width, image.height, image.pixels)
|
|
106
|
+
Pura::Bmp.encode(bmp_img, path)
|
|
107
|
+
when :gif
|
|
108
|
+
gif_img = Pura::Gif::Image.new(image.width, image.height, image.pixels)
|
|
109
|
+
Pura::Gif.encode(gif_img, path)
|
|
110
|
+
when :tiff
|
|
111
|
+
tiff_img = Pura::Tiff::Image.new(image.width, image.height, image.pixels)
|
|
112
|
+
Pura::Tiff.encode(tiff_img, path)
|
|
113
|
+
when :ico
|
|
114
|
+
ico_img = Pura::Ico::Image.new(image.width, image.height, image.pixels)
|
|
115
|
+
Pura::Ico.encode(ico_img, path)
|
|
116
|
+
when :webp
|
|
117
|
+
raise ArgumentError, "WebP encoding not available" unless defined?(Pura::Webp)
|
|
118
|
+
|
|
119
|
+
webp_img = Pura::Webp::Image.new(image.width, image.height, image.pixels)
|
|
120
|
+
Pura::Webp.encode(webp_img, path, **options)
|
|
121
|
+
else
|
|
122
|
+
raise ArgumentError, "Unsupported output format: #{format}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class Wrapper
|
|
129
|
+
include Operations
|
|
130
|
+
|
|
131
|
+
attr_reader :width, :height, :pixels
|
|
132
|
+
|
|
133
|
+
def initialize(width, height, pixels)
|
|
134
|
+
@width = width
|
|
135
|
+
@height = height
|
|
136
|
+
@pixels = pixels.dup
|
|
137
|
+
@pixels.force_encoding(Encoding::BINARY)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def pixel_at(x, y)
|
|
141
|
+
raise IndexError, "Coordinates out of bounds" if x.negative? || x >= @width || y.negative? || y >= @height
|
|
142
|
+
|
|
143
|
+
offset = ((y * @width) + x) * 3
|
|
144
|
+
[@pixels.getbyte(offset), @pixels.getbyte(offset + 1), @pixels.getbyte(offset + 2)]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_rgb_array
|
|
148
|
+
result = []
|
|
149
|
+
i = 0
|
|
150
|
+
size = @pixels.bytesize
|
|
151
|
+
while i < size
|
|
152
|
+
result << [@pixels.getbyte(i), @pixels.getbyte(i + 1), @pixels.getbyte(i + 2)]
|
|
153
|
+
i += 3
|
|
154
|
+
end
|
|
155
|
+
result
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def to_ppm
|
|
159
|
+
"P6\n#{@width} #{@height}\n255\n" + @pixels
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def resize(new_width, new_height)
|
|
163
|
+
raw = Pura::Jpeg::Image.new(@width, @height, @pixels)
|
|
164
|
+
resized = raw.resize(new_width, new_height)
|
|
165
|
+
self.class.new(resized.width, resized.height, resized.pixels)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def resize_fit(max_width, max_height)
|
|
169
|
+
raw = Pura::Jpeg::Image.new(@width, @height, @pixels)
|
|
170
|
+
fitted = raw.resize_fit(max_width, max_height)
|
|
171
|
+
self.class.new(fitted.width, fitted.height, fitted.pixels)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def resize_fill(fill_width, fill_height)
|
|
175
|
+
raw = Pura::Jpeg::Image.new(@width, @height, @pixels)
|
|
176
|
+
filled = raw.resize_fill(fill_width, fill_height)
|
|
177
|
+
self.class.new(filled.width, filled.height, filled.pixels)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def crop(x, y, w, h)
|
|
181
|
+
raw = Pura::Jpeg::Image.new(@width, @height, @pixels)
|
|
182
|
+
cropped = raw.crop(x, y, w, h)
|
|
183
|
+
self.class.new(cropped.width, cropped.height, cropped.pixels)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/pura-image.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pura-jpeg"
|
|
4
|
+
require "pura-png"
|
|
5
|
+
require "pura-bmp"
|
|
6
|
+
require "pura-gif"
|
|
7
|
+
require "pura-tiff"
|
|
8
|
+
require "pura-ico"
|
|
9
|
+
begin
|
|
10
|
+
require "pura-webp"
|
|
11
|
+
rescue LoadError
|
|
12
|
+
# pura-webp is optional
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "pura/image/version"
|
|
16
|
+
require_relative "pura/image/operations"
|
|
17
|
+
require_relative "pura/image/processor"
|
|
18
|
+
|
|
19
|
+
module Pura
|
|
20
|
+
module Image
|
|
21
|
+
class << self
|
|
22
|
+
def load(path)
|
|
23
|
+
Processor.load(path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def save(image, path, **options)
|
|
27
|
+
Processor.save(image, path, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def convert(input_path, output_path, **options)
|
|
31
|
+
Processor.convert(input_path, output_path, **options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def detect_format(data)
|
|
35
|
+
Processor.detect_format(data)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def supported_formats
|
|
39
|
+
formats = %i[jpeg png bmp gif tiff ico]
|
|
40
|
+
formats << :webp if defined?(Pura::Webp)
|
|
41
|
+
formats
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/pura-image.gemspec
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/pura/image/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "pura-image"
|
|
7
|
+
spec.version = Pura::Image::VERSION
|
|
8
|
+
spec.authors = ["komagata"]
|
|
9
|
+
spec.email = ["komagata@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Pure Ruby image processing library"
|
|
12
|
+
spec.description = "Unified image processing library bundling all pura-* format gems " \
|
|
13
|
+
"with image_processing gem compatible API for Rails Active Storage."
|
|
14
|
+
spec.homepage = "https://github.com/komagata/pura-image"
|
|
15
|
+
spec.license = "MIT"
|
|
16
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
17
|
+
|
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
20
|
+
|
|
21
|
+
spec.files = Dir["lib/**/*.rb"] + ["pura-image.gemspec", "Gemfile", "Rakefile", "README.md", "LICENSE"]
|
|
22
|
+
|
|
23
|
+
spec.add_dependency "pura-jpeg", "~> 0.1"
|
|
24
|
+
spec.add_dependency "pura-png", "~> 0.1"
|
|
25
|
+
spec.add_dependency "pura-bmp", "~> 0.1"
|
|
26
|
+
spec.add_dependency "pura-gif", "~> 0.1"
|
|
27
|
+
spec.add_dependency "pura-tiff", "~> 0.1"
|
|
28
|
+
spec.add_dependency "pura-ico", "~> 0.1"
|
|
29
|
+
|
|
30
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
31
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pura-image
|
|
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: pura-jpeg
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: pura-png
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: pura-bmp
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: pura-gif
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.1'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.1'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: pura-tiff
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.1'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.1'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: pura-ico
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.1'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.1'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: minitest
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '5.0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '5.0'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: rake
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '13.0'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '13.0'
|
|
124
|
+
description: Unified image processing library bundling all pura-* format gems with
|
|
125
|
+
image_processing gem compatible API for Rails Active Storage.
|
|
126
|
+
email:
|
|
127
|
+
- komagata@gmail.com
|
|
128
|
+
executables: []
|
|
129
|
+
extensions: []
|
|
130
|
+
extra_rdoc_files: []
|
|
131
|
+
files:
|
|
132
|
+
- Gemfile
|
|
133
|
+
- LICENSE
|
|
134
|
+
- README.md
|
|
135
|
+
- Rakefile
|
|
136
|
+
- lib/image_processing/pura.rb
|
|
137
|
+
- lib/pura-image.rb
|
|
138
|
+
- lib/pura/image/operations.rb
|
|
139
|
+
- lib/pura/image/processor.rb
|
|
140
|
+
- lib/pura/image/version.rb
|
|
141
|
+
- pura-image.gemspec
|
|
142
|
+
homepage: https://github.com/komagata/pura-image
|
|
143
|
+
licenses:
|
|
144
|
+
- MIT
|
|
145
|
+
metadata:
|
|
146
|
+
homepage_uri: https://github.com/komagata/pura-image
|
|
147
|
+
source_code_uri: https://github.com/komagata/pura-image
|
|
148
|
+
rdoc_options: []
|
|
149
|
+
require_paths:
|
|
150
|
+
- lib
|
|
151
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
152
|
+
requirements:
|
|
153
|
+
- - ">="
|
|
154
|
+
- !ruby/object:Gem::Version
|
|
155
|
+
version: 3.0.0
|
|
156
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
157
|
+
requirements:
|
|
158
|
+
- - ">="
|
|
159
|
+
- !ruby/object:Gem::Version
|
|
160
|
+
version: '0'
|
|
161
|
+
requirements: []
|
|
162
|
+
rubygems_version: 3.6.9
|
|
163
|
+
specification_version: 4
|
|
164
|
+
summary: Pure Ruby image processing library
|
|
165
|
+
test_files: []
|