safe_image 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -10
- data/README.md +176 -168
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +43 -41
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -281
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +250 -48
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +367 -97
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +69 -24
- data/lib/safe_image/sandbox.rb +44 -191
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +186 -55
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -294
- metadata +48 -26
- data/lib/safe_image/discourse_compat.rb +0 -452
- data/lib/safe_image/svg_sanitizer.rb +0 -102
- data/lib/safe_image/vips_glue.rb +0 -361
|
@@ -5,147 +5,171 @@ module SafeImage
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
DEFAULT_PROFILE = File.expand_path("RT_sRGB.icm", __dir__)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
55
|
-
output =
|
|
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,
|
|
70
|
+
argv = [command, *IMAGEMAGICK_LIMIT_ARGS, input_arg, "-auto-orient"]
|
|
62
71
|
if crop == :north
|
|
63
|
-
argv.concat(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 <<
|
|
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 =
|
|
93
|
-
output =
|
|
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,
|
|
122
|
+
command,
|
|
123
|
+
*IMAGEMAGICK_LIMIT_ARGS,
|
|
124
|
+
input_arg,
|
|
100
125
|
"-auto-orient",
|
|
101
|
-
"-gravity",
|
|
102
|
-
"
|
|
103
|
-
"-
|
|
104
|
-
"
|
|
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 <<
|
|
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 =
|
|
114
|
-
|
|
115
|
-
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,
|
|
124
|
-
argv.concat([
|
|
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 =
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
205
|
+
# hex string.
|
|
173
206
|
def dominant_color(path, timeout: Runner::DEFAULT_TIMEOUT)
|
|
174
207
|
command = convert_command
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
197
|
-
|
|
235
|
+
when 6, 8
|
|
236
|
+
digits[0, 6]
|
|
237
|
+
when 2, 4
|
|
238
|
+
digits[0, 2] * 3
|
|
198
239
|
end
|
|
199
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
215
|
-
|
|
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",
|
|
218
|
-
|
|
219
|
-
"-
|
|
220
|
-
"
|
|
221
|
-
"-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
288
|
-
#
|
|
289
|
-
info =
|
|
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 == "
|
|
292
|
-
output_format: 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
|