jekyll-imgflow 0.3.2 → 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.
@@ -2,162 +2,89 @@
2
2
 
3
3
  require "uri"
4
4
  require "net/http"
5
- require "tempfile"
6
5
  require_relative "base_provider"
7
6
 
8
7
  module JekyllImgFlow
9
8
  module Providers
10
9
  # Imgproxy provider implementation using the standardized tag interface
11
- class Imgproxy < BaseProvider
10
+ class Imgproxy < HttpBase
12
11
  TIMEOUT = 10 # seconds
13
12
 
14
- def available?
15
- # Check if imgproxy service is running
16
- return false unless @config.respond_to?(:imgproxy_url) && @config.imgproxy_url
17
-
18
- check_http_service(@config.imgproxy_url)
13
+ def service_url
14
+ @config.respond_to?(:imgproxy_url) ? @config.imgproxy_url : nil
19
15
  end
20
16
 
21
- def execute(input_path, output_path)
22
- return if @operations.empty?
23
-
24
- # Build single Imgproxy URL with all operations combined
25
- url = build_combined_imgproxy_url(input_path)
26
-
27
- # Fetch the result in one request
28
- fetch_and_save(url, output_path)
29
- output_path
30
- ensure
31
- reset_operations
17
+ def build_combined_imgproxy_url(input_path)
18
+ build_combined_url(input_path)
32
19
  end
33
20
 
34
- def build_combined_imgproxy_url(input_path)
35
- raise "imgproxy_url not set" unless @config.imgproxy_url
21
+ def build_combined_url(input_path)
22
+ raise "imgproxy_url not set" unless service_url
36
23
 
37
24
  encoded_url = encode_file_url(input_path)
38
25
  operations = []
39
26
 
40
- # Add all operations to single URL path
41
- @operations.each do |operation|
42
- case operation[:type]
43
- when :resize
44
- # Tags now provide complete calculated values
45
- operations << if operation[:height]
46
- "rs:fill:#{operation[:width]}:#{operation[:height]}:1"
47
- else
48
- "rs:fit:#{operation[:width]}::1"
49
- end
50
-
51
- when :crop
52
- opts = operation[:options]
53
- params = operation[:params] || {}
54
-
55
- # Check for keep parameter (smartcrop support)
56
- keep = opts[:keep] || params[:keep] || params[:position]
57
-
58
- if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
59
- # Use smartcrop with gravity:sm (libvips smart crop)
60
- crop_width = opts[:calculated_width]
61
- crop_height = opts[:calculated_height]
62
-
63
- operations << "g:sm"
64
- else
65
- # Use basic cropping with explicit coordinates
66
- if operation[:ratio]
67
- crop_x = opts[:calculated_x]
68
- crop_y = opts[:calculated_y]
69
- crop_width = opts[:calculated_width]
70
- crop_height = opts[:calculated_height]
71
- else
72
- crop_x = opts[:x]
73
- crop_y = opts[:y]
74
- crop_width = opts[:width]
75
- crop_height = opts[:height]
76
- end
77
- # imgproxy crop format: c:width:height:gravity
78
- # Use nowe (north-west) gravity with x/y offsets for pixel-precise cropping
79
- operations << "g:nowe:#{crop_x}:#{crop_y}"
80
- end
81
- operations << "c:#{crop_width}:#{crop_height}"
82
-
83
- when :quality
84
- imgproxy_quality = translate_quality_to_imgproxy(operation[:quality])
85
- operations << "q:#{imgproxy_quality}"
86
-
87
- when :format
88
- # Assume validated input from tags
89
- operations << "f:#{operation[:format]}"
90
-
91
- when :watermark
92
- watermark_path = operation[:watermark_path]
93
- position = operation[:options][:position]
94
- operation[:options][:opacity]
95
-
96
- # Imgproxy watermark parameters
97
- encoded_watermark = encode_file_url(watermark_path)
98
- position_param = translate_imgproxy_position(position)
99
- operations << "wm:#{encoded_watermark}:#{position_param}:10:10"
100
-
101
- when :alpha_opacity
102
- opacity = operation[:opacity]
103
- operations << "a:#{(opacity * 255).round}"
104
- end
105
- end
27
+ @operations.each { |operation| append_imgproxy_operation(operation, operations) }
106
28
 
