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.
@@ -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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "safe_image_native"