jekyll-imgflow 0.1.9 → 0.2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -13
  3. data/lib/jekyll-imgflow/batch_manager.rb +10 -7
  4. data/lib/jekyll-imgflow/build_time_processor.rb +70 -61
  5. data/lib/jekyll-imgflow/config.rb +3 -6
  6. data/lib/jekyll-imgflow/filename_generator.rb +8 -10
  7. data/lib/jekyll-imgflow/generated_file_cleaner.rb +81 -0
  8. data/lib/jekyll-imgflow/hooks.rb +13 -6
  9. data/lib/jekyll-imgflow/html_generator.rb +14 -19
  10. data/lib/jekyll-imgflow/imgflow_tag.rb +84 -69
  11. data/lib/jekyll-imgflow/manifest_manager.rb +132 -53
  12. data/lib/jekyll-imgflow/operation_processor.rb +43 -22
  13. data/lib/jekyll-imgflow/picture_tag_adaptor.rb +1 -1
  14. data/lib/jekyll-imgflow/preset_manager.rb +58 -7
  15. data/lib/jekyll-imgflow/presets/gallery.yml +7 -0
  16. data/lib/jekyll-imgflow/presets/hero.yml +7 -0
  17. data/lib/jekyll-imgflow/presets/thumbnail.yml +7 -0
  18. data/lib/jekyll-imgflow/processing_stats.rb +80 -0
  19. data/lib/jekyll-imgflow/provider_registry.rb +18 -3
  20. data/lib/jekyll-imgflow/providers/base_provider.rb +12 -1
  21. data/lib/jekyll-imgflow/providers/flyimg.rb +2 -1
  22. data/lib/jekyll-imgflow/providers/imagemagick.rb +72 -3
  23. data/lib/jekyll-imgflow/providers/imgproxy.rb +2 -1
  24. data/lib/jekyll-imgflow/providers/libvips.rb +152 -100
  25. data/lib/jekyll-imgflow/providers/sharp.rb +2 -1
  26. data/lib/jekyll-imgflow/providers/weserv.rb +3 -2
  27. data/lib/jekyll-imgflow/tags/opacity_tag.rb +8 -1
  28. data/lib/jekyll-imgflow/tags/resize_tag.rb +2 -0
  29. data/lib/jekyll-imgflow/tags/watermark_tag.rb +8 -1
  30. data/lib/jekyll-imgflow/tasks.rb +129 -0
  31. data/lib/jekyll-imgflow/version.rb +1 -1
  32. data/lib/jekyll-imgflow.rb +2 -0
  33. metadata +24 -156
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "shellwords"
4
3
  require "open3"
4
+ require "fileutils"
5
5
  require_relative "base_provider"
6
6
 
7
7
  module JekyllImgFlow
@@ -17,15 +17,20 @@ module JekyllImgFlow
17
17
  def execute(input_path, output_path)
18
18
  return if @operations.empty?
19
19
 
20
- # Build single vips command with all operations
21
- command = build_vips_command(input_path, output_path)
22
- execute_command(command)
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) }
23
24
 
24
- reset_operations
25
25
  output_path
26
+ ensure
27
+ reset_operations
26
28
  end
27
29
 
28
- def build_vips_command(input_path, output_path)
30
+ # Build all vips commands as an array of command arrays.
31
+ # Each sub-array is passed directly to Open3.capture3 (no shell).
32
+ # Returns Array[Array[String]].
33
+ def build_vips_commands(input_path, output_path)
29
34
  has_crop = @operations.any? { |op| op[:type] == :crop }
30
35
  has_resize = @operations.any? { |op| op[:type] == :resize }
31
36
  has_watermark = @operations.any? { |op| op[:type] == :watermark }
@@ -38,20 +43,32 @@ module JekyllImgFlow
38
43
  elsif has_crop && has_resize
39
44
  build_sequential_crop_resize(input_path, output_path)
40
45
  elsif has_resize
41
- build_resize_command(input_path, output_path)
46
+ [build_resize_command(input_path, output_path)]
42
47
  elsif has_crop
43
- build_crop_command(input_path, output_path)
48
+ svg?(input_path) ? build_svg_crop_pipeline(input_path, output_path) : [build_crop_command(input_path, output_path)]
44
49
  else
45
- build_copy_command(input_path, output_path)
50
+ [build_copy_command(input_path, output_path)]
46
51
  end
47
52
  end
48
53
 