107
- # Preserve format when no explicit format operation is requested.
108
- # Without this, chained HTTP requests (e.g. quality-only steps after a
109
- # prior format conversion) would lose the previously converted format.
110
- unless @operations.any? { |op| op[:type] == :format }
111
- input_ext = File.extname(input_path).delete(".").downcase
112
- operations << "f:#{input_ext}" unless input_ext.empty?
113
- end
114
-
115
- # Build combined URL: base/insecure/operations/operations/.../plain/encoded_url
29
+ preserve_input_format(input_path, operations)
116
30
  operations_path = operations.join("/")
117
- "#{@config.imgproxy_url}/insecure/#{operations_path}/plain/#{encoded_url}"
31
+ "#{service_url}/insecure/#{operations_path}/plain/#{encoded_url}"
118
32
  end
119
33
 
120
34
  private
121
35
 
122
- def translate_imgproxy_position(position)
123
- # Translate compass directions to Imgproxy position format
124
- case position
125
- when "northwest" then "tl"
126
- when "northeast" then "tr"
127
- when "southwest" then "bl"
128
- when "southeast" then "br"
129
- when "center" then "c"
130
- else position
36
+ def append_imgproxy_operation(operation, operations)
37
+ case operation[:type]
38
+ when :resize
39
+ operations << if operation[:height]
40
+ "rs:fill:#{operation[:width]}:#{operation[:height]}:1"
41
+ else
42
+ "rs:fit:#{operation[:width]}::1"
43
+ end
44
+ when :crop
45
+ append_imgproxy_crop(operation, operations)
46
+ when :quality
47
+ operations << "q:#{operation[:quality]}"
48
+ when :format
49
+ operations << "f:#{operation[:format]}"
50
+ when :watermark
51
+ append_imgproxy_watermark(operation, operations)
52
+ when :alpha_opacity
53
+ operations << "a:#{alpha_byte_value(operation[:opacity])}"
131
54
  end
132
55
  end
133
56
 
134
- def fetch_and_save(url, output_path)
135
- response = fetch_with_timeout(url)
136
- raise "Imgproxy request failed" if response.empty?
137
-
138
- FileUtils.mkdir_p(File.dirname(output_path))
139
- File.write(output_path, response)
57
+ def append_imgproxy_crop(operation, operations)
58
+ geo = crop_geometry(operation)
59
+
60
+ operations << if geo[:smartcrop]
61
+ # Use smartcrop with gravity:sm (libvips smart crop)
62
+ "g:sm"
63
+ else
64
+ # imgproxy crop format: c:width:height:gravity
65
+ # Use nowe (north-west) gravity with x/y offsets for pixel-precise cropping
66
+ "g:nowe:#{geo[:x]}:#{geo[:y]}"
67
+ end
68
+ operations << "c:#{geo[:width]}:#{geo[:height]}"
140
69
  end
141
70
 
142
- def fetch_with_timeout(url)
143
- uri = URI(url)
144
- Net::HTTP.start(uri.host, uri.port,
145
- use_ssl: uri.scheme == "https",
146
- open_timeout: TIMEOUT,
147
- read_timeout: TIMEOUT) do |http|
148
- request = Net::HTTP::Get.new(uri)
149
- response = http.request(request)
71
+ def append_imgproxy_watermark(operation, operations)
72
+ parts = watermark_parts(operation)
73
+ encoded_watermark = encode_file_url(parts[:watermark_path])
74
+ position_param = compass_to_short(parts[:position])
75
+ operations << "wm:#{encoded_watermark}:#{position_param}:10:10"
76
+ end
150
77
 
151
- raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
78
+ def preserve_input_format(input_path, operations)
79
+ return if op?(:format)
152
80
 
153
- response.body
154
- end
155
- rescue StandardError => e
156
- raise "Imgproxy request failed: #{e.message}"
81
+ ext = input_format_ext(input_path)
82
+ operations << "f:#{ext}" unless ext.empty?
157
83
  end
158
84
 
159
- def translate_quality_to_imgproxy(quality)
160
- quality
85
+ # Thin wrapper kept for test compatibility.
86
+ def translate_imgproxy_position(position)
87
+ compass_to_short(position)
161
88
  end
