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.
@@ -1,3 +1,382 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "safe_image_native"
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
@@ -8,7 +8,20 @@ module SafeImage
8
8
 
9
9
  MAX_PNGQUANT_SIZE = 500_000
10
10
 
11
- def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true)
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
- Runner.run!(argv, timeout: timeout)
43
- if tmp_path.file? && File.size(tmp_path) < File.size(path)
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
@@ -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
- def initialize(max_pixels: nil, backend: :vips, execution: :inline, encoder: :auto, chroma_subsampling: :auto)
13
- @max_pixels = max_pixels
14
- @backend = backend.to_sym
15
- @execution = execution.to_sym
16
- @encoder = encoder.to_sym
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?(input)
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 @backend
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, :magick
95
- probe_info = Native.probe(input.to_s)
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
- def use_jpegli_for_generated_jpeg?(input)
132
- case @encoder
133
- when :auto
134
- @backend == :vips && JpegliBackend.available?
135
- when :cjpegli
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
- if info[:encoder] == "cjpegli"
172
- "#{@backend == :vips ? "libvips-direct" : "imagemagick"}+cjpegli"
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)