54
+ # Convenience wrapper for backward compatibility with tests.
55
+ # Returns the commands joined as a debug string.
56
+ def build_vips_command(input_path, output_path)
57
+ build_vips_commands(input_path, output_path).map do |cmd|
58
+ if cmd.is_a?(Array) && cmd.first == :cleanup
59
+ "rm -f #{cmd[1]}"
60
+ else
61
+ cmd.join(" ")
62
+ end
63
+ end.join(" && ")
64
+ end
65
+
49
66
  def build_alpha_pipeline(input_path, output_path, has_crop, has_resize)
50
67
  commands = []
51
68
  temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
52
69
 
53
70
  if has_crop && has_resize
54
- commands << build_sequential_crop_resize(input_path, temp_path)
71
+ commands.concat(build_sequential_crop_resize(input_path, temp_path))
55
72
  base_path = temp_path
56
73
  elsif has_resize
57
74
  commands << build_resize_command(input_path, temp_path)
@@ -64,19 +81,18 @@ module JekyllImgFlow
64
81
  end
65
82
 
66
83
  commands << build_alpha_command(base_path, output_path)
67
- commands << "rm -f #{temp_path.shellescape}" unless base_path == input_path
68
- commands.join(" && ")
84
+ commands << [:cleanup, temp_path] unless base_path == input_path
85
+ commands
69
86
  end
70
87
 
71
88
  def build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
72
89
  temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
73
- final_path = output_path
74
90
 
75
91
  commands = []
76
92
 
77
93
  # Step 1: Process crop/resize/copy to produce base image
78
94
  if has_crop && has_resize
79
- commands << build_sequential_crop_resize(input_path, temp_path)
95
+ commands.concat(build_sequential_crop_resize(input_path, temp_path))
80
96
  base_path = temp_path
81
97
  elsif has_resize
82
98
  commands << build_resize_command(input_path, temp_path)
@@ -97,106 +113,115 @@ module JekyllImgFlow
97
113
  end
98
114
 
99
115
  # Step 3: Composite watermark over base
100
- commands << build_composite_command(base_path, final_path)
116
+ commands.concat(build_composite_commands(base_path, output_path))
101
117
 
102
118
  # Step 4: Cleanup temp files
103
119
  temp_files = [temp_path, base_path == input_path ? nil : base_path].compact
104
- temp_files.each { |f| commands << "rm -f #{f.shellescape}" }
120
+ temp_files.each { |f| commands << [:cleanup, f] }
105
121
 
106
- commands.join(" && ")
122
+ commands
107
123
  end
108
124
 
109
- def build_composite_command(base_path, output_path)
125
+ def build_composite_commands(base_path, output_path)
110
126
  wm_op = @operations.find { |op| op[:type] == :watermark }
111
127
  watermark_path = wm_op[:watermark_path]
112
128
  position = wm_op[:options][:position]
113
129
  opacity = wm_op[:options][:opacity]
114
- format_spec = build_format_spec(output_path)
115
130
 
116
131
  if opacity && opacity < 1.0
117
132
  wm_temp = watermark_path.gsub(/\.[^.]+$/, ".tmp_wm.png")
118
133
  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}"
134
+ # Step 1: Apply alpha to watermark
135
+ alpha_cmd = ["vips", "linear", watermark_path,
136
+ "#{wm_temp}[alpha]", "1 1 1 #{alpha_value}", "0"]
137
+ # Step 2: Composite
138
+ xy_args = position_to_vips_xy(position, base_path, watermark_path)
139
+ composite_cmd = ["vips", "composite2", base_path, wm_temp,
140
+ build_format_spec(output_path), "over"] + xy_args
141
+ # Step 3: Cleanup wm_temp
142
+ [alpha_cmd, composite_cmd, [:cleanup, wm_temp]]
127
143
  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(" ")
144
+ xy_args = position_to_vips_xy(position, base_path, watermark_path)
145
+ [["vips", "composite2", base_path, watermark_path,
146
+ build_format_spec(output_path), "over"] + xy_args]
131
147
  end
132
148
  end
133
149
 
150
+ # Return x/y arguments as an array of strings (no shell substitution).
151
+ # Pre-computes image dimensions via vips header in Ruby.
134
152
  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
153
  case position.to_s
138
154
  when "northeast", "top-right"
139
- # Need watermark width; use shell substitution
140
- "--x $(vips header #{watermark_path.shellescape} width) --y 0"
155
+ width = vips_image_dimension(watermark_path, "width")
156
+ ["--x", width.to_s, "--y", "0"]
141
157
  when "southwest", "bottom-left"
