rack_resize 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +1 -1
- data/.ruby-version +1 -1
- data/Gemfile +3 -0
- data/README.md +131 -27
- data/benchmark/http_benchmark.rb +151 -0
- data/benchmark/processor_benchmark.rb +83 -0
- data/config.ru +1 -1
- data/lib/rack_resize/color_utils.rb +200 -0
- data/lib/rack_resize/configuration.rb +1 -1
- data/lib/rack_resize/input_parsers/cloudflare.rb +1 -1
- data/lib/rack_resize/input_parsers/query_string.rb +30 -0
- data/lib/rack_resize/processing.rb +25 -5
- data/lib/rack_resize/processors/imlib2.rb +40 -3
- data/lib/rack_resize/processors/mini_magick.rb +17 -25
- data/lib/rack_resize/processors/sips.rb +55 -23
- data/lib/rack_resize/processors/vips.rb +18 -3
- data/lib/rack_resize/rack_app.rb +10 -7
- data/lib/rack_resize.rb +6 -5
- data/rack_resize.gemspec +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaecfcc4b1f54da6b128ae9fe7939db1676ab6069a0c15bbedaab5545c74a413
|
|
4
|
+
data.tar.gz: f7c136233b7a6bbed40c40fca10399139f30ab3393857851690f05593d2b44b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66afe23035cedbf3b32723375b16c75351e1b9d2c6907fa7989b80452181d102b76c719cea27261587e67331739ece84c68c62682791769d4a2c053c36e7d404
|
|
7
|
+
data.tar.gz: 0b6a7229f59709ca20b55ebeb0efd17cbf9ef8269410a1ae25a6479edb43e90a35294a5930c9d748aba4e5bc8f1bafb313e5a35fc3fd9dd2ce51544ba4a71bef
|
data/.github/workflows/test.yml
CHANGED
|
@@ -19,7 +19,7 @@ jobs:
|
|
|
19
19
|
- name: Install package
|
|
20
20
|
run: |
|
|
21
21
|
sudo apt-get update
|
|
22
|
-
sudo apt-get install -y libvips42 libvips-tools imagemagick libimlib2 libimlib2-dev
|
|
22
|
+
sudo apt-get install -y libvips42 libvips-tools imagemagick libimlib2 libimlib2-dev libheif-plugin-aomenc libavif-dev
|
|
23
23
|
|
|
24
24
|
- uses: ruby/setup-ruby@v1
|
|
25
25
|
with:
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.0.
|
|
1
|
+
4.0.6
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@ end
|
|
|
13
13
|
How to use with Rack
|
|
14
14
|
|
|
15
15
|
```ruby
|
|
16
|
-
use RackResize::RackApp, processor: :
|
|
16
|
+
use RackResize::RackApp, processor: :mini_magick, assets_folders: ["samples"]
|
|
17
17
|
use Rack::Static, urls: [""], root: "samples", index: "index.html" # optional
|
|
18
18
|
```
|
|
19
19
|
|
|
@@ -23,16 +23,77 @@ Cloudflare format: (can be used with helpers from `carrierwave-cloudflare` gem)
|
|
|
23
23
|
```
|
|
24
24
|
/cdn-cgi/image/width=426,format=auto/assets/pets/dog.jpg
|
|
25
25
|
```
|
|
26
|
-
Fastly
|
|
26
|
+
Fastly / bunny.net format (query string params):
|
|
27
27
|
```
|
|
28
|
-
/assets/pets/dog.jpg?width=300
|
|
28
|
+
/assets/pets/dog.jpg?width=300&height=200&fit=cover&format=webp&quality=85
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Supported Parameters:
|
|
32
|
+
|
|
33
|
+
| Parameter | Shortcut | Type | Description |
|
|
34
|
+
|------------|----------|------|-------------|
|
|
35
|
+
| `width` | `w` | integer | Target width in pixels. Scales proportionally if `height` is omitted. |
|
|
36
|
+
| `height` | `h` | integer | Target height in pixels. Scales proportionally if `width` is omitted. |
|
|
37
|
+
| `fit` | — | string | Resize mode: `contain` (default), `cover`, `crop`. See below. |
|
|
38
|
+
| `format` | `f` | string | Output format: `jpeg`, `png`, `webp`, `avif`, `gif`. Use `auto` to keep original. |
|
|
39
|
+
| `quality` | `q` | integer (1–100) | Compression quality. Defaults to `default_quality` config value (95). |
|
|
40
|
+
| `dpr` | — | float (0.1–10) | Device pixel ratio. Multiplies `width` and `height` before processing. |
|
|
41
|
+
| `bg-color` | `bg` | color | Background color for flattening transparency. Also aliased as `background`. See formats below. |
|
|
42
|
+
|
|
43
|
+
**Background color formats:**
|
|
44
|
+
- CSS named color: `white`, `red`, `cornflowerblue` (all 148 CSS colors supported)
|
|
45
|
+
- 3-digit hex: `#a84`
|
|
46
|
+
- 6-digit hex: `#aa8844`
|
|
47
|
+
- 8-digit hex: `#aa884480` (last two digits = alpha 0–255)
|
|
48
|
+
- Decimal RGB: `0,255,0`
|
|
49
|
+
- Decimal RGBA: `0,255,0,0.5` (alpha 0.0–1.0)
|
|
50
|
+
|
|
51
|
+
Supported by: `vips`, `mini_magick`. Accepted but ignored by `sips` and `imlib2`.
|
|
52
|
+
|
|
53
|
+
**Fit modes:**
|
|
54
|
+
- `contain` — resizes to fit within the given box, preserving aspect ratio (default)
|
|
55
|
+
- `cover` / `crop` — resizes and center-crops to fill the exact box
|
|
56
|
+
|
|
57
|
+
**Example URLs (query string format):**
|
|
58
|
+
```
|
|
59
|
+
# Resize to width only
|
|
60
|
+
/samples/image_1.jpeg?w=400
|
|
61
|
+
|
|
62
|
+
# Resize to fit within 400×300 box
|
|
63
|
+
/samples/image_1.jpeg?width=400&height=300
|
|
64
|
+
|
|
65
|
+
# Cover-crop to exact 400×300
|
|
66
|
+
/samples/image_1.jpeg?width=400&height=300&fit=cover
|
|
67
|
+
|
|
68
|
+
# Convert to WebP at 80% quality
|
|
69
|
+
/samples/image_1.jpeg?w=400&f=webp&q=80
|
|
70
|
+
|
|
71
|
+
# Retina (2×) resize
|
|
72
|
+
/samples/image_1.jpeg?w=200&dpr=2
|
|
73
|
+
|
|
74
|
+
# Flatten transparency with a background color
|
|
75
|
+
/samples/image.png?w=400&f=jpeg&bg-color=white
|
|
76
|
+
/samples/image.png?w=400&f=jpeg&bg=%23ff0000
|
|
77
|
+
/samples/image.png?w=400&f=jpeg&background=0,255,0,0.5
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Example URLs (Cloudflare format):**
|
|
81
|
+
```
|
|
82
|
+
# Resize to width
|
|
83
|
+
/cdn-cgi/image/width=400/samples/image_1.jpeg
|
|
84
|
+
|
|
85
|
+
# Cover-crop with format conversion
|
|
86
|
+
/cdn-cgi/image/width=400,height=300,fit=cover,format=webp/samples/image_1.jpeg
|
|
87
|
+
|
|
88
|
+
# Quality + format
|
|
89
|
+
/cdn-cgi/image/width=400,format=avif,quality=80/samples/image_1.jpeg
|
|
29
90
|
```
|
|
30
91
|
|
|
31
92
|
### Configuration:
|
|
32
93
|
|
|
33
94
|
```ruby
|
|
34
95
|
RackResize.configure do |config|
|
|
35
|
-
config.assets_folders = {
|
|
96
|
+
config.assets_folders = { assets: Rails.root.join('app', 'assets', 'images') }
|
|
36
97
|
config.processor = :sips / :vips / :mini_magick / :imlib2
|
|
37
98
|
config.default_quality = 95
|
|
38
99
|
config.save_resized = false
|
|
@@ -45,12 +106,25 @@ end
|
|
|
45
106
|
|
|
46
107
|
<table>
|
|
47
108
|
<tr>
|
|
48
|
-
<th>
|
|
49
|
-
<th>dependency</th>
|
|
109
|
+
<th>processor</th>
|
|
50
110
|
<th>config</th>
|
|
111
|
+
<th>system library</th>
|
|
112
|
+
<th>gems</th>
|
|
113
|
+
</tr>
|
|
114
|
+
<tr>
|
|
115
|
+
<td>sips (macOS only)</td>
|
|
116
|
+
<td><code>processor: :sips</code></td>
|
|
117
|
+
<td>built-in on macOS</td>
|
|
118
|
+
<td>none</td>
|
|
51
119
|
</tr>
|
|
52
120
|
<tr>
|
|
53
121
|
<td>mini_magick</td>
|
|
122
|
+
<td><code>processor: :mini_magick</code></td>
|
|
123
|
+
<td>
|
|
124
|
+
|
|
125
|
+
ImageMagick — `brew install imagemagick`
|
|
126
|
+
|
|
127
|
+
</td>
|
|
54
128
|
<td>
|
|
55
129
|
|
|
56
130
|
```ruby
|
|
@@ -59,14 +133,15 @@ gem "mini_magick"
|
|
|
59
133
|
```
|
|
60
134
|
|
|
61
135
|
</td>
|
|
136
|
+
</tr>
|
|
137
|
+
<tr>
|
|
138
|
+
<td>vips</td>
|
|
139
|
+
<td><code>processor: :vips</code></td>
|
|
62
140
|
<td>
|
|
63
141
|
|
|
64
|
-
`
|
|
142
|
+
libvips — `brew install vips`
|
|
65
143
|
|
|
66
144
|
</td>
|
|
67
|
-
</tr>
|
|
68
|
-
<tr>
|
|
69
|
-
<td>vips</td>
|
|
70
145
|
<td>
|
|
71
146
|
|
|
72
147
|
```ruby
|
|
@@ -74,35 +149,64 @@ gem "image_processing"
|
|
|
74
149
|
gem "ruby-vips"
|
|
75
150
|
```
|
|
76
151
|
|
|
77
|
-
</td>
|
|
78
|
-
<td>
|
|
79
|
-
|
|
80
|
-
`processor: :vips`
|
|
81
|
-
|
|
82
152
|
</td>
|
|
83
153
|
</tr>
|
|
84
154
|
<tr>
|
|
85
|
-
<td>
|
|
86
|
-
<td>
|
|
155
|
+
<td>imlib2</td>
|
|
156
|
+
<td><code>processor: :imlib2</code></td>
|
|
87
157
|
<td>
|
|
88
158
|
|
|
89
|
-
`
|
|
159
|
+
Imlib2 — `brew install imlib2`
|
|
90
160
|
|
|
91
161
|
</td>
|
|
92
|
-
</tr>
|
|
93
|
-
<tr>
|
|
94
|
-
<td>imlib2</td>
|
|
95
162
|
<td>
|
|
96
163
|
|
|
97
164
|
```ruby
|
|
98
|
-
gem
|
|
165
|
+
gem "rszr"
|
|
99
166
|
```
|
|
100
167
|
|
|
101
|
-
</td>
|
|
102
|
-
<td>
|
|
103
|
-
|
|
104
|
-
`processor: :imlib2`
|
|
105
|
-
|
|
106
168
|
</td>
|
|
107
169
|
</tr>
|
|
108
170
|
</table>
|
|
171
|
+
|
|
172
|
+
### Supported Image Formats:
|
|
173
|
+
|
|
174
|
+
| Format | sips | mini_magick | vips | imlib2 |
|
|
175
|
+
|--------|:----:|:-----------:|:----:|:------:|
|
|
176
|
+
| JPEG | ✅ | ✅ | ✅ | ✅ |
|
|
177
|
+
| PNG | ✅ | ✅ | ✅ | ✅ |
|
|
178
|
+
| GIF | ✅ | ✅ | ✅ | ✅ |
|
|
179
|
+
| WebP | ❌ | ✅ ¹ | ✅ ¹ | ❌ |
|
|
180
|
+
| AVIF | ✅ ² | ✅ ³ | ✅ ⁴ | ❌ |
|
|
181
|
+
| HEIC | ✅ | ✅ ³ | ✅ ⁴ | ❌ |
|
|
182
|
+
| SVG | ❌ | ✅ ⁵ | ✅ ⁶ | ❌ |
|
|
183
|
+
|
|
184
|
+
¹ Requires ImageMagick / libvips built with **libwebp** support (`brew install webp`)
|
|
185
|
+
² macOS 13 (Ventura) or later
|
|
186
|
+
³ Requires ImageMagick built with **libheif** support (`brew install libheif`)
|
|
187
|
+
⁴ Requires libvips built with **libheif** support (`brew install libheif`)
|
|
188
|
+
⁵ Requires **Inkscape** or **librsvg** (`brew install librsvg`)
|
|
189
|
+
⁶ Requires libvips built with **librsvg** support (`brew install librsvg`)
|
|
190
|
+
|
|
191
|
+
### Performance Benchmarks:
|
|
192
|
+
|
|
193
|
+
Measured on an Apple M1 Pro with Ruby 4.0.5. Output size: 300×200 px.
|
|
194
|
+
Run with `ruby benchmark/processor_benchmark.rb`
|
|
195
|
+
|
|
196
|
+
**JPEG — image_1.jpeg (51 KB)**
|
|
197
|
+
|
|
198
|
+
| Processor | i/s | ms/i | vs fastest |
|
|
199
|
+
|-------------|------:|------:|:----------:|
|
|
200
|
+
| imlib2 | 613.0 | 1.63 | — |
|
|
201
|
+
| vips | 216.9 | 4.61 | 2.83× |
|
|
202
|
+
| mini_magick | 36.4 | 27.49 | 16.85× |
|
|
203
|
+
| sips | 20.1 | 49.68 | 30.45× |
|
|
204
|
+
|
|
205
|
+
**PNG — sample.png (2 KB)**
|
|
206
|
+
|
|
207
|
+
| Processor | i/s | ms/i | vs fastest |
|
|
208
|
+
|-------------|------:|-----:|:----------:|
|
|
209
|
+
| vips | 431.1 | 2.32 | — |
|
|
210
|
+
| imlib2 | 221.7 | 4.51 | 1.94× |
|
|
211
|
+
| mini_magick | 36.9 | 27.12| 11.69× |
|
|
212
|
+
| sips | 19.4 | 51.62| 22.25× |
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# HTTP benchmark: starts a WEBrick server in a background thread, mounts one
|
|
4
|
+
# RackResize::RackApp per available processor at a distinct path prefix, then
|
|
5
|
+
# measures throughput with benchmark-ips over persistent HTTP connections.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# bundle exec ruby benchmark/http_benchmark.rb
|
|
9
|
+
|
|
10
|
+
require 'bundler/setup'
|
|
11
|
+
require 'rack'
|
|
12
|
+
require 'webrick'
|
|
13
|
+
require 'net/http'
|
|
14
|
+
require 'benchmark/ips'
|
|
15
|
+
require 'logger'
|
|
16
|
+
require 'stringio'
|
|
17
|
+
require 'timeout'
|
|
18
|
+
require_relative '../lib/rack_resize'
|
|
19
|
+
|
|
20
|
+
SAMPLES_DIR = File.expand_path('../samples', __dir__)
|
|
21
|
+
BENCH_PORT = 14_569
|
|
22
|
+
BENCH_IMAGE = 'image_1.jpeg'
|
|
23
|
+
BENCH_PARAMS = 'width=300&height=200'
|
|
24
|
+
NULL_LOGGER = Logger.new(IO::NULL)
|
|
25
|
+
|
|
26
|
+
# ── One RackApp per available processor ──────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
available_apps = {}
|
|
29
|
+
|
|
30
|
+
puts "Loading processors..."
|
|
31
|
+
%i[sips vips mini_magick imlib2].each do |name|
|
|
32
|
+
begin
|
|
33
|
+
app = RackResize::RackApp.new(
|
|
34
|
+
processor: name,
|
|
35
|
+
assets_folders: { '/' => SAMPLES_DIR },
|
|
36
|
+
save_resized: false,
|
|
37
|
+
logger: NULL_LOGGER,
|
|
38
|
+
)
|
|
39
|
+
available_apps[name] = app
|
|
40
|
+
puts " ✓ #{name}"
|
|
41
|
+
rescue => e
|
|
42
|
+
puts " ✗ #{name}: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
abort "\nNo processors available." if available_apps.empty?
|
|
47
|
+
|
|
48
|
+
# ── URLMap: /sips → sips_app, /vips → vips_app, etc. ────────────────────────
|
|
49
|
+
|
|
50
|
+
rack_app = Rack::Builder.new do
|
|
51
|
+
available_apps.each do |name, app|
|
|
52
|
+
map("/#{name}") { run app }
|
|
53
|
+
end
|
|
54
|
+
end.to_app
|
|
55
|
+
|
|
56
|
+
# ── WEBrick with a minimal Rack env bridge ───────────────────────────────────
|
|
57
|
+
|
|
58
|
+
webrick = WEBrick::HTTPServer.new(
|
|
59
|
+
Port: BENCH_PORT,
|
|
60
|
+
Logger: WEBrick::Log.new(IO::NULL),
|
|
61
|
+
AccessLog: [],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
webrick.mount_proc('/') do |req, res|
|
|
65
|
+
env = {
|
|
66
|
+
'REQUEST_METHOD' => req.request_method,
|
|
67
|
+
'PATH_INFO' => req.path,
|
|
68
|
+
'QUERY_STRING' => req.query_string.to_s,
|
|
69
|
+
'SERVER_NAME' => 'localhost',
|
|
70
|
+
'SERVER_PORT' => BENCH_PORT.to_s,
|
|
71
|
+
'HTTP_HOST' => "localhost:#{BENCH_PORT}",
|
|
72
|
+
'SCRIPT_NAME' => '',
|
|
73
|
+
'rack.version' => Rack::VERSION,
|
|
74
|
+
'rack.input' => StringIO.new(''),
|
|
75
|
+
'rack.errors' => $stderr,
|
|
76
|
+
'rack.multithread' => true,
|
|
77
|
+
'rack.multiprocess' => false,
|
|
78
|
+
'rack.run_once' => false,
|
|
79
|
+
'rack.url_scheme' => 'http',
|
|
80
|
+
}
|
|
81
|
+
req.header.each do |key, values|
|
|
82
|
+
env["HTTP_#{key.upcase.tr('-', '_')}"] = values.join(', ')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
status, headers, body = rack_app.call(env)
|
|
86
|
+
|
|
87
|
+
res.status = status
|
|
88
|
+
headers.each { |k, v| res[k] = v }
|
|
89
|
+
body_str = +''
|
|
90
|
+
body.each { |chunk| body_str << chunk }
|
|
91
|
+
res.body = body_str
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
server_thread = Thread.new { webrick.start }
|
|
95
|
+
|
|
96
|
+
Timeout.timeout(10) do
|
|
97
|
+
loop do
|
|
98
|
+
TCPSocket.new('localhost', BENCH_PORT).close
|
|
99
|
+
break
|
|
100
|
+
rescue Errno::ECONNREFUSED
|
|
101
|
+
sleep 0.05
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
puts "\nServer listening on port #{BENCH_PORT}"
|
|
106
|
+
|
|
107
|
+
# ── Smoke-test each endpoint before benchmarking ─────────────────────────────
|
|
108
|
+
|
|
109
|
+
puts "\nVerifying endpoints..."
|
|
110
|
+
available_apps.keys.each do |name|
|
|
111
|
+
uri = URI("http://localhost:#{BENCH_PORT}/#{name}/#{BENCH_IMAGE}?#{BENCH_PARAMS}")
|
|
112
|
+
resp = Net::HTTP.get_response(uri)
|
|
113
|
+
if resp.is_a?(Net::HTTPOK)
|
|
114
|
+
puts " ✓ /#{name}/#{BENCH_IMAGE} → 200 (#{resp.body.bytesize} B)"
|
|
115
|
+
else
|
|
116
|
+
warn " ✗ /#{name}/#{BENCH_IMAGE} → #{resp.code}: #{resp.body}"
|
|
117
|
+
available_apps.delete(name)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
abort "\nNo processors passed smoke test." if available_apps.empty?
|
|
122
|
+
|
|
123
|
+
# ── Persistent HTTP connections, one per processor ───────────────────────────
|
|
124
|
+
|
|
125
|
+
connections = available_apps.keys.to_h do |name|
|
|
126
|
+
http = Net::HTTP.new('localhost', BENCH_PORT)
|
|
127
|
+
http.start
|
|
128
|
+
[name, http]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
puts "\n── HTTP rack server benchmark ───────────────────────────────────────────────"
|
|
132
|
+
puts " image : #{BENCH_IMAGE}"
|
|
133
|
+
puts " params: #{BENCH_PARAMS}"
|
|
134
|
+
puts
|
|
135
|
+
|
|
136
|
+
Benchmark.ips do |x|
|
|
137
|
+
x.config(time: 10, warmup: 3)
|
|
138
|
+
|
|
139
|
+
connections.each do |name, http|
|
|
140
|
+
path = "/#{name}/#{BENCH_IMAGE}?#{BENCH_PARAMS}"
|
|
141
|
+
x.report(name.to_s) { http.get(path) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
x.compare!
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
connections.each_value(&:finish)
|
|
150
|
+
webrick.shutdown
|
|
151
|
+
server_thread.join
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Direct processor benchmark: instantiates each available RackResize processor
|
|
4
|
+
# and calls resize() directly on images from the samples/ folder.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# bundle exec ruby benchmark/processor_benchmark.rb
|
|
8
|
+
|
|
9
|
+
require 'bundler/setup'
|
|
10
|
+
require 'benchmark/ips'
|
|
11
|
+
require_relative '../lib/rack_resize'
|
|
12
|
+
|
|
13
|
+
SAMPLES_DIR = File.expand_path('../samples', __dir__)
|
|
14
|
+
BENCH_IMAGES = Dir[File.join(SAMPLES_DIR, '{image_1,sample}.{jpg,jpeg,png,webp,heic,avif}')].sort
|
|
15
|
+
BENCH_WIDTH = 300
|
|
16
|
+
BENCH_HEIGHT = 200
|
|
17
|
+
|
|
18
|
+
abort 'No sample images found in samples/.' if BENCH_IMAGES.empty?
|
|
19
|
+
|
|
20
|
+
# ── Collect available processor instances ────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
PROCESSOR_CLASSES = {
|
|
23
|
+
sips: RackResize::Processors::Sips,
|
|
24
|
+
vips: RackResize::Processors::Vips,
|
|
25
|
+
mini_magick: RackResize::Processors::MiniMagick,
|
|
26
|
+
imlib2: RackResize::Processors::Imlib2,
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
puts "Loading processors..."
|
|
30
|
+
processors = {}
|
|
31
|
+
|
|
32
|
+
PROCESSOR_CLASSES.each do |name, klass|
|
|
33
|
+
begin
|
|
34
|
+
instance = klass.new
|
|
35
|
+
# Verify the processor actually works before including it
|
|
36
|
+
instance.resize(
|
|
37
|
+
source_file: BENCH_IMAGES.first,
|
|
38
|
+
target_file: nil,
|
|
39
|
+
target_width: BENCH_WIDTH,
|
|
40
|
+
target_height: BENCH_HEIGHT,
|
|
41
|
+
)
|
|
42
|
+
processors[name] = instance
|
|
43
|
+
puts " ✓ #{name}"
|
|
44
|
+
rescue => e
|
|
45
|
+
puts " ✗ #{name}: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
abort "\nNo processors available." if processors.empty?
|
|
50
|
+
|
|
51
|
+
puts "\nSample images: #{BENCH_IMAGES.map { |f| File.basename(f) }.join(', ')}"
|
|
52
|
+
|
|
53
|
+
# ── Benchmark each sample image independently ─────────────────────────────────
|
|
54
|
+
|
|
55
|
+
BENCH_IMAGES.each do |image_path|
|
|
56
|
+
image_name = File.basename(image_path)
|
|
57
|
+
image_size = File.size(image_path)
|
|
58
|
+
|
|
59
|
+
puts "\n── Direct processor benchmark ───────────────────────────────────────────────"
|
|
60
|
+
puts " image : #{image_name} (#{image_size / 1024} KB)"
|
|
61
|
+
puts " output: #{BENCH_WIDTH}x#{BENCH_HEIGHT}"
|
|
62
|
+
puts
|
|
63
|
+
|
|
64
|
+
Benchmark.ips do |x|
|
|
65
|
+
x.config(time: 5, warmup: 2)
|
|
66
|
+
|
|
67
|
+
processors.each do |name, processor|
|
|
68
|
+
next if name.to_s == "imlib2" && image_name =~ /heic|webp|avif/
|
|
69
|
+
next if name.to_s == "sips" && image_name =~ /webp/
|
|
70
|
+
|
|
71
|
+
x.report(name.to_s) do
|
|
72
|
+
processor.resize(
|
|
73
|
+
source_file: image_path,
|
|
74
|
+
target_file: nil,
|
|
75
|
+
target_width: BENCH_WIDTH,
|
|
76
|
+
target_height: BENCH_HEIGHT,
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
x.compare!
|
|
82
|
+
end
|
|
83
|
+
end
|
data/config.ru
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
require_relative "lib/rack_resize"
|
|
8
8
|
require "rack/static"
|
|
9
9
|
|
|
10
|
-
use RackResize::RackApp, processor: :
|
|
10
|
+
use RackResize::RackApp, processor: :vips, assets_folders: ["samples"]
|
|
11
11
|
|
|
12
12
|
use Rack::Static, urls: [""], root: "samples", index: "index.html"
|
|
13
13
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
module RackResize::ColorUtils
|
|
2
|
+
extend self
|
|
3
|
+
|
|
4
|
+
CSS_COLORS = {
|
|
5
|
+
'aliceblue' => [240, 248, 255],
|
|
6
|
+
'antiquewhite' => [250, 235, 215],
|
|
7
|
+
'aqua' => [0, 255, 255],
|
|
8
|
+
'aquamarine' => [127, 255, 212],
|
|
9
|
+
'azure' => [240, 255, 255],
|
|
10
|
+
'beige' => [245, 245, 220],
|
|
11
|
+
'bisque' => [255, 228, 196],
|
|
12
|
+
'black' => [0, 0, 0],
|
|
13
|
+
'blanchedalmond' => [255, 235, 205],
|
|
14
|
+
'blue' => [0, 0, 255],
|
|
15
|
+
'blueviolet' => [138, 43, 226],
|
|
16
|
+
'brown' => [165, 42, 42],
|
|
17
|
+
'burlywood' => [222, 184, 135],
|
|
18
|
+
'cadetblue' => [95, 158, 160],
|
|
19
|
+
'chartreuse' => [127, 255, 0],
|
|
20
|
+
'chocolate' => [210, 105, 30],
|
|
21
|
+
'coral' => [255, 127, 80],
|
|
22
|
+
'cornflowerblue' => [100, 149, 237],
|
|
23
|
+
'cornsilk' => [255, 248, 220],
|
|
24
|
+
'crimson' => [220, 20, 60],
|
|
25
|
+
'cyan' => [0, 255, 255],
|
|
26
|
+
'darkblue' => [0, 0, 139],
|
|
27
|
+
'darkcyan' => [0, 139, 139],
|
|
28
|
+
'darkgoldenrod' => [184, 134, 11],
|
|
29
|
+
'darkgray' => [169, 169, 169],
|
|
30
|
+
'darkgreen' => [0, 100, 0],
|
|
31
|
+
'darkgrey' => [169, 169, 169],
|
|
32
|
+
'darkkhaki' => [189, 183, 107],
|
|
33
|
+
'darkmagenta' => [139, 0, 139],
|
|
34
|
+
'darkolivegreen' => [85, 107, 47],
|
|
35
|
+
'darkorange' => [255, 140, 0],
|
|
36
|
+
'darkorchid' => [153, 50, 204],
|
|
37
|
+
'darkred' => [139, 0, 0],
|
|
38
|
+
'darksalmon' => [233, 150, 122],
|
|
39
|
+
'darkseagreen' => [143, 188, 143],
|
|
40
|
+
'darkslateblue' => [72, 61, 139],
|
|
41
|
+
'darkslategray' => [47, 79, 79],
|
|
42
|
+
'darkslategrey' => [47, 79, 79],
|
|
43
|
+
'darkturquoise' => [0, 206, 209],
|
|
44
|
+
'darkviolet' => [148, 0, 211],
|
|
45
|
+
'deeppink' => [255, 20, 147],
|
|
46
|
+
'deepskyblue' => [0, 191, 255],
|
|
47
|
+
'dimgray' => [105, 105, 105],
|
|
48
|
+
'dimgrey' => [105, 105, 105],
|
|
49
|
+
'dodgerblue' => [30, 144, 255],
|
|
50
|
+
'firebrick' => [178, 34, 34],
|
|
51
|
+
'floralwhite' => [255, 250, 240],
|
|
52
|
+
'forestgreen' => [34, 139, 34],
|
|
53
|
+
'fuchsia' => [255, 0, 255],
|
|
54
|
+
'gainsboro' => [220, 220, 220],
|
|
55
|
+
'ghostwhite' => [248, 248, 255],
|
|
56
|
+
'gold' => [255, 215, 0],
|
|
57
|
+
'goldenrod' => [218, 165, 32],
|
|
58
|
+
'gray' => [128, 128, 128],
|
|
59
|
+
'green' => [0, 128, 0],
|
|
60
|
+
'greenyellow' => [173, 255, 47],
|
|
61
|
+
'grey' => [128, 128, 128],
|
|
62
|
+
'honeydew' => [240, 255, 240],
|
|
63
|
+
'hotpink' => [255, 105, 180],
|
|
64
|
+
'indianred' => [205, 92, 92],
|
|
65
|
+
'indigo' => [75, 0, 130],
|
|
66
|
+
'ivory' => [255, 255, 240],
|
|
67
|
+
'khaki' => [240, 230, 140],
|
|
68
|
+
'lavender' => [230, 230, 250],
|
|
69
|
+
'lavenderblush' => [255, 240, 245],
|
|
70
|
+
'lawngreen' => [124, 252, 0],
|
|
71
|
+
'lemonchiffon' => [255, 250, 205],
|
|
72
|
+
'lightblue' => [173, 216, 230],
|
|
73
|
+
'lightcoral' => [240, 128, 128],
|
|
74
|
+
'lightcyan' => [224, 255, 255],
|
|
75
|
+
'lightgoldenrodyellow' => [250, 250, 210],
|
|
76
|
+
'lightgray' => [211, 211, 211],
|
|
77
|
+
'lightgreen' => [144, 238, 144],
|
|
78
|
+
'lightgrey' => [211, 211, 211],
|
|
79
|
+
'lightpink' => [255, 182, 193],
|
|
80
|
+
'lightsalmon' => [255, 160, 122],
|
|
81
|
+
'lightseagreen' => [32, 178, 170],
|
|
82
|
+
'lightskyblue' => [135, 206, 250],
|
|
83
|
+
'lightslategray' => [119, 136, 153],
|
|
84
|
+
'lightslategrey' => [119, 136, 153],
|
|
85
|
+
'lightsteelblue' => [176, 196, 222],
|
|
86
|
+
'lightyellow' => [255, 255, 224],
|
|
87
|
+
'lime' => [0, 255, 0],
|
|
88
|
+
'limegreen' => [50, 205, 50],
|
|
89
|
+
'linen' => [250, 240, 230],
|
|
90
|
+
'magenta' => [255, 0, 255],
|
|
91
|
+
'maroon' => [128, 0, 0],
|
|
92
|
+
'mediumaquamarine' => [102, 205, 170],
|
|
93
|
+
'mediumblue' => [0, 0, 205],
|
|
94
|
+
'mediumorchid' => [186, 85, 211],
|
|
95
|
+
'mediumpurple' => [147, 112, 219],
|
|
96
|
+
'mediumseagreen' => [60, 179, 113],
|
|
97
|
+
'mediumslateblue' => [123, 104, 238],
|
|
98
|
+
'mediumspringgreen' => [0, 250, 154],
|
|
99
|
+
'mediumturquoise' => [72, 209, 204],
|
|
100
|
+
'mediumvioletred' => [199, 21, 133],
|
|
101
|
+
'midnightblue' => [25, 25, 112],
|
|
102
|
+
'mintcream' => [245, 255, 250],
|
|
103
|
+
'mistyrose' => [255, 228, 225],
|
|
104
|
+
'moccasin' => [255, 228, 181],
|
|
105
|
+
'navajowhite' => [255, 222, 173],
|
|
106
|
+
'navy' => [0, 0, 128],
|
|
107
|
+
'oldlace' => [253, 245, 230],
|
|
108
|
+
'olive' => [128, 128, 0],
|
|
109
|
+
'olivedrab' => [107, 142, 35],
|
|
110
|
+
'orange' => [255, 165, 0],
|
|
111
|
+
'orangered' => [255, 69, 0],
|
|
112
|
+
'orchid' => [218, 112, 214],
|
|
113
|
+
'palegoldenrod' => [238, 232, 170],
|
|
114
|
+
'palegreen' => [152, 251, 152],
|
|
115
|
+
'paleturquoise' => [175, 238, 238],
|
|
116
|
+
'palevioletred' => [219, 112, 147],
|
|
117
|
+
'papayawhip' => [255, 239, 213],
|
|
118
|
+
'peachpuff' => [255, 218, 185],
|
|
119
|
+
'peru' => [205, 133, 63],
|
|
120
|
+
'pink' => [255, 192, 203],
|
|
121
|
+
'plum' => [221, 160, 221],
|
|
122
|
+
'powderblue' => [176, 224, 230],
|
|
123
|
+
'purple' => [128, 0, 128],
|
|
124
|
+
'rebeccapurple' => [102, 51, 153],
|
|
125
|
+
'red' => [255, 0, 0],
|
|
126
|
+
'rosybrown' => [188, 143, 143],
|
|
127
|
+
'royalblue' => [65, 105, 225],
|
|
128
|
+
'saddlebrown' => [139, 69, 19],
|
|
129
|
+
'salmon' => [250, 128, 114],
|
|
130
|
+
'sandybrown' => [244, 164, 96],
|
|
131
|
+
'seagreen' => [46, 139, 87],
|
|
132
|
+
'seashell' => [255, 245, 238],
|
|
133
|
+
'sienna' => [160, 82, 45],
|
|
134
|
+
'silver' => [192, 192, 192],
|
|
135
|
+
'skyblue' => [135, 206, 235],
|
|
136
|
+
'slateblue' => [106, 90, 205],
|
|
137
|
+
'slategray' => [112, 128, 144],
|
|
138
|
+
'slategrey' => [112, 128, 144],
|
|
139
|
+
'snow' => [255, 250, 250],
|
|
140
|
+
'springgreen' => [0, 255, 127],
|
|
141
|
+
'steelblue' => [70, 130, 180],
|
|
142
|
+
'tan' => [210, 180, 140],
|
|
143
|
+
'teal' => [0, 128, 128],
|
|
144
|
+
'thistle' => [216, 191, 216],
|
|
145
|
+
'tomato' => [255, 99, 71],
|
|
146
|
+
'turquoise' => [64, 224, 208],
|
|
147
|
+
'violet' => [238, 130, 238],
|
|
148
|
+
'wheat' => [245, 222, 179],
|
|
149
|
+
'white' => [255, 255, 255],
|
|
150
|
+
'whitesmoke' => [248, 248, 248],
|
|
151
|
+
'yellow' => [255, 255, 0],
|
|
152
|
+
'yellowgreen' => [154, 205, 50],
|
|
153
|
+
}.freeze
|
|
154
|
+
|
|
155
|
+
# Returns [r, g, b, a] where r/g/b are 0-255 integers and a is a 0.0-1.0 float.
|
|
156
|
+
# Returns nil if the value cannot be parsed.
|
|
157
|
+
# Supported formats:
|
|
158
|
+
# CSS named color: "red", "white", "cornflowerblue"
|
|
159
|
+
# 3-digit hex: "#a84"
|
|
160
|
+
# 6-digit hex: "#aa8844"
|
|
161
|
+
# 8-digit hex: "#aa884480" (last two digits = alpha 0-255)
|
|
162
|
+
# decimal RGB: "0,255,0"
|
|
163
|
+
# decimal RGBA: "0,255,0,0.5" (alpha 0.0-1.0)
|
|
164
|
+
def parse_color(value)
|
|
165
|
+
return nil if value.nil?
|
|
166
|
+
v = value.to_s.strip.downcase
|
|
167
|
+
return nil if v.empty?
|
|
168
|
+
|
|
169
|
+
if (m = v.match(/\A#([0-9a-f])([0-9a-f])([0-9a-f])\z/))
|
|
170
|
+
return [m[1].hex * 17, m[2].hex * 17, m[3].hex * 17, 1.0]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if (m = v.match(/\A#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})\z/))
|
|
174
|
+
return [m[1].to_i(16), m[2].to_i(16), m[3].to_i(16), 1.0]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if (m = v.match(/\A#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})\z/))
|
|
178
|
+
return [m[1].to_i(16), m[2].to_i(16), m[3].to_i(16), m[4].to_i(16) / 255.0]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if CSS_COLORS.key?(v)
|
|
182
|
+
return CSS_COLORS[v] + [1.0]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if (m = v.match(/\A(\d+),(\d+),(\d+)(?:,(\d*\.?\d+))?\z/))
|
|
186
|
+
r = m[1].to_i.clamp(0, 255)
|
|
187
|
+
g = m[2].to_i.clamp(0, 255)
|
|
188
|
+
b = m[3].to_i.clamp(0, 255)
|
|
189
|
+
a = m[4] ? m[4].to_f.clamp(0.0, 1.0) : 1.0
|
|
190
|
+
return [r, g, b, a]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def to_hex(bg_color)
|
|
197
|
+
r, g, b, _a = bg_color
|
|
198
|
+
"#%02x%02x%02x" % [r, g, b]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -64,7 +64,7 @@ class RackResize::Configuration
|
|
|
64
64
|
when :sips then RackResize::Processors::Sips.new
|
|
65
65
|
when :imlib2 then RackResize::Processors::Imlib2.new
|
|
66
66
|
else
|
|
67
|
-
raise "RackResize -
|
|
67
|
+
raise "RackResize - Unknown image processor #{@processor.inspect}"
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -9,7 +9,7 @@ module RackResize::InputParsers::Cloudflare
|
|
|
9
9
|
return { route_matched: false, req_params: nil, asset_path: nil }
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
fullpath = fullpath.delete_prefix(
|
|
12
|
+
fullpath = fullpath.delete_prefix(cf_path_prefix).delete_prefix("/")
|
|
13
13
|
file_path_match = fullpath.match(%r{(?<params>[^\/]+)(?<file>\/.+?)(-[\da-f]{8})?(?<ext>\.\w{2,})$})
|
|
14
14
|
|
|
15
15
|
return { route_matched: true, req_params: nil, asset_path: nil } unless file_path_match
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RackResize::InputParsers::QueryString
|
|
2
|
+
extend self
|
|
3
|
+
|
|
4
|
+
# Supports Fastly and bunny.net style query params:
|
|
5
|
+
# /assets/photo.jpg?width=200&height=100&quality=85&fit=cover
|
|
6
|
+
# /assets/photo.jpg?w=200&h=100&dpr=2&format=webp
|
|
7
|
+
# /assets/photo.jpg?w=200&f=webp&q=80&fit=contain
|
|
8
|
+
#
|
|
9
|
+
# Shortcuts: f=format, q=quality
|
|
10
|
+
|
|
11
|
+
CANONICAL_PARAMS = %w[width height quality dpr format fit bg-color background].freeze
|
|
12
|
+
PARAM_ALIASES = { 'f' => :format, 'q' => :quality, 'h' => :height, 'w' => :width, 'bg' => :'bg-color' }.freeze
|
|
13
|
+
RESIZE_PARAMS = (CANONICAL_PARAMS + PARAM_ALIASES.keys).freeze
|
|
14
|
+
|
|
15
|
+
def parse_input(fullpath, query_string)
|
|
16
|
+
params = Rack::Utils.parse_query(query_string.to_s)
|
|
17
|
+
|
|
18
|
+
unless params.keys.any? { |k| RESIZE_PARAMS.include?(k) }
|
|
19
|
+
return { route_matched: false, req_params: nil, asset_path: nil }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Aliases are applied first; canonical names overwrite them if both are present.
|
|
23
|
+
aliased = params.slice(*PARAM_ALIASES.keys).transform_keys { |k| PARAM_ALIASES[k] }
|
|
24
|
+
canonical = params.slice(*CANONICAL_PARAMS).transform_keys(&:to_sym)
|
|
25
|
+
req_params = aliased.merge(canonical)
|
|
26
|
+
asset_path = fullpath.sub(/-[\da-f]{8}(?=\.\w{2,}$)/, '')
|
|
27
|
+
|
|
28
|
+
{ route_matched: true, req_params:, asset_path: }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'digest'
|
|
2
|
+
require 'fileutils'
|
|
2
3
|
|
|
3
4
|
class RackResize::Processing
|
|
4
5
|
attr_reader :config
|
|
@@ -9,7 +10,9 @@ class RackResize::Processing
|
|
|
9
10
|
|
|
10
11
|
def process!(source_file:, req_params:)
|
|
11
12
|
if config.save_resized?
|
|
12
|
-
|
|
13
|
+
cache_key = Digest::MD5.hexdigest("#{source_file}:#{req_params.to_a.sort.to_s}")
|
|
14
|
+
output_ext = output_extension(source_file, req_params[:format])
|
|
15
|
+
tmp_file_name = config.cache_folder.join(cache_key + output_ext)
|
|
13
16
|
FileUtils.mkdir_p(tmp_file_name.dirname)
|
|
14
17
|
|
|
15
18
|
unless tmp_file_name.exist?
|
|
@@ -17,7 +20,12 @@ class RackResize::Processing
|
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
logger.info("Serving cached file #{tmp_file_name}")
|
|
20
|
-
|
|
23
|
+
begin
|
|
24
|
+
return StringIO.new(File.open(tmp_file_name.to_s, "rb", &:read))
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
process_file(req_params:, source_file:, target_file: tmp_file_name.to_s)
|
|
27
|
+
return StringIO.new(File.open(tmp_file_name.to_s, "rb", &:read))
|
|
28
|
+
end
|
|
21
29
|
else
|
|
22
30
|
file_content = process_file(req_params:, source_file:, target_file: nil)
|
|
23
31
|
return StringIO.new(file_content)
|
|
@@ -26,15 +34,20 @@ class RackResize::Processing
|
|
|
26
34
|
|
|
27
35
|
def process_file(source_file:, req_params:, target_file: nil)
|
|
28
36
|
dpr = req_params[:dpr]&.to_f || 1.0
|
|
29
|
-
dpr =
|
|
37
|
+
dpr = dpr.clamp(0.1, 10.0)
|
|
30
38
|
|
|
31
39
|
max = config.max_dimension.to_f
|
|
32
40
|
target_width = (req_params[:w]&.to_i || req_params[:width]&.to_i)&.*(dpr)&.clamp(1, max)
|
|
33
41
|
target_height = (req_params[:h]&.to_i || req_params[:height]&.to_i)&.*(dpr)&.clamp(1, max)
|
|
34
42
|
|
|
43
|
+
fit = req_params[:fit]
|
|
44
|
+
format = req_params[:format].then { |f| f == 'auto' ? nil : f }
|
|
45
|
+
quality = req_params[:quality]&.to_i&.clamp(1, 100)
|
|
46
|
+
bg_color = RackResize::ColorUtils.parse_color(req_params[:"bg-color"] || req_params[:background])
|
|
47
|
+
|
|
35
48
|
start_time = Time.now
|
|
36
49
|
begin
|
|
37
|
-
return config.processor_instance.resize(source_file:, target_file:, target_width:, target_height:)
|
|
50
|
+
return config.processor_instance.resize(source_file:, target_file:, target_width:, target_height:, fit:, format:, quality:, bg_color:)
|
|
38
51
|
ensure
|
|
39
52
|
processing_time = (Time.now.to_f - start_time.to_f).round(3)
|
|
40
53
|
logger.info("RESIZE IMAGE #{config.processor} #{source_file} - #{req_params} - #{processing_time}s")
|
|
@@ -44,6 +57,13 @@ class RackResize::Processing
|
|
|
44
57
|
def logger
|
|
45
58
|
config.logger ||
|
|
46
59
|
(defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
|
|
47
|
-
(require
|
|
60
|
+
(@_logger ||= (require 'logger'; Logger.new($stdout)))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def output_extension(source_file, format)
|
|
66
|
+
return File.extname(source_file) if format.nil?
|
|
67
|
+
".#{format.to_s.downcase}"
|
|
48
68
|
end
|
|
49
69
|
end
|
|
@@ -6,14 +6,51 @@ end
|
|
|
6
6
|
|
|
7
7
|
class RackResize::Processors::Imlib2
|
|
8
8
|
|
|
9
|
-
def resize(source_file:, target_file:, target_width:, target_height:)
|
|
9
|
+
def resize(source_file:, target_file:, target_width:, target_height:, fit: nil, format: nil, quality: nil, bg_color: nil)
|
|
10
|
+
quality ||= RackResize.config.default_quality
|
|
11
|
+
cover = (fit == 'cover' || fit == 'crop') && target_width && target_height
|
|
12
|
+
|
|
10
13
|
image = Rszr::Image.load(source_file)
|
|
11
|
-
|
|
14
|
+
|
|
15
|
+
if target_width || target_height
|
|
16
|
+
if cover
|
|
17
|
+
target_w = target_width.to_i
|
|
18
|
+
target_h = target_height.to_i
|
|
19
|
+
src_w = image.width
|
|
20
|
+
src_h = image.height
|
|
21
|
+
|
|
22
|
+
if target_w.to_f / src_w >= target_h.to_f / src_h
|
|
23
|
+
image.resize!(target_w, :auto)
|
|
24
|
+
crop_y = [(image.height - target_h) / 2, 0].max
|
|
25
|
+
image.crop!(0, crop_y, target_w, target_h)
|
|
26
|
+
else
|
|
27
|
+
image.resize!(:auto, target_h)
|
|
28
|
+
crop_x = [(image.width - target_w) / 2, 0].max
|
|
29
|
+
image.crop!(crop_x, 0, target_w, target_h)
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
image.resize!(target_width || :auto, target_height || :auto)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
out_format = rszr_format(format || source_file.to_s)
|
|
12
37
|
|
|
13
38
|
if target_file
|
|
14
39
|
image.save(target_file)
|
|
40
|
+
nil
|
|
15
41
|
else
|
|
16
|
-
image.save_data(format:
|
|
42
|
+
image.save_data(format: out_format, quality: quality)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def rszr_format(format_or_path)
|
|
49
|
+
case format_or_path.to_s.downcase
|
|
50
|
+
when /\.png$/, 'png' then :png
|
|
51
|
+
when /\.webp$/, 'webp' then :webp
|
|
52
|
+
when /\.gif$/, 'gif' then :gif
|
|
53
|
+
else :jpeg
|
|
17
54
|
end
|
|
18
55
|
end
|
|
19
56
|
end
|
|
@@ -4,18 +4,29 @@ rescue LoadError
|
|
|
4
4
|
raise LoadError, "RackResize::Processors::MiniMagick requires the image_processing gem. Please add `gem \"image_processing\"` to your Gemfile."
|
|
5
5
|
end
|
|
6
6
|
|
|
7
|
-
# require 'mini_magick'
|
|
8
|
-
# MiniMagick.logger.level = :debug
|
|
9
|
-
|
|
10
7
|
class RackResize::Processors::MiniMagick
|
|
11
8
|
|
|
12
|
-
def resize(source_file:, target_width:, target_height:, target_file: nil)
|
|
9
|
+
def resize(source_file:, target_width:, target_height:, target_file: nil, fit: nil, format: nil, quality: nil, bg_color: nil)
|
|
10
|
+
quality ||= RackResize.config.default_quality
|
|
11
|
+
cover = (fit == 'cover' || fit == 'crop') && target_width && target_height
|
|
12
|
+
|
|
13
13
|
image = ImageProcessing::MiniMagick.source(source_file)
|
|
14
|
-
image = image.
|
|
15
|
-
|
|
14
|
+
image = image.convert(format) if format
|
|
15
|
+
|
|
16
|
+
if target_width || target_height
|
|
17
|
+
image = cover ? image.resize_to_fill(target_width, target_height)
|
|
18
|
+
: image.resize_to_limit(target_width, target_height)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if bg_color
|
|
22
|
+
image = image.background(RackResize::ColorUtils.to_hex(bg_color)).flatten
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
image = image.saver(quality: quality)
|
|
16
26
|
|
|
17
27
|
if target_file
|
|
18
28
|
image.call(destination: target_file)
|
|
29
|
+
nil
|
|
19
30
|
else
|
|
20
31
|
begin
|
|
21
32
|
tmp_file = image.call
|
|
@@ -25,23 +36,4 @@ class RackResize::Processors::MiniMagick
|
|
|
25
36
|
end
|
|
26
37
|
end
|
|
27
38
|
end
|
|
28
|
-
|
|
29
|
-
def resize_mm(source_file:, target_width:, target_height:, target_file: nil)
|
|
30
|
-
image = MiniMagick::Image.open(source_file)
|
|
31
|
-
image.combine_options do |img|
|
|
32
|
-
img.resize("#{target_width}x#{target_height}>") if target_width || target_height
|
|
33
|
-
img.quality(RackResize.config.default_quality)
|
|
34
|
-
|
|
35
|
-
if target_file
|
|
36
|
-
img.write(target_file)
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
unless target_file
|
|
41
|
-
return File.open(image.path, 'rb', &:read)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
ensure
|
|
45
|
-
image&.destroy!
|
|
46
|
-
end
|
|
47
39
|
end
|
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
require "open3"
|
|
2
|
+
require "tempfile"
|
|
2
3
|
|
|
3
4
|
class RackResize::Processors::Sips
|
|
4
5
|
|
|
5
|
-
def resize(source_file:, target_width:, target_height:, target_file: nil)
|
|
6
|
+
def resize(source_file:, target_width:, target_height:, target_file: nil, fit: nil, format: nil, quality: nil, bg_color: nil)
|
|
7
|
+
quality ||= RackResize.config.default_quality
|
|
8
|
+
cover = (fit == 'cover' || fit == 'crop') && target_width && target_height
|
|
9
|
+
|
|
6
10
|
args = %w[sips --deleteColorManagementProperties]
|
|
7
|
-
args += ["-s", "
|
|
11
|
+
args += ["-s", "format", sips_format(format)] if format
|
|
12
|
+
args += ["-s", "formatOptions", quality.to_s]
|
|
8
13
|
|
|
9
14
|
if target_width && target_height
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
src_w, src_h = sips_dimensions(source_file)
|
|
16
|
+
if cover
|
|
17
|
+
if src_w.nil? || src_h.nil?
|
|
18
|
+
args += ["--resampleWidth", target_width.to_i.to_s]
|
|
19
|
+
elsif target_width.to_f / src_w >= target_height.to_f / src_h
|
|
20
|
+
args += ["--resampleWidth", target_width.to_i.to_s]
|
|
21
|
+
else
|
|
22
|
+
args += ["--resampleHeight", target_height.to_i.to_s]
|
|
23
|
+
end
|
|
24
|
+
elsif src_w && src_h
|
|
25
|
+
if (target_width.to_f / src_w) <= (target_height.to_f / src_h)
|
|
26
|
+
args += ["--resampleWidth", target_width.to_i.to_s]
|
|
27
|
+
else
|
|
28
|
+
args += ["--resampleHeight", target_height.to_i.to_s]
|
|
29
|
+
end
|
|
18
30
|
end
|
|
19
31
|
else
|
|
20
32
|
args += ["--resampleWidth", target_width.to_i.to_s] if target_width
|
|
@@ -22,20 +34,40 @@ class RackResize::Processors::Sips
|
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
if target_file
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
run_sips(*args, "-o", target_file.to_s, source_file.to_s)
|
|
38
|
+
sips_crop!(target_file.to_s, target_width, target_height) if cover
|
|
39
|
+
nil
|
|
40
|
+
else
|
|
41
|
+
tmp = Tempfile.new(["result", File.extname(source_file)])
|
|
42
|
+
begin
|
|
43
|
+
run_sips(*args, "-o", tmp.path, source_file.to_s)
|
|
44
|
+
sips_crop!(tmp.path, target_width, target_height) if cover
|
|
45
|
+
File.binread(tmp.path)
|
|
46
|
+
ensure
|
|
47
|
+
tmp.close
|
|
48
|
+
tmp.unlink
|
|
49
|
+
end
|
|
28
50
|
end
|
|
51
|
+
end
|
|
29
52
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def sips_dimensions(source_file)
|
|
56
|
+
info, status = Open3.capture2("sips", "-g", "pixelWidth", "-g", "pixelHeight", source_file.to_s)
|
|
57
|
+
raise "sips failed to read dimensions for #{source_file}" unless status.success?
|
|
58
|
+
[info[/pixelWidth: (\d+)/, 1]&.to_f, info[/pixelHeight: (\d+)/, 1]&.to_f]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_sips(*args)
|
|
62
|
+
_, status = Open3.capture2(*args)
|
|
63
|
+
raise "sips failed (exit #{status.exitstatus}): #{args.join(' ')}" unless status.success?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sips_crop!(path, width, height)
|
|
67
|
+
run_sips("sips", "--cropToHeightWidth", height.to_i.to_s, width.to_i.to_s, path)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sips_format(format)
|
|
71
|
+
format.to_s.downcase == 'jpg' ? 'jpeg' : format.to_s.downcase
|
|
40
72
|
end
|
|
41
73
|
end
|
|
@@ -6,13 +6,28 @@ end
|
|
|
6
6
|
|
|
7
7
|
class RackResize::Processors::Vips
|
|
8
8
|
|
|
9
|
-
def resize(source_file:, target_file:, target_width:, target_height:)
|
|
9
|
+
def resize(source_file:, target_file:, target_width:, target_height:, fit: nil, format: nil, quality: nil, bg_color: nil)
|
|
10
|
+
quality ||= RackResize.config.default_quality
|
|
11
|
+
cover = (fit == 'cover' || fit == 'crop') && target_width && target_height
|
|
12
|
+
|
|
10
13
|
image = ImageProcessing::Vips.source(source_file)
|
|
11
|
-
image = image.
|
|
12
|
-
|
|
14
|
+
image = image.convert(format) if format
|
|
15
|
+
|
|
16
|
+
if target_width || target_height
|
|
17
|
+
image = cover ? image.resize_to_fill(target_width, target_height)
|
|
18
|
+
: image.resize_to_limit(target_width, target_height)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if bg_color
|
|
22
|
+
r, g, b, _a = bg_color
|
|
23
|
+
image = image.flatten(background: [r, g, b])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
image = image.saver(quality: quality)
|
|
13
27
|
|
|
14
28
|
if target_file
|
|
15
29
|
image.call(destination: target_file)
|
|
30
|
+
nil
|
|
16
31
|
else
|
|
17
32
|
begin
|
|
18
33
|
tmp_file = image.call
|
data/lib/rack_resize/rack_app.rb
CHANGED
|
@@ -18,8 +18,9 @@ class RackResize::RackApp
|
|
|
18
18
|
request = Rack::Request.new(env)
|
|
19
19
|
fullpath = request.path_info
|
|
20
20
|
|
|
21
|
-
RackResize::InputParsers::Cloudflare.parse_input(fullpath, cf_path_prefix: @cf_path_prefix)
|
|
22
|
-
|
|
21
|
+
result = RackResize::InputParsers::Cloudflare.parse_input(fullpath, cf_path_prefix: @cf_path_prefix)
|
|
22
|
+
result = RackResize::InputParsers::QueryString.parse_input(fullpath, request.query_string) unless result[:route_matched]
|
|
23
|
+
result => {route_matched:, req_params:, asset_path:}
|
|
23
24
|
|
|
24
25
|
return @app.call(env) unless route_matched
|
|
25
26
|
return error_resp("can't parse file path") unless asset_path
|
|
@@ -31,7 +32,7 @@ class RackResize::RackApp
|
|
|
31
32
|
config.assets_folders.each do |prefix, folder|
|
|
32
33
|
if asset_path.start_with?(prefix)
|
|
33
34
|
asset_file = folder.join(asset_path.delete_prefix(prefix + (prefix.end_with?("/") ? "" : "/")))
|
|
34
|
-
if asset_file.expand_path.to_s.start_with?(folder.to_s)
|
|
35
|
+
if asset_file.expand_path.to_s.start_with?(folder.expand_path.to_s)
|
|
35
36
|
has_matched = true
|
|
36
37
|
end
|
|
37
38
|
break
|
|
@@ -47,20 +48,22 @@ class RackResize::RackApp
|
|
|
47
48
|
return error_resp("invalid file path")
|
|
48
49
|
end
|
|
49
50
|
unless asset_file.exist?
|
|
50
|
-
@processing.logger.info("RackResize::RackApp - File path
|
|
51
|
+
@processing.logger.info("RackResize::RackApp - File path not found #{asset_path} => #{asset_file}")
|
|
51
52
|
return error_resp("file not exists on a server")
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
file_content = @processing.process!(source_file: asset_file, req_params:)
|
|
55
|
-
|
|
56
|
+
output_format = req_params[:format].then { |f| (f && f != 'auto') ? f : nil }
|
|
57
|
+
return send_file(asset_file:, file_content:, output_format:)
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def error_resp(message, http_code: 404)
|
|
59
61
|
[http_code, {}, [message]]
|
|
60
62
|
end
|
|
61
63
|
|
|
62
|
-
def send_file(asset_file:, file_content: nil)
|
|
63
|
-
|
|
64
|
+
def send_file(asset_file:, file_content: nil, output_format: nil)
|
|
65
|
+
ext = (output_format && output_format != 'auto') ? ".#{output_format}" : File.extname(asset_file)
|
|
66
|
+
content_type = Rack::Mime.mime_type(ext, "application/octet-stream")
|
|
64
67
|
|
|
65
68
|
[
|
|
66
69
|
200,
|
data/lib/rack_resize.rb
CHANGED
|
@@ -2,17 +2,18 @@ module RackResize
|
|
|
2
2
|
autoload :Configuration, "#{__dir__}/rack_resize/configuration"
|
|
3
3
|
autoload :RackApp, "#{__dir__}/rack_resize/rack_app"
|
|
4
4
|
autoload :Processing, "#{__dir__}/rack_resize/processing"
|
|
5
|
-
|
|
5
|
+
autoload :ColorUtils, "#{__dir__}/rack_resize/color_utils"
|
|
6
6
|
|
|
7
7
|
module Processors
|
|
8
|
-
autoload :Sips,
|
|
9
|
-
autoload :Vips,
|
|
8
|
+
autoload :Sips, "#{__dir__}/rack_resize/processors/sips"
|
|
9
|
+
autoload :Vips, "#{__dir__}/rack_resize/processors/vips"
|
|
10
10
|
autoload :MiniMagick, "#{__dir__}/rack_resize/processors/mini_magick"
|
|
11
|
-
autoload :Imlib2,
|
|
11
|
+
autoload :Imlib2, "#{__dir__}/rack_resize/processors/imlib2"
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
module InputParsers
|
|
15
|
-
autoload :Cloudflare,
|
|
15
|
+
autoload :Cloudflare, "#{__dir__}/rack_resize/input_parsers/cloudflare"
|
|
16
|
+
autoload :QueryString, "#{__dir__}/rack_resize/input_parsers/query_string"
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
class << self
|
data/rack_resize.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rack_resize
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Pavel Evstigneev
|
|
@@ -41,10 +41,14 @@ files:
|
|
|
41
41
|
- Gemfile
|
|
42
42
|
- README.md
|
|
43
43
|
- Rakefile
|
|
44
|
+
- benchmark/http_benchmark.rb
|
|
45
|
+
- benchmark/processor_benchmark.rb
|
|
44
46
|
- config.ru
|
|
45
47
|
- lib/rack_resize.rb
|
|
48
|
+
- lib/rack_resize/color_utils.rb
|
|
46
49
|
- lib/rack_resize/configuration.rb
|
|
47
50
|
- lib/rack_resize/input_parsers/cloudflare.rb
|
|
51
|
+
- lib/rack_resize/input_parsers/query_string.rb
|
|
48
52
|
- lib/rack_resize/processing.rb
|
|
49
53
|
- lib/rack_resize/processors/imlib2.rb
|
|
50
54
|
- lib/rack_resize/processors/mini_magick.rb
|
|
@@ -71,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
71
75
|
- !ruby/object:Gem::Version
|
|
72
76
|
version: '0'
|
|
73
77
|
requirements: []
|
|
74
|
-
rubygems_version: 4.0.
|
|
78
|
+
rubygems_version: 4.0.16
|
|
75
79
|
specification_version: 4
|
|
76
80
|
summary: Image resizing on a fly
|
|
77
81
|
test_files: []
|