162
89
  end
163
90
  end
@@ -7,48 +7,49 @@ require_relative "base_provider"
7
7
  module JekyllImgFlow
8
8
  module Providers
9
9
  # Libvips provider implementation using the standardized tag interface
10
- class Libvips < BaseProvider
10
+ class Libvips < CliBase
11
11
  def available?
12
- # Check if vips CLI is available
13
- _, _, status = Open3.capture3("which", "vips")
14
- status.success?
12
+ cli_available?("vips")
15
13
  end
16
14
 
17
- def execute(input_path, output_path)
18
- return if @operations.empty?
19
-
20
- # Build and execute vips commands (array form, no shell).
21
- # Cleanup markers ([:cleanup, path]) are handled by execute_command.
22
- commands = build_vips_commands(input_path, output_path)
23
- commands.each { |cmd_array| execute_command(cmd_array) }
15
+ def build_commands(input_path, output_path)
16
+ build_vips_commands(input_path, output_path)
17
+ end
24
18
 
25
- output_path
26
- ensure
27
- reset_operations
19
+ def run_command(cmd)
20
+ execute_command(cmd)
28
21
  end
29
22
 
30
23
  # Build all vips commands as an array of command arrays.
31
24
  # Each sub-array is passed directly to Open3.capture3 (no shell).
32
25
  # Returns Array[Array[String]].
33
26
  def build_vips_commands(input_path, output_path)
34
- has_crop = @operations.any? { |op| op[:type] == :crop }
35
- has_resize = @operations.any? { |op| op[:type] == :resize }
36
- has_watermark = @operations.any? { |op| op[:type] == :watermark }
37
- has_alpha = @operations.any? { |op| op[:type] == :alpha_opacity }
38
-
39
- if has_watermark
40
- build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
41
- elsif has_alpha
42
- build_alpha_pipeline(input_path, output_path, has_crop, has_resize)
43
- elsif has_crop && has_resize
44
- build_sequential_crop_resize(input_path, output_path)
45
- elsif has_resize
46
- [build_resize_command(input_path, output_path)]
47
- elsif has_crop
48
- svg?(input_path) ? build_svg_crop_pipeline(input_path, output_path) : [build_crop_command(input_path, output_path)]
49
- else
50
- [build_copy_command(input_path, output_path)]
51
- end
27
+ flags = operation_flags
28
+ operation_builder = select_operation_builder(flags)
29
+ operation_builder.call(input_path, output_path)
30
+ end
31
+
32
+ def select_operation_builder(flags)
33
+ return ->(input, output) { build_watermark_pipeline(input, output, flags[:crop], flags[:resize]) } if flags[:watermark]
34
+ return ->(input, output) { build_alpha_pipeline(input, output, flags[:crop], flags[:resize]) } if flags[:alpha]
35
+ return ->(input, output) { build_sequential_crop_resize(input, output) } if flags[:crop] && flags[:resize]
36
+ return ->(input, output) { [build_resize_command(input, output)] } if flags[:resize]
37
+ return ->(input, output) { crop_commands(input, output) } if flags[:crop]
38
+
39
+ ->(input, output) { [build_copy_command(input, output)] }
40
+ end
41
+
42
+ def operation_flags
43
+ {
44
+ crop: op?(:crop),
45
+ resize: op?(:resize),
46
+ watermark: op?(:watermark),
47
+ alpha: op?(:alpha_opacity)
48
+ }
49
+ end
50
+
51
+ def crop_commands(input_path, output_path)
52
+ svg?(input_path) ? build_svg_crop_pipeline(input_path, output_path) : [build_crop_command(input_path, output_path)]
52
53
  end
53
54
 
54
55
  # Convenience wrapper for backward compatibility with tests.
@@ -64,85 +65,62 @@ module JekyllImgFlow
64
65
  end
65
66
 
66
67
  def build_alpha_pipeline(input_path, output_path, has_crop, has_resize)