142
- "--x 0 --y $(vips header #{watermark_path.shellescape} height)"
158
+ height = vips_image_dimension(watermark_path, "height")
159
+ ["--x", "0", "--y", height.to_s]
143
160
  when "southeast", "bottom-right"
144
- "--x $(vips header #{watermark_path.shellescape} width) " \
145
- "--y $(vips header #{watermark_path.shellescape} height)"
161
+ width = vips_image_dimension(watermark_path, "width")
162
+ height = vips_image_dimension(watermark_path, "height")
163
+ ["--x", width.to_s, "--y", height.to_s]
146
164
  else
147
- "--x 0 --y 0" # northwest, top-left, center, centre, and default
165
+ ["--x", "0", "--y", "0"] # northwest, top-left, center, centre, and default
148
166
  end
149
167
  end
150
168
 
169
+ # Get an image dimension (width or height) using vips header.
170
+ # Returns integer or 0 if vips is unavailable.
171
+ def vips_image_dimension(image_path, field)
172
+ stdout, _, status = Open3.capture3("vips", "header", image_path, field)
173
+ return 0 unless status.success?
174
+
175
+ stdout.strip.to_i
176
+ rescue StandardError
177
+ 0
178
+ end
179
+
151
180
  def build_alpha_command(input_path, output_path)
152
181
  alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
153
182
  opacity = alpha_op[:opacity]
154
183
  alpha_value = opacity.round(2)
155
184
 
156
185
  # 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(" ")
186
+ ["vips", "linear", input_path, output_path,
187
+ "1 1 1 #{alpha_value}", "0"]
159
188
  end
160
189
 
161
190
  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}"
191
+ if svg?(input_path)
192
+ # SVG: use thumbnail with --crop to do crop+resize in one step
193
+ resize_op = @operations.find { |op| op[:type] == :resize }
194
+ format_spec = build_format_spec(output_path)
195
+ width = resize_op[:width]
196
+ height = resize_op[:height]
197
+ [["vips", "thumbnail", input_path, format_spec,
198
+ width.to_s, "--height=#{height}", "--crop=attention"]]
199
+ 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]]
204
+ end
173
205
  end
174
206
 
175
207
  def build_resize_command(input_path, output_path)
176
208
  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
209
+ format_spec = build_format_spec(output_path)
210
+ width = resize_op[:width]
211
+ height = resize_op[:height]
212
+
213
+ # Use `vips thumbnail` for all resize operations. thumbnail is the
214
+ # recommended high-level resize API — it uses shrink-on-load for
215
+ # JPEGs (efficient), handles SVGs safely by bounding render size
216
+ # during load, and supports all image formats.
217
+ # See https://www.libvips.org/API/current/func.thumbnail.html
218
+ if width && height
219
+ # Both dimensions: thumbnail to width, cap height, crop to fill
220
+ ["vips", "thumbnail", input_path, format_spec,
221
+ width.to_s, "--height=#{height}", "--crop=attention"]
195
222
  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(" ")
223
+ # Only width: thumbnail maintains aspect ratio
224
+ ["vips", "thumbnail", input_path, format_spec, width.to_s]
200
225
  end
201
226
  end
202
227
 
@@ -204,81 +229,108 @@ module JekyllImgFlow
204
229
  crop_op = @operations.find { |op| op[:type] == :crop }
205
230
  opts = crop_op[:options] || {}
206
231
  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
232
  keep = opts[:keep] || params[:keep] || params[:position]
212
233
 
213
234
  if crop_op[:ratio] && keep && %w[attention entropy center
214
235
  centre].include?(keep.to_s)
215
- # Use libvips smartcrop for intelligent cropping
216
236
  crop_width = opts[:calculated_width]
217
237
  crop_height = opts[:calculated_height]
218
-
219
- # Map keep parameter to libvips interestingness
220
238
  interestingness = case keep.to_s
221
239
  when "entropy" then "entropy"
222
240
  when "center", "centre" then "centre"
223
- else "attention" # default
241
+ else "attention"
224
242
  end
225
-
226
- # vips smartcrop input.jpg output.jpg width height --interesting=attention
227
- ["vips", "smartcrop", input_path.shellescape, output_path.shellescape,
243
+ ["vips", "smartcrop", input_path, output_path,
228
244
  crop_width.to_s, crop_height.to_s,
229
- "--interesting=#{interestingness}"].join(" ")
245
+ "--interesting=#{interestingness}"]
230
246
  else
