safe_image 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +351 -0
- data/README.md +637 -288
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +256 -98
- data/lib/safe_image/fonts/DEJAVU-LICENSE +187 -0
- data/lib/safe_image/fonts/DejaVuSans.ttf +0 -0
- data/lib/safe_image/ico.rb +286 -0
- data/lib/safe_image/image_magick_backend.rb +39 -3
- data/lib/safe_image/imagemagick_policy/policy.xml +8 -1
- data/lib/safe_image/jpegli_backend.rb +3 -1
- data/lib/safe_image/native.rb +380 -1
- data/lib/safe_image/optimizer.rb +79 -4
- data/lib/safe_image/processor.rb +24 -70
- data/lib/safe_image/remote.rb +188 -10
- data/lib/safe_image/runner.rb +10 -2
- data/lib/safe_image/sandbox.rb +82 -29
- data/lib/safe_image/svg_css.rb +314 -0
- data/lib/safe_image/svg_metadata.rb +183 -27
- data/lib/safe_image/svg_sanitizer.rb +524 -43
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +57 -0
- data/lib/safe_image/vips_glue.rb +361 -0
- data/lib/safe_image/zygote.rb +619 -0
- data/lib/safe_image.rb +154 -35
- metadata +47 -15
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
data/lib/safe_image/native.rb
CHANGED
|
@@ -1,3 +1,382 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "vips_glue"
|
|
4
|
+
|
|
5
|
+
module SafeImage
|
|
6
|
+
# The libvips fast path, implemented in pure Ruby on top of the VipsGlue
|
|
7
|
+
# Fiddle binding (formerly a compiled C extension; the function surface and
|
|
8
|
+
# messages are unchanged). Loaders are explicit per extension, every decode
|
|
9
|
+
# enforces the pixel cap from the header before pixel data is touched, and
|
|
10
|
+
# all images are released deterministically.
|
|
11
|
+
module Native
|
|
12
|
+
LOADERS = {
|
|
13
|
+
"jpg" => "jpegload",
|
|
14
|
+
"png" => "pngload",
|
|
15
|
+
"webp" => "webpload",
|
|
16
|
+
"gif" => "gifload",
|
|
17
|
+
"heic" => "heifload",
|
|
18
|
+
"avif" => "heifload",
|
|
19
|
+
"jxl" => "jxlload"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def probe(path)
|
|
24
|
+
started = monotime
|
|
25
|
+
VipsGlue.with_images do |track|
|
|
26
|
+
image, format = load_image(track, String(path))
|
|
27
|
+
{
|
|
28
|
+
format: format,
|
|
29
|
+
width: VipsGlue.width(image),
|
|
30
|
+
height: VipsGlue.height(image),
|
|
31
|
+
duration_ms: monotime - started
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def thumbnail(input, output, width, height, format, quality, max_pixels)
|
|
37
|
+
started = monotime
|
|
38
|
+
input = String(input)
|
|
39
|
+
output = String(output)
|
|
40
|
+
width = Integer(width)
|
|
41
|
+
height = Integer(height)
|
|
42
|
+
quality = Integer(quality)
|
|
43
|
+
raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
|
|
44
|
+
validate_quality!(quality)
|
|
45
|
+
out_format = output_format!(format)
|
|
46
|
+
|
|
47
|
+
VipsGlue.with_images do |track|
|
|
48
|
+
# Route through the explicit loader for the path's extension and keep
|
|
49
|
+
# the resulting image object for the resize. Do not call the generic
|
|
50
|
+
# filename-based `thumbnail` operation here: it re-sniffs the path and
|
|
51
|
+
# can observe different bytes if a hostile local path is replaced
|
|
52
|
+
# between the header check and the decode.
|
|
53
|
+
image, input_format = load_image(track, input, autorotate: true)
|
|
54
|
+
check_pixels!(image, max_pixels)
|
|
55
|
+
|
|
56
|
+
thumb = track.call(
|
|
57
|
+
VipsGlue.operation(
|
|
58
|
+
"thumbnail_image",
|
|
59
|
+
{ in: image, width: width, height: height,
|
|
60
|
+
size: "both", crop: "centre", fail_on: "error" }
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
save_image(thumb, output, out_format, quality)
|
|
64
|
+
info(input_format, out_format, thumb, started)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resize(input, output, scale, format, quality, max_pixels)
|
|
69
|
+
started = monotime
|
|
70
|
+
scale = Float(scale)
|
|
71
|
+
quality = Integer(quality)
|
|
72
|
+
unless scale.finite? && scale.positive? && scale <= 100.0
|
|
73
|
+
raise ArgumentError, "scale must be finite and in 0..100"
|
|
74
|
+
end
|
|
75
|
+
validate_quality!(quality)
|
|
76
|
+
out_format = output_format!(format)
|
|
77
|
+
|
|
78
|
+
VipsGlue.with_images do |track|
|
|
79
|
+
image, input_format = load_image(track, String(input), autorotate: true)
|
|
80
|
+
check_pixels!(image, max_pixels)
|
|
81
|
+
rotated = track.call(VipsGlue.operation("autorot", { in: image }))
|
|
82
|
+
resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
|
|
83
|
+
save_image(resized, String(output), out_format, quality)
|
|
84
|
+
info(input_format, out_format, resized, started)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def crop_north(input, output, width, height, format, quality, max_pixels)
|
|
89
|
+
started = monotime
|
|
90
|
+
width = Integer(width)
|
|
91
|
+
height = Integer(height)
|
|
92
|
+
quality = Integer(quality)
|
|
93
|
+
raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
|
|
94
|
+
validate_quality!(quality)
|
|
95
|
+
out_format = output_format!(format)
|
|
96
|
+
|
|
97
|
+
VipsGlue.with_images do |track|
|
|
98
|
+
image, input_format = load_image(track, String(input), autorotate: true)
|
|
99
|
+
check_pixels!(image, max_pixels)
|
|
100
|
+
rotated = track.call(VipsGlue.operation("autorot", { in: image }))
|
|
101
|
+
|
|
102
|
+
scale = [width.fdiv(VipsGlue.width(rotated)), height.fdiv(VipsGlue.height(rotated))].max * 1.0000001
|
|
103
|
+
resized = track.call(VipsGlue.operation("resize", { in: rotated, scale: scale }))
|
|
104
|
+
left = [(VipsGlue.width(resized) - width) / 2, 0].max
|
|
105
|
+
cropped = track.call(
|
|
106
|
+
VipsGlue.operation("extract_area", { input: resized, left: left, top: 0, width: width, height: height })
|
|
107
|
+
)
|
|
108
|
+
save_image(cropped, String(output), out_format, quality)
|
|
109
|
+
info(input_format, out_format, cropped, started)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def convert(input, output, format, quality, max_pixels)
|
|
114
|
+
started = monotime
|
|
115
|
+
quality = Integer(quality)
|
|
116
|
+
validate_quality!(quality)
|
|
117
|
+
out_format = output_format!(format)
|
|
118
|
+
|
|
119
|
+
VipsGlue.with_images do |track|
|
|
120
|
+
image, input_format = load_image(track, String(input), autorotate: true)
|
|
121
|
+
check_pixels!(image, max_pixels)
|
|
122
|
+
rotated = track.call(VipsGlue.operation("autorot", { in: image }))
|
|
123
|
+
|
|
124
|
+
# JPEG has no alpha; flatten onto white to match the ImageMagick
|
|
125
|
+
# convert path (libvips composites onto black otherwise).
|
|
126
|
+
final =
|
|
127
|
+
if out_format == "jpg" && VipsGlue.alpha?(rotated)
|
|
128
|
+
track.call(VipsGlue.operation("flatten", { in: rotated, background: [255.0, 255.0, 255.0] }))
|
|
129
|
+
else
|
|
130
|
+
rotated
|
|
131
|
+
end
|
|
132
|
+
save_image(final, String(output), out_format, quality)
|
|
133
|
+
info(input_format, out_format, final, started)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Alpha-weighted average colour as [r, g, b] integers. Premultiplying
|
|
138
|
+
# keeps parity with ImageMagick's resize-based average; per-band means
|
|
139
|
+
# come from vips_stats (row b+1, column 4 of the stats matrix).
|
|
140
|
+
def dominant_color(path, max_pixels)
|
|
141
|
+
VipsGlue.with_images do |track|
|
|
142
|
+
image, = load_image(track, String(path))
|
|
143
|
+
check_pixels!(image, max_pixels)
|
|
144
|
+
|
|
145
|
+
srgb =
|
|
146
|
+
if VipsGlue.colourspace_supported?(image)
|
|
147
|
+
track.call(VipsGlue.operation("colourspace", { in: image, space: "srgb" }))
|
|
148
|
+
else
|
|
149
|
+
image
|
|
150
|
+
end
|
|
151
|
+
has_alpha = VipsGlue.alpha?(srgb)
|
|
152
|
+
work = has_alpha ? track.call(VipsGlue.operation("premultiply", { in: srgb })) : srgb
|
|
153
|
+
|
|
154
|
+
stats = track.call(VipsGlue.operation("stats", { in: work }))
|
|
155
|
+
columns = VipsGlue.width(stats)
|
|
156
|
+
matrix = VipsGlue.image_bytes(stats).unpack("d*")
|
|
157
|
+
mean = ->(band) { matrix[(band + 1) * columns + 4] || 0.0 }
|
|
158
|
+
|
|
159
|
+
bands = VipsGlue.bands(work)
|
|
160
|
+
colour_bands = has_alpha ? bands - 1 : bands
|
|
161
|
+
colour_bands = colour_bands.clamp(1, 3)
|
|
162
|
+
raise InvalidImageError, "image has no colour bands" if colour_bands < 1
|
|
163
|
+
|
|
164
|
+
alpha_mean = has_alpha ? mean.call(bands - 1) : 255.0
|
|
165
|
+
(0...3).map do |band|
|
|
166
|
+
value = mean.call([band, colour_bands - 1].min)
|
|
167
|
+
value = alpha_mean.positive? ? value * 255.0 / alpha_mean : 0.0 if has_alpha
|
|
168
|
+
value.round.clamp(0, 255)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def pages(path, max_pixels)
|
|
174
|
+
VipsGlue.with_images do |track|
|
|
175
|
+
image, = load_image(track, String(path))
|
|
176
|
+
check_pixels!(image, max_pixels)
|
|
177
|
+
VipsGlue.pages(image)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def orientation(path, max_pixels)
|
|
182
|
+
VipsGlue.with_images do |track|
|
|
183
|
+
image, = load_image(track, String(path))
|
|
184
|
+
check_pixels!(image, max_pixels)
|
|
185
|
+
value = VipsGlue.orientation(image)
|
|
186
|
+
(1..8).cover?(value) ? value : 1
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Encodes a raw RGBA buffer (top-down rows) as PNG. Used by the
|
|
191
|
+
# pure-Ruby ICO decoder.
|
|
192
|
+
def png_from_rgba(bytes, width, height, output)
|
|
193
|
+
bytes = String(bytes)
|
|
194
|
+
width = Integer(width)
|
|
195
|
+
height = Integer(height)
|
|
196
|
+
raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
|
|
197
|
+
raise LimitError, "rgba buffer dimensions exceed 4096x4096" if width > 4096 || height > 4096
|
|
198
|
+
raise ArgumentError, "rgba buffer must be width*height*4 bytes" if bytes.bytesize != width * height * 4
|
|
199
|
+
|
|
200
|
+
VipsGlue.with_images do |track|
|
|
201
|
+
image = track.call(VipsGlue.image_from_memory(bytes, width, height, 4, 0)) # 0 = uchar
|
|
202
|
+
srgb = track.call(VipsGlue.operation("copy", { in: image, interpretation: "srgb" }))
|
|
203
|
+
save_image(srgb, String(output), "png", 100)
|
|
204
|
+
end
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Renders a letter avatar: a Pango glyph mask blended in white at 80%
|
|
209
|
+
# opacity over a solid background via a single linear transform. The
|
|
210
|
+
# markup string is escaped by the Ruby caller; font and fontfile come
|
|
211
|
+
# from an allowlist.
|
|
212
|
+
def letter_avatar(output, size, red, green, blue, markup, font, fontfile)
|
|
213
|
+
size = Integer(size)
|
|
214
|
+
markup = String(markup)
|
|
215
|
+
font = String(font)
|
|
216
|
+
fontfile = String(fontfile)
|
|
217
|
+
channels = [Integer(red), Integer(green), Integer(blue)]
|
|
218
|
+
raise ArgumentError, "size must be 1..4096" unless (1..4096).cover?(size)
|
|
219
|
+
unless channels.all? { |value| (0..255).cover?(value) }
|
|
220
|
+
raise ArgumentError, "background channels must be 0..255"
|
|
221
|
+
end
|
|
222
|
+
unless VipsGlue.type_find?("text")
|
|
223
|
+
raise UnsupportedFormatError, "this libvips build has no text renderer (Pango support missing)"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
VipsGlue.with_images do |track|
|
|
227
|
+
mask =
|
|
228
|
+
if markup.empty?
|
|
229
|
+
# Blank letter: solid background only.
|
|
230
|
+
track.call(VipsGlue.operation("black", { width: size, height: size }))
|
|
231
|
+
else
|
|
232
|
+
text_inputs = { text: markup, font: font, dpi: 72 }
|
|
233
|
+
text_inputs[:fontfile] = fontfile unless fontfile.empty?
|
|
234
|
+
text = track.call(VipsGlue.operation("text", text_inputs))
|
|
235
|
+
|
|
236
|
+
# vips_text returns the tight ink box; crop to the canvas when
|
|
237
|
+
# the pointsize overflows it, then centre the ink optically.
|
|
238
|
+
text_w = VipsGlue.width(text)
|
|
239
|
+
text_h = VipsGlue.height(text)
|
|
240
|
+
if text_w > size || text_h > size
|
|
241
|
+
crop_w = [text_w, size].min
|
|
242
|
+
crop_h = [text_h, size].min
|
|
243
|
+
text = track.call(
|
|
244
|
+
VipsGlue.operation(
|
|
245
|
+
"extract_area",
|
|
246
|
+
{ input: text, left: (text_w - crop_w) / 2, top: (text_h - crop_h) / 2,
|
|
247
|
+
width: crop_w, height: crop_h }
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
text_w = crop_w
|
|
251
|
+
text_h = crop_h
|
|
252
|
+
end
|
|
253
|
+
track.call(
|
|
254
|
+
VipsGlue.operation(
|
|
255
|
+
"embed",
|
|
256
|
+
{ in: text, x: (size - text_w) / 2, y: (size - text_h) / 2, width: size, height: size }
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# blend = bg + (white - bg) * 0.8 * mask/255, one linear op.
|
|
262
|
+
opacity = 204.0 / 255.0 # FFFFFFCC
|
|
263
|
+
a = channels.map { |value| (255.0 - value) * opacity / 255.0 }
|
|
264
|
+
blended = track.call(VipsGlue.operation("linear", { in: mask, a: a, b: channels.map(&:to_f) }))
|
|
265
|
+
cast = track.call(VipsGlue.operation("cast", { in: blended, format: "uchar" }))
|
|
266
|
+
srgb = track.call(VipsGlue.operation("copy", { in: cast, interpretation: "srgb" }))
|
|
267
|
+
save_image(srgb, String(output), "png", 100)
|
|
268
|
+
end
|
|
269
|
+
true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
def monotime
|
|
275
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def info(input_format, output_format, image, started)
|
|
279
|
+
{
|
|
280
|
+
input_format: input_format,
|
|
281
|
+
output_format: output_format,
|
|
282
|
+
width: VipsGlue.width(image),
|
|
283
|
+
height: VipsGlue.height(image),
|
|
284
|
+
duration_ms: monotime - started
|
|
285
|
+
}
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def normalized_format(ext)
|
|
289
|
+
case ext.to_s.downcase
|
|
290
|
+
when "jpg", "jpeg" then "jpg"
|
|
291
|
+
when "png" then "png"
|
|
292
|
+
when "webp" then "webp"
|
|
293
|
+
when "gif" then "gif"
|
|
294
|
+
when "heic", "heif" then "heic"
|
|
295
|
+
when "avif" then "avif"
|
|
296
|
+
when "jxl" then "jxl"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def output_format!(format)
|
|
301
|
+
normalized = normalized_format(String(format))
|
|
302
|
+
raise UnsupportedFormatError, "unsupported output format" if normalized.nil? || normalized == "heic"
|
|
303
|
+
normalized
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def validate_quality!(quality)
|
|
307
|
+
raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def load_image(track, path, autorotate: false)
|
|
311
|
+
format = normalized_format(File.extname(path).delete_prefix("."))
|
|
312
|
+
raise UnsupportedFormatError, "unsupported input format" unless format
|
|
313
|
+
|
|
314
|
+
case format
|
|
315
|
+
when "gif"
|
|
316
|
+
# libnsgif loader: first frame only (the n=1 default), matching the
|
|
317
|
+
# [0] semantics of the ImageMagick compatibility backend.
|
|
318
|
+
raise UnsupportedFormatError, "this libvips build has no GIF loader" unless VipsGlue.type_find?("gifload")
|
|
319
|
+
when "jxl"
|
|
320
|
+
raise UnsupportedFormatError, "this libvips build has no JPEG XL loader" unless VipsGlue.type_find?("jxlload")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
options = { filename: path, access: "sequential", fail_on: "error" }
|
|
324
|
+
image = track.call(VipsGlue.operation(LOADERS.fetch(format), options))
|
|
325
|
+
|
|
326
|
+
# autorot flips/rotates pull rows out of input order, which a
|
|
327
|
+
# sequential source can only serve while the image fits its readahead
|
|
328
|
+
# window (~512px); larger oriented images fail with "out of order
|
|
329
|
+
# read". Reload those with random access — the open itself is
|
|
330
|
+
# header-only, so the caller's pixel cap still runs before any decode.
|
|
331
|
+
if autorotate && VipsGlue.orientation(image) > 1
|
|
332
|
+
image = track.call(VipsGlue.operation(LOADERS.fetch(format), options.merge(access: "random")))
|
|
333
|
+
end
|
|
334
|
+
[image, format]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def check_pixels!(image, max_pixels)
|
|
338
|
+
if max_pixels.nil?
|
|
339
|
+
limit = DEFAULT_MAX_PIXELS
|
|
340
|
+
else
|
|
341
|
+
limit = Integer(max_pixels)
|
|
342
|
+
raise ArgumentError, "max_pixels must be positive" if limit <= 0
|
|
343
|
+
end
|
|
344
|
+
width = VipsGlue.width(image)
|
|
345
|
+
height = VipsGlue.height(image)
|
|
346
|
+
raise InvalidImageError, "image dimensions are invalid" if width <= 0 || height <= 0
|
|
347
|
+
pixels = width * height
|
|
348
|
+
raise LimitError, "image has #{pixels} pixels, exceeds #{limit}" if pixels > limit
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# libvips renamed the strip-metadata save option from "strip" to "keep"
|
|
352
|
+
# (VIPS_FOREIGN_KEEP_NONE = 0) in 8.15; pick the spelling at runtime so
|
|
353
|
+
# one gem build serves distro packages from 8.13 up.
|
|
354
|
+
def strip_args
|
|
355
|
+
@strip_args ||= (VipsGlue.version <=> [8, 15]) >= 0 ? { keep: 0 } : { strip: true }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def save_image(image, path, format, quality)
|
|
359
|
+
case format
|
|
360
|
+
when "jpg"
|
|
361
|
+
VipsGlue.operation("jpegsave", { in: image, filename: path, Q: quality, interlace: false, **strip_args }, output: nil)
|
|
362
|
+
when "png"
|
|
363
|
+
VipsGlue.operation("pngsave", { in: image, filename: path, compression: 6, **strip_args }, output: nil)
|
|
364
|
+
when "webp"
|
|
365
|
+
VipsGlue.operation("webpsave", { in: image, filename: path, Q: quality, **strip_args }, output: nil)
|
|
366
|
+
when "avif"
|
|
367
|
+
VipsGlue.operation("heifsave", { in: image, filename: path, Q: quality, compression: "av1", **strip_args }, output: nil)
|
|
368
|
+
when "gif"
|
|
369
|
+
# cgif-backed saver; optional at libvips build time. GIF output is
|
|
370
|
+
# palette-quantised and has no quality parameter.
|
|
371
|
+
raise UnsupportedFormatError, "this libvips build cannot save GIF (cgif support missing)" unless VipsGlue.type_find?("gifsave")
|
|
372
|
+
VipsGlue.operation("gifsave", { in: image, filename: path, **strip_args }, output: nil)
|
|
373
|
+
when "jxl"
|
|
374
|
+
raise UnsupportedFormatError, "this libvips build cannot save JPEG XL" unless VipsGlue.type_find?("jxlsave")
|
|
375
|
+
VipsGlue.operation("jxlsave", { in: image, filename: path, Q: quality, **strip_args }, output: nil)
|
|
376
|
+
else
|
|
377
|
+
raise UnsupportedFormatError, "unsupported output format"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
data/lib/safe_image/optimizer.rb
CHANGED
|
@@ -8,7 +8,20 @@ module SafeImage
|
|
|
8
8
|
|
|
9
9
|
MAX_PNGQUANT_SIZE = 500_000
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# EXIF orientation values mapped onto jpegtran's lossless transforms.
|
|
12
|
+
JPEGTRAN_OPERATIONS = {
|
|
13
|
+
2 => ["-flip", "horizontal"],
|
|
14
|
+
3 => ["-rotate", "180"],
|
|
15
|
+
4 => ["-flip", "vertical"],
|
|
16
|
+
5 => ["-transpose"],
|
|
17
|
+
6 => ["-rotate", "90"],
|
|
18
|
+
7 => ["-transverse"],
|
|
19
|
+
8 => ["-rotate", "270"]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# assume_upright: skips the JPEG orientation check; only for callers
|
|
23
|
+
# optimising output this gem just encoded (which is always upright).
|
|
24
|
+
def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true, assume_upright: false)
|
|
12
25
|
path = PathSafety.ensure_regular_file!(path)
|
|
13
26
|
|
|
14
27
|
ext = path.extname.delete_prefix(".").downcase
|
|
@@ -16,9 +29,26 @@ module SafeImage
|
|
|
16
29
|
|
|
17
30
|
before = File.size(path)
|
|
18
31
|
tools = []
|
|
32
|
+
rotated_from = nil
|
|
33
|
+
trimmed = false
|
|
19
34
|
|
|
20
35
|
case ext
|
|
21
36
|
when "jpg"
|
|
37
|
+
# Stripping metadata deletes the EXIF orientation tag, so an oriented
|
|
38
|
+
# image must have the rotation baked into its pixels first or it ships
|
|
39
|
+
# sideways. jpegtran does that losslessly; without it, leave the file
|
|
40
|
+
# untouched rather than strip-without-rotate.
|
|
41
|
+
orientation = strip_metadata && !assume_upright ? jpeg_orientation(path) : 1
|
|
42
|
+
if orientation > 1
|
|
43
|
+
unless Runner.available?("jpegtran")
|
|
44
|
+
raise Error, "jpegtran is required to optimize a JPEG with EXIF orientation" if strict
|
|
45
|
+
return { format: ext, before_bytes: before, after_bytes: before, saved_bytes: 0, tools: tools, rotated_from: nil, trimmed: false }
|
|
46
|
+
end
|
|
47
|
+
trimmed = upright!(path, orientation, timeout: timeout)
|
|
48
|
+
rotated_from = orientation
|
|
49
|
+
tools << "jpegtran"
|
|
50
|
+
end
|
|
51
|
+
|
|
22
52
|
if Runner.available?("jpegoptim")
|
|
23
53
|
argv = ["jpegoptim", "--quiet"]
|
|
24
54
|
argv << (strip_metadata ? "--strip-all" : "--strip-none")
|
|
@@ -39,8 +69,18 @@ module SafeImage
|
|
|
39
69
|
argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
|
|
40
70
|
argv << "--quality=#{quality}" if quality # e.g. "65-90"
|
|
41
71
|
argv << path.to_s
|
|
42
|
-
|
|
43
|
-
|
|
72
|
+
skipped = false
|
|
73
|
+
begin
|
|
74
|
+
Runner.run!(argv, timeout: timeout)
|
|
75
|
+
rescue CommandError => e
|
|
76
|
+
# 98: --skip-if-larger declined the result; 99: --quality not
|
|
77
|
+
# met. Both mean "keep the original", not a failure — and the
|
|
78
|
+
# pre-created tempfile is still empty, so it must not win the
|
|
79
|
+
# size comparison below.
|
|
80
|
+
raise unless [98, 99].include?(e.status)
|
|
81
|
+
skipped = true
|
|
82
|
+
end
|
|
83
|
+
if !skipped && tmp_path.file? && File.size(tmp_path).positive? && File.size(tmp_path) < File.size(path)
|
|
44
84
|
FileUtils.mv(tmp_path, path)
|
|
45
85
|
tools << "pngquant"
|
|
46
86
|
end
|
|
@@ -71,8 +111,43 @@ module SafeImage
|
|
|
71
111
|
before_bytes: before,
|
|
72
112
|
after_bytes: after,
|
|
73
113
|
saved_bytes: before - after,
|
|
74
|
-
tools: tools
|
|
114
|
+
tools: tools,
|
|
115
|
+
rotated_from: rotated_from,
|
|
116
|
+
trimmed: trimmed
|
|
75
117
|
}
|
|
76
118
|
end
|
|
119
|
+
|
|
120
|
+
def jpeg_orientation(path)
|
|
121
|
+
case SafeImage.config.backend
|
|
122
|
+
when :vips then VipsBackend.orientation(path.to_s)
|
|
123
|
+
when :imagemagick then ImageMagickBackend.orientation(path.to_s)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Applies the orientation's lossless jpegtran transform in place, dropping
|
|
128
|
+
# the metadata in the same pass (-copy none; this path only runs when
|
|
129
|
+
# strip_metadata is set). -perfect refuses dimensions that are not
|
|
130
|
+
# MCU-aligned; the -trim retry drops the partial edge blocks (under one
|
|
131
|
+
# MCU, at most 15px) instead of hiding a lossy re-encode here. Returns
|
|
132
|
+
# true when the fallback trimmed.
|
|
133
|
+
def upright!(path, orientation, timeout:)
|
|
134
|
+
transform = JPEGTRAN_OPERATIONS.fetch(orientation)
|
|
135
|
+
tmp = Tempfile.new([path.basename(".*").to_s, ".jpegtran.jpg"], path.dirname.to_s)
|
|
136
|
+
tmp_path = Pathname.new(tmp.path)
|
|
137
|
+
tmp.close
|
|
138
|
+
begin
|
|
139
|
+
trimmed = false
|
|
140
|
+
begin
|
|
141
|
+
Runner.run!(["jpegtran", "-copy", "none", "-perfect", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
|
|
142
|
+
rescue CommandError
|
|
143
|
+
Runner.run!(["jpegtran", "-copy", "none", "-trim", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
|
|
144
|
+
trimmed = true
|
|
145
|
+
end
|
|
146
|
+
FileUtils.mv(tmp_path, path)
|
|
147
|
+
trimmed
|
|
148
|
+
ensure
|
|
149
|
+
FileUtils.rm_f(tmp_path)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
77
152
|
end
|
|
78
153
|
end
|
data/lib/safe_image/processor.rb
CHANGED
|
@@ -6,14 +6,14 @@ require "tempfile"
|
|
|
6
6
|
|
|
7
7
|
module SafeImage
|
|
8
8
|
class Processor
|
|
9
|
-
SUPPORTED_INPUTS = %w[jpg jpeg png webp heic heif avif].freeze
|
|
10
|
-
SUPPORTED_OUTPUTS = %w[jpg jpeg png webp avif].freeze
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@
|
|
9
|
+
SUPPORTED_INPUTS = %w[jpg jpeg png gif webp heic heif avif jxl].freeze
|
|
10
|
+
SUPPORTED_OUTPUTS = %w[jpg jpeg png gif webp avif jxl].freeze
|
|
11
|
+
# Formats the post-processing optimizer tools understand; other outputs
|
|
12
|
+
# skip the optimize pass instead of erroring.
|
|
13
|
+
OPTIMIZABLE_OUTPUTS = %w[jpg png].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(max_pixels: nil, chroma_subsampling: :auto)
|
|
16
|
+
@max_pixels = max_pixels || SafeImage.config.max_pixels
|
|
17
17
|
@chroma_subsampling = chroma_subsampling
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -50,49 +50,17 @@ module SafeImage
|
|
|
50
50
|
raise UnsupportedFormatError, "unsupported output format: #{out_format.inspect}"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
if @execution == :sandbox || @execution == :sandbox_if_available
|
|
54
|
-
if @execution == :sandbox && !Sandbox.available?
|
|
55
|
-
raise Error, "sandbox execution requested but Landlock::SafeExec is unavailable"
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
info = Sandbox.thumbnail(
|
|
59
|
-
input: input.to_s,
|
|
60
|
-
output: output.to_s,
|
|
61
|
-
width: width,
|
|
62
|
-
height: height,
|
|
63
|
-
format: out_format,
|
|
64
|
-
quality: quality,
|
|
65
|
-
max_pixels: @max_pixels,
|
|
66
|
-
backend: @backend,
|
|
67
|
-
optimize: optimize,
|
|
68
|
-
optimize_mode: optimize_mode
|
|
69
|
-
)
|
|
70
|
-
if info
|
|
71
|
-
return Result.new(
|
|
72
|
-
input: input.to_s,
|
|
73
|
-
output: output.to_s,
|
|
74
|
-
input_format: info.fetch(:input_format),
|
|
75
|
-
output_format: info.fetch(:output_format),
|
|
76
|
-
width: info.fetch(:width),
|
|
77
|
-
height: info.fetch(:height),
|
|
78
|
-
filesize: File.size(output),
|
|
79
|
-
backend: "sandboxed-#{info.fetch(:backend)}",
|
|
80
|
-
duration_ms: info.fetch(:duration_ms),
|
|
81
|
-
optimizer: info[:optimizer]
|
|
82
|
-
)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
53
|
output.dirname.mkpath
|
|
54
|
+
backend = SafeImage.config.backend
|
|
87
55
|
info =
|
|
88
|
-
if out_format == "jpg" && use_jpegli_for_generated_jpeg?(
|
|
56
|
+
if out_format == "jpg" && use_jpegli_for_generated_jpeg?(backend)
|
|
89
57
|
jpegli_thumbnail(input: input, output: output, width: width, height: height, quality: quality, source_format: input.extname.delete_prefix(".").downcase)
|
|
90
58
|
else
|
|
91
|
-
case
|
|
59
|
+
case backend
|
|
92
60
|
when :vips
|
|
93
61
|
Native.thumbnail(input.to_s, output.to_s, width, height, out_format, quality, @max_pixels)
|
|
94
|
-
when :imagemagick
|
|
95
|
-
probe_info =
|
|
62
|
+
when :imagemagick
|
|
63
|
+
probe_info = ImageMagickBackend.probe(input.to_s)
|
|
96
64
|
validate_pixels!(probe_info.fetch(:width), probe_info.fetch(:height))
|
|
97
65
|
ImageMagickBackend.thumbnail(
|
|
98
66
|
input: input.to_s,
|
|
@@ -102,14 +70,12 @@ module SafeImage
|
|
|
102
70
|
format: out_format,
|
|
103
71
|
quality: quality
|
|
104
72
|
)
|
|
105
|
-
else
|
|
106
|
-
raise ArgumentError, "unknown backend: #{@backend.inspect}"
|
|
107
73
|
end
|
|
108
74
|
end
|
|
109
75
|
|
|
110
76
|
opt_info = nil
|
|
111
|
-
if optimize
|
|
112
|
-
opt_info = Optimizer.optimize(output, mode: optimize_mode, strip_metadata: true, quality: out_format == "jpg" ? quality : nil)
|
|
77
|
+
if optimize && OPTIMIZABLE_OUTPUTS.include?(out_format)
|
|
78
|
+
opt_info = Optimizer.optimize(output, mode: optimize_mode, strip_metadata: true, quality: out_format == "jpg" ? quality : nil, assume_upright: true)
|
|
113
79
|
end
|
|
114
80
|
|
|
115
81
|
Result.new(
|
|
@@ -120,7 +86,7 @@ module SafeImage
|
|
|
120
86
|
width: info.fetch(:width),
|
|
121
87
|
height: info.fetch(:height),
|
|
122
88
|
filesize: File.size(output),
|
|
123
|
-
backend: result_backend(info),
|
|
89
|
+
backend: result_backend(info, backend),
|
|
124
90
|
duration_ms: info.fetch(:duration_ms),
|
|
125
91
|
optimizer: opt_info&.fetch(:tools, nil)
|
|
126
92
|
)
|
|
@@ -128,23 +94,14 @@ module SafeImage
|
|
|
128
94
|
|
|
129
95
|
private
|
|
130
96
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
true
|
|
137
|
-
when :vips, :imagemagick, :magick
|
|
138
|
-
false
|
|
139
|
-
else
|
|
140
|
-
raise ArgumentError, "unknown encoder: #{@encoder.inspect}"
|
|
141
|
-
end
|
|
97
|
+
# cjpegli is an output-quality tool, not a configuration choice: installed
|
|
98
|
+
# means used. It encodes only pixels this gem already decoded, so it is
|
|
99
|
+
# not part of the untrusted-input surface the backend choice controls.
|
|
100
|
+
def use_jpegli_for_generated_jpeg?(backend)
|
|
101
|
+
backend == :vips && JpegliBackend.available?
|
|
142
102
|
end
|
|
143
103
|
|
|
144
104
|
def jpegli_thumbnail(input:, output:, width:, height:, quality:, source_format:)
|
|
145
|
-
raise UnsupportedFormatError, "cjpegli is not installed" unless JpegliBackend.available?
|
|
146
|
-
raise ArgumentError, "encoder: :cjpegli currently requires backend: :vips" unless @backend == :vips
|
|
147
|
-
|
|
148
105
|
output.dirname.mkpath
|
|
149
106
|
Tempfile.create([output.basename(".*").to_s, ".safe-image.png"], output.dirname.to_s) do |tmp|
|
|
150
107
|
tmp_path = Pathname.new(tmp.path)
|
|
@@ -167,12 +124,9 @@ module SafeImage
|
|
|
167
124
|
format == "jpeg" ? "jpg" : format
|
|
168
125
|
end
|
|
169
126
|
|
|
170
|
-
def result_backend(info)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
else
|
|
174
|
-
@backend == :vips ? "libvips-direct" : "imagemagick"
|
|
175
|
-
end
|
|
127
|
+
def result_backend(info, backend)
|
|
128
|
+
base = backend == :vips ? "libvips-direct" : "imagemagick"
|
|
129
|
+
info[:encoder] == "cjpegli" ? "#{base}+cjpegli" : base
|
|
176
130
|
end
|
|
177
131
|
|
|
178
132
|
def safe_existing_file!(path)
|