safe_image 0.3.0 → 0.4.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -12
  3. data/README.md +140 -287
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +42 -40
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -290
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +225 -98
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -5,147 +5,171 @@ module SafeImage
5
5
  module_function
6
6
 
7
7
  DEFAULT_PROFILE = File.expand_path("RT_sRGB.icm", __dir__)
8
- DECODERS = {
9
- "jpg" => "jpeg",
10
- "jpeg" => "jpeg",
11
- "png" => "png",
12
- "gif" => "gif",
13
- "webp" => "webp",
14
- "heic" => "heic",
15
- "heif" => "heic",
16
- "avif" => "heic",
17
- "ico" => "ico",
18
- "jxl" => "jxl"
19
- }.freeze
20
-
21
- IMAGEMAGICK_LIMIT_ARGS = [
22
- "-limit", "memory", "256MiB",
23
- "-limit", "map", "512MiB",
24
- "-limit", "disk", "1GiB",
25
- "-limit", "area", "128MP",
26
- "-limit", "time", "20",
27
- "-limit", "thread", "2"
8
+
9
+ IMAGEMAGICK_LIMIT_ARGS = %w[
10
+ -limit
11
+ memory
12
+ 256MiB
13
+ -limit
14
+ map
15
+ 512MiB
16
+ -limit
17
+ disk
18
+ 1GiB
19
+ -limit
20
+ area
21
+ 128MP
22
+ -limit
23
+ time
24
+ 20
25
+ -limit
26
+ thread
27
+ 2
28
28
  ].freeze
29
29
 
30
30
  ALLOWED_FONTS = %w[NimbusSans-Regular DejaVu-Sans Liberation-Sans Arial Helvetica Adwaita-Sans].freeze
31
31
 
32
32
  def probe(path, timeout: Runner::DEFAULT_TIMEOUT, max_pixels: nil)
33
33
  raise UnsupportedFormatError, "ImageMagick identify not available" unless Runner.available?("identify")
34
- path = PathSafety.ensure_imagemagick_input_file!(path)
35
- ext = File.extname(path).delete_prefix(".").downcase
36
- decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
37
- stdout, = Runner.run!(["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%m %w %h %n\n", "#{decoder}:#{path}"], timeout: timeout)
34
+ input, ext, input_arg = imagemagick_input(path, frame: nil)
35
+ stdout, =
36
+ Runner.run!(
37
+ ["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%m %w %h %n\n", input_arg],
38
+ timeout: timeout,
39
+ read: [input]
40
+ )
38
41
  _magick_format, width, height, frames = stdout.each_line.first.to_s.split
39
42
  width = width.to_i
40
43
  height = height.to_i
41
44
  if max_pixels && width * height > Integer(max_pixels)
42
45
  raise LimitError, "image has #{width * height} pixels, exceeds #{max_pixels}"
43
46
  end
44
- { input_format: ext == "jpeg" ? "jpg" : ext, width: width, height: height, frames: frames.to_i, duration_ms: 0.0 }
47
+ { input_format: Formats.normalize(ext), width: width, height: height, frames: frames.to_i, duration_ms: 0.0 }
45
48
  end
46
49
 
47
50
  def thumbnail(input:, output:, width:, height:, format:, quality:, timeout: Runner::DEFAULT_TIMEOUT)
48
- resize_like(input: input, output: output, width: width, height: height, format: format, quality: quality, crop: :centre, timeout: timeout)
51
+ resize_like(
52
+ input: input,
53
+ output: output,
54
+ width: width,
55
+ height: height,
56
+ format: format,
57
+ quality: quality,
58
+ crop: :centre,
59
+ timeout: timeout
60
+ )
49
61
  end
50
62
 
51
63
  def resize_like(input:, output:, width:, height:, format:, quality:, crop: false, timeout: Runner::DEFAULT_TIMEOUT)
52
64
  command = convert_command
53
65
 
54
- input = PathSafety.ensure_imagemagick_input_file!(input)
55
- output = PathSafety.ensure_safe_output_path!(output).to_s
56
- output = PathSafety.ensure_imagemagick_safe!(output)
57
- ext = File.extname(input).delete_prefix(".").downcase
58
- decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
66
+ input, ext, input_arg = imagemagick_input(input, frame: 0)
67
+ output, output_arg = imagemagick_output(format, output)
59
68
 
60
69
  quality = validate_quality!(quality)
61
- argv = [command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{input}[0]", "-auto-orient"]
70
+ argv = [command, *IMAGEMAGICK_LIMIT_ARGS, input_arg, "-auto-orient"]
62
71
  if crop == :north
63
- argv.concat([
64
- "-gravity", "north",
65
- "-background", "transparent",
66
- "-thumbnail", "#{Integer(width)}x#{Integer(height)}^",
67
- "-crop", "#{Integer(width)}x#{Integer(height)}+0+0",
68
- "-unsharp", "2x0.5+0.7+0",
69
- "-interlace", "none"
70
- ])
72
+ argv.concat(
73
+ [
74
+ "-gravity",
75
+ "north",
76
+ "-background",
77
+ "transparent",
78
+ "-thumbnail",
79
+ "#{Integer(width)}x#{Integer(height)}^",
80
+ "-crop",
81
+ "#{Integer(width)}x#{Integer(height)}+0+0",
82
+ "-unsharp",
83
+ "2x0.5+0.7+0",
84
+ "-interlace",
85
+ "none"
86
+ ]
87
+ )
71
88
  else
72
- argv.concat([
73
- "-gravity", "center",
74
- "-background", "transparent",
75
- "-thumbnail", "#{Integer(width)}x#{Integer(height)}^",
76
- "-extent", "#{Integer(width)}x#{Integer(height)}",
77
- "-interpolate", "catrom",
78
- "-unsharp", "2x0.5+0.7+0",
79
- "-interlace", "none"
80
- ])
89
+ argv.concat(
90
+ [
91
+ "-gravity",
92
+ "center",
93
+ "-background",
94
+ "transparent",
95
+ "-thumbnail",
96
+ "#{Integer(width)}x#{Integer(height)}^",
97
+ "-extent",
98
+ "#{Integer(width)}x#{Integer(height)}",
99
+ "-interpolate",
100
+ "catrom",
101
+ "-unsharp",
102
+ "2x0.5+0.7+0",
103
+ "-interlace",
104
+ "none"
105
+ ]
106
+ )
81
107
  end
82
108
  argv.concat(["-profile", DEFAULT_PROFILE]) if File.file?(DEFAULT_PROFILE)
83
109
  argv.concat(["-quality", quality.to_s]) if quality
84
- argv << output_spec(format, output)
110
+ argv << output_arg
85
111
 
86
- run_image_command(argv, output, ext, format, timeout)
112
+ run_image_command(argv, output, ext, format, timeout, read: [input])
87
113
  end
88
114
 
89
115
  def downsize(input:, output:, dimensions:, format:, timeout: Runner::DEFAULT_TIMEOUT)
90
116
  command = convert_command
91
117
 
92
- input = PathSafety.ensure_imagemagick_input_file!(input)
93
- output = PathSafety.ensure_safe_output_path!(output).to_s
94
- output = PathSafety.ensure_imagemagick_safe!(output)
95
- ext = File.extname(input).delete_prefix(".").downcase
96
- decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
118
+ input, ext, input_arg = imagemagick_input(input, frame: 0)
119
+ output, output_arg = imagemagick_output(format, output)
97
120
  dimensions = validate_dimensions!(dimensions)
98
121
  argv = [
99
- command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{input}[0]",
122
+ command,
123
+ *IMAGEMAGICK_LIMIT_ARGS,
124
+ input_arg,
100
125
  "-auto-orient",
101
- "-gravity", "center",
102
- "-background", "transparent",
103
- "-interlace", "none",
104
- "-resize", dimensions,
126
+ "-gravity",
127
+ "center",
128
+ "-background",
129
+ "transparent",
130
+ "-interlace",
131
+ "none",
132
+ "-resize",
133
+ dimensions
105
134
  ]
106
135
  argv.concat(["-profile", DEFAULT_PROFILE]) if File.file?(DEFAULT_PROFILE)
107
- argv << output_spec(format, output)
108
- run_image_command(argv, output, ext, format, timeout)
136
+ argv << output_arg
137
+ run_image_command(argv, output, ext, format, timeout, read: [input])
109
138
  end
110
139
 
111
140
  def convert(input:, output:, format:, quality: nil, timeout: Runner::DEFAULT_TIMEOUT)
112
141
  command = convert_command
113
- input = PathSafety.ensure_imagemagick_input_file!(input)
114
- output = PathSafety.ensure_safe_output_path!(output).to_s
115
- output = PathSafety.ensure_imagemagick_safe!(output)
116
- ext = File.extname(input).delete_prefix(".").downcase
117
- decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
118
- normalized_format = format.to_s.downcase
119
- normalized_format = "jpg" if normalized_format == "jpeg"
120
- output_arg = output_spec(normalized_format, output)
142
+ input, ext, input_arg = imagemagick_input(input, frame: 0)
143
+ normalized_format = Formats.normalize(format)
144
+ output, output_arg = imagemagick_output(normalized_format, output)
121
145
  quality = validate_quality!(quality)
122
146
 
123
- argv = [command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{input}[0]", "-auto-orient", "-interlace", "none"]
124
- argv.concat(["-background", "white", "-flatten"]) if normalized_format == "jpg"
147
+ argv = [command, *IMAGEMAGICK_LIMIT_ARGS, input_arg, "-auto-orient", "-interlace", "none"]
148
+ argv.concat(%w[-background white -flatten]) if normalized_format == "jpg"
125
149
  argv.concat(["-quality", quality.to_s]) if quality
126
150
  argv << output_arg
127
- run_image_command(argv, output, ext, normalized_format, timeout)
128
- end
129
-
130
- def convert_to_jpeg(input:, output:, quality: nil, timeout: Runner::DEFAULT_TIMEOUT)
131
- convert(input: input, output: output, format: "jpg", quality: quality, timeout: timeout)
151
+ run_image_command(argv, output, ext, normalized_format, timeout, read: [input])
132
152
  end
133
153
 
134
154
  def convert_ico_to_png(input:, output:, timeout: Runner::DEFAULT_TIMEOUT)
135
155
  command = convert_command
136
- input = PathSafety.ensure_imagemagick_input_file!(input)
137
- output = PathSafety.ensure_safe_output_path!(output).to_s
138
- output = PathSafety.ensure_imagemagick_safe!(output)
139
- argv = [command, *IMAGEMAGICK_LIMIT_ARGS, "ico:#{input}[-1]", "-auto-orient", "-background", "transparent", output_spec("png", output)]
140
- run_image_command(argv, output, "ico", "png", timeout)
156
+ input, ext, input_arg = imagemagick_input(input, frame: -1)
157
+ raise UnsupportedFormatError, "convert_favicon_to_png requires ico input, got #{ext.inspect}" unless ext == "ico"
158
+
159
+ output, output_arg = imagemagick_output("png", output)
160
+ argv = [command, *IMAGEMAGICK_LIMIT_ARGS, input_arg, "-auto-orient", "-background", "transparent", output_arg]
161
+ run_image_command(argv, output, "ico", "png", timeout, read: [input])
141
162
  end
142
163
 
143
164
  def frame_count(path, timeout: Runner::DEFAULT_TIMEOUT, max_pixels: nil)
144
165
  raise UnsupportedFormatError, "ImageMagick identify not available" unless Runner.available?("identify")
145
- path = PathSafety.ensure_imagemagick_input_file!(path)
146
- ext = File.extname(path).delete_prefix(".").downcase
147
- decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
148
- stdout, = Runner.run!(["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%w %h %n\n", "#{decoder}:#{path}"], timeout: timeout)
166
+ input, _ext, input_arg = imagemagick_input(path, frame: nil)
167
+ stdout, =
168
+ Runner.run!(
169
+ ["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%w %h %n\n", input_arg],
170
+ timeout: timeout,
171
+ read: [input]
172
+ )
149
173
  width, height, frames = stdout.each_line.first.to_s.split.map(&:to_i)
150
174
  if max_pixels && width.to_i * height.to_i > Integer(max_pixels)
151
175
  raise LimitError, "image has #{width * height} pixels, exceeds #{max_pixels}"
@@ -155,37 +179,52 @@ module SafeImage
155
179
 
156
180
  def orientation(path, timeout: Runner::DEFAULT_TIMEOUT)
157
181
  raise UnsupportedFormatError, "ImageMagick identify not available" unless Runner.available?("identify")
158
- path = PathSafety.ensure_imagemagick_input_file!(path)
159
- ext = File.extname(path).delete_prefix(".").downcase
160
- decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
161
- stdout, = Runner.run!(
162
- ["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%[EXIF:Orientation]", "#{decoder}:#{path}[0]"],
163
- timeout: timeout
164
- )
182
+ input, _ext, input_arg = imagemagick_input(path, frame: 0)
183
+ stdout, =
184
+ Runner.run!(
185
+ ["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%[EXIF:Orientation]", input_arg],
186
+ timeout: timeout,
187
+ read: [input]
188
+ )
165
189
  value = stdout.to_s.strip
166
190
  value.empty? ? 1 : value.to_i
167
- rescue CommandError
191
+ rescue CommandError => e
192
+ raise unless missing_orientation_property?(e)
193
+
168
194
  1
169
195
  end
170
196
 
197
+ def missing_orientation_property?(error)
198
+ return false unless error.category == :exit_status
199
+
200
+ detail = [error.stderr, error.stdout, error.message].join("\n")
201
+ detail.match?(/EXIF:Orientation|unknown image property|no such (?:property|attribute)|undefined/i)
202
+ end
203
+
171
204
  # Averages the whole image down to one pixel and reports it as an RRGGBB
172
- # hex string, mirroring Discourse's Upload#calculate_dominant_color!.
205
+ # hex string.
173
206
  def dominant_color(path, timeout: Runner::DEFAULT_TIMEOUT)
174
207
  command = convert_command
175
- path = PathSafety.ensure_imagemagick_input_file!(path)
176
- ext = File.extname(path).delete_prefix(".").downcase
177
- decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
178
- stdout, = Runner.run!(
179
- [
180
- command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{path}[0]",
181
- "-depth", "8",
182
- "-resize", "1x1",
183
- "-define", "histogram:unique-colors=true",
184
- "-format", "%c",
185
- "histogram:info:"
186
- ],
187
- timeout: timeout
188
- )
208
+ input, _ext, input_arg = imagemagick_input(path, frame: 0)
209
+ stdout, =
210
+ Runner.run!(
211
+ [
212
+ command,
213
+ *IMAGEMAGICK_LIMIT_ARGS,
214
+ input_arg,
215
+ "-depth",
216
+ "8",
217
+ "-resize",
218
+ "1x1",
219
+ "-define",
220
+ "histogram:unique-colors=true",
221
+ "-format",
222
+ "%c",
223
+ "histogram:info:"
224
+ ],
225
+ timeout: timeout,
226
+ read: [input]
227
+ )
189
228
 
190
229
  # Typical output: `1: (110,116,93) #6F745E srgb(110,116,93)`. Alpha adds
191
230
  # two more hex digits; grayscale images report one channel (two digits,
@@ -193,66 +232,86 @@ module SafeImage
193
232
  digits = stdout[/#(\h+)/, 1]
194
233
  hex =
195
234
  case digits&.length
196
- when 6, 8 then digits[0, 6]
197
- when 2, 4 then digits[0, 2] * 3
235
+ when 6, 8
236
+ digits[0, 6]
237
+ when 2, 4
238
+ digits[0, 2] * 3
198
239
  end
199
- raise InvalidImageError, "could not parse dominant color from ImageMagick output: #{stdout.strip.inspect}" if hex.nil?
240
+ if hex.nil?
241
+ raise InvalidImageError, "could not parse dominant color from ImageMagick output: #{stdout.strip.inspect}"
242
+ end
200
243
  hex.upcase
201
244
  end
202
245
 
203
- def letter_avatar(output:, size:, background_rgb:, letter:, pointsize:, font: "NimbusSans-Regular", timeout: Runner::DEFAULT_TIMEOUT)
246
+ def letter_avatar(
247
+ output:,
248
+ size:,
249
+ background_rgb:,
250
+ letter:,
251
+ pointsize:,
252
+ font: "NimbusSans-Regular",
253
+ timeout: Runner::DEFAULT_TIMEOUT
254
+ )
204
255
  command = convert_command
205
- output = PathSafety.ensure_safe_output_path!(output).to_s
206
- output = PathSafety.ensure_imagemagick_safe!(output)
256
+ output, output_arg = imagemagick_output("png", output)
207
257
  rgb = Array(background_rgb).map { |v| Integer(v) }
208
258
  raise ArgumentError, "background_rgb must have three channels" unless rgb.length == 3
209
259
  glyph = letter.to_s.each_grapheme_cluster.first.to_s.gsub("%", "%%")
210
260
  font_name = font.to_s
211
- raise ArgumentError, "unsupported font: #{font_name.inspect}" unless ALLOWED_FONTS.include?(font_name)
261
+ if ALLOWED_FONTS.none? { |candidate| candidate == font_name }
262
+ raise ArgumentError, "unsupported font: #{font_name.inspect}"
263
+ end
212
264
 
213
265
  argv = [
214
- command, *IMAGEMAGICK_LIMIT_ARGS,
215
- "-size", "#{Integer(size)}x#{Integer(size)}",
266
+ command,
267
+ *IMAGEMAGICK_LIMIT_ARGS,
268
+ "-size",
269
+ "#{Integer(size)}x#{Integer(size)}",
216
270
  "xc:rgb(#{rgb[0]},#{rgb[1]},#{rgb[2]})",
217
- "-pointsize", Integer(pointsize).to_s,
218
- "-fill", "#FFFFFFCC",
219
- "-font", font_name,
220
- "-gravity", "Center",
221
- "-annotate", "-0+34", glyph,
222
- "-depth", "8",
223
- output_spec("png", output)
271
+ "-pointsize",
272
+ Integer(pointsize).to_s,
273
+ "-fill",
274
+ "#FFFFFFCC",
275
+ "-font",
276
+ font_name,
277
+ "-gravity",
278
+ "Center",
279
+ "-annotate",
280
+ "-0+34",
281
+ glyph,
282
+ "-depth",
283
+ "8",
284
+ output_arg
224
285
  ]
225
286
  run_image_command(argv, output, "generated", "png", timeout)
226
287
  end
227
288
 
228
- def fix_orientation(input:, output: input, timeout: Runner::DEFAULT_TIMEOUT)
289
+ def fix_orientation(input:, output:, timeout: Runner::DEFAULT_TIMEOUT)
229
290
  command = convert_command
291
+ input, ext, input_arg = imagemagick_input(input, frame: 0)
292
+ output, output_arg = imagemagick_output(ext, output)
293
+ argv = [command, *IMAGEMAGICK_LIMIT_ARGS, input_arg, "-auto-orient", output_arg]
294
+ run_image_command(argv, output, ext, ext, timeout, read: [input])
295
+ end
296
+
297
+ def imagemagick_input(input, frame:)
230
298
  input = PathSafety.ensure_imagemagick_input_file!(input)
299
+ ext = Formats.extension(input)
300
+ decoder = Formats.imagemagick_decoder(ext)
301
+ frame_suffix = frame.nil? ? "" : "[#{Integer(frame)}]"
302
+ [input, ext, "#{decoder}:#{input}#{frame_suffix}"]
303
+ end
304
+
305
+ def imagemagick_output(format, output)
231
306
  output = PathSafety.ensure_safe_output_path!(output).to_s
232
307
  output = PathSafety.ensure_imagemagick_safe!(output)
233
- ext = File.extname(input).delete_prefix(".").downcase
234
- decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
235
- argv = [command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{input}[0]", "-auto-orient", output_spec(ext, output)]
236
- run_image_command(argv, output, ext, ext, timeout)
237
- end
308
+ normalized = Formats.normalize(format)
309
+ ext = Formats.extension(output)
310
+ unless ext == normalized
311
+ raise UnsupportedFormatError, "output extension #{ext.inspect} does not match format #{normalized.inspect}"
312
+ end
238
313
 
239
- def output_spec(format, output)
240
- ext = File.extname(output).delete_prefix(".").downcase
241
- ext = "jpg" if ext == "jpeg"
242
- normalized = format.to_s.downcase
243
- normalized = "jpg" if normalized == "jpeg"
244
- raise UnsupportedFormatError, "output extension #{ext.inspect} does not match format #{normalized.inspect}" unless ext == normalized
245
-
246
- coder = {
247
- "jpg" => "jpeg",
248
- "png" => "png",
249
- "gif" => "gif",
250
- "webp" => "webp",
251
- "avif" => "avif",
252
- "ico" => "ico",
253
- "jxl" => "jxl"
254
- }.fetch(normalized) { raise UnsupportedFormatError, "unsupported ImageMagick output format: #{normalized.inspect}" }
255
- "#{coder}:#{output}"
314
+ [output, "#{Formats.imagemagick_output_coder(normalized)}:#{output}"]
256
315
  end
257
316
 
258
317
  def validate_quality!(quality)
@@ -264,12 +323,10 @@ module SafeImage
264
323
 
265
324
  def validate_dimensions!(dimensions)
266
325
  dimensions = dimensions.to_s
267
- patterns = [
268
- /\A\d+(?:\.\d+)?%\z/,
269
- /\A\d+x\d+[!<>^]?\z/,
270
- /\A\d+@\z/
271
- ]
272
- raise ArgumentError, "unsupported ImageMagick geometry: #{dimensions.inspect}" unless patterns.any? { |pattern| pattern.match?(dimensions) }
326
+ patterns = [/\A\d+(?:\.\d+)?%\z/, /\A\d+x\d+[!<>^]?\z/, /\A\d+@\z/]
327
+ unless patterns.any? { |pattern| pattern.match?(dimensions) }
328
+ raise ArgumentError, "unsupported ImageMagick geometry: #{dimensions.inspect}"
329
+ end
273
330
  dimensions
274
331
  end
275
332
 
@@ -279,17 +336,17 @@ module SafeImage
279
336
  raise UnsupportedFormatError, "ImageMagick convert/magick not available"
280
337
  end
281
338
 
282
- def run_image_command(argv, output, input_format, output_format, timeout)
339
+ def run_image_command(argv, output, input_format, output_format, timeout, read: [])
283
340
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
284
- Runner.run!(argv, timeout: timeout)
341
+ Runner.run!(argv, timeout: timeout, read: read, write: [File.dirname(output), output])
285
342
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
286
343
 
287
- # Output dimensions via the fast native header read, or identify when
288
- # libvips is not installed (this backend must work without it).
289
- info = VipsGlue.available? ? Native.probe(output) : probe(output)
344
+ # Output dimensions via the helper's native header read, or identify when
345
+ # the helper is unavailable (this backend must work without libvips).
346
+ info = Native.available? ? Native.probe(output) : probe(output)
290
347
  {
291
- input_format: input_format == "jpeg" ? "jpg" : input_format,
292
- output_format: output_format == "jpeg" ? "jpg" : output_format,
348
+ input_format: input_format == "generated" ? "generated" : Formats.normalize(input_format),
349
+ output_format: Formats.normalize(output_format),
293
350
  width: info.fetch(:width),
294
351
  height: info.fetch(:height),
295
352
  duration_ms: duration_ms