231
- # Use basic extract_area for manual cropping or when no keep parameter
232
247
  crop_x = crop_op[:ratio] ? opts[:calculated_x] : (opts[:x] || 0)
233
248
  crop_y = crop_op[:ratio] ? opts[:calculated_y] : (opts[:y] || 0)
234
249
  crop_width = crop_op[:ratio] ? opts[:calculated_width] : opts[:width]
235
250
  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(" ")
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]
240
253
  end
241
254
  end
242
255
 
256
+ # SVG crop: use `vips thumbnail` with --crop to crop during thumbnailing.
257
+ # This avoids the coordinate mismatch that occurs when rasterizing at a
258
+ # different size than the SVG's viewBox (which caused "bad extract area").
259
+ # thumbnail --crop uses smartcrop (attention/entropy/centre) to select
260
+ # the most interesting region — this is the correct behavior for SVGs
261
+ # since they have no fixed pixel dimensions.
262
+ 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)
267
+ 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
275
+
276
+ [["vips", "thumbnail", input_path, format_spec,
277
+ crop_width.to_s, "--height=#{crop_height}",
278
+ "--crop=#{interestingness}"]]
279
+ end
280
+
243
281
  def build_copy_command(input_path, output_path)
244
- # vips copy input.jpg output.jpg[format]
245
282
  format_spec = build_format_spec(output_path)
246
- ["vips", "copy", input_path.shellescape, format_spec].join(" ")
283
+ # For SVGs, `vips copy` rasterizes at intrinsic size (potentially
284
+ # huge). Use thumbnail with a large cap to bound the render safely.
285
+ return ["vips", "thumbnail", input_path, format_spec, "2000"] if svg?(input_path)
286
+
287
+ # vips copy input.jpg output.jpg[format]
288
+ ["vips", "copy", input_path, format_spec]
247
289
  end
248
290
 
291
+ # Build vips output specification with format and quality.
292
+ # Returns a raw string — no shell quoting needed since commands
293
+ # are executed via Open3.capture3 array form (no shell).
249
294
  def build_format_spec(output_path)
250
295
  # Build vips output specification with format and quality
251
296
  format_op = @operations.find { |op| op[:type] == :format }
252
297
  quality_op = @operations.find { |op| op[:type] == :quality }
253
298
 
254
299
  # Simple case: no format/quality operations
255
- return output_path.shellescape unless format_op || quality_op
300
+ return output_path unless format_op || quality_op
256
301
 
257
302
  # Extract format from operation or output path
258
303
  ext = format_op ? format_op[:format] : File.extname(output_path).delete(".")
259
304
  base = output_path.gsub(/\.[^.]+$/, "")
260
305
 
261
306
  # Build format spec with quality if present
262
- # Use single quotes to preserve bracket syntax (shellescape breaks it)
263
307
  if quality_op
264
308
  quality = quality_op[:quality]
265
- "'#{base}.#{ext}[Q=#{quality}]'"
309
+ "#{base}.#{ext}[Q=#{quality}]"
266
310
  else
267
- "'#{base}.#{ext}'"
311
+ "#{base}.#{ext}"
268
312
  end
269
313
  end
270
314
 
315
+ # Execute a command array via Open3.capture3 (no shell invocation).
316
+ # @param command [Array<String>] Command and arguments as array
271
317
  def execute_command(command)
272
- Jekyll.logger.debug "LibVips command: #{command}"
318
+ if command.is_a?(Array) && command.first == :cleanup
319
+ FileUtils.rm_f(command[1])
320
+ return
321
+ end
322
+
323
+ Jekyll.logger.debug "LibVips command: #{command.join(' ')}"
273
324
 
274
- # Execute command and capture output
275
- stdout, stderr, status = Open3.capture3(command)
325
+ # Execute command array directly (no shell)
326
+ stdout, stderr, status = Open3.capture3(*command)
276
327
  output = "#{stdout}#{stderr}"
277
328
  success = status.success?
278
329
 
279
330
  unless success
331
+ cmd_str = command.join(" ")
280
332
  # 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")
333
+ raise "LibVips command failed: #{cmd_str}\nError: #{output}" unless output.include?("same file") || output.include?("VipsForeignSave")
282
334
 
283
335
  Jekyll.logger.warn "LibVips warning: #{output.strip}"
284
336
  return output.strip # Continue despite warnings
