safe_image 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +703 -0
- data/SECURITY.md +48 -0
- data/ext/safe_image_native/extconf.rb +8 -0
- data/ext/safe_image_native/safe_image_native.c +392 -0
- data/lib/safe_image/RT_sRGB.icm +0 -0
- data/lib/safe_image/discourse_compat.rb +283 -0
- data/lib/safe_image/image_magick_backend.rb +263 -0
- data/lib/safe_image/imagemagick_policy/policy.xml +22 -0
- data/lib/safe_image/jpegli_backend.rb +109 -0
- data/lib/safe_image/native.rb +3 -0
- data/lib/safe_image/optimizer.rb +78 -0
- data/lib/safe_image/path_safety.rb +63 -0
- data/lib/safe_image/processor.rb +196 -0
- data/lib/safe_image/remote.rb +309 -0
- data/lib/safe_image/result.rb +28 -0
- data/lib/safe_image/runner.rb +174 -0
- data/lib/safe_image/sandbox.rb +236 -0
- data/lib/safe_image/svg_metadata.rb +132 -0
- data/lib/safe_image/svg_sanitizer.rb +102 -0
- data/lib/safe_image/version.rb +5 -0
- data/lib/safe_image/vips_backend.rb +50 -0
- data/lib/safe_image.rb +272 -0
- metadata +140 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module SafeImage
|
|
8
|
+
# Compatibility-shaped API for the operations Discourse currently performs in
|
|
9
|
+
# OptimizedImage, UploadCreator, ShrinkUploadedImage and FileHelper.
|
|
10
|
+
module DiscourseCompat
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def resize(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
|
|
14
|
+
if backend.to_sym == :vips
|
|
15
|
+
return SafeImage.thumbnail(
|
|
16
|
+
input: from,
|
|
17
|
+
output: to,
|
|
18
|
+
width: width,
|
|
19
|
+
height: height,
|
|
20
|
+
quality: quality || 85,
|
|
21
|
+
backend: backend,
|
|
22
|
+
optimize: optimize,
|
|
23
|
+
max_pixels: max_pixels,
|
|
24
|
+
encoder: encoder,
|
|
25
|
+
chroma_subsampling: chroma_subsampling
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
probe = compat_probe(from, backend: :imagemagick, max_pixels: max_pixels)
|
|
30
|
+
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
31
|
+
info = ImageMagickBackend.thumbnail(
|
|
32
|
+
input: probe.input,
|
|
33
|
+
output: output,
|
|
34
|
+
width: width,
|
|
35
|
+
height: height,
|
|
36
|
+
format: File.extname(output).delete_prefix(".").downcase,
|
|
37
|
+
quality: quality
|
|
38
|
+
)
|
|
39
|
+
Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: quality) if optimize
|
|
40
|
+
result_from_info(probe.input, output, info, "imagemagick")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def crop(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
|
|
44
|
+
probe = compat_probe(from, backend: backend, max_pixels: max_pixels)
|
|
45
|
+
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
46
|
+
format = File.extname(output).delete_prefix(".").downcase
|
|
47
|
+
|
|
48
|
+
info =
|
|
49
|
+
if backend.to_sym == :vips && use_jpegli_for_generated_jpeg?(format, backend, encoder)
|
|
50
|
+
with_temp_png(output) do |tmp_path|
|
|
51
|
+
VipsBackend.crop_north(
|
|
52
|
+
input: probe.input,
|
|
53
|
+
output: tmp_path,
|
|
54
|
+
width: width,
|
|
55
|
+
height: height,
|
|
56
|
+
format: "png",
|
|
57
|
+
quality: 100,
|
|
58
|
+
max_pixels: max_pixels
|
|
59
|
+
)
|
|
60
|
+
JpegliBackend.encode(
|
|
61
|
+
input: tmp_path,
|
|
62
|
+
output: output,
|
|
63
|
+
quality: quality || JpegliBackend::DEFAULT_QUALITY,
|
|
64
|
+
chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(chroma_subsampling, input_format: probe.input_format),
|
|
65
|
+
input_format: probe.input_format
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
elsif backend.to_sym == :vips
|
|
69
|
+
VipsBackend.crop_north(
|
|
70
|
+
input: probe.input,
|
|
71
|
+
output: output,
|
|
72
|
+
width: width,
|
|
73
|
+
height: height,
|
|
74
|
+
format: format,
|
|
75
|
+
quality: quality || 85,
|
|
76
|
+
max_pixels: max_pixels
|
|
77
|
+
)
|
|
78
|
+
else
|
|
79
|
+
ImageMagickBackend.resize_like(
|
|
80
|
+
input: probe.input,
|
|
81
|
+
output: output,
|
|
82
|
+
width: width,
|
|
83
|
+
height: height,
|
|
84
|
+
format: format,
|
|
85
|
+
quality: quality,
|
|
86
|
+
crop: :north
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: quality) if optimize
|
|
90
|
+
result_from_info(probe.input, output, info, compat_backend_name(backend, info))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def downsize(from, to, dimensions, backend: :imagemagick, optimize: true, max_pixels: nil, quality: 85, encoder: :auto, chroma_subsampling: :auto)
|
|
94
|
+
probe = compat_probe(from, backend: backend, max_pixels: max_pixels)
|
|
95
|
+
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
96
|
+
format = File.extname(output).delete_prefix(".").downcase
|
|
97
|
+
info =
|
|
98
|
+
if backend.to_sym == :vips && use_jpegli_for_generated_jpeg?(format, backend, encoder)
|
|
99
|
+
with_temp_png(output) do |tmp_path|
|
|
100
|
+
VipsBackend.downsize(
|
|
101
|
+
input: probe.input,
|
|
102
|
+
output: tmp_path,
|
|
103
|
+
dimensions: dimensions,
|
|
104
|
+
format: "png",
|
|
105
|
+
quality: 100,
|
|
106
|
+
max_pixels: max_pixels
|
|
107
|
+
)
|
|
108
|
+
JpegliBackend.encode(
|
|
109
|
+
input: tmp_path,
|
|
110
|
+
output: output,
|
|
111
|
+
quality: quality,
|
|
112
|
+
chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(chroma_subsampling, input_format: probe.input_format),
|
|
113
|
+
input_format: probe.input_format
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
elsif backend.to_sym == :vips
|
|
117
|
+
VipsBackend.downsize(
|
|
118
|
+
input: probe.input,
|
|
119
|
+
output: output,
|
|
120
|
+
dimensions: dimensions,
|
|
121
|
+
format: format,
|
|
122
|
+
quality: quality,
|
|
123
|
+
max_pixels: max_pixels
|
|
124
|
+
)
|
|
125
|
+
else
|
|
126
|
+
ImageMagickBackend.downsize(
|
|
127
|
+
input: probe.input,
|
|
128
|
+
output: output,
|
|
129
|
+
dimensions: dimensions,
|
|
130
|
+
format: format
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
Optimizer.optimize(output, mode: :lossless, strip_metadata: true) if optimize
|
|
134
|
+
result_from_info(probe.input, output, info, compat_backend_name(backend, info))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
|
|
138
|
+
probe = compat_probe(from, backend: :imagemagick, max_pixels: max_pixels)
|
|
139
|
+
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
140
|
+
normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
|
|
141
|
+
|
|
142
|
+
info =
|
|
143
|
+
if use_jpegli_for_convert?(probe.input, normalized_format, encoder)
|
|
144
|
+
JpegliBackend.convert(
|
|
145
|
+
input: probe.input,
|
|
146
|
+
output: output,
|
|
147
|
+
quality: quality || JpegliBackend::DEFAULT_QUALITY,
|
|
148
|
+
chroma_subsampling: chroma_subsampling
|
|
149
|
+
)
|
|
150
|
+
else
|
|
151
|
+
if encoder.to_sym == :cjpegli
|
|
152
|
+
raise UnsupportedFormatError, "cjpegli cannot directly encode #{File.extname(probe.input).delete_prefix(".").downcase.inspect}; use encoder: :auto or another encoder"
|
|
153
|
+
end
|
|
154
|
+
ImageMagickBackend.convert(input: probe.input, output: output, format: format, quality: quality)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: normalized_format == "jpg" ? quality : nil) if optimize && info[:encoder] != "cjpegli"
|
|
158
|
+
result_from_info(probe.input, output, info, info[:encoder] == "cjpegli" ? "cjpegli" : "imagemagick")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def use_jpegli_for_convert?(input, normalized_format, encoder)
|
|
162
|
+
encoder = encoder.to_sym
|
|
163
|
+
return false unless normalized_format == "jpg"
|
|
164
|
+
return false if encoder == :imagemagick
|
|
165
|
+
raise ArgumentError, "unknown encoder: #{encoder.inspect}" unless %i[auto cjpegli].include?(encoder)
|
|
166
|
+
return true if encoder == :cjpegli && JpegliBackend.suitable_direct_input?(input)
|
|
167
|
+
encoder == :auto && JpegliBackend.available? && JpegliBackend.suitable_direct_input?(input)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def use_jpegli_for_generated_jpeg?(format, backend, encoder)
|
|
171
|
+
encoder = encoder.to_sym
|
|
172
|
+
normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
|
|
173
|
+
return false unless normalized_format == "jpg"
|
|
174
|
+
return false if %i[vips imagemagick magick].include?(encoder)
|
|
175
|
+
raise ArgumentError, "unknown encoder: #{encoder.inspect}" unless %i[auto cjpegli].include?(encoder)
|
|
176
|
+
raise ArgumentError, "encoder: :cjpegli currently requires backend: :vips" if encoder == :cjpegli && backend.to_sym != :vips
|
|
177
|
+
encoder == :cjpegli || (backend.to_sym == :vips && JpegliBackend.available?)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def with_temp_png(output)
|
|
181
|
+
output_path = Pathname.new(output)
|
|
182
|
+
output_path.dirname.mkpath
|
|
183
|
+
Tempfile.create([output_path.basename(".*").to_s, ".safe-image.png"], output_path.dirname.to_s) do |tmp|
|
|
184
|
+
tmp_path = Pathname.new(tmp.path)
|
|
185
|
+
tmp.close
|
|
186
|
+
yield tmp_path
|
|
187
|
+
ensure
|
|
188
|
+
FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def compat_backend_name(backend, info)
|
|
193
|
+
base = backend.to_sym == :vips ? "libvips-direct" : "imagemagick"
|
|
194
|
+
info[:encoder] == "cjpegli" ? "#{base}+cjpegli" : base
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def convert_to_jpeg(from, to, quality: nil, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
|
|
198
|
+
convert(from, to, format: "jpg", quality: quality, optimize: optimize, max_pixels: max_pixels, encoder: encoder, chroma_subsampling: chroma_subsampling)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def fix_orientation(from, to = from, max_pixels: nil)
|
|
202
|
+
probe = compat_probe(from, backend: :imagemagick, max_pixels: max_pixels)
|
|
203
|
+
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
204
|
+
info = ImageMagickBackend.fix_orientation(input: probe.input, output: output)
|
|
205
|
+
result_from_info(probe.input, output, info, "imagemagick")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)
|
|
209
|
+
frame_count(from, max_pixels: max_pixels) if max_pixels
|
|
210
|
+
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
211
|
+
info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(from).expand_path.to_s, output: output)
|
|
212
|
+
Optimizer.optimize(output, mode: :lossless, strip_metadata: true) if optimize
|
|
213
|
+
result_from_info(from, output, info, "imagemagick")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def frame_count(path, max_pixels: nil)
|
|
217
|
+
ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def animated?(path, max_pixels: nil)
|
|
221
|
+
frame_count(path, max_pixels: max_pixels).to_i > 1
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "NimbusSans-Regular")
|
|
225
|
+
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
226
|
+
info = ImageMagickBackend.letter_avatar(
|
|
227
|
+
output: output,
|
|
228
|
+
size: size,
|
|
229
|
+
background_rgb: background_rgb,
|
|
230
|
+
letter: letter,
|
|
231
|
+
pointsize: pointsize,
|
|
232
|
+
font: font
|
|
233
|
+
)
|
|
234
|
+
result_from_info("generated", output, info, "imagemagick")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)
|
|
238
|
+
Optimizer.optimize(
|
|
239
|
+
path,
|
|
240
|
+
mode: allow_lossy_png ? :lossy : :lossless,
|
|
241
|
+
strip_metadata: strip_metadata,
|
|
242
|
+
quality: quality,
|
|
243
|
+
strict: strict
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def compat_probe(path, backend:, max_pixels: nil)
|
|
248
|
+
path = Pathname.new(path).expand_path.to_s
|
|
249
|
+
if backend.to_sym == :vips
|
|
250
|
+
SafeImage.probe(path, max_pixels: max_pixels)
|
|
251
|
+
else
|
|
252
|
+
info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
|
|
253
|
+
Result.new(
|
|
254
|
+
input: path,
|
|
255
|
+
output: nil,
|
|
256
|
+
input_format: info.fetch(:input_format),
|
|
257
|
+
output_format: nil,
|
|
258
|
+
width: info.fetch(:width),
|
|
259
|
+
height: info.fetch(:height),
|
|
260
|
+
filesize: File.size(path),
|
|
261
|
+
backend: "imagemagick",
|
|
262
|
+
duration_ms: info.fetch(:duration_ms),
|
|
263
|
+
optimizer: nil
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def result_from_info(input, output, info, backend)
|
|
269
|
+
Result.new(
|
|
270
|
+
input: input.to_s,
|
|
271
|
+
output: output.to_s,
|
|
272
|
+
input_format: info.fetch(:input_format),
|
|
273
|
+
output_format: info.fetch(:output_format),
|
|
274
|
+
width: info.fetch(:width),
|
|
275
|
+
height: info.fetch(:height),
|
|
276
|
+
filesize: File.size(output),
|
|
277
|
+
backend: backend,
|
|
278
|
+
duration_ms: info.fetch(:duration_ms),
|
|
279
|
+
optimizer: nil
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeImage
|
|
4
|
+
module ImageMagickBackend
|
|
5
|
+
module_function
|
|
6
|
+
|
|
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
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
IMAGEMAGICK_LIMIT_ARGS = [
|
|
21
|
+
"-limit", "memory", "256MiB",
|
|
22
|
+
"-limit", "map", "512MiB",
|
|
23
|
+
"-limit", "disk", "1GiB",
|
|
24
|
+
"-limit", "area", "128MP",
|
|
25
|
+
"-limit", "time", "20",
|
|
26
|
+
"-limit", "thread", "2"
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
ALLOWED_FONTS = %w[NimbusSans-Regular DejaVu-Sans Liberation-Sans Arial Helvetica Adwaita-Sans].freeze
|
|
30
|
+
|
|
31
|
+
def probe(path, timeout: Runner::DEFAULT_TIMEOUT, max_pixels: nil)
|
|
32
|
+
raise UnsupportedFormatError, "ImageMagick identify not available" unless Runner.available?("identify")
|
|
33
|
+
path = PathSafety.ensure_imagemagick_input_file!(path)
|
|
34
|
+
ext = File.extname(path).delete_prefix(".").downcase
|
|
35
|
+
decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
|
|
36
|
+
stdout, = Runner.run!(["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%m %w %h %n\n", "#{decoder}:#{path}"], timeout: timeout)
|
|
37
|
+
_magick_format, width, height, frames = stdout.each_line.first.to_s.split
|
|
38
|
+
width = width.to_i
|
|
39
|
+
height = height.to_i
|
|
40
|
+
if max_pixels && width * height > Integer(max_pixels)
|
|
41
|
+
raise LimitError, "image has #{width * height} pixels, exceeds #{max_pixels}"
|
|
42
|
+
end
|
|
43
|
+
{ input_format: ext == "jpeg" ? "jpg" : ext, width: width, height: height, frames: frames.to_i, duration_ms: 0.0 }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def thumbnail(input:, output:, width:, height:, format:, quality:, timeout: Runner::DEFAULT_TIMEOUT)
|
|
47
|
+
resize_like(input: input, output: output, width: width, height: height, format: format, quality: quality, crop: :centre, timeout: timeout)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resize_like(input:, output:, width:, height:, format:, quality:, crop: false, timeout: Runner::DEFAULT_TIMEOUT)
|
|
51
|
+
command = convert_command
|
|
52
|
+
|
|
53
|
+
input = PathSafety.ensure_imagemagick_input_file!(input)
|
|
54
|
+
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
55
|
+
output = PathSafety.ensure_imagemagick_safe!(output)
|
|
56
|
+
ext = File.extname(input).delete_prefix(".").downcase
|
|
57
|
+
decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
|
|
58
|
+
|
|
59
|
+
quality = validate_quality!(quality)
|
|
60
|
+
argv = [command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{input}[0]", "-auto-orient"]
|
|
61
|
+
if crop == :north
|
|
62
|
+
argv.concat([
|
|
63
|
+
"-gravity", "north",
|
|
64
|
+
"-background", "transparent",
|
|
65
|
+
"-thumbnail", "#{Integer(width)}x#{Integer(height)}^",
|
|
66
|
+
"-crop", "#{Integer(width)}x#{Integer(height)}+0+0",
|
|
67
|
+
"-unsharp", "2x0.5+0.7+0",
|
|
68
|
+
"-interlace", "none"
|
|
69
|
+
])
|
|
70
|
+
else
|
|
71
|
+
argv.concat([
|
|
72
|
+
"-gravity", "center",
|
|
73
|
+
"-background", "transparent",
|
|
74
|
+
"-thumbnail", "#{Integer(width)}x#{Integer(height)}^",
|
|
75
|
+
"-extent", "#{Integer(width)}x#{Integer(height)}",
|
|
76
|
+
"-interpolate", "catrom",
|
|
77
|
+
"-unsharp", "2x0.5+0.7+0",
|
|
78
|
+
"-interlace", "none"
|
|
79
|
+
])
|
|
80
|
+
end
|
|
81
|
+
argv.concat(["-profile", DEFAULT_PROFILE]) if File.file?(DEFAULT_PROFILE)
|
|
82
|
+
argv.concat(["-quality", quality.to_s]) if quality
|
|
83
|
+
argv << output_spec(format, output)
|
|
84
|
+
|
|
85
|
+
run_image_command(argv, output, ext, format, timeout)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def downsize(input:, output:, dimensions:, format:, timeout: Runner::DEFAULT_TIMEOUT)
|
|
89
|
+
command = convert_command
|
|
90
|
+
|
|
91
|
+
input = PathSafety.ensure_imagemagick_input_file!(input)
|
|
92
|
+
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
93
|
+
output = PathSafety.ensure_imagemagick_safe!(output)
|
|
94
|
+
ext = File.extname(input).delete_prefix(".").downcase
|
|
95
|
+
decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
|
|
96
|
+
dimensions = validate_dimensions!(dimensions)
|
|
97
|
+
argv = [
|
|
98
|
+
command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{input}[0]",
|
|
99
|
+
"-auto-orient",
|
|
100
|
+
"-gravity", "center",
|
|
101
|
+
"-background", "transparent",
|
|
102
|
+
"-interlace", "none",
|
|
103
|
+
"-resize", dimensions,
|
|
104
|
+
]
|
|
105
|
+
argv.concat(["-profile", DEFAULT_PROFILE]) if File.file?(DEFAULT_PROFILE)
|
|
106
|
+
argv << output_spec(format, output)
|
|
107
|
+
run_image_command(argv, output, ext, format, timeout)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def convert(input:, output:, format:, quality: nil, timeout: Runner::DEFAULT_TIMEOUT)
|
|
111
|
+
command = convert_command
|
|
112
|
+
input = PathSafety.ensure_imagemagick_input_file!(input)
|
|
113
|
+
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
114
|
+
output = PathSafety.ensure_imagemagick_safe!(output)
|
|
115
|
+
ext = File.extname(input).delete_prefix(".").downcase
|
|
116
|
+
decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
|
|
117
|
+
normalized_format = format.to_s.downcase
|
|
118
|
+
normalized_format = "jpg" if normalized_format == "jpeg"
|
|
119
|
+
output_arg = output_spec(normalized_format, output)
|
|
120
|
+
quality = validate_quality!(quality)
|
|
121
|
+
|
|
122
|
+
argv = [command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{input}[0]", "-auto-orient", "-interlace", "none"]
|
|
123
|
+
argv.concat(["-background", "white", "-flatten"]) if normalized_format == "jpg"
|
|
124
|
+
argv.concat(["-quality", quality.to_s]) if quality
|
|
125
|
+
argv << output_arg
|
|
126
|
+
run_image_command(argv, output, ext, normalized_format, timeout)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def convert_to_jpeg(input:, output:, quality: nil, timeout: Runner::DEFAULT_TIMEOUT)
|
|
130
|
+
convert(input: input, output: output, format: "jpg", quality: quality, timeout: timeout)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def convert_ico_to_png(input:, output:, timeout: Runner::DEFAULT_TIMEOUT)
|
|
134
|
+
command = convert_command
|
|
135
|
+
input = PathSafety.ensure_imagemagick_input_file!(input)
|
|
136
|
+
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
137
|
+
output = PathSafety.ensure_imagemagick_safe!(output)
|
|
138
|
+
argv = [command, *IMAGEMAGICK_LIMIT_ARGS, "ico:#{input}[-1]", "-auto-orient", "-background", "transparent", output_spec("png", output)]
|
|
139
|
+
run_image_command(argv, output, "ico", "png", timeout)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def frame_count(path, timeout: Runner::DEFAULT_TIMEOUT, max_pixels: nil)
|
|
143
|
+
raise UnsupportedFormatError, "ImageMagick identify not available" unless Runner.available?("identify")
|
|
144
|
+
path = PathSafety.ensure_imagemagick_input_file!(path)
|
|
145
|
+
ext = File.extname(path).delete_prefix(".").downcase
|
|
146
|
+
decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
|
|
147
|
+
stdout, = Runner.run!(["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%w %h %n\n", "#{decoder}:#{path}"], timeout: timeout)
|
|
148
|
+
width, height, frames = stdout.each_line.first.to_s.split.map(&:to_i)
|
|
149
|
+
if max_pixels && width.to_i * height.to_i > Integer(max_pixels)
|
|
150
|
+
raise LimitError, "image has #{width * height} pixels, exceeds #{max_pixels}"
|
|
151
|
+
end
|
|
152
|
+
frames.to_i
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def orientation(path, timeout: Runner::DEFAULT_TIMEOUT)
|
|
156
|
+
raise UnsupportedFormatError, "ImageMagick identify not available" unless Runner.available?("identify")
|
|
157
|
+
path = PathSafety.ensure_imagemagick_input_file!(path)
|
|
158
|
+
ext = File.extname(path).delete_prefix(".").downcase
|
|
159
|
+
decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
|
|
160
|
+
stdout, = Runner.run!(
|
|
161
|
+
["identify", *IMAGEMAGICK_LIMIT_ARGS, "-ping", "-format", "%[EXIF:Orientation]", "#{decoder}:#{path}[0]"],
|
|
162
|
+
timeout: timeout
|
|
163
|
+
)
|
|
164
|
+
value = stdout.to_s.strip
|
|
165
|
+
value.empty? ? 1 : value.to_i
|
|
166
|
+
rescue CommandError
|
|
167
|
+
1
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def letter_avatar(output:, size:, background_rgb:, letter:, pointsize:, font: "NimbusSans-Regular", timeout: Runner::DEFAULT_TIMEOUT)
|
|
171
|
+
command = convert_command
|
|
172
|
+
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
173
|
+
output = PathSafety.ensure_imagemagick_safe!(output)
|
|
174
|
+
rgb = Array(background_rgb).map { |v| Integer(v) }
|
|
175
|
+
raise ArgumentError, "background_rgb must have three channels" unless rgb.length == 3
|
|
176
|
+
glyph = letter.to_s.each_grapheme_cluster.first.to_s.gsub("%", "%%")
|
|
177
|
+
font_name = font.to_s
|
|
178
|
+
raise ArgumentError, "unsupported font: #{font_name.inspect}" unless ALLOWED_FONTS.include?(font_name)
|
|
179
|
+
|
|
180
|
+
argv = [
|
|
181
|
+
command, *IMAGEMAGICK_LIMIT_ARGS,
|
|
182
|
+
"-size", "#{Integer(size)}x#{Integer(size)}",
|
|
183
|
+
"xc:rgb(#{rgb[0]},#{rgb[1]},#{rgb[2]})",
|
|
184
|
+
"-pointsize", Integer(pointsize).to_s,
|
|
185
|
+
"-fill", "#FFFFFFCC",
|
|
186
|
+
"-font", font_name,
|
|
187
|
+
"-gravity", "Center",
|
|
188
|
+
"-annotate", "-0+34", glyph,
|
|
189
|
+
"-depth", "8",
|
|
190
|
+
output_spec("png", output)
|
|
191
|
+
]
|
|
192
|
+
run_image_command(argv, output, "generated", "png", timeout)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def fix_orientation(input:, output: input, timeout: Runner::DEFAULT_TIMEOUT)
|
|
196
|
+
command = convert_command
|
|
197
|
+
input = PathSafety.ensure_imagemagick_input_file!(input)
|
|
198
|
+
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
199
|
+
output = PathSafety.ensure_imagemagick_safe!(output)
|
|
200
|
+
ext = File.extname(input).delete_prefix(".").downcase
|
|
201
|
+
decoder = DECODERS.fetch(ext) { raise UnsupportedFormatError, "unsupported ImageMagick input format: #{ext.inspect}" }
|
|
202
|
+
argv = [command, *IMAGEMAGICK_LIMIT_ARGS, "#{decoder}:#{input}[0]", "-auto-orient", output_spec(ext, output)]
|
|
203
|
+
run_image_command(argv, output, ext, ext, timeout)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def output_spec(format, output)
|
|
207
|
+
ext = File.extname(output).delete_prefix(".").downcase
|
|
208
|
+
ext = "jpg" if ext == "jpeg"
|
|
209
|
+
normalized = format.to_s.downcase
|
|
210
|
+
normalized = "jpg" if normalized == "jpeg"
|
|
211
|
+
raise UnsupportedFormatError, "output extension #{ext.inspect} does not match format #{normalized.inspect}" unless ext == normalized
|
|
212
|
+
|
|
213
|
+
coder = {
|
|
214
|
+
"jpg" => "jpeg",
|
|
215
|
+
"png" => "png",
|
|
216
|
+
"gif" => "gif",
|
|
217
|
+
"webp" => "webp",
|
|
218
|
+
"avif" => "avif",
|
|
219
|
+
"ico" => "ico"
|
|
220
|
+
}.fetch(normalized) { raise UnsupportedFormatError, "unsupported ImageMagick output format: #{normalized.inspect}" }
|
|
221
|
+
"#{coder}:#{output}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def validate_quality!(quality)
|
|
225
|
+
return nil if quality.nil?
|
|
226
|
+
quality = Integer(quality)
|
|
227
|
+
raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
|
|
228
|
+
quality
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def validate_dimensions!(dimensions)
|
|
232
|
+
dimensions = dimensions.to_s
|
|
233
|
+
patterns = [
|
|
234
|
+
/\A\d+(?:\.\d+)?%\z/,
|
|
235
|
+
/\A\d+x\d+[!<>^]?\z/,
|
|
236
|
+
/\A\d+@\z/
|
|
237
|
+
]
|
|
238
|
+
raise ArgumentError, "unsupported ImageMagick geometry: #{dimensions.inspect}" unless patterns.any? { |pattern| pattern.match?(dimensions) }
|
|
239
|
+
dimensions
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def convert_command
|
|
243
|
+
Runner.available?("magick") ? "magick" : Runner.resolve_executable!("convert") && "convert"
|
|
244
|
+
rescue UnsupportedFormatError
|
|
245
|
+
raise UnsupportedFormatError, "ImageMagick convert/magick not available"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def run_image_command(argv, output, input_format, output_format, timeout)
|
|
249
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
250
|
+
Runner.run!(argv, timeout: timeout)
|
|
251
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
|
|
252
|
+
|
|
253
|
+
info = Native.probe(output)
|
|
254
|
+
{
|
|
255
|
+
input_format: input_format == "jpeg" ? "jpg" : input_format,
|
|
256
|
+
output_format: output_format == "jpeg" ? "jpg" : output_format,
|
|
257
|
+
width: info.fetch(:width),
|
|
258
|
+
height: info.fetch(:height),
|
|
259
|
+
duration_ms: duration_ms
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<policymap>
|
|
2
|
+
<!-- Defensive ImageMagick policy for safe_image compatibility
|
|
3
|
+
backend. We only need raster image coders used explicitly by this gem. -->
|
|
4
|
+
<policy domain="delegate" rights="none" pattern="*" />
|
|
5
|
+
<policy domain="filter" rights="none" pattern="*" />
|
|
6
|
+
<policy domain="path" rights="none" pattern="@*" />
|
|
7
|
+
|
|
8
|
+
<policy domain="coder" rights="none" pattern="*" />
|
|
9
|
+
<policy domain="coder" rights="read|write" pattern="{JPEG,JPG,PNG,GIF,WEBP,HEIC,HEIF,AVIF,ICO,ICC,ICM,XC}" />
|
|
10
|
+
|
|
11
|
+
<!-- Ghostscript-backed / document / vector-ish formats: deny explicitly even
|
|
12
|
+
if a broader system policy is present. -->
|
|
13
|
+
<policy domain="coder" rights="none" pattern="{PS,PS2,PS3,EPS,EPSF,PDF,XPS,PCL,MSL,MVG,HTTPS,HTTP,URL,TEXT,LABEL}" />
|
|
14
|
+
<policy domain="module" rights="none" pattern="{PS,PS2,PS3,EPS,EPSF,PDF,XPS,PCL,MSL,MVG,HTTPS,HTTP,URL}" />
|
|
15
|
+
|
|
16
|
+
<policy domain="resource" name="memory" value="512MiB" />
|
|
17
|
+
<policy domain="resource" name="map" value="512MiB" />
|
|
18
|
+
<policy domain="resource" name="disk" value="1GiB" />
|
|
19
|
+
<policy domain="resource" name="file" value="128" />
|
|
20
|
+
<policy domain="resource" name="thread" value="2" />
|
|
21
|
+
<policy domain="resource" name="time" value="30" />
|
|
22
|
+
</policymap>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module SafeImage
|
|
8
|
+
module JpegliBackend
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
DIRECT_INPUTS = %w[png].freeze
|
|
12
|
+
CHROMA_SUBSAMPLING = %w[420 422 444].freeze
|
|
13
|
+
DEFAULT_QUALITY = 85
|
|
14
|
+
|
|
15
|
+
def available?
|
|
16
|
+
Runner.available?("cjpegli")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def suitable_direct_input?(input)
|
|
20
|
+
DIRECT_INPUTS.include?(normalized_ext(input))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def convert(input:, output:, quality: DEFAULT_QUALITY, chroma_subsampling: :auto, timeout: Runner::DEFAULT_TIMEOUT)
|
|
24
|
+
raise UnsupportedFormatError, "cjpegli is not installed" unless available?
|
|
25
|
+
|
|
26
|
+
input = PathSafety.ensure_regular_file!(input)
|
|
27
|
+
output = PathSafety.ensure_safe_output_path!(output).to_s
|
|
28
|
+
ensure_jpeg_output!(output)
|
|
29
|
+
|
|
30
|
+
input_format = normalized_ext(input)
|
|
31
|
+
unless DIRECT_INPUTS.include?(input_format)
|
|
32
|
+
raise UnsupportedFormatError, "cjpegli direct input format is unsupported: #{input_format.inspect}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
quality = validate_quality!(quality)
|
|
36
|
+
chroma_subsampling = validate_chroma_subsampling!(chroma_subsampling, input_format: input_format)
|
|
37
|
+
encode(input: input, output: output, quality: quality, chroma_subsampling: chroma_subsampling, timeout: timeout, input_format: input_format)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def encode(input:, output:, quality: DEFAULT_QUALITY, chroma_subsampling: "420", timeout: Runner::DEFAULT_TIMEOUT, input_format: nil)
|
|
41
|
+
raise UnsupportedFormatError, "cjpegli is not installed" unless available?
|
|
42
|
+
|
|
43
|
+
input = PathSafety.ensure_regular_file!(input)
|
|
44
|
+
output_path = PathSafety.ensure_safe_output_path!(output)
|
|
45
|
+
ensure_jpeg_output!(output_path)
|
|
46
|
+
output_path.dirname.mkpath
|
|
47
|
+
|
|
48
|
+
input_format ||= normalized_ext(input)
|
|
49
|
+
quality = validate_quality!(quality)
|
|
50
|
+
chroma_subsampling = validate_chroma_subsampling!(chroma_subsampling, input_format: input_format)
|
|
51
|
+
|
|
52
|
+
tmp = Tempfile.new([output_path.basename(".*").to_s, ".cjpegli.jpg"], output_path.dirname.to_s)
|
|
53
|
+
tmp_path = Pathname.new(tmp.path)
|
|
54
|
+
tmp.close
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
argv = [
|
|
58
|
+
"cjpegli",
|
|
59
|
+
input.to_s,
|
|
60
|
+
tmp_path.to_s,
|
|
61
|
+
"--quality=#{quality}",
|
|
62
|
+
"--chroma_subsampling=#{chroma_subsampling}"
|
|
63
|
+
]
|
|
64
|
+
Runner.run!(argv, timeout: timeout, read: [input.to_s], write: [output_path.dirname.to_s])
|
|
65
|
+
raise Error, "cjpegli did not create output" unless tmp_path.file? && File.size(tmp_path).positive?
|
|
66
|
+
FileUtils.mv(tmp_path, output_path)
|
|
67
|
+
|
|
68
|
+
info = Native.probe(output_path.to_s)
|
|
69
|
+
{
|
|
70
|
+
input_format: input_format,
|
|
71
|
+
output_format: "jpg",
|
|
72
|
+
width: info.fetch(:width),
|
|
73
|
+
height: info.fetch(:height),
|
|
74
|
+
duration_ms: info.fetch(:duration_ms),
|
|
75
|
+
encoder: "cjpegli",
|
|
76
|
+
chroma_subsampling: chroma_subsampling
|
|
77
|
+
}
|
|
78
|
+
ensure
|
|
79
|
+
FileUtils.rm_f(tmp_path)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_quality!(quality)
|
|
84
|
+
quality = DEFAULT_QUALITY if quality.nil?
|
|
85
|
+
quality = Integer(quality)
|
|
86
|
+
raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
|
|
87
|
+
quality
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_chroma_subsampling!(value, input_format: nil)
|
|
91
|
+
value = :auto if value.nil?
|
|
92
|
+
value = "444" if value.to_sym == :auto && input_format.to_s == "png"
|
|
93
|
+
value = "420" if value.to_sym == :auto
|
|
94
|
+
value = value.to_s
|
|
95
|
+
raise ArgumentError, "chroma_subsampling must be one of #{CHROMA_SUBSAMPLING.join(", ")}" unless CHROMA_SUBSAMPLING.include?(value)
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ensure_jpeg_output!(output)
|
|
100
|
+
ext = normalized_ext(output)
|
|
101
|
+
raise UnsupportedFormatError, "cjpegli only outputs jpg/jpeg, got #{ext.inspect}" unless ext == "jpg"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalized_ext(path)
|
|
105
|
+
ext = File.extname(path.to_s).delete_prefix(".").downcase
|
|
106
|
+
ext == "jpeg" ? "jpg" : ext
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|