safe_image 0.3.0 → 0.5.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 +61 -12
- data/README.md +140 -287
- 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 +42 -40
- 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 -290
- 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 +225 -98
- 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 +248 -144
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +60 -23
- data/lib/safe_image/sandbox.rb +44 -218
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +74 -69
- 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 -306
- metadata +43 -37
- data/lib/safe_image/discourse_compat.rb +0 -441
- data/lib/safe_image/svg_css.rb +0 -314
- data/lib/safe_image/svg_sanitizer.rb +0 -583
- data/lib/safe_image/vips_glue.rb +0 -361
- data/lib/safe_image/zygote.rb +0 -619
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module SafeImage
|
|
8
|
+
module NativeHelper
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
DEFAULT_HELPER = File.expand_path("safe_image_vips_helper", __dir__).freeze
|
|
12
|
+
HELPER = ENV.fetch("SAFE_IMAGE_VIPS_HELPER", DEFAULT_HELPER).freeze
|
|
13
|
+
STDERR_TAIL_BYTES = 2000
|
|
14
|
+
|
|
15
|
+
def available?
|
|
16
|
+
verify!
|
|
17
|
+
true
|
|
18
|
+
rescue Error, CommandError, LoadError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ensure_available!
|
|
23
|
+
return if File.executable?(HELPER)
|
|
24
|
+
|
|
25
|
+
raise VipsUnavailableError, "compiled safe_image_vips_helper is missing or not executable: #{HELPER}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def verify!
|
|
29
|
+
return true if @verified
|
|
30
|
+
|
|
31
|
+
call!("version", sandbox: false)
|
|
32
|
+
@verified = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def probe(path, max_pixels)
|
|
36
|
+
call!("probe", input: path, max_pixels: max_pixels)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def thumbnail(input, output, width, height, format, quality, max_pixels)
|
|
40
|
+
call!(
|
|
41
|
+
"thumbnail",
|
|
42
|
+
input: input,
|
|
43
|
+
output: output,
|
|
44
|
+
width: width,
|
|
45
|
+
height: height,
|
|
46
|
+
format: format,
|
|
47
|
+
quality: quality,
|
|
48
|
+
max_pixels: max_pixels
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resize(input, output, scale, format, quality, max_pixels)
|
|
53
|
+
call!(
|
|
54
|
+
"resize",
|
|
55
|
+
input: input,
|
|
56
|
+
output: output,
|
|
57
|
+
scale: scale,
|
|
58
|
+
format: format,
|
|
59
|
+
quality: quality,
|
|
60
|
+
max_pixels: max_pixels
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def crop_north(input, output, width, height, format, quality, max_pixels)
|
|
65
|
+
call!(
|
|
66
|
+
"crop-north",
|
|
67
|
+
input: input,
|
|
68
|
+
output: output,
|
|
69
|
+
width: width,
|
|
70
|
+
height: height,
|
|
71
|
+
format: format,
|
|
72
|
+
quality: quality,
|
|
73
|
+
max_pixels: max_pixels
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def convert(input, output, format, quality, max_pixels)
|
|
78
|
+
call!("convert", input: input, output: output, format: format, quality: quality, max_pixels: max_pixels)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def png_from_rgba(raw_input, width, height, output)
|
|
82
|
+
call!("png-from-rgba", raw_input: raw_input, width: width, height: height, output: output)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def letter_avatar(output, size, red, green, blue, markup, font, fontfile)
|
|
86
|
+
call!(
|
|
87
|
+
"letter-avatar",
|
|
88
|
+
output: output,
|
|
89
|
+
size: size,
|
|
90
|
+
red: red,
|
|
91
|
+
green: green,
|
|
92
|
+
blue: blue,
|
|
93
|
+
markup: markup,
|
|
94
|
+
font: font,
|
|
95
|
+
fontfile: fontfile
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def dominant_color(path, max_pixels)
|
|
100
|
+
call!("dominant-color", input: path, max_pixels: max_pixels).fetch(:value)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def pages(path, max_pixels)
|
|
104
|
+
call!("pages", input: path, max_pixels: max_pixels).fetch(:value)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def orientation(path, max_pixels)
|
|
108
|
+
call!("orientation", input: path, max_pixels: max_pixels).fetch(:value)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def call!(command, sandbox: SafeImage.sandbox?, **options)
|
|
112
|
+
ensure_available!
|
|
113
|
+
|
|
114
|
+
Dir.mktmpdir("safe-image-vips-helper-") do |tmpdir|
|
|
115
|
+
response = File.join(tmpdir, "response.json")
|
|
116
|
+
argv = [HELPER, command, "--response", response]
|
|
117
|
+
options.each do |key, value|
|
|
118
|
+
next if value.nil?
|
|
119
|
+
|
|
120
|
+
argv << "--#{key.to_s.tr("_", "-")}" << value.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
stdout = stderr = nil
|
|
124
|
+
status = nil
|
|
125
|
+
if sandbox
|
|
126
|
+
begin
|
|
127
|
+
stdout, stderr, status = run_sandboxed!(argv, tmpdir, options)
|
|
128
|
+
rescue Sandbox.landlock_command_error => e
|
|
129
|
+
stdout = e.stdout
|
|
130
|
+
stderr = e.stderr
|
|
131
|
+
status = e.status
|
|
132
|
+
payload = read_response(response, stderr: stderr)
|
|
133
|
+
raise_helper_error(payload, status, command, stderr: stderr, stdout: stdout)
|
|
134
|
+
end
|
|
135
|
+
else
|
|
136
|
+
begin
|
|
137
|
+
stdout, stderr, status = run_process!(argv, tmpdir)
|
|
138
|
+
rescue CommandError => e
|
|
139
|
+
stdout = e.stdout
|
|
140
|
+
stderr = e.stderr
|
|
141
|
+
status = e.status
|
|
142
|
+
payload = read_response(response, stderr: stderr)
|
|
143
|
+
raise_helper_error(payload, status, command, stderr: stderr, stdout: stdout)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
payload = read_response(response, stderr: stderr)
|
|
148
|
+
helper_failed = status.respond_to?(:success?) ? !status.success? : false
|
|
149
|
+
raise_helper_error(payload, status, command, stderr: stderr, stdout: stdout) if helper_failed || !payload[:ok]
|
|
150
|
+
payload.reject { |key, _| key == :ok }
|
|
151
|
+
end
|
|
152
|
+
rescue LoadError
|
|
153
|
+
raise Error, "landlock sandbox requested but the landlock gem is unavailable"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def run_process!(argv, tmpdir)
|
|
157
|
+
stdout, stderr = Runner.run_process!(argv, helper_env(tmpdir), timeout: Runner::DEFAULT_TIMEOUT)
|
|
158
|
+
[stdout, stderr, nil]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def run_sandboxed!(argv, tmpdir, options)
|
|
162
|
+
require "landlock"
|
|
163
|
+
|
|
164
|
+
result =
|
|
165
|
+
Sandbox.landlock_capture!(
|
|
166
|
+
argv,
|
|
167
|
+
read:
|
|
168
|
+
Sandbox.existing_paths([*Sandbox.default_read_paths, *Sandbox.runtime_read_paths, *read_paths(options)]),
|
|
169
|
+
write: Sandbox.existing_paths([tmpdir, *write_paths(options)]),
|
|
170
|
+
execute: Sandbox.existing_paths([File.dirname(HELPER), *Sandbox.default_execute_paths]),
|
|
171
|
+
env: helper_env(tmpdir),
|
|
172
|
+
unsetenv_others: true,
|
|
173
|
+
bind_tcp: landlock_abi >= 4 ? [1] : [],
|
|
174
|
+
scope: landlock_abi >= 6 ? %i[abstract_unix_socket signal] : [],
|
|
175
|
+
timeout: Runner::DEFAULT_TIMEOUT,
|
|
176
|
+
rlimits: Sandbox::DEFAULT_RLIMITS,
|
|
177
|
+
seccomp_deny_network: true,
|
|
178
|
+
max_output_bytes: Runner::MAX_OUTPUT_BYTES,
|
|
179
|
+
truncate_output: false
|
|
180
|
+
)
|
|
181
|
+
stdout = result.stdout if result.respond_to?(:stdout)
|
|
182
|
+
stderr = result.stderr if result.respond_to?(:stderr)
|
|
183
|
+
status = result.status if result.respond_to?(:status)
|
|
184
|
+
[stdout, stderr, status]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def helper_env(tmpdir)
|
|
188
|
+
Runner.command_env(tmpdir).merge("SAFE_IMAGE_SANDBOX_CHILD" => "1")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def landlock_abi
|
|
192
|
+
@landlock_abi ||= Landlock.abi_version
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def tail(value)
|
|
196
|
+
text = value.to_s
|
|
197
|
+
return nil if text.empty?
|
|
198
|
+
|
|
199
|
+
text.bytesize > STDERR_TAIL_BYTES ? text.byteslice(-STDERR_TAIL_BYTES, STDERR_TAIL_BYTES) : text
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def read_response(path, stderr: nil)
|
|
203
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
204
|
+
rescue Errno::ENOENT
|
|
205
|
+
{
|
|
206
|
+
ok: false,
|
|
207
|
+
error: "CommandError",
|
|
208
|
+
message: "native vips helper did not write a response",
|
|
209
|
+
stderr_tail: tail(stderr)
|
|
210
|
+
}
|
|
211
|
+
rescue JSON::ParserError => e
|
|
212
|
+
{
|
|
213
|
+
ok: false,
|
|
214
|
+
error: "CommandError",
|
|
215
|
+
message: "native vips helper wrote invalid JSON: #{e.message}",
|
|
216
|
+
stderr_tail: tail(stderr)
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def raise_helper_error(payload, status, command, stderr: nil, stdout: nil)
|
|
221
|
+
message =
|
|
222
|
+
(
|
|
223
|
+
if payload[:message].to_s.empty?
|
|
224
|
+
"native vips helper failed with status #{exit_status(status).inspect}"
|
|
225
|
+
else
|
|
226
|
+
payload[:message]
|
|
227
|
+
end
|
|
228
|
+
)
|
|
229
|
+
original_class = payload[:error].to_s
|
|
230
|
+
stderr_tail = payload[:stderr_tail] || tail(stderr)
|
|
231
|
+
case original_class
|
|
232
|
+
when "LimitError"
|
|
233
|
+
raise LimitError.new(message, original_error_class: original_class, stderr_tail: stderr_tail)
|
|
234
|
+
when "UnsupportedFormatError"
|
|
235
|
+
raise UnsupportedFormatError.new(message, original_error_class: original_class, stderr_tail: stderr_tail)
|
|
236
|
+
when "VipsUnavailableError"
|
|
237
|
+
raise VipsUnavailableError.new(message, original_error_class: original_class, stderr_tail: stderr_tail)
|
|
238
|
+
when "ArgumentError"
|
|
239
|
+
raise ArgumentError, append_helper_detail(message, original_class, stderr_tail)
|
|
240
|
+
when "InvalidImageError", ""
|
|
241
|
+
raise InvalidImageError.new(message, original_error_class: original_class, stderr_tail: stderr_tail)
|
|
242
|
+
else
|
|
243
|
+
raise CommandError.new(
|
|
244
|
+
"native vips helper downgraded unknown error class #{original_class.inspect}: #{message}",
|
|
245
|
+
command: [HELPER, command],
|
|
246
|
+
status: exit_status(status),
|
|
247
|
+
stdout: stdout.to_s,
|
|
248
|
+
stderr: stderr.to_s,
|
|
249
|
+
category: :native_helper,
|
|
250
|
+
operation: command,
|
|
251
|
+
original_error_class: original_class,
|
|
252
|
+
stderr_tail: stderr_tail
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def exit_status(status)
|
|
258
|
+
status.respond_to?(:exitstatus) ? status.exitstatus : status
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def append_helper_detail(message, _original_class, stderr_tail)
|
|
262
|
+
return message if stderr_tail.nil? || stderr_tail.empty?
|
|
263
|
+
|
|
264
|
+
"#{message} (native vips helper stderr tail: #{stderr_tail})"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def read_paths(options)
|
|
268
|
+
[options[:input], options[:raw_input], options[:fontfile]].compact.reject(&:empty?)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def write_paths(options)
|
|
272
|
+
output = options[:output]
|
|
273
|
+
return [] unless output
|
|
274
|
+
|
|
275
|
+
expanded = File.expand_path(output)
|
|
276
|
+
paths = [File.dirname(expanded)]
|
|
277
|
+
paths << expanded if File.exist?(expanded)
|
|
278
|
+
paths
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module SafeImage
|
|
6
|
+
module OperationBackends
|
|
7
|
+
# Shared operation-strategy plumbing: path normalization, max-pixel config,
|
|
8
|
+
# optimizer post-processing and uniform Result construction.
|
|
9
|
+
class Base
|
|
10
|
+
attr_reader :config
|
|
11
|
+
|
|
12
|
+
def initialize(config:)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def frame_count(path, max_pixels: nil)
|
|
17
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
18
|
+
# ico directories are counted by the pure-Ruby parser on either backend;
|
|
19
|
+
# everything else is a header-only count.
|
|
20
|
+
if File.extname(PathSafety.local_path(path)).downcase == ".ico"
|
|
21
|
+
return Ico.frame_count(path, max_pixels: max_pixels)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
backend_frame_count(path, max_pixels: max_pixels)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def input_output!(input, output)
|
|
30
|
+
input, output = PathSafety.ensure_distinct_file_paths!(input, output)
|
|
31
|
+
[input.to_s, output.to_s]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def safe_output!(output)
|
|
35
|
+
PathSafety.ensure_safe_output_path!(output).to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resolved_max_pixels(max_pixels)
|
|
39
|
+
SafeImage.resolved_max_pixels(max_pixels, config: config)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Post-processing applies only to the formats the optimizer tools
|
|
43
|
+
# understand; other outputs (gif, jxl, ...) skip the pass.
|
|
44
|
+
def optimize_output(output, quality)
|
|
45
|
+
format = Formats.extension(output)
|
|
46
|
+
return unless Formats.optimizable_output?(format)
|
|
47
|
+
|
|
48
|
+
write_through_tempfile(output) do |tmp_path|
|
|
49
|
+
Optimizer.optimize(
|
|
50
|
+
input: output,
|
|
51
|
+
output: tmp_path,
|
|
52
|
+
mode: :lossless,
|
|
53
|
+
strip_metadata: true,
|
|
54
|
+
quality: quality,
|
|
55
|
+
assume_upright: true
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Writes via a sibling tempfile and renames into place so callers never
|
|
61
|
+
# observe a partially-written destination.
|
|
62
|
+
def write_through_tempfile(output)
|
|
63
|
+
StagedOutput.replace(output, suffix: ".safe-image#{File.extname(output)}") { |tmp_path| yield tmp_path }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def result_from_info(input, output, info, backend, tier: nil, optimizer: nil)
|
|
67
|
+
Result.build(
|
|
68
|
+
input: input,
|
|
69
|
+
output: output,
|
|
70
|
+
input_format: info.fetch(:input_format),
|
|
71
|
+
output_format: info.fetch(:output_format),
|
|
72
|
+
width: info.fetch(:width),
|
|
73
|
+
height: info.fetch(:height),
|
|
74
|
+
backend: backend,
|
|
75
|
+
encoder: info[:encoder],
|
|
76
|
+
duration_ms: info.fetch(:duration_ms),
|
|
77
|
+
optimizer: optimizer,
|
|
78
|
+
tier: tier
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module SafeImage
|
|
6
|
+
module OperationBackends
|
|
7
|
+
# ImageMagick operation orchestration. Inputs/outputs still pass the shared
|
|
8
|
+
# path safety checks before the lower backend builds argv arrays with coder:
|
|
9
|
+
# prefixes and the restrictive policy.xml.
|
|
10
|
+
class ImageMagick < Base
|
|
11
|
+
def resize(input:, output:, width:, height:, quality:, optimize:, max_pixels:, chroma_subsampling: nil)
|
|
12
|
+
input, output = input_output!(input, output)
|
|
13
|
+
probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
|
|
14
|
+
output = safe_output!(output)
|
|
15
|
+
info =
|
|
16
|
+
ImageMagickBackend.thumbnail(
|
|
17
|
+
input: probe.input,
|
|
18
|
+
output: output,
|
|
19
|
+
width: width,
|
|
20
|
+
height: height,
|
|
21
|
+
format: Formats.extension(output),
|
|
22
|
+
quality: quality
|
|
23
|
+
)
|
|
24
|
+
optimize_output(output, quality) if optimize
|
|
25
|
+
result_from_info(probe.input, output, info, :imagemagick, tier: :resize)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def crop(input:, output:, width:, height:, quality:, optimize:, max_pixels:, chroma_subsampling: nil)
|
|
29
|
+
input, output = input_output!(input, output)
|
|
30
|
+
probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
|
|
31
|
+
output = safe_output!(output)
|
|
32
|
+
info =
|
|
33
|
+
ImageMagickBackend.resize_like(
|
|
34
|
+
input: probe.input,
|
|
35
|
+
output: output,
|
|
36
|
+
width: width,
|
|
37
|
+
height: height,
|
|
38
|
+
format: Formats.extension(output),
|
|
39
|
+
quality: quality,
|
|
40
|
+
crop: :north
|
|
41
|
+
)
|
|
42
|
+
optimize_output(output, quality) if optimize
|
|
43
|
+
result_from_info(probe.input, output, info, :imagemagick, tier: :crop)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def downsize(input:, output:, dimensions:, optimize:, max_pixels:, quality: nil, chroma_subsampling: nil)
|
|
47
|
+
input, output = input_output!(input, output)
|
|
48
|
+
probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
|
|
49
|
+
output = safe_output!(output)
|
|
50
|
+
info =
|
|
51
|
+
ImageMagickBackend.downsize(
|
|
52
|
+
input: probe.input,
|
|
53
|
+
output: output,
|
|
54
|
+
dimensions: dimensions,
|
|
55
|
+
format: Formats.extension(output)
|
|
56
|
+
)
|
|
57
|
+
optimize_output(output, nil) if optimize
|
|
58
|
+
result_from_info(probe.input, output, info, :imagemagick, tier: :downsize)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def convert(input:, output:, format:, quality:, optimize:, max_pixels:, chroma_subsampling: nil)
|
|
62
|
+
input, output = input_output!(input, output)
|
|
63
|
+
probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
|
|
64
|
+
normalized_format = Formats.normalize(format)
|
|
65
|
+
info = ImageMagickBackend.convert(input: probe.input, output: output, format: format, quality: quality)
|
|
66
|
+
optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
|
|
67
|
+
result_from_info(probe.input, output, info, :imagemagick, tier: :convert)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fix_orientation(input:, output:, max_pixels:, quality: nil)
|
|
71
|
+
input, output = input_output!(input, output)
|
|
72
|
+
probe = operation_probe(input, max_pixels: resolved_max_pixels(max_pixels))
|
|
73
|
+
info = ImageMagickBackend.fix_orientation(input: probe.input, output: output)
|
|
74
|
+
result_from_info(probe.input, output, info, :imagemagick, tier: :fix_orientation)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def convert_favicon_to_png(input:, output:, optimize:, max_pixels: nil)
|
|
78
|
+
input, output = input_output!(input, output)
|
|
79
|
+
info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(input).expand_path.to_s, output: output)
|
|
80
|
+
optimize_output(output, nil) if optimize
|
|
81
|
+
result_from_info(input, output, info, :imagemagick, tier: :convert_favicon)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def letter_avatar(output:, size:, background_rgb:, letter:, pointsize:, font:)
|
|
85
|
+
output = safe_output!(output)
|
|
86
|
+
request = {
|
|
87
|
+
output: output,
|
|
88
|
+
size: size,
|
|
89
|
+
background_rgb: background_rgb,
|
|
90
|
+
letter: letter,
|
|
91
|
+
pointsize: pointsize,
|
|
92
|
+
font: font
|
|
93
|
+
}
|
|
94
|
+
result_from_info(
|
|
95
|
+
"generated",
|
|
96
|
+
output,
|
|
97
|
+
ImageMagickBackend.letter_avatar(**request),
|
|
98
|
+
:imagemagick,
|
|
99
|
+
tier: :letter_avatar
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def operation_probe(path, max_pixels:)
|
|
106
|
+
path = Pathname.new(path).expand_path.to_s
|
|
107
|
+
info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
|
|
108
|
+
Result.metadata(
|
|
109
|
+
input: path,
|
|
110
|
+
input_format: info.fetch(:input_format),
|
|
111
|
+
width: info.fetch(:width),
|
|
112
|
+
height: info.fetch(:height),
|
|
113
|
+
backend: :imagemagick,
|
|
114
|
+
duration_ms: info.fetch(:duration_ms)
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def backend_frame_count(path, max_pixels:)
|
|
119
|
+
ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|