67
- commands = []
68
- temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
69
-
70
- if has_crop && has_resize
71
- commands.concat(build_sequential_crop_resize(input_path, temp_path))
72
- base_path = temp_path
73
- elsif has_resize
74
- commands << build_resize_command(input_path, temp_path)
75
- base_path = temp_path
76
- elsif has_crop
77
- commands << build_crop_command(input_path, temp_path)
78
- base_path = temp_path
79
- else
80
- base_path = input_path
81
- end
82
-
68
+ temp = temp_path(input_path, "tmp_base.jpg")
69
+ commands, base_path = build_base_pipeline(input_path, temp, has_crop, has_resize)
83
70
  commands << build_alpha_command(base_path, output_path)
84
- commands << [:cleanup, temp_path] unless base_path == input_path
71
+ commands << [:cleanup, temp] unless base_path == input_path
85
72
  commands
86
73
  end
87
74
 
88
75
  def build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
89
- temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
90
-
91
- commands = []
92
-
93
- # Step 1: Process crop/resize/copy to produce base image
94
- if has_crop && has_resize
95
- commands.concat(build_sequential_crop_resize(input_path, temp_path))
96
- base_path = temp_path
97
- elsif has_resize
98
- commands << build_resize_command(input_path, temp_path)
99
- base_path = temp_path
100
- elsif has_crop
101
- commands << build_crop_command(input_path, temp_path)
102
- base_path = temp_path
103
- else
104
- base_path = input_path
105
- end
76
+ temp = temp_path(input_path, "tmp_base.jpg")
77
+ commands, base_path = build_base_pipeline(input_path, temp, has_crop, has_resize)
78
+ base_path, commands = apply_watermark_alpha(base_path, input_path, commands)
79
+ commands.concat(build_composite_commands(base_path, output_path))
80
+ cleanup_paths(commands, input_path, temp, base_path)
81
+ commands
82
+ end
106
83
 
107
- # Step 2: Apply alpha opacity if present (before watermark)
108
- alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
109
- if alpha_op
110
- alpha_temp = input_path.gsub(/\.[^.]+$/, ".tmp_alpha.jpg")
111
- commands << build_alpha_command(base_path, alpha_temp)
112
- base_path = alpha_temp
113
- end
84
+ def build_base_pipeline(input_path, temp_path, has_crop, has_resize)
85
+ return [build_sequential_crop_resize(input_path, temp_path), temp_path] if has_crop && has_resize
86
+ return [[build_resize_command(input_path, temp_path)], temp_path] if has_resize
87
+ return [[build_crop_command(input_path, temp_path)], temp_path] if has_crop
114
88
 
115
- # Step 3: Composite watermark over base
116
- commands.concat(build_composite_commands(base_path, output_path))
89
+ [[], input_path]
90
+ end
117
91
 
118
- # Step 4: Cleanup temp files
119
- temp_files = [temp_path, base_path == input_path ? nil : base_path].compact
120
- temp_files.each { |f| commands << [:cleanup, f] }
92
+ def apply_watermark_alpha(base_path, input_path, commands)
93
+ alpha_op = find_op(:alpha_opacity)
94
+ return [base_path, commands] unless alpha_op
121
95
 
122
- commands
96
+ alpha_temp = temp_path(input_path, "tmp_alpha.jpg")
97
+ [alpha_temp, commands << build_alpha_command(base_path, alpha_temp)]
98
+ end
99
+
100
+ def cleanup_paths(commands, input_path, temp_path, base_path)
101
+ temp_files = [temp_path, base_path == input_path ? nil : base_path].compact
102
+ temp_files.each { |file| commands << [:cleanup, file] }
123
103
  end
124
104
 
125
105
  def build_composite_commands(base_path, output_path)
126
- wm_op = @operations.find { |op| op[:type] == :watermark }
127
- watermark_path = wm_op[:watermark_path]
128
- position = wm_op[:options][:position]
129
- opacity = wm_op[:options][:opacity]
130
-
131
- if opacity && opacity < 1.0
132
- wm_temp = watermark_path.gsub(/\.[^.]+$/, ".tmp_wm.png")
133
- alpha_value = opacity
106
+ wm_op = find_op(:watermark)
107
+ parts = watermark_parts(wm_op)
108
+
109
+ if parts[:opacity] && parts[:opacity] < 1.0
110
+ wm_temp = temp_path(parts[:watermark_path], "tmp_wm.png")
111
+ alpha_value = parts[:opacity]
134
112
  # Step 1: Apply alpha to watermark
