safe_image 0.3.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -12
  3. data/README.md +140 -287
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +42 -40
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -290
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +225 -98
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -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
@@ -1,153 +1,280 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tempfile"
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
14
  # EXIF orientation values mapped onto jpegtran's lossless transforms.
12
15
  JPEGTRAN_OPERATIONS = {
13
- 2 => ["-flip", "horizontal"],
14
- 3 => ["-rotate", "180"],
15
- 4 => ["-flip", "vertical"],
16
+ 2 => %w[-flip horizontal],
17
+ 3 => %w[-rotate 180],
18
+ 4 => %w[-flip vertical],
16
19
  5 => ["-transpose"],
17
- 6 => ["-rotate", "90"],
20
+ 6 => %w[-rotate 90],
18
21
  7 => ["-transverse"],
19
- 8 => ["-rotate", "270"]
22
+ 8 => %w[-rotate 270]
20
23
  }.freeze
21
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
29
+
22
30
  # assume_upright: skips the JPEG orientation check; only for callers
23
31
  # optimising output this gem just encoded (which is always upright).
24
- def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true, assume_upright: false)
25
- path = PathSafety.ensure_regular_file!(path)
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)
26
44
 
27
- ext = path.extname.delete_prefix(".").downcase
28
- ext = "jpg" if ext == "jpeg"
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
29
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)
30
70
  before = File.size(path)
31
- tools = []
32
- rotated_from = nil
33
- trimmed = false
71
+ state = { tools: [], rotated_from: nil, trimmed: false }
34
72
 
35
73
  case ext
36
74
  when "jpg"
37
- # Stripping metadata deletes the EXIF orientation tag, so an oriented
38
- # image must have the rotation baked into its pixels first or it ships
39
- # sideways. jpegtran does that losslessly; without it, leave the file
40
- # untouched rather than strip-without-rotate.
41
- orientation = strip_metadata && !assume_upright ? jpeg_orientation(path) : 1
42
- if orientation > 1
43
- unless Runner.available?("jpegtran")
44
- raise Error, "jpegtran is required to optimize a JPEG with EXIF orientation" if strict
45
- return { format: ext, before_bytes: before, after_bytes: before, saved_bytes: 0, tools: tools, rotated_from: nil, trimmed: false }
46
- end
47
- trimmed = upright!(path, orientation, timeout: timeout)
48
- rotated_from = orientation
49
- tools << "jpegtran"
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
87
+ when "png"
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))
50
115
  end
51
116
 
52
- if Runner.available?("jpegoptim")
53
- argv = ["jpegoptim", "--quiet"]
54
- argv << (strip_metadata ? "--strip-all" : "--strip-none")
55
- argv << "--max=#{Integer(quality)}" if quality
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"
56
150
  argv << path.to_s
57
- Runner.run!(argv, timeout: timeout)
58
- tools << "jpegoptim"
59
- else
60
- raise Error, "jpegoptim is required for strict JPEG optimisation" if strict
61
- end
62
- when "png"
63
- if mode.to_sym == :lossy && before < MAX_PNGQUANT_SIZE
64
- if Runner.available?("pngquant")
65
- tmp = Tempfile.new([path.basename(".*").to_s, ".pngquant.png"], path.dirname.to_s)
66
- tmp_path = Pathname.new(tmp.path)
67
- tmp.close
68
- begin
69
- argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
70
- argv << "--quality=#{quality}" if quality # e.g. "65-90"
71
- argv << path.to_s
72
- skipped = false
73
- begin
74
- Runner.run!(argv, timeout: timeout)
75
- rescue CommandError => e
76
- # 98: --skip-if-larger declined the result; 99: --quality not
77
- # met. Both mean "keep the original", not a failure — and the
78
- # pre-created tempfile is still empty, so it must not win the
79
- # size comparison below.
80
- raise unless [98, 99].include?(e.status)
81
- skipped = true
82
- end
83
- if !skipped && tmp_path.file? && File.size(tmp_path).positive? && File.size(tmp_path) < File.size(path)
84
- FileUtils.mv(tmp_path, path)
85
- tools << "pngquant"
86
- end
87
- ensure
88
- FileUtils.rm_f(tmp_path)
89
- end
90
- elsif strict
91
- raise Error, "pngquant is required for strict lossy PNG optimisation"
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"
92
164
  end
93
165
  end
166
+ elsif strict
167
+ raise Error, "pngquant is required for strict lossy PNG optimisation"
168
+ end
169
+ end
94
170
 
95
- if Runner.available?("oxipng")
96
- argv = ["oxipng", "--quiet", "-o", "3"]
97
- argv.concat(["--strip", strip_metadata ? "safe" : "none"])
98
- argv << path.to_s
99
- Runner.run!(argv, timeout: timeout)
100
- tools << "oxipng"
101
- else
102
- raise Error, "oxipng is required for strict PNG optimisation" if strict
103
- end
104
- else
105
- raise UnsupportedFormatError, "unsupported optimize format: #{ext.inspect}"
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"
106
178
  end
179
+ end
107
180
 
108
- after = File.size(path)
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)
109
198
  {
110
- format: ext,
199
+ format: format,
111
200
  before_bytes: before,
112
201
  after_bytes: after,
113
202
  saved_bytes: before - after,
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,
114
215
  tools: tools,
115
- rotated_from: rotated_from,
116
- trimmed: trimmed
216
+ rotated_from: nil,
217
+ trimmed: false
117
218
  }
118
219
  end
119
220
 
221
+ def normalized_extension(path)
222
+ Formats.extension(path)
223
+ end
224
+
120
225
  def jpeg_orientation(path)
121
226
  case SafeImage.config.backend
122
- when :vips then VipsBackend.orientation(path.to_s)
123
- when :imagemagick then ImageMagickBackend.orientation(path.to_s)
227
+ when :vips
228
+ VipsBackend.orientation(path.to_s)
229
+ when :imagemagick
230
+ ImageMagickBackend.orientation(path.to_s)
124
231
  end
125
232
  end
126
233
 
127
- # Applies the orientation's lossless jpegtran transform in place, dropping
128
- # the metadata in the same pass (-copy none; this path only runs when
129
- # strip_metadata is set). -perfect refuses dimensions that are not
130
- # MCU-aligned; the -trim retry drops the partial edge blocks (under one
131
- # MCU, at most 15px) instead of hiding a lossy re-encode here. Returns
132
- # true when the fallback trimmed.
133
- def upright!(path, orientation, timeout:)
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:)
134
241
  transform = JPEGTRAN_OPERATIONS.fetch(orientation)
135
- tmp = Tempfile.new([path.basename(".*").to_s, ".jpegtran.jpg"], path.dirname.to_s)
136
- tmp_path = Pathname.new(tmp.path)
137
- tmp.close
138
- begin
242
+ StagedOutput.with_temp_path_near(path, suffix: ".jpegtran.jpg") do |tmp_path|
139
243
  trimmed = false
140
244
  begin
141
- Runner.run!(["jpegtran", "-copy", "none", "-perfect", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
142
- rescue CommandError
143
- Runner.run!(["jpegtran", "-copy", "none", "-trim", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
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
+ )
144
260
  trimmed = true
145
261
  end
146
262
  FileUtils.mv(tmp_path, path)
147
263
  trimmed
148
- ensure
149
- FileUtils.rm_f(tmp_path)
150
264
  end
151
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!
152
279
  end
153
280
  end