@@ -21,8 +21,9 @@ module JekyllImgFlow
21
21
  command = build_sharp_command(input_path, output_path)
22
22
  execute_command(command)
23
23
 
24
- reset_operations
25
24
  output_path
25
+ ensure
26
+ reset_operations
26
27
  end
27
28
 
28
29
  def build_sharp_command(input_path, output_path)
@@ -9,7 +9,7 @@ module JekyllImgFlow
9
9
  module Providers
10
10
  # Weserv provider implementation using the standardized tag interface
11
11
  class Weserv < BaseProvider
12
- TIMEOUT = 10
12
+ TIMEOUT = 30
13
13
 
14
14
  def available?
15
15
  # Check if weserv service is running
@@ -26,8 +26,9 @@ module JekyllImgFlow
26
26
 
27
27
  # Fetch the result in one request
28
28
  fetch_and_save(url, output_path)
29
- reset_operations
30
29
  output_path
30
+ ensure
31
+ reset_operations
31
32
  end
32
33
 
33
34
  def build_combined_weserv_url(input_path)
@@ -24,13 +24,20 @@ module JekyllImgFlow
24
24
 
25
25
  def validate_opacity(value)
26
26
  # Check if value is numeric before converting
27
- raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.01 and 0.99." unless value.to_s.match?(/\A\d*\.?\d+\z/)
27
+ raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.01 and 0.99." unless valid_numeric?(value)
28
28
 
29
29
  opacity = value.to_f
30
30
  raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.01 and 0.99." unless opacity.between?(0.01, 0.99)
31
31
 
32
32
  opacity
33
33
  end
34
+
35
+ def valid_numeric?(value)
36
+ Float(value)
37
+ true
38
+ rescue ArgumentError, TypeError
39
+ false
40
+ end
34
41
  end
35
42
  end
36
43
  end
@@ -47,6 +47,8 @@ module JekyllImgFlow
47
47
 
48
48
  Jekyll.logger.debug "🔍 ResizeTag: About to execute provider - input: #{input_path}, output: #{output_path}"
49
49
  @provider.resize(width, height, resize_data)
50
+ @provider.quality(options[:quality]) if options[:quality]
51
+ @provider.convert_format(options[:format]) if options[:format]
50
52
  result = @provider.execute(input_path, output_path)
51
53
  Jekyll.logger.debug "🔍 ResizeTag: Provider executed - result: #{result}, output exists: #{File.exist?(output_path)}"
52
54
  result
@@ -39,13 +39,20 @@ module JekyllImgFlow
39
39
 
40
40
  def validate_opacity(value)
41
41
  # Check if value is numeric before converting
42
- raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.0 and 1.0." unless value.to_s.match?(/\A\d*\.?\d+\z/)
42
+ raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.0 and 1.0." unless valid_numeric?(value)
43
43
 
44
44
  opacity = value.to_f
45
45
  raise ArgumentError, "Invalid opacity: '#{value}'. Must be between 0.0 and 1.0." unless opacity.between?(0.0, 1.0)
46
46
 
47
47
  opacity
48
48
  end
49
+
50
+ def valid_numeric?(value)
51
+ Float(value)
52
+ true
53
+ rescue ArgumentError, TypeError
54
+ false
55
+ end
49
56
  end
50
57
  end
51
58
  end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rake"