135
- alpha_cmd = ["vips", "linear", watermark_path,
113
+ alpha_cmd = ["vips", "linear", parts[:watermark_path],
136
114
  "#{wm_temp}[alpha]", "1 1 1 #{alpha_value}", "0"]
137
115
  # Step 2: Composite
138
- xy_args = position_to_vips_xy(position, base_path, watermark_path)
116
+ xy_args = position_to_vips_xy(parts[:position], base_path, parts[:watermark_path])
139
117
  composite_cmd = ["vips", "composite2", base_path, wm_temp,
140
118
  build_format_spec(output_path), "over"] + xy_args
141
119
  # Step 3: Cleanup wm_temp
142
120
  [alpha_cmd, composite_cmd, [:cleanup, wm_temp]]
143
121
  else
144
- xy_args = position_to_vips_xy(position, base_path, watermark_path)
145
- [["vips", "composite2", base_path, watermark_path,
122
+ xy_args = position_to_vips_xy(parts[:position], base_path, parts[:watermark_path])
123
+ [["vips", "composite2", base_path, parts[:watermark_path],
146
124
  build_format_spec(output_path), "over"] + xy_args]
147
125
  end
148
126
  end
@@ -169,6 +147,9 @@ module JekyllImgFlow
169
147
  # Get an image dimension (width or height) using vips header.
170
148
  # Returns integer or 0 if vips is unavailable.
171
149
  def vips_image_dimension(image_path, field)
150
+ return 0 unless %w[width height].include?(field)
151
+
152
+ # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- Open3 receives an argument array and field is allowlisted.
172
153
  stdout, _, status = Open3.capture3("vips", "header", image_path, field)
173
154
  return 0 unless status.success?
174
155
 
@@ -178,9 +159,8 @@ module JekyllImgFlow
178
159
  end
179
160
 
180
161
  def build_alpha_command(input_path, output_path)
181
- alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
182
- opacity = alpha_op[:opacity]
183
- alpha_value = opacity.round(2)
162
+ alpha_op = find_op(:alpha_opacity)
163
+ alpha_value = alpha_op[:opacity].round(2)
184
164
 
185
165
  # vips linear multiplies alpha band: "1 1 1 alpha" scales alpha
186
166
  ["vips", "linear", input_path, output_path,
@@ -190,22 +170,22 @@ module JekyllImgFlow
190
170
  def build_sequential_crop_resize(input_path, output_path)
191
171
  if svg?(input_path)
192
172
  # SVG: use thumbnail with --crop to do crop+resize in one step
193
- resize_op = @operations.find { |op| op[:type] == :resize }
173
+ resize_op = find_op(:resize)
194
174
  format_spec = build_format_spec(output_path)
195
175
  width = resize_op[:width]
196
176
  height = resize_op[:height]
197
177
  [["vips", "thumbnail", input_path, format_spec,
198
178
  width.to_s, "--height=#{height}", "--crop=attention"]]
199
179
  else
200
- temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_crop.jpg")
201
- [build_crop_command(input_path, temp_path),
202
- build_resize_command(temp_path, output_path),
203
- [:cleanup, temp_path]]
180
+ temp = temp_path(input_path, "tmp_crop.jpg")
181
+ [build_crop_command(input_path, temp),
182
+ build_resize_command(temp, output_path),
183
+ [:cleanup, temp]]
204
184
  end
205
185
  end
206
186
 
207
187
  def build_resize_command(input_path, output_path)
208
- resize_op = @operations.find { |op| op[:type] == :resize }
188
+ resize_op = find_op(:resize)
209
189
  format_spec = build_format_spec(output_path)
210
190
  width = resize_op[:width]
211
191
  height = resize_op[:height]
@@ -226,31 +206,23 @@ module JekyllImgFlow
226
206
  end
227
207
 
228
208
  def build_crop_command(input_path, output_path)
229
- crop_op = @operations.find { |op| op[:type] == :crop }
230
- opts = crop_op[:options] || {}
231
- params = crop_op[:params] || {}
232
- keep = opts[:keep] || params[:keep] || params[:position]
233
-
234
- if crop_op[:ratio] && keep && %w[attention entropy center
235
- centre].include?(keep.to_s)
236
- crop_width = opts[:calculated_width]
237
- crop_height = opts[:calculated_height]
238
- interestingness = case keep.to_s
239
- when "entropy" then "entropy"
240
- when "center", "centre" then "centre"
241
- else "attention"
242
- end
243
- ["vips", "smartcrop", input_path, output_path,
244
- crop_width.to_s, crop_height.to_s,
245
- "--interesting=#{interestingness}"]
246
- else
247
- crop_x = crop_op[:ratio] ? opts[:calculated_x] : (opts[:x] || 0)
248
- crop_y = crop_op[:ratio] ? opts[:calculated_y] : (opts[:y] || 0)
249
- crop_width = crop_op[:ratio] ? opts[:calculated_width] : opts[:width]
250
- crop_height = crop_op[:ratio] ? opts[:calculated_height] : opts[:height]
251
- ["vips", "extract_area", input_path, output_path,
252
- crop_x.to_s, crop_y.to_s, crop_width.to_s, crop_height.to_s]
253
- end
209
+ crop_op = find_op(:crop)
210
+ geo = crop_geometry(crop_op)
211
+ return build_smartcrop_command(geo, input_path, output_path) if geo[:smartcrop]
212
+
213
+ build_extract_command(geo, input_path, output_path)
214
+ end
215
+
216
+ def build_smartcrop_command(geo, input_path, output_path)
217
+ interestingness = smartcrop_interestingness(geo[:keep])
218
+ ["vips", "smartcrop", input_path, output_path,
219
+ geo[:width].to_s, geo[:height].to_s,
220
+ "--interesting=#{interestingness}"]
221
+ end
222
+
223
+ def build_extract_command(geo, input_path, output_path)
224
+ ["vips", "extract_area", input_path, output_path,
225
+ geo[:x].to_s, geo[:y].to_s, geo[:width].to_s, geo[:height].to_s]
254
226
  end
255
227
 
256
228
  # SVG crop: use `vips thumbnail` with --crop to crop during thumbnailing.
@@ -260,18 +232,12 @@ module JekyllImgFlow
260
232
  # the most interesting region — this is the correct behavior for SVGs
261
233
  # since they have no fixed pixel dimensions.
262
234
  def build_svg_crop_pipeline(input_path, output_path)
263
- crop_op = @operations.find { |op| op[:type] == :crop }
264
- opts = crop_op[:options] || {}
265
- crop_width = crop_op[:ratio] ? opts[:calculated_width] : (opts[:width] || 1000)
266
- crop_height = crop_op[:ratio] ? opts[:calculated_height] : (opts[:height] || 1000)
235
+ crop_op = find_op(:crop)
236
+ geo = crop_geometry(crop_op)
237
+ crop_width = geo[:width] || 1000
238
+ crop_height = geo[:height] || 1000
267
239
  format_spec = build_format_spec(output_path)
268
-
269
- keep = opts[:keep] || crop_op[:params]&.[](:keep) || crop_op[:params]&.[](:position)
270
- interestingness = case keep.to_s
271
- when "entropy" then "entropy"
272
- when "center", "centre" then "centre"
273
- else "attention"
274
- end
240
+ interestingness = smartcrop_interestingness(geo[:keep])
275
241
 
276
242
  [["vips", "thumbnail", input_path, format_spec,
277
243
  crop_width.to_s, "--height=#{crop_height}",
@@ -293,8 +259,8 @@ module JekyllImgFlow
293
259
  # are executed via Open3.capture3 array form (no shell).
294
260
  def build_format_spec(output_path)
295
261
  # Build vips output specification with format and quality
296
- format_op = @operations.find { |op| op[:type] == :format }
297
- quality_op = @operations.find { |op| op[:type] == :quality }
262
+ format_op = find_op(:format)
263
+ quality_op = find_op(:quality)
298
264
 
299
265
  # Simple case: no format/quality operations
300
266
  return output_path unless format_op || quality_op
@@ -323,6 +289,7 @@ module JekyllImgFlow
323
289
  Jekyll.logger.debug "LibVips command: #{command.join(' ')}"
324
290
 
325
291
  # Execute command array directly (no shell)
292
+ # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- command is an argument array; no shell is invoked.
326
293
  stdout, stderr, status = Open3.capture3(*command)
327
294
  output = "#{stdout}#{stderr}"
328
295
  success = status.success?