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 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
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "pura-jpeg", path: "/tmp/pura-jpeg"
6
+ gem "pura-png", path: "/tmp/pura-png"
7
+
8
+ gem "rubocop", require: false
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,8 @@
1
+ require "rake/testtask"
2
+
3
+ Rake::TestTask.new(:test) do |t|
4
+ t.libs << "lib"
5
+ t.test_files = FileList["test/test_*.rb"]
6
+ end
7
+
8
+ task default: :test
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pura
4
+ module Image
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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
@@ -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: []