jekyll-imgflow 0.1.5

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +661 -0
  3. data/README.md +214 -0
  4. data/lib/jekyll-imgflow/batch_manager.rb +263 -0
  5. data/lib/jekyll-imgflow/build_time_processor.rb +152 -0
  6. data/lib/jekyll-imgflow/config.rb +130 -0
  7. data/lib/jekyll-imgflow/filename_generator.rb +102 -0
  8. data/lib/jekyll-imgflow/helpers/http_downloader.rb +68 -0
  9. data/lib/jekyll-imgflow/hooks.rb +57 -0
  10. data/lib/jekyll-imgflow/html_generator.rb +335 -0
  11. data/lib/jekyll-imgflow/imgflow_tag.rb +247 -0
  12. data/lib/jekyll-imgflow/manifest_manager.rb +297 -0
  13. data/lib/jekyll-imgflow/operation_processor.rb +183 -0
  14. data/lib/jekyll-imgflow/parser.rb +199 -0
  15. data/lib/jekyll-imgflow/path_resolver.rb +130 -0
  16. data/lib/jekyll-imgflow/picture_tag_adaptor.rb +299 -0
  17. data/lib/jekyll-imgflow/picture_tag_preset_migrator.rb +210 -0
  18. data/lib/jekyll-imgflow/preset_manager.rb +149 -0
  19. data/lib/jekyll-imgflow/provider_registry.rb +106 -0
  20. data/lib/jekyll-imgflow/providers/base_provider.rb +198 -0
  21. data/lib/jekyll-imgflow/providers/flyimg.rb +187 -0
  22. data/lib/jekyll-imgflow/providers/imagemagick.rb +138 -0
  23. data/lib/jekyll-imgflow/providers/imgproxy.rb +164 -0
  24. data/lib/jekyll-imgflow/providers/libvips.rb +292 -0
  25. data/lib/jekyll-imgflow/providers/sharp.rb +213 -0
  26. data/lib/jekyll-imgflow/providers/weserv.rb +160 -0
  27. data/lib/jekyll-imgflow/tag_scanner.rb +227 -0
  28. data/lib/jekyll-imgflow/tags/base_tag.rb +52 -0
  29. data/lib/jekyll-imgflow/tags/crop_tag.rb +220 -0
  30. data/lib/jekyll-imgflow/tags/format_tag.rb +64 -0
  31. data/lib/jekyll-imgflow/tags/opacity_tag.rb +36 -0
  32. data/lib/jekyll-imgflow/tags/optimize_tag.rb +52 -0
  33. data/lib/jekyll-imgflow/tags/quality_tag.rb +31 -0
  34. data/lib/jekyll-imgflow/tags/resize_tag.rb +58 -0
  35. data/lib/jekyll-imgflow/tags/tag_registry.rb +70 -0
  36. data/lib/jekyll-imgflow/tags/watermark_tag.rb +51 -0
  37. data/lib/jekyll-imgflow/version.rb +5 -0
  38. data/lib/jekyll-imgflow.rb +30 -0
  39. metadata +261 -0
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+ require "tempfile"
6
+ require_relative "base_provider"
7
+
8
+ module JekyllImgFlow
9
+ module Providers
10
+ # Imgproxy provider implementation using the standardized tag interface
11
+ class Imgproxy < BaseProvider
12
+ TIMEOUT = 10 # seconds
13
+
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)
19
+ end
20
+
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
+ reset_operations
30
+ output_path
31
+ end
32
+
33
+ def build_combined_imgproxy_url(input_path)
34
+ raise "imgproxy_url not set" unless @config.imgproxy_url
35
+
36
+ encoded_url = encode_file_url(input_path)
37
+ operations = []
38
+
39
+ # Add all operations to single URL path
40
+ @operations.each do |operation|
41
+ case operation[:type]
42
+ when :resize
43
+ # Tags now provide complete calculated values
44
+ operations << if operation[:height]
45
+ "rs:fill:#{operation[:width]}:#{operation[:height]}:1"
46
+ else
47
+ "rs:fit:#{operation[:width]}::1"
48
+ end
49
+
50
+ when :crop
51
+ opts = operation[:options]
52
+ params = operation[:params] || {}
53
+
54
+ # Check for keep parameter (smartcrop support)
55
+ keep = opts[:keep] || params[:keep] || params[:position]
56
+
57
+ if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
58
+ # Use smartcrop with gravity:sm (libvips smart crop)
59
+ crop_width = opts[:calculated_width]
60
+ crop_height = opts[:calculated_height]
61
+
62
+ operations << "g:sm"
63
+ operations << "c:#{crop_width}:#{crop_height}"
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
+ operations << "c:#{crop_width}:#{crop_height}"
81
+ end
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
106
+
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
116
+ operations_path = operations.join("/")
117
+ "#{@config.imgproxy_url}/insecure/#{operations_path}/plain/#{encoded_url}"
118
+ end
119
+
120
+ private
121
+
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
131
+ end
132
+ end
133
+
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)
140
+ end
141
+
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)
150
+
151
+ raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
152
+
153
+ response.body
154
+ end
155
+ rescue StandardError => e
156
+ raise "Imgproxy request failed: #{e.message}"
157
+ end
158
+
159
+ def translate_quality_to_imgproxy(quality)
160
+ quality
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "open3"
5
+ require_relative "base_provider"
6
+
7
+ module JekyllImgFlow
8
+ module Providers
9
+ # Libvips provider implementation using the standardized tag interface
10
+ class Libvips < BaseProvider
11
+ def available?
12
+ # Check if vips CLI is available
13
+ _, _, status = Open3.capture3("which", "vips")
14
+ status.success?
15
+ end
16
+
17
+ def execute(input_path, output_path)
18
+ return if @operations.empty?
19
+
20
+ # Build single vips command with all operations
21
+ command = build_vips_command(input_path, output_path)
22
+ execute_command(command)
23
+
24
+ reset_operations
25
+ output_path
26
+ end
27
+
28
+ def build_vips_command(input_path, output_path)
29
+ has_crop = @operations.any? { |op| op[:type] == :crop }
30
+ has_resize = @operations.any? { |op| op[:type] == :resize }
31
+ has_watermark = @operations.any? { |op| op[:type] == :watermark }
32
+ has_alpha = @operations.any? { |op| op[:type] == :alpha_opacity }
33
+
34
+ if has_watermark
35
+ build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
36
+ elsif has_alpha
37
+ build_alpha_pipeline(input_path, output_path, has_crop, has_resize)
38
+ elsif has_crop && has_resize
39
+ build_sequential_crop_resize(input_path, output_path)
40
+ elsif has_resize
41
+ build_resize_command(input_path, output_path)
42
+ elsif has_crop
43
+ build_crop_command(input_path, output_path)
44
+ else
45
+ build_copy_command(input_path, output_path)
46
+ end
47
+ end
48
+
49
+ def build_alpha_pipeline(input_path, output_path, has_crop, has_resize)
50
+ commands = []
51
+ temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
52
+
53
+ if has_crop && has_resize
54
+ commands << build_sequential_crop_resize(input_path, temp_path)
55
+ base_path = temp_path
56
+ elsif has_resize
57
+ commands << build_resize_command(input_path, temp_path)
58
+ base_path = temp_path
59
+ elsif has_crop
60
+ commands << build_crop_command(input_path, temp_path)
61
+ base_path = temp_path
62
+ else
63
+ base_path = input_path
64
+ end
65
+
66
+ commands << build_alpha_command(base_path, output_path)
67
+ commands << "rm -f #{temp_path.shellescape}" unless base_path == input_path
68
+ commands.join(" && ")
69
+ end
70
+
71
+ def build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
72
+ temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
73
+ final_path = output_path
74
+
75
+ commands = []
76
+
77
+ # Step 1: Process crop/resize/copy to produce base image
78
+ if has_crop && has_resize
79
+ commands << build_sequential_crop_resize(input_path, temp_path)
80
+ base_path = temp_path
81
+ elsif has_resize
82
+ commands << build_resize_command(input_path, temp_path)
83
+ base_path = temp_path
84
+ elsif has_crop
85
+ commands << build_crop_command(input_path, temp_path)
86
+ base_path = temp_path
87
+ else
88
+ base_path = input_path
89
+ end
90
+
91
+ # Step 2: Apply alpha opacity if present (before watermark)
92
+ alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
93
+ if alpha_op
94
+ alpha_temp = input_path.gsub(/\.[^.]+$/, ".tmp_alpha.jpg")
95
+ commands << build_alpha_command(base_path, alpha_temp)
96
+ base_path = alpha_temp
97
+ end
98
+
99
+ # Step 3: Composite watermark over base
100
+ commands << build_composite_command(base_path, final_path)
101
+
102
+ # Step 4: Cleanup temp files
103
+ temp_files = [temp_path, base_path == input_path ? nil : base_path].compact
104
+ temp_files.each { |f| commands << "rm -f #{f.shellescape}" }
105
+
106
+ commands.join(" && ")
107
+ end
108
+
109
+ def build_composite_command(base_path, output_path)
110
+ wm_op = @operations.find { |op| op[:type] == :watermark }
111
+ watermark_path = wm_op[:watermark_path]
112
+ position = wm_op[:options][:position]
113
+ opacity = wm_op[:options][:opacity]
114
+ format_spec = build_format_spec(output_path)
115
+
116
+ if opacity && opacity < 1.0
117
+ wm_temp = watermark_path.gsub(/\.[^.]+$/, ".tmp_wm.png")
118
+ alpha_value = opacity
119
+ temp_cmd = ["vips", "linear", watermark_path.shellescape,
120
+ "'#{wm_temp}[alpha]'", "\"1 1 1 #{alpha_value}\"", "0"].join(" ")
121
+ full_cmd = ["#{temp_cmd} && vips composite2",
122
+ base_path.shellescape, wm_temp.shellescape,
123
+ format_spec, "over",
124
+ position_to_vips_xy(position, base_path, watermark_path)].join(" ")
125
+ # Add cleanup
126
+ "#{full_cmd} && rm -f #{wm_temp.shellescape}"
127
+ else
128
+ ["vips", "composite2", base_path.shellescape,
129
+ watermark_path.shellescape, format_spec, "over",
130
+ position_to_vips_xy(position, base_path, watermark_path)].join(" ")
131
+ end
132
+ end
133
+
134
+ def position_to_vips_xy(position, _base_path, watermark_path)
135
+ # Get image dimensions to calculate x/y from compass positions
136
+ # vips composite2 uses --x and --y for overlay placement
137
+ case position.to_s
138
+ when "northeast", "top-right"
139
+ # Need watermark width; use shell substitution
140
+ "--x $(vips header #{watermark_path.shellescape} width) --y 0"
141
+ when "southwest", "bottom-left"
142
+ "--x 0 --y $(vips header #{watermark_path.shellescape} height)"
143
+ when "southeast", "bottom-right"
144
+ "--x $(vips header #{watermark_path.shellescape} width) " \
145
+ "--y $(vips header #{watermark_path.shellescape} height)"
146
+ else
147
+ "--x 0 --y 0" # northwest, top-left, center, centre, and default
148
+ end
149
+ end
150
+
151
+ def build_alpha_command(input_path, output_path)
152
+ alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
153
+ opacity = alpha_op[:opacity]
154
+ alpha_value = opacity.round(2)
155
+
156
+ # vips linear multiplies alpha band: "1 1 1 alpha" scales alpha
157
+ ["vips", "linear", input_path.shellescape, output_path.shellescape,
158
+ "\"1 1 1 #{alpha_value}\"", "0"].join(" ")
159
+ end
160
+
161
+ def build_sequential_crop_resize(input_path, output_path)
162
+ # Build temp file path for intermediate crop result
163
+ temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_crop.jpg")
164
+
165
+ # First command: crop only
166
+ crop_cmd = build_crop_command(input_path, temp_path)
167
+
168
+ # Second command: resize + format + quality + alpha (all together)
169
+ resize_cmd = build_resize_command(temp_path, output_path)
170
+
171
+ # Combine with && and cleanup temp file
172
+ "#{crop_cmd} && #{resize_cmd} && rm -f #{temp_path.shellescape}"
173
+ end
174
+
175
+ def build_resize_command(input_path, output_path)
176
+ resize_op = @operations.find { |op| op[:type] == :resize }
177
+ resize_data = resize_op[:options] || {}
178
+
179
+ if resize_op[:width] && resize_op[:height]
180
+ # Both dimensions specified - use VipsResize with vscale for exact dimensions
181
+ # ResizeTag has already done the calculations, so we use both scale factors
182
+ scale_x = resize_data[:scale_x]
183
+ scale_y = resize_data[:scale_y]
184
+ format_spec = build_format_spec(output_path)
185
+
186
+ # Only add vscale if we have both scale factors
187
+ if scale_x && scale_y
188
+ ["vips", "VipsResize", input_path.shellescape, format_spec,
189
+ scale_x.to_s, "--vscale=#{scale_y}"].join(" ")
190
+ else
191
+ # Fallback to scale_x only if scale_y is missing
192
+ ["vips", "VipsResize", input_path.shellescape, format_spec,
193
+ (scale_x || 1.0).to_s].join(" ")
194
+ end
195
+ else
196
+ # Only one dimension - use VipsResize with scale_x (maintain aspect ratio)
197
+ scale_x = resize_data[:scale_x] || 1.0
198
+ format_spec = build_format_spec(output_path)
199
+ ["vips", "VipsResize", input_path.shellescape, format_spec, scale_x.to_s].join(" ")
200
+ end
201
+ end
202
+
203
+ def build_crop_command(input_path, output_path)
204
+ crop_op = @operations.find { |op| op[:type] == :crop }
205
+ opts = crop_op[:options] || {}
206
+ params = crop_op[:params] || {}
207
+
208
+ # Check if we should use smartcrop (when keep parameter is specified)
209
+ # JPT keep options: attention (default), entropy, center
210
+ # Check both options (from CropTag) and params (from OperationProcessor)
211
+ keep = opts[:keep] || params[:keep] || params[:position]
212
+
213
+ if crop_op[:ratio] && keep && %w[attention entropy center
214
+ centre].include?(keep.to_s)
215
+ # Use libvips smartcrop for intelligent cropping
216
+ crop_width = opts[:calculated_width]
217
+ crop_height = opts[:calculated_height]
218
+
219
+ # Map keep parameter to libvips interestingness
220
+ interestingness = case keep.to_s
221
+ when "entropy" then "entropy"
222
+ when "center", "centre" then "centre"
223
+ else "attention" # default
224
+ end
225
+
226
+ # vips smartcrop input.jpg output.jpg width height --interesting=attention
227
+ ["vips", "smartcrop", input_path.shellescape, output_path.shellescape,
228
+ crop_width.to_s, crop_height.to_s,
229
+ "--interesting=#{interestingness}"].join(" ")
230
+ else
231
+ # Use basic extract_area for manual cropping or when no keep parameter
232
+ crop_x = crop_op[:ratio] ? opts[:calculated_x] : (opts[:x] || 0)
233
+ crop_y = crop_op[:ratio] ? opts[:calculated_y] : (opts[:y] || 0)
234
+ crop_width = crop_op[:ratio] ? opts[:calculated_width] : opts[:width]
235
+ crop_height = crop_op[:ratio] ? opts[:calculated_height] : opts[:height]
236
+
237
+ # vips extract_area input.jpg output.jpg left top width height
238
+ ["vips", "extract_area", input_path.shellescape, output_path.shellescape,
239
+ crop_x.to_s, crop_y.to_s, crop_width.to_s, crop_height.to_s].join(" ")
240
+ end
241
+ end
242
+
243
+ def build_copy_command(input_path, output_path)
244
+ # vips copy input.jpg output.jpg[format]
245
+ format_spec = build_format_spec(output_path)
246
+ ["vips", "copy", input_path.shellescape, format_spec].join(" ")
247
+ end
248
+
249
+ def build_format_spec(output_path)
250
+ # Build vips output specification with format and quality
251
+ format_op = @operations.find { |op| op[:type] == :format }
252
+ quality_op = @operations.find { |op| op[:type] == :quality }
253
+
254
+ # Simple case: no format/quality operations
255
+ return output_path.shellescape unless format_op || quality_op
256
+
257
+ # Extract format from operation or output path
258
+ ext = format_op ? format_op[:format] : File.extname(output_path).delete(".")
259
+ base = output_path.gsub(/\.[^.]+$/, "")
260
+
261
+ # Build format spec with quality if present
262
+ # Use single quotes to preserve bracket syntax (shellescape breaks it)
263
+ if quality_op
264
+ quality = quality_op[:quality]
265
+ "'#{base}.#{ext}[Q=#{quality}]'"
266
+ else
267
+ "'#{base}.#{ext}'"
268
+ end
269
+ end
270
+
271
+ def execute_command(command)
272
+ Jekyll.logger.debug "LibVips command: #{command}"
273
+
274
+ # Execute command and capture output
275
+ stdout, stderr, status = Open3.capture3(command)
276
+ output = "#{stdout}#{stderr}"
277
+ success = status.success?
278
+
279
+ unless success
280
+ # Filter out expected warnings and only log real errors
281
+ raise "LibVips command failed: #{command}\nError: #{output}" unless output.include?("same file") || output.include?("VipsForeignSave")
282
+
283
+ Jekyll.logger.warn "LibVips warning: #{output.strip}"
284
+ return output.strip # Continue despite warnings
285
+
286
+ end
287
+
288
+ output.strip
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "open3"
5
+ require_relative "base_provider"
6
+
7
+ module JekyllImgFlow
8
+ module Providers
9
+ # Sharp provider implementation using the standardized tag interface
10
+ class Sharp < BaseProvider
11
+ def available?
12
+ # Check if sharp CLI is available
13
+ _, _, status = Open3.capture3("which", "sharp")
14
+ status.success?
15
+ end
16
+
17
+ def execute(input_path, output_path)
18
+ return if @operations.empty?
19
+
20
+ # Build Sharp command
21
+ command = build_sharp_command(input_path, output_path)
22
+ execute_command(command)
23
+
24
+ reset_operations
25
+ output_path
26
+ end
27
+
28
+ def build_sharp_command(input_path, output_path)
29
+ has_crop = @operations.any? { |op| op[:type] == :crop }
30
+ has_resize = @operations.any? { |op| op[:type] == :resize }
31
+ has_watermark = @operations.any? { |op| op[:type] == :watermark }
32
+
33
+ if has_watermark
34
+ build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
35
+ elsif has_crop && has_resize
36
+ build_sequential_crop_resize(input_path, output_path)
37
+ elsif has_resize
38
+ build_resize_command(input_path, output_path)
39
+ elsif has_crop
40
+ build_crop_command(input_path, output_path)
41
+ else
42
+ build_copy_command(input_path, output_path)
43
+ end
44
+ end
45
+
46
+ def build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
47
+ temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
48
+ commands = []
49
+
50
+ if has_crop && has_resize
51
+ commands << build_sequential_crop_resize(input_path, temp_path)
52
+ base_path = temp_path
53
+ elsif has_resize
54
+ commands << build_resize_command(input_path, temp_path)
55
+ base_path = temp_path
56
+ elsif has_crop
57
+ commands << build_crop_command(input_path, temp_path)
58
+ base_path = temp_path
59
+ else
60
+ base_path = input_path
61
+ end
62
+
63
+ commands << build_composite_command(base_path, output_path)
64
+ commands << "rm -f #{temp_path.shellescape}" unless base_path == input_path
65
+ commands.join(" && ")
66
+ end
67
+
68
+ def build_composite_command(base_path, output_path)
69
+ wm_op = @operations.find { |op| op[:type] == :watermark }
70
+ watermark_path = wm_op[:watermark_path]
71
+ position = wm_op[:options][:position]
72
+ opacity = wm_op[:options][:opacity]
73
+
74
+ gravity = translate_position(position)
75
+
76
+ command_parts = ["sharp", "-i", base_path.shellescape,
77
+ "-o", output_path.shellescape]
78
+
79
+ format_op = @operations.find { |op| op[:type] == :format }
80
+ quality_op = @operations.find { |op| op[:type] == :quality }
81
+ command_parts += ["-f", format_op[:format]] if format_op
82
+ command_parts += ["-q#{quality_op[:quality]}"] if quality_op
83
+
84
+ if opacity && opacity < 1.0
85
+ wm_temp = watermark_path.gsub(/\.[^.]+$/, ".tmp_wm.png")
86
+ alpha_value = (opacity * 255).round
87
+ temp_cmd = ["sharp", "-i", watermark_path.shellescape,
88
+ "-o", wm_temp.shellescape,
89
+ "ensureAlpha", alpha_value.to_s].join(" ")
90
+ command_parts += ["composite", wm_temp.shellescape,
91
+ "--gravity", gravity, "--blend", "over"]
92
+ "#{temp_cmd} && #{command_parts.join(' ')} " \
93
+ "&& rm -f #{wm_temp.shellescape}"
94
+ else
95
+ command_parts += ["composite", watermark_path.shellescape,
96
+ "--gravity", gravity, "--blend", "over"]
97
+ command_parts.join(" ")
98
+ end
99
+ end
100
+
101
+ def translate_position(position)
102
+ case position.to_s
103
+ when "northwest" then "northwest"
104
+ when "northeast" then "northeast"
105
+ when "southwest" then "southwest"
106
+ when "southeast" then "southeast"
107
+ when "center" then "center"
108
+ else position.to_s
109
+ end
110
+ end
111
+
112
+ def build_sequential_crop_resize(input_path, output_path)
113
+ # Build temp file path for intermediate crop result
114
+ temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_crop.jpg")
115
+
116
+ # First command: crop only
117
+ crop_cmd = build_crop_command(input_path, temp_path)
118
+
119
+ # Second command: resize + format + quality + alpha (all together)
120
+ resize_cmd = build_resize_command(temp_path, output_path)
121
+
122
+ # Combine with && and cleanup temp file
123
+ "#{crop_cmd} && #{resize_cmd} && rm -f #{temp_path.shellescape}"
124
+ end
125
+
126
+ def build_resize_command(input_path, output_path)
127
+ resize_op = @operations.find { |op| op[:type] == :resize }
128
+
129
+ # sharp -i input.jpg -o output.jpg resize width [height] -f format -q quality
130
+ command_parts = ["sharp", "-i", input_path.shellescape, "-o", output_path.shellescape]
131
+
132
+ # Add resize
133
+ command_parts += if resize_op[:height]
134
+ ["resize", resize_op[:width].to_s, resize_op[:height].to_s]
135
+ else
136
+ ["resize", resize_op[:width].to_s]
137
+ end
138
+
139
+ # Add format, quality, alpha
140
+ format_op = @operations.find { |op| op[:type] == :format }
141
+ quality_op = @operations.find { |op| op[:type] == :quality }
142
+ alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
143
+
144
+ command_parts += ["-f", format_op[:format]] if format_op
145
+ command_parts += ["-q#{quality_op[:quality]}"] if quality_op
146
+ if alpha_op
147
+ alpha_value = (alpha_op[:opacity] * 255).round
148
+ command_parts += ["alpha", "{alpha:#{alpha_value}}"]
149
+ end
150
+
151
+ command_parts.join(" ")
152
+ end
153
+
154
+ def build_crop_command(input_path, output_path)
155
+ crop_op = @operations.find { |op| op[:type] == :crop }
156
+ opts = crop_op[:options] || {}
157
+ params = crop_op[:params] || {}
158
+
159
+ # Check if we should use smartcrop (when keep parameter is specified)
160
+ # JPT keep options: attention (default), entropy, center
161
+ # Check both options (from CropTag) and params (from OperationProcessor)
162
+ keep = opts[:keep] || params[:keep] || params[:position]
163
+
164
+ if crop_op[:ratio] && keep && %w[attention entropy center
165
+ centre].include?(keep.to_s)
166
+ # Use smartcrop for intelligent cropping (Sharp uses libvips backend)
167
+ crop_width = opts[:calculated_width]
168
+ crop_height = opts[:calculated_height]
169
+
170
+ # Map keep parameter to libvips interestingness
171
+ interestingness = case keep.to_s
172
+ when "entropy" then "entropy"
173
+ when "center", "centre" then "centre"
174
+ else "attention" # default
175
+ end
176
+
177
+ # sharp -i input.jpg -o output.jpg smartcrop width height --interesting=attention
178
+ ["sharp", "-i", input_path.shellescape, "-o", output_path.shellescape,
179
+ "smartcrop", crop_width.to_s, crop_height.to_s,
180
+ "--interesting=#{interestingness}"].join(" ")
181
+ else
182
+ # Use basic extract for manual cropping or when no keep parameter
183
+ crop_x = crop_op[:ratio] ? opts[:calculated_x] : (opts[:x] || 0)
184
+ crop_y = crop_op[:ratio] ? opts[:calculated_y] : (opts[:y] || 0)
185
+ crop_width = crop_op[:ratio] ? opts[:calculated_width] : opts[:width]
186
+ crop_height = crop_op[:ratio] ? opts[:calculated_height] : opts[:height]
187
+
188
+ # sharp -i input.jpg -o output.jpg extract top left width height
189
+ ["sharp", "-i", input_path.shellescape, "-o", output_path.shellescape,
190
+ "extract", crop_y.to_s, crop_x.to_s, crop_width.to_s, crop_height.to_s].join(" ")
191
+ end
192
+ end
193
+
194
+ def build_copy_command(input_path, output_path)
195
+ # sharp -i input.jpg -o output.jpg -f format -q quality
196
+ command_parts = ["sharp", "-i", input_path.shellescape, "-o", output_path.shellescape]
197
+
198
+ format_op = @operations.find { |op| op[:type] == :format }
199
+ quality_op = @operations.find { |op| op[:type] == :quality }
200
+ alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
201
+
202
+ command_parts += ["-f", format_op[:format]] if format_op
203
+ command_parts += ["-q#{quality_op[:quality]}"] if quality_op
204
+ if alpha_op
205
+ alpha_value = (alpha_op[:opacity] * 255).round
206
+ command_parts += ["alpha", "{alpha:#{alpha_value}}"]
207
+ end
208
+
209
+ command_parts.join(" ")
210
+ end
211
+ end
212
+ end
213
+ end