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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module SafeImage
|
|
6
|
+
module OperationBackends
|
|
7
|
+
# libvips operation orchestration. This is the configured-backend path for
|
|
8
|
+
# untrusted raster bytes; optional cjpegli/jpegtran tiers run only after
|
|
9
|
+
# SafeImage has decoded or validated the input.
|
|
10
|
+
class Vips < Base
|
|
11
|
+
NATIVE_CONVERT_DEFAULT_QUALITY = QualityDefaults::NATIVE_CONVERT_JPEG
|
|
12
|
+
|
|
13
|
+
def resize(input:, output:, width:, height:, quality:, optimize:, max_pixels:, chroma_subsampling:)
|
|
14
|
+
input, output = input_output!(input, output)
|
|
15
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
16
|
+
result =
|
|
17
|
+
Processor.new(max_pixels: max_pixels, chroma_subsampling: chroma_subsampling, config: config).thumbnail(
|
|
18
|
+
input: input,
|
|
19
|
+
output: output,
|
|
20
|
+
width: width,
|
|
21
|
+
height: height,
|
|
22
|
+
quality: quality || QualityDefaults::JPEG,
|
|
23
|
+
optimize: optimize
|
|
24
|
+
)
|
|
25
|
+
result.with(tier: result.backend.include?("cjpegli") ? :cjpegli : :resize)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def crop(input:, output:, width:, height:, quality:, optimize:, max_pixels:, chroma_subsampling:)
|
|
29
|
+
input, output = input_output!(input, output)
|
|
30
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
31
|
+
probe = operation_probe(input, max_pixels: max_pixels)
|
|
32
|
+
output = safe_output!(output)
|
|
33
|
+
format = Formats.extension(output)
|
|
34
|
+
|
|
35
|
+
info =
|
|
36
|
+
if jpegli_for_generated_jpeg?(format)
|
|
37
|
+
JpegliBackend.encode_generated_jpeg(
|
|
38
|
+
output: output,
|
|
39
|
+
quality: quality || JpegliBackend::DEFAULT_QUALITY,
|
|
40
|
+
chroma_subsampling: chroma_subsampling,
|
|
41
|
+
input_format: probe.input_format
|
|
42
|
+
) do |tmp_path|
|
|
43
|
+
VipsBackend.crop_north(
|
|
44
|
+
input: probe.input,
|
|
45
|
+
output: tmp_path,
|
|
46
|
+
width: width,
|
|
47
|
+
height: height,
|
|
48
|
+
format: "png",
|
|
49
|
+
quality: 100,
|
|
50
|
+
max_pixels: max_pixels
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
VipsBackend.crop_north(
|
|
55
|
+
input: probe.input,
|
|
56
|
+
output: output,
|
|
57
|
+
width: width,
|
|
58
|
+
height: height,
|
|
59
|
+
format: format,
|
|
60
|
+
quality: quality || QualityDefaults::JPEG,
|
|
61
|
+
max_pixels: max_pixels
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
optimize_output(output, quality) if optimize
|
|
65
|
+
result_from_info(probe.input, output, info, :vips, tier: info[:encoder] == "cjpegli" ? :cjpegli : :crop)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def downsize(input:, output:, dimensions:, optimize:, max_pixels:, quality:, chroma_subsampling:)
|
|
69
|
+
input, output = input_output!(input, output)
|
|
70
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
71
|
+
probe = operation_probe(input, max_pixels: max_pixels)
|
|
72
|
+
output = safe_output!(output)
|
|
73
|
+
format = Formats.extension(output)
|
|
74
|
+
info =
|
|
75
|
+
if jpegli_for_generated_jpeg?(format)
|
|
76
|
+
JpegliBackend.encode_generated_jpeg(
|
|
77
|
+
output: output,
|
|
78
|
+
quality: quality,
|
|
79
|
+
chroma_subsampling: chroma_subsampling,
|
|
80
|
+
input_format: probe.input_format
|
|
81
|
+
) do |tmp_path|
|
|
82
|
+
VipsBackend.downsize(
|
|
83
|
+
input: probe.input,
|
|
84
|
+
output: tmp_path,
|
|
85
|
+
dimensions: dimensions,
|
|
86
|
+
format: "png",
|
|
87
|
+
quality: 100,
|
|
88
|
+
max_pixels: max_pixels
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
VipsBackend.downsize(
|
|
93
|
+
input: probe.input,
|
|
94
|
+
output: output,
|
|
95
|
+
dimensions: dimensions,
|
|
96
|
+
format: format,
|
|
97
|
+
quality: quality,
|
|
98
|
+
max_pixels: max_pixels
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
optimize_output(output, nil) if optimize
|
|
102
|
+
result_from_info(probe.input, output, info, :vips, tier: info[:encoder] == "cjpegli" ? :cjpegli : :downsize)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def convert(input:, output:, format:, quality:, optimize:, max_pixels:, chroma_subsampling:)
|
|
106
|
+
input, output = input_output!(input, output)
|
|
107
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
108
|
+
input = PathSafety.ensure_regular_file!(input).to_s
|
|
109
|
+
normalized_format = Formats.normalize(format)
|
|
110
|
+
|
|
111
|
+
if jpegli_for_convert?(input, normalized_format)
|
|
112
|
+
info =
|
|
113
|
+
jpegli_convert_after_native_decode(
|
|
114
|
+
input: input,
|
|
115
|
+
output: output,
|
|
116
|
+
quality: quality || JpegliBackend::DEFAULT_QUALITY,
|
|
117
|
+
max_pixels: max_pixels,
|
|
118
|
+
chroma_subsampling: chroma_subsampling
|
|
119
|
+
)
|
|
120
|
+
return result_from_info(input, output, info, :vips, tier: :cjpegli)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
info =
|
|
124
|
+
write_through_tempfile(output) do |tmp_path|
|
|
125
|
+
Native.convert(input, tmp_path, normalized_format, quality || NATIVE_CONVERT_DEFAULT_QUALITY, max_pixels)
|
|
126
|
+
end
|
|
127
|
+
optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
|
|
128
|
+
result_from_info(input, output, info, :vips, tier: :native_convert)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def fix_orientation(input:, output:, max_pixels:, quality:)
|
|
132
|
+
input, output = input_output!(input, output)
|
|
133
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
134
|
+
input = PathSafety.ensure_regular_file!(input).to_s
|
|
135
|
+
format = Formats.extension(input)
|
|
136
|
+
# Validates the format against the native loader allowlist and enforces
|
|
137
|
+
# the pixel cap before any pixel decode.
|
|
138
|
+
orient = VipsBackend.orientation(input, max_pixels: max_pixels)
|
|
139
|
+
|
|
140
|
+
# Lossless tier: jpegtran transforms JPEG DCT coefficients directly, so
|
|
141
|
+
# there is no generation loss. -perfect refuses when the dimensions are
|
|
142
|
+
# not MCU-aligned; only that expected refusal falls through to the
|
|
143
|
+
# observable re-encode tier.
|
|
144
|
+
tier = :native_reencode
|
|
145
|
+
if format == "jpg" && orient > 1 && Runner.available?("jpegtran")
|
|
146
|
+
begin
|
|
147
|
+
return jpegtran_fix_orientation(input, output, orient)
|
|
148
|
+
rescue CommandError => e
|
|
149
|
+
raise unless Optimizer.jpegtran_perfect_reject?(e)
|
|
150
|
+
|
|
151
|
+
tier = :jpegtran_fallback_reencode
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
quality = quality.nil? ? QualityDefaults::FIX_ORIENTATION_REENCODE_JPEG : Integer(quality)
|
|
156
|
+
raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
|
|
157
|
+
|
|
158
|
+
info =
|
|
159
|
+
write_through_tempfile(output) { |tmp_path| Native.resize(input, tmp_path, 1.0, format, quality, max_pixels) }
|
|
160
|
+
result_from_info(input, output, info, :vips, tier: tier)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def convert_favicon_to_png(input:, output:, optimize:, max_pixels:)
|
|
164
|
+
input, output = input_output!(input, output)
|
|
165
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
166
|
+
# Pure-Ruby ICO parse; libvips only encodes the extracted pixels.
|
|
167
|
+
info = Ico.convert_to_png(input, output, max_pixels: max_pixels)
|
|
168
|
+
optimize_output(output, nil) if optimize
|
|
169
|
+
result_from_info(input, output, info, :ico_vips, tier: :ico_ruby)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def letter_avatar(output:, size:, background_rgb:, letter:, pointsize:, font:)
|
|
173
|
+
output = safe_output!(output)
|
|
174
|
+
request = {
|
|
175
|
+
output: output,
|
|
176
|
+
size: size,
|
|
177
|
+
background_rgb: background_rgb,
|
|
178
|
+
letter: letter,
|
|
179
|
+
pointsize: pointsize,
|
|
180
|
+
font: font
|
|
181
|
+
}
|
|
182
|
+
result_from_info("generated", output, VipsBackend.letter_avatar(**request), :vips, tier: :letter_avatar)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def operation_probe(path, max_pixels:)
|
|
188
|
+
Processor.new(max_pixels: max_pixels, config: config).probe(Pathname.new(path).expand_path.to_s)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def backend_frame_count(path, max_pixels:)
|
|
192
|
+
VipsBackend.frame_count(path, max_pixels: max_pixels)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def jpegli_for_convert?(input, normalized_format)
|
|
196
|
+
normalized_format == "jpg" && JpegliBackend.available? && JpegliBackend.suitable_direct_input?(input)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# cjpegli is an output-quality tool, not a configuration choice: installed
|
|
200
|
+
# means used for JPEG output on the native path. It encodes only pixels
|
|
201
|
+
# this gem already decoded, so it is not part of the untrusted-input
|
|
202
|
+
# surface the backend choice controls.
|
|
203
|
+
def jpegli_for_generated_jpeg?(format)
|
|
204
|
+
Formats.normalize(format) == "jpg" && JpegliBackend.available?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def jpegli_convert_after_native_decode(input:, output:, quality:, max_pixels:, chroma_subsampling:)
|
|
208
|
+
JpegliBackend.encode_generated_jpeg(
|
|
209
|
+
output: output,
|
|
210
|
+
quality: quality,
|
|
211
|
+
chroma_subsampling: chroma_subsampling
|
|
212
|
+
) { |tmp_path| Native.convert(input, tmp_path, "png", 100, max_pixels) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def jpegtran_fix_orientation(input, output, orient)
|
|
216
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
217
|
+
info =
|
|
218
|
+
write_through_tempfile(output) do |tmp_path|
|
|
219
|
+
Runner.run!(
|
|
220
|
+
[
|
|
221
|
+
"jpegtran",
|
|
222
|
+
"-copy",
|
|
223
|
+
"none",
|
|
224
|
+
"-perfect",
|
|
225
|
+
*Optimizer::JPEGTRAN_OPERATIONS.fetch(orient),
|
|
226
|
+
"-outfile",
|
|
227
|
+
tmp_path,
|
|
228
|
+
input
|
|
229
|
+
],
|
|
230
|
+
read: [input],
|
|
231
|
+
write: [tmp_path, File.dirname(tmp_path)]
|
|
232
|
+
)
|
|
233
|
+
Native.probe(tmp_path)
|
|
234
|
+
end
|
|
235
|
+
result_from_info(
|
|
236
|
+
input,
|
|
237
|
+
output,
|
|
238
|
+
{
|
|
239
|
+
input_format: "jpg",
|
|
240
|
+
output_format: "jpg",
|
|
241
|
+
width: info.fetch(:width),
|
|
242
|
+
height: info.fetch(:height),
|
|
243
|
+
duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
|
|
244
|
+
},
|
|
245
|
+
:jpegtran,
|
|
246
|
+
tier: :jpegtran_lossless
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "operation_backends/base"
|
|
4
|
+
require_relative "operation_backends/vips"
|
|
5
|
+
require_relative "operation_backends/image_magick"
|
|
6
|
+
|
|
7
|
+
module SafeImage
|
|
8
|
+
# Factory for operation orchestration strategies. Transform and metadata
|
|
9
|
+
# operation objects select one strategy per call from the supplied config; the
|
|
10
|
+
# strategy owns the backend-specific pipeline details.
|
|
11
|
+
module OperationBackends
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def for(config)
|
|
15
|
+
case config.backend
|
|
16
|
+
when :vips
|
|
17
|
+
Vips.new(config: config)
|
|
18
|
+
when :imagemagick
|
|
19
|
+
ImageMagick.new(config: config)
|
|
20
|
+
else
|
|
21
|
+
raise ArgumentError, "unknown backend: #{config.backend.inspect}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_constant :OperationBackends
|
|
27
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeImage
|
|
4
|
+
# Shared plumbing for public-operation implementations. Concrete operation
|
|
5
|
+
# classes contain the inline implementation; child-process sandboxing happens
|
|
6
|
+
# at the helper/command boundary, not by proxying these Ruby objects.
|
|
7
|
+
class OperationSet
|
|
8
|
+
attr_reader :config
|
|
9
|
+
|
|
10
|
+
def initialize(config:)
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def resolved_max_pixels(max_pixels)
|
|
17
|
+
SafeImage.resolved_max_pixels(max_pixels, config: config)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private_constant :OperationSet
|
|
22
|
+
end
|
data/lib/safe_image/optimizer.rb
CHANGED
|
@@ -1,78 +1,280 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "fileutils"
|
|
4
4
|
|
|
5
5
|
module SafeImage
|
|
6
6
|
module Optimizer
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
|
+
# pngquant's lossy trial is worthwhile only for small generated PNGs; above
|
|
10
|
+
# this size the extra decode/quantize pass is comparatively expensive. The
|
|
11
|
+
# lossless PNG optimizer still runs for larger PNGs.
|
|
9
12
|
MAX_PNGQUANT_SIZE = 500_000
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
# EXIF orientation values mapped onto jpegtran's lossless transforms.
|
|
15
|
+
JPEGTRAN_OPERATIONS = {
|
|
16
|
+
2 => %w[-flip horizontal],
|
|
17
|
+
3 => %w[-rotate 180],
|
|
18
|
+
4 => %w[-flip vertical],
|
|
19
|
+
5 => ["-transpose"],
|
|
20
|
+
6 => %w[-rotate 90],
|
|
21
|
+
7 => ["-transverse"],
|
|
22
|
+
8 => %w[-rotate 270]
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def jpegtran_perfect_reject?(error)
|
|
26
|
+
error.is_a?(CommandError) && error.category == :exit_status && error.status.to_i == 1 &&
|
|
27
|
+
Array(error.command).include?("-perfect")
|
|
28
|
+
end
|
|
13
29
|
|
|
14
|
-
|
|
15
|
-
|
|
30
|
+
# assume_upright: skips the JPEG orientation check; only for callers
|
|
31
|
+
# optimising output this gem just encoded (which is always upright).
|
|
32
|
+
def optimize(
|
|
33
|
+
input:,
|
|
34
|
+
output:,
|
|
35
|
+
mode: :lossless,
|
|
36
|
+
strip_metadata: true,
|
|
37
|
+
quality: nil,
|
|
38
|
+
timeout: Runner::DEFAULT_TIMEOUT,
|
|
39
|
+
strict: true,
|
|
40
|
+
assume_upright: false
|
|
41
|
+
)
|
|
42
|
+
input, output = PathSafety.ensure_distinct_file_paths!(input, output)
|
|
43
|
+
ext = normalized_extension(input)
|
|
16
44
|
|
|
45
|
+
StagedOutput.replace(output, suffix: ".safe-image.#{ext}") do |tmp_path|
|
|
46
|
+
FileUtils.cp(input, tmp_path)
|
|
47
|
+
optimize_working_file!(
|
|
48
|
+
tmp_path,
|
|
49
|
+
mode: mode,
|
|
50
|
+
strip_metadata: strip_metadata,
|
|
51
|
+
quality: quality,
|
|
52
|
+
timeout: timeout,
|
|
53
|
+
strict: strict,
|
|
54
|
+
assume_upright: assume_upright
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def optimize_working_file!(
|
|
60
|
+
path,
|
|
61
|
+
mode: :lossless,
|
|
62
|
+
strip_metadata: true,
|
|
63
|
+
quality: nil,
|
|
64
|
+
timeout: Runner::DEFAULT_TIMEOUT,
|
|
65
|
+
strict: true,
|
|
66
|
+
assume_upright: false
|
|
67
|
+
)
|
|
68
|
+
path = PathSafety.ensure_regular_file!(path)
|
|
69
|
+
ext = normalized_extension(path)
|
|
17
70
|
before = File.size(path)
|
|
18
|
-
|
|
71
|
+
state = { tools: [], rotated_from: nil, trimmed: false }
|
|
19
72
|
|
|
20
73
|
case ext
|
|
21
74
|
when "jpg"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
75
|
+
skipped =
|
|
76
|
+
optimize_jpeg!(
|
|
77
|
+
path,
|
|
78
|
+
state,
|
|
79
|
+
strip_metadata: strip_metadata,
|
|
80
|
+
quality: quality,
|
|
81
|
+
timeout: timeout,
|
|
82
|
+
strict: strict,
|
|
83
|
+
assume_upright: assume_upright,
|
|
84
|
+
before: before
|
|
85
|
+
)
|
|
86
|
+
return skipped if skipped
|
|
32
87
|
when "png"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
88
|
+
optimize_png!(
|
|
89
|
+
path,
|
|
90
|
+
state,
|
|
91
|
+
mode: mode,
|
|
92
|
+
strip_metadata: strip_metadata,
|
|
93
|
+
quality: quality,
|
|
94
|
+
timeout: timeout,
|
|
95
|
+
strict: strict,
|
|
96
|
+
before: before
|
|
97
|
+
)
|
|
98
|
+
else
|
|
99
|
+
raise UnsupportedFormatError, "unsupported optimize format: #{ext.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
build_result(ext, before, File.size(path), state)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def optimize_jpeg!(path, state, strip_metadata:, quality:, timeout:, strict:, assume_upright:, before:)
|
|
106
|
+
# Stripping metadata deletes the EXIF orientation tag, so an oriented
|
|
107
|
+
# image must have the rotation baked into its pixels first or it ships
|
|
108
|
+
# sideways. jpegtran does that losslessly; without it, leave the file
|
|
109
|
+
# untouched rather than strip-without-rotate.
|
|
110
|
+
orientation = strip_metadata && !assume_upright ? jpeg_orientation(path) : 1
|
|
111
|
+
if orientation > 1
|
|
112
|
+
unless Runner.available?("jpegtran")
|
|
113
|
+
raise Error, "jpegtran is required to optimize a JPEG with EXIF orientation" if strict
|
|
114
|
+
return skipped_result("jpg", before, state.fetch(:tools))
|
|
53
115
|
end
|
|
54
116
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
117
|
+
state[:trimmed] = upright_working_file!(path, orientation, timeout: timeout)
|
|
118
|
+
state[:rotated_from] = orientation
|
|
119
|
+
state[:tools] << "jpegtran"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if Runner.available?("jpegoptim")
|
|
123
|
+
argv = %w[jpegoptim --quiet]
|
|
124
|
+
argv << (strip_metadata ? "--strip-all" : "--strip-none")
|
|
125
|
+
argv << "--max=#{Integer(quality)}" if quality
|
|
126
|
+
argv << path.to_s
|
|
127
|
+
Runner.run!(argv, timeout: timeout, read: [path.to_s], write: [path.to_s, File.dirname(path.to_s)])
|
|
128
|
+
state[:tools] << "jpegoptim"
|
|
129
|
+
else
|
|
130
|
+
raise Error, "jpegoptim is required for strict JPEG optimisation" if strict
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def optimize_png!(path, state, mode:, strip_metadata:, quality:, timeout:, strict:, before:)
|
|
137
|
+
# Discourse/image_optim parity: run the lossless PNG optimizer before the
|
|
138
|
+
# optional lossy pngquant pass.
|
|
139
|
+
png_lossless_optimizer!(path, state, strip_metadata: strip_metadata, timeout: timeout, strict: strict)
|
|
140
|
+
if mode.to_sym == :lossy && before < MAX_PNGQUANT_SIZE
|
|
141
|
+
pngquant!(path, state, quality: quality, timeout: timeout, strict: strict)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def pngquant!(path, state, quality:, timeout:, strict:)
|
|
146
|
+
if Runner.available?("pngquant")
|
|
147
|
+
StagedOutput.with_temp_path_near(path, suffix: ".pngquant.png") do |tmp_path|
|
|
148
|
+
argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
|
|
149
|
+
argv << "--quality=#{quality}" if quality # e.g. "65-90"
|
|
58
150
|
argv << path.to_s
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
151
|
+
skipped = false
|
|
152
|
+
begin
|
|
153
|
+
Runner.run!(argv, timeout: timeout, read: [path.to_s], write: [tmp_path.to_s, File.dirname(tmp_path.to_s)])
|
|
154
|
+
rescue CommandError => e
|
|
155
|
+
# 98: --skip-if-larger declined the result; 99: --quality not met.
|
|
156
|
+
# Both mean "keep the original", not a failure — and the pre-created
|
|
157
|
+
# tempfile is still empty, so it must not win the size comparison.
|
|
158
|
+
raise if [98, 99].none? { |status| status == e.status }
|
|
159
|
+
skipped = true
|
|
160
|
+
end
|
|
161
|
+
if !skipped && tmp_path.file? && File.size(tmp_path).positive? && File.size(tmp_path) < File.size(path)
|
|
162
|
+
FileUtils.mv(tmp_path, path)
|
|
163
|
+
state[:tools] << "pngquant"
|
|
164
|
+
end
|
|
63
165
|
end
|
|
64
|
-
|
|
65
|
-
raise
|
|
166
|
+
elsif strict
|
|
167
|
+
raise Error, "pngquant is required for strict lossy PNG optimisation"
|
|
66
168
|
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def png_lossless_optimizer!(path, state, strip_metadata:, timeout:, strict:)
|
|
172
|
+
if Runner.available?("oxipng")
|
|
173
|
+
oxipng!(path, state, strip_metadata: strip_metadata, timeout: timeout)
|
|
174
|
+
elsif Runner.available?("optipng")
|
|
175
|
+
optipng!(path, state, strip_metadata: strip_metadata, timeout: timeout)
|
|
176
|
+
elsif strict
|
|
177
|
+
raise Error, "oxipng or optipng is required for strict PNG optimisation"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
67
180
|
|
|
68
|
-
|
|
181
|
+
def oxipng!(path, state, strip_metadata:, timeout:)
|
|
182
|
+
argv = %w[oxipng --quiet -o 3]
|
|
183
|
+
argv.concat(["--strip", strip_metadata ? "safe" : "none"])
|
|
184
|
+
argv << path.to_s
|
|
185
|
+
Runner.run!(argv, timeout: timeout, read: [path.to_s], write: [path.to_s, File.dirname(path.to_s)])
|
|
186
|
+
state[:tools] << "oxipng"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def optipng!(path, state, strip_metadata:, timeout:)
|
|
190
|
+
argv = %w[optipng -quiet -o2]
|
|
191
|
+
argv.concat(%w[-strip all]) if strip_metadata
|
|
192
|
+
argv << path.to_s
|
|
193
|
+
Runner.run!(argv, timeout: timeout, read: [path.to_s], write: [path.to_s, File.dirname(path.to_s)])
|
|
194
|
+
state[:tools] << "optipng"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def build_result(format, before, after, state)
|
|
69
198
|
{
|
|
70
|
-
format:
|
|
199
|
+
format: format,
|
|
71
200
|
before_bytes: before,
|
|
72
201
|
after_bytes: after,
|
|
73
202
|
saved_bytes: before - after,
|
|
74
|
-
tools: tools
|
|
203
|
+
tools: state.fetch(:tools),
|
|
204
|
+
rotated_from: state.fetch(:rotated_from),
|
|
205
|
+
trimmed: state.fetch(:trimmed)
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def skipped_result(format, bytes, tools)
|
|
210
|
+
{
|
|
211
|
+
format: format,
|
|
212
|
+
before_bytes: bytes,
|
|
213
|
+
after_bytes: bytes,
|
|
214
|
+
saved_bytes: 0,
|
|
215
|
+
tools: tools,
|
|
216
|
+
rotated_from: nil,
|
|
217
|
+
trimmed: false
|
|
75
218
|
}
|
|
76
219
|
end
|
|
220
|
+
|
|
221
|
+
def normalized_extension(path)
|
|
222
|
+
Formats.extension(path)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def jpeg_orientation(path)
|
|
226
|
+
case SafeImage.config.backend
|
|
227
|
+
when :vips
|
|
228
|
+
VipsBackend.orientation(path.to_s)
|
|
229
|
+
when :imagemagick
|
|
230
|
+
ImageMagickBackend.orientation(path.to_s)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Applies the orientation's lossless jpegtran transform to the working copy,
|
|
235
|
+
# dropping the metadata in the same pass (-copy none; this path only runs
|
|
236
|
+
# when strip_metadata is set). -perfect refuses dimensions that are not
|
|
237
|
+
# MCU-aligned; the -trim retry drops the partial edge blocks (under one MCU,
|
|
238
|
+
# at most 15px) instead of hiding a lossy re-encode here. Returns true when
|
|
239
|
+
# the fallback trimmed.
|
|
240
|
+
def upright_working_file!(path, orientation, timeout:)
|
|
241
|
+
transform = JPEGTRAN_OPERATIONS.fetch(orientation)
|
|
242
|
+
StagedOutput.with_temp_path_near(path, suffix: ".jpegtran.jpg") do |tmp_path|
|
|
243
|
+
trimmed = false
|
|
244
|
+
begin
|
|
245
|
+
Runner.run!(
|
|
246
|
+
["jpegtran", "-copy", "none", "-perfect", *transform, "-outfile", tmp_path.to_s, path.to_s],
|
|
247
|
+
timeout: timeout,
|
|
248
|
+
read: [path.to_s],
|
|
249
|
+
write: [tmp_path.to_s, File.dirname(tmp_path.to_s)]
|
|
250
|
+
)
|
|
251
|
+
rescue CommandError => e
|
|
252
|
+
raise unless jpegtran_perfect_reject?(e)
|
|
253
|
+
|
|
254
|
+
Runner.run!(
|
|
255
|
+
["jpegtran", "-copy", "none", "-trim", *transform, "-outfile", tmp_path.to_s, path.to_s],
|
|
256
|
+
timeout: timeout,
|
|
257
|
+
read: [path.to_s],
|
|
258
|
+
write: [tmp_path.to_s, File.dirname(tmp_path.to_s)]
|
|
259
|
+
)
|
|
260
|
+
trimmed = true
|
|
261
|
+
end
|
|
262
|
+
FileUtils.mv(tmp_path, path)
|
|
263
|
+
trimmed
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private_class_method :optimize_working_file!,
|
|
268
|
+
:optimize_jpeg!,
|
|
269
|
+
:optimize_png!,
|
|
270
|
+
:pngquant!,
|
|
271
|
+
:png_lossless_optimizer!,
|
|
272
|
+
:oxipng!,
|
|
273
|
+
:optipng!,
|
|
274
|
+
:build_result,
|
|
275
|
+
:skipped_result,
|
|
276
|
+
:normalized_extension,
|
|
277
|
+
:jpeg_orientation,
|
|
278
|
+
:upright_working_file!
|
|
77
279
|
end
|
|
78
280
|
end
|
|
@@ -37,10 +37,21 @@ module SafeImage
|
|
|
37
37
|
reject_symlink_components!(path.dirname)
|
|
38
38
|
if File.exist?(path.to_s)
|
|
39
39
|
raise UnsafePathError, "output path is a symlink: #{path}" if File.lstat(path.to_s).symlink?
|
|
40
|
+
raise UnsafePathError, "output path is a directory: #{path}" if File.directory?(path.to_s)
|
|
40
41
|
end
|
|
41
42
|
path
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
def ensure_distinct_file_paths!(input, output)
|
|
46
|
+
input = ensure_regular_file!(input)
|
|
47
|
+
output = ensure_safe_output_path!(output)
|
|
48
|
+
same_path = input.to_s == output.to_s
|
|
49
|
+
same_file = output.exist? && File.identical?(input.to_s, output.to_s)
|
|
50
|
+
raise UnsafePathError, "input and output must be different paths" if same_path || same_file
|
|
51
|
+
|
|
52
|
+
[input, output]
|
|
53
|
+
end
|
|
54
|
+
|
|
44
55
|
def ensure_imagemagick_safe!(path)
|
|
45
56
|
path = local_path(path)
|
|
46
57
|
raise UnsafePathError, "path contains NUL" if path.include?("\0")
|