5
+ require "rubygems"
6
+
7
+ module JekyllImgFlow
8
+ # Rake tasks for listing and installing built-in presets.
9
+ #
10
+ # Tasks (defined in the host project's Rakefile):
11
+ # imgflow:presets — list built-in presets and their install status
12
+ # imgflow:install_presets — copy built-in presets into the site's
13
+ # _data/imgflow/presets/ directory (use overwrite=true to replace)
14
+ module Tasks
15
+ extend Rake::DSL
16
+
17
+ class << self
18
+ # Path to built-in presets shipped with the gem
19
+ # @return [String]
20
+ def builtin_presets_dir
21
+ File.expand_path("presets", __dir__)
22
+ end
23
+
24
+ # Names of built-in presets (without .yml extension)
25
+ # @return [Array<String>]
26
+ def builtin_preset_names
27
+ return [] unless Dir.exist?(builtin_presets_dir)
28
+
29
+ Dir.glob(File.join(builtin_presets_dir, "*.yml")).map do |f|
30
+ File.basename(f, ".yml")
31
+ end.sort
32
+ end
33
+
34
+ # Path where presets are installed in the consuming site
35
+ # @return [String]
36
+ def site_presets_dir
37
+ File.join(Dir.pwd, "_data", "imgflow", "presets")
38
+ end
39
+
40
+ # List built-in presets and show their install status in the current site
41
+ def list_presets
42
+ puts "ImgFlow built-in presets:"
43
+ puts
44
+ builtin_preset_names.each do |name|
45
+ gem_path = File.join(builtin_presets_dir, "#{name}.yml")
46
+ site_path = File.join(site_presets_dir, "#{name}.yml")
47
+ site_exists = File.exist?(site_path)
48
+
49
+ status = if !site_exists
50
+ "not installed"
51
+ elsif same_content?(gem_path, site_path)
52
+ "up to date"
53
+ else
54
+ "modified or outdated"
55
+ end
56
+
57
+ puts " #{name}"
58
+ puts " gem: #{gem_path}"
59
+ puts " site: #{site_path}"
60
+ puts " status: #{status}"
61
+ puts
62
+ end
63
+ puts "To install: bundle exec rake imgflow:install_presets"
64
+ puts "To overwrite modified copies: bundle exec rake 'imgflow:install_presets[true]'"
65
+ end
66
+
67
+ # Copy built-in presets into the site's _data/imgflow/presets/ directory
68
+ # @param overwrite [Boolean] If true, replace modified copies
69
+ # @return [Hash] Summary with :installed and :skipped arrays
70
+ def install_presets(overwrite: false)
71
+ installed = []
72
+ skipped = []
73
+
74
+ FileUtils.mkdir_p(site_presets_dir)
75
+
76
+ builtin_preset_names.each do |name|
77
+ gem_path = File.join(builtin_presets_dir, "#{name}.yml")
78
+ site_path = File.join(site_presets_dir, "#{name}.yml")
79
+
80
+ if File.exist?(site_path) && !overwrite
81
+ if same_content?(gem_path, site_path)
82
+ puts " #{name}.yml: already up to date (skip)"
83
+ else
84
+ puts " #{name}.yml: exists and differs (skip — use overwrite: true to replace)"
85
+ skipped << name
86
+ end
87
+ else
88
+ existed = File.exist?(site_path)
89
+ FileUtils.cp(gem_path, site_path)
90
+ action = existed ? "updated" : "installed"
91
+ puts " #{name}.yml: #{action}"
92
+ installed << name
93
+ end
94
+ end
95
+
96
+ { installed: installed, skipped: skipped }
97
+ end
98
+
99
+ # Check if two files have identical content
100
+ # @param path_a [String]
101
+ # @param path_b [String]
102
+ # @return [Boolean]
103
+ def same_content?(path_a, path_b)
104
+ File.read(path_a) == File.read(path_b)
105
+ rescue Errno::ENOENT
106
+ false
107
+ end
108
+
109
+ def install_rake_tasks
110
+ return if Rake::Task.task_defined?("imgflow:presets")
111
+
112
+ namespace :imgflow do
113
+ desc "List built-in ImgFlow presets and their install status"
114
+ task :presets do
115
+ JekyllImgFlow::Tasks.list_presets
116
+ end
117
+
118
+ desc "Copy built-in ImgFlow presets into _data/imgflow/presets/"
119
+ task :install_presets, [:overwrite] do |_task, args|
120
+ overwrite = args[:overwrite] == "true"
121
+ JekyllImgFlow::Tasks.install_presets(overwrite: overwrite)
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ install_rake_tasks
128
+ end
129
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JekyllImgFlow
4
- VERSION = "0.1.9"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -8,12 +8,14 @@ require "net/http"
8
8
  require "uri"
9
9
  require_relative "jekyll-imgflow/version"
10
10
  require_relative "jekyll-imgflow/config"
11
+ require_relative "jekyll-imgflow/generated_file_cleaner"
11
12
  require_relative "jekyll-imgflow/manifest_manager"
12
13
  require_relative "jekyll-imgflow/tag_scanner"
13
14
  require_relative "jekyll-imgflow/path_resolver"
14
15
  require_relative "jekyll-imgflow/filename_generator"
15
16
  require_relative "jekyll-imgflow/animated_gif_detector"
16
17
  require_relative "jekyll-imgflow/operation_processor"
18
+ require_relative "jekyll-imgflow/processing_stats"
17
19
  require_relative "jekyll-imgflow/batch_manager"
18
20
  require_relative "jekyll-imgflow/preset_manager"
19
21
  require_relative "jekyll-imgflow/helpers/http_downloader"