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.
@@ -7,37 +7,23 @@ require_relative "base_provider"
7
7
  module JekyllImgFlow
8
8
  module Providers
9
9
  # Sharp provider implementation using the standardized tag interface
10
- class Sharp < BaseProvider
10
+ class Sharp < CliBase
11
11
  def available?
12
- # Check if sharp CLI is available
13
- _, _, status = Open3.capture3("which", "sharp")
14
- status.success?
12
+ cli_available?("sharp")
15
13
  end
16
14
 
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
- output_path
25
- ensure
26
- reset_operations
15
+ def build_commands(input_path, output_path)
16
+ build_sharp_command(input_path, output_path)
27
17
  end
28
18
 
29
19
  def build_sharp_command(input_path, output_path)
30
- has_crop = @operations.any? { |op| op[:type] == :crop }
31
- has_resize = @operations.any? { |op| op[:type] == :resize }
32
- has_watermark = @operations.any? { |op| op[:type] == :watermark }
33
-
34
- if has_watermark
35
- build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
36
- elsif has_crop && has_resize
20
+ if op?(:watermark)
21
+ build_watermark_pipeline(input_path, output_path, op?(:crop), op?(:resize))
22
+ elsif op?(:crop) && op?(:resize)
37
23
  build_sequential_crop_resize(input_path, output_path)
38
- elsif has_resize
24
+ elsif op?(:resize)
39
25
  build_resize_command(input_path, output_path)
40
- elsif has_crop
26
+ elsif op?(:crop)
41
27
  build_crop_command(input_path, output_path)
42
28
  else
43
29
  build_copy_command(input_path, output_path)
@@ -45,47 +31,41 @@ module JekyllImgFlow
45
31
  end
46
32
 
47
33
  def build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
48
- temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
34
+ temp = temp_path(input_path, "tmp_base.jpg")
49
35
  commands = []
50
36
 
51
37
  if has_crop && has_resize
52
- commands << build_sequential_crop_resize(input_path, temp_path)
53
- base_path = temp_path
38
+ commands << build_sequential_crop_resize(input_path, temp)
39
+ base_path = temp
54
40
  elsif has_resize
55
- commands << build_resize_command(input_path, temp_path)
56
- base_path = temp_path
41
+ commands << build_resize_command(input_path, temp)
42
+ base_path = temp
57
43
  elsif has_crop
58
- commands << build_crop_command(input_path, temp_path)
59
- base_path = temp_path
44
+ commands << build_crop_command(input_path, temp)
45
+ base_path = temp
60
46
  else
61
47
  base_path = input_path
62
48
  end
63
49
 
64
50
  commands << build_composite_command(base_path, output_path)
65
- commands << "rm -f #{temp_path.shellescape}" unless base_path == input_path
51
+ commands << "rm -f #{temp.shellescape}" unless base_path == input_path
66
52
  commands.join(" && ")
67
53
  end
68
54
 
69
55
  def build_composite_command(base_path, output_path)
70
- wm_op = @operations.find { |op| op[:type] == :watermark }
71
- watermark_path = wm_op[:watermark_path]
72
- position = wm_op[:options][:position]
73
- opacity = wm_op[:options][:opacity]
74
-
75
- gravity = translate_position(position)
56
+ wm_op = find_op(:watermark)
57
+ parts = watermark_parts(wm_op)
58
+ gravity = translate_position(parts[:position])
76
59
 
77
60
  command_parts = ["sharp", "-i", base_path.shellescape,
78
61
  "-o", output_path.shellescape]
79
62
 
80
- format_op = @operations.find { |op| op[:type] == :format }
81
- quality_op = @operations.find { |op| op[:type] == :quality }
82
- command_parts += ["-f", sharp_format(format_op[:format])] if format_op
83
- command_parts += ["-q#{quality_op[:quality]}"] if quality_op
63
+ add_format_quality(command_parts)
84
64
 
85
- if opacity && opacity < 1.0
86
- wm_temp = watermark_path.gsub(/\.[^.]+$/, ".tmp_wm.png")
87
- alpha_value = (opacity * 255).round
88
- temp_cmd = ["sharp", "-i", watermark_path.shellescape,
65
+ if parts[:opacity] && parts[:opacity] < 1.0
66
+ wm_temp = temp_path(parts[:watermark_path], "tmp_wm.png")
67
+ alpha_value = alpha_byte_value(parts[:opacity])
68
+ temp_cmd = ["sharp", "-i", parts[:watermark_path].shellescape,
89
69
  "-o", wm_temp.shellescape,
90
70
  "ensureAlpha", alpha_value.to_s].join(" ")
91
71
  command_parts += ["composite", wm_temp.shellescape,
@@ -93,7 +73,7 @@ module JekyllImgFlow
93
73
  "#{temp_cmd} && #{command_parts.join(' ')} " \
94
74
  "&& rm -f #{wm_temp.shellescape}"
95
75
  else
96
- command_parts += ["composite", watermark_path.shellescape,
76
+ command_parts += ["composite", parts[:watermark_path].shellescape,
97
77
  "--gravity", gravity, "--blend", "over"]
98
78
  command_parts.join(" ")
99
79
  end
@@ -112,20 +92,20 @@ module JekyllImgFlow
112
92
 
113
93
  def build_sequential_crop_resize(input_path, output_path)
114
94
  # Build temp file path for intermediate crop result
115
- temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_crop.jpg")
95
+ temp = temp_path(input_path, "tmp_crop.jpg")
116
96
 
117
97
  # First command: crop only
118
- crop_cmd = build_crop_command(input_path, temp_path)
98
+ crop_cmd = build_crop_command(input_path, temp)
119
99
 
120
100
  # Second command: resize + format + quality + alpha (all together)
121
- resize_cmd = build_resize_command(temp_path, output_path)
101
+ resize_cmd = build_resize_command(temp, output_path)
122
102
 
123
103
  # Combine with && and cleanup temp file
124
- "#{crop_cmd} && #{resize_cmd} && rm -f #{temp_path.shellescape}"
104
+ "#{crop_cmd} && #{resize_cmd} && rm -f #{temp.shellescape}"
125
105
  end
126
106
 
127
107
  def build_resize_command(input_path, output_path)
128
- resize_op = @operations.find { |op| op[:type] == :resize }
108
+ resize_op = find_op(:resize)
129
109
 
130
110
  # sharp -i input.jpg -o output.jpg resize width [height] -f format -q quality
131
111
  command_parts = ["sharp", "-i", input_path.shellescape, "-o", output_path.shellescape]
@@ -137,58 +117,28 @@ module JekyllImgFlow
137
117
  ["resize", resize_op[:width].to_s]
138
118
  end
139
119
 
140
- # Add format, quality, alpha
141
- format_op = @operations.find { |op| op[:type] == :format }
142
- quality_op = @operations.find { |op| op[:type] == :quality }
143
- alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
144
-
145
- command_parts += ["-f", sharp_format(format_op[:format])] if format_op
146
- command_parts += ["-q#{quality_op[:quality]}"] if quality_op
147
- if alpha_op
148
- alpha_value = (alpha_op[:opacity] * 255).round
149
- command_parts += ["alpha", "{alpha:#{alpha_value}}"]
150
- end
151
-
120
+ add_format_quality_alpha(command_parts)
152
121
  command_parts.join(" ")
153
122
  end
154
123
 
155
124
  def build_crop_command(input_path, output_path)
156
- crop_op = @operations.find { |op| op[:type] == :crop }
157
- opts = crop_op[:options] || {}
158
- params = crop_op[:params] || {}
125
+ crop_op = find_op(:crop)
126
+ geo = crop_geometry(crop_op)
159
127
 
160
- # Check if we should use smartcrop (when keep parameter is specified)
161
- # JPT keep options: attention (default), entropy, center
162
- # Check both options (from CropTag) and params (from OperationProcessor)
163
- keep = opts[:keep] || params[:keep] || params[:position]
164
-
165
- if crop_op[:ratio] && keep && %w[attention entropy center
166
- centre].include?(keep.to_s)
128
+ if geo[:smartcrop]
167
129
  # Use smartcrop for intelligent cropping (Sharp uses libvips backend)
168
- crop_width = opts[:calculated_width]
169
- crop_height = opts[:calculated_height]
170
-
171
- # Map keep parameter to libvips interestingness
172
- interestingness = case keep.to_s
173
- when "entropy" then "entropy"
174
- when "center", "centre" then "centre"
175
- else "attention" # default
176
- end
130
+ interestingness = smartcrop_interestingness(geo[:keep])
177
131
 
178
132
  # sharp -i input.jpg -o output.jpg smartcrop width height --interesting=attention
179
133
  ["sharp", "-i", input_path.shellescape, "-o", output_path.shellescape,
180
- "smartcrop", crop_width.to_s, crop_height.to_s,
134
+ "smartcrop", geo[:width].to_s, geo[:height].to_s,
181
135
  "--interesting=#{interestingness}"].join(" ")
182
136
  else
183
137
  # Use basic extract for manual cropping or when no keep parameter
184
- crop_x = crop_op[:ratio] ? opts[:calculated_x] : (opts[:x] || 0)
185
- crop_y = crop_op[:ratio] ? opts[:calculated_y] : (opts[:y] || 0)
186
- crop_width = crop_op[:ratio] ? opts[:calculated_width] : opts[:width]
187
- crop_height = crop_op[:ratio] ? opts[:calculated_height] : opts[:height]
188
-
189
138
  # sharp -i input.jpg -o output.jpg extract top left width height
190
139
  ["sharp", "-i", input_path.shellescape, "-o", output_path.shellescape,
191
- "extract", crop_y.to_s, crop_x.to_s, crop_width.to_s, crop_height.to_s].join(" ")
140
+ "extract", geo[:y].to_s, geo[:x].to_s, geo[:width].to_s,
141
+ geo[:height].to_s].join(" ")
192
142
  end
193
143
  end
194
144
 
@@ -196,17 +146,7 @@ module JekyllImgFlow
196
146
  # sharp -i input.jpg -o output.jpg -f format -q quality
197
147
  command_parts = ["sharp", "-i", input_path.shellescape, "-o", output_path.shellescape]
198
148
 
199
- format_op = @operations.find { |op| op[:type] == :format }
200
- quality_op = @operations.find { |op| op[:type] == :quality }
201
- alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
202
-
203
- command_parts += ["-f", sharp_format(format_op[:format])] if format_op
204
- command_parts += ["-q#{quality_op[:quality]}"] if quality_op
205
- if alpha_op
206
- alpha_value = (alpha_op[:opacity] * 255).round
207
- command_parts += ["alpha", "{alpha:#{alpha_value}}"]
208
- end
209
-
149
+ add_format_quality_alpha(command_parts)
210
150
  command_parts.join(" ")
211
151
  end
212
152
 
@@ -215,6 +155,23 @@ module JekyllImgFlow
215
155
  def sharp_format(format)
216
156
  format.to_s == "jpg" ? "jpeg" : format
217
157
  end
158
+
159
+ private
160
+
161
+ def add_format_quality(command_parts)
162
+ format_op = find_op(:format)
163
+ quality_op = find_op(:quality)
164
+ command_parts.push("-f", sharp_format(format_op[:format])) if format_op
165
+ command_parts.push("-q#{quality_op[:quality]}") if quality_op
166
+ end
167
+
168
+ def add_format_quality_alpha(command_parts)
169
+ add_format_quality(command_parts)
170
+ alpha_op = find_op(:alpha_opacity)
171
+ return unless alpha_op
172
+
173
+ command_parts.push("alpha", "{alpha:#{alpha_byte_value(alpha_op[:opacity])}}")
174
+ end
218
175
  end
219
176
  end
220
177
  end
@@ -2,159 +2,104 @@
2
2
 
3
3
  require "net/http"
4
4
  require "uri"
5
- require "tempfile"
6
5
  require_relative "base_provider"
7
6
 
8
7
  module JekyllImgFlow
9
8
  module Providers
10
9
  # Weserv provider implementation using the standardized tag interface
11
- class Weserv < BaseProvider
10
+ class Weserv < HttpBase
12
11
  TIMEOUT = 30
13
-
14
- def available?
15
- # Check if weserv service is running
16
- return false unless @config.respond_to?(:weserv_url) && @config.weserv_url
17
-
18
- check_http_service(@config.weserv_url)
12
+ # SVGs without explicit pixel dimensions (e.g. width="100%") are valid
13
+ # but cause librsvg to rasterize at the viewBox size, which can be huge.
14
+ # When no resize operation is present, cap the rasterization at 2000px
15
+ # on the longest edge so weserv processes the SVG at a reasonable size.
16
+ SVG_DEFAULT_MAX_SIZE = 2000
17
+
18
+ def service_url
19
+ @config.respond_to?(:weserv_url) ? @config.weserv_url : nil
19
20
  end
20
21
 
21
- def execute(input_path, output_path)
22
- return if @operations.empty?
23
-
24
- # Build single Weserv URL with all operations combined
25
- url = build_combined_weserv_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
22
+ def build_combined_weserv_url(input_path)
23
+ build_combined_url(input_path)
32
24
  end
33
25
 
34
- def build_combined_weserv_url(input_path)
35
- raise "weserv_url not set" unless @config.weserv_url
26
+ def build_combined_url(input_path)
27
+ raise "weserv_url not set" unless service_url
36
28
 
37
29
  encoded_url = encode_file_url(input_path)
38
30
  params = ["url=#{URI.encode_www_form_component(encoded_url)}"]
39
31
 
40
- # Add all operations to single URL
41
- @operations.each do |operation|
42
- case operation[:type]
43
- when :resize
44
- # Tags now provide complete calculated values
45
- params << if operation[:height]
46
- "w=#{operation[:width]}&h=#{operation[:height]}&fit=fill"
47
- else
48
- "w=#{operation[:width]}"
49
- end
50
-
51
- when :crop
52
- opts = operation[:options]
53
- op_params = operation[:params] || {}
54
-
55
- # Check for keep parameter (smartcrop support)
56
- keep = opts[:keep] || op_params[:keep] || op_params[:position]
57
-
58
- if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
59
- # Use smartcrop (WeServ supports smartcrop)
60
- crop_width = opts[:calculated_width]
61
- crop_height = opts[:calculated_height]
62
-
63
- # Add smartcrop parameters according to WeServ docs
64
- params << "crop=#{crop_width}x#{crop_height}"
65
- params << "a=smart" # smart attention
66
-
67
- # Map keep parameter to WeServ attention type
68
- case keep.to_s
69
- when "attention" then params << "a=attention"
70
- when "entropy" then params << "a=entropy"
71
- when "center", "centre" then params << "a=center"
72
- end
73
- else
74
- # Use basic cropping
75
- if operation[:ratio]
76
- crop_x = opts[:calculated_x]
77
- crop_y = opts[:calculated_y]
78
- crop_width = opts[:calculated_width]
79
- crop_height = opts[:calculated_height]
80
- else
81
- crop_x = opts[:x]
82
- crop_y = opts[:y]
83
- crop_width = opts[:width]
84
- crop_height = opts[:height]
85
- end
86
- params << "cx=#{crop_x}&cy=#{crop_y}&cw=#{crop_width}&ch=#{crop_height}"
87
- end
88
-
89
- when :quality
90
- weserv_quality = translate_quality_to_weserv(operation[:quality])
91
- params << "q=#{weserv_quality}"
92
-
93
- when :format
94
- # Assume validated input from tags
95
- params << "output=#{operation[:format]}"
96
-
97
- when :watermark
98
- watermark_path = operation[:watermark_path]
99
- position = operation[:options][:position]
100
- opacity = operation[:options][:opacity]
101
-
102
- # Weserv watermark parameters
103
- params << "wm=#{encode_file_url(watermark_path)}"
104
- params << "wmpos=#{translate_weserv_position(position)}"
105
- params << "wmo=#{opacity}" if opacity
106
-
107
- when :alpha_opacity
108
- opacity = operation[:opacity]
109
- params << "alpha=#{(opacity * 255).round}"
110
- end
111
- end
32
+ @operations.each { |operation| append_weserv_operation(operation, params) }
112
33
 
113
- query_string = params.join("&")
114
- "#{@config.weserv_url}/?#{query_string}"
34
+ ensure_svg_size_limit(input_path, params)
35
+
36
+ "#{service_url}/?#{params.join('&')}"
115
37
  end
116
38
 
117
39
  private
118
40
 
119
- def translate_weserv_position(position)
120
- # Translate compass directions to Weserv position format
121
- case position
122
- when "northwest" then "tl"
123
- when "northeast" then "tr"
124
- when "southwest" then "bl"
125
- when "southeast" then "br"
126
- when "center" then "c"
127
- else position
128
- end
41
+ def ensure_svg_size_limit(input_path, params)
42
+ return unless svg_file?(input_path)
43
+ return if params.any? { |p| p.start_with?("w=", "h=", "crop=") || p.include?("cw=") }
44
+
45
+ params << "w=#{SVG_DEFAULT_MAX_SIZE}"
129
46
  end
130
47
 
131
- def fetch_and_save(url, output_path)
132
- response = fetch_with_timeout(url)
133
- raise "Weserv request failed" if response.empty?
48
+ def svg_file?(input_path)
49
+ File.extname(input_path).downcase == ".svg"
50
+ end
134
51
 
135
- FileUtils.mkdir_p(File.dirname(output_path))
136
- File.write(output_path, response)
52
+ def append_weserv_operation(operation, params)
53
+ case operation[:type]
54
+ when :resize
55
+ params << if operation[:height]
56
+ "w=#{operation[:width]}&h=#{operation[:height]}&fit=fill"
57
+ else
58
+ "w=#{operation[:width]}"
59
+ end
60
+ when :crop
61
+ append_weserv_crop(operation, params)
62
+ when :quality
63
+ params << "q=#{operation[:quality]}"
64
+ when :format
65
+ params << "output=#{operation[:format]}"
66
+ when :watermark
67
+ append_weserv_watermark(operation, params)
68
+ when :alpha_opacity
69
+ params << "alpha=#{alpha_byte_value(operation[:opacity])}"
70
+ end
137
71
  end
138
72
 
139
- def fetch_with_timeout(url)
140
- uri = URI(url)
141
- Net::HTTP.start(uri.host, uri.port,
142
- use_ssl: uri.scheme == "https",
143
- open_timeout: TIMEOUT,
144
- read_timeout: TIMEOUT) do |http|
145
- request = Net::HTTP::Get.new(uri)
146
- response = http.request(request)
73
+ def append_weserv_crop(operation, params)
74
+ geo = crop_geometry(operation)
147
75
 
148
- raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
76
+ if geo[:smartcrop]
77
+ params << "crop=#{geo[:width]}x#{geo[:height]}"
78
+ params << "a=smart"
79
+ params << weserv_attention(geo[:keep])
80
+ else
81
+ params << "cx=#{geo[:x]}&cy=#{geo[:y]}&cw=#{geo[:width]}&ch=#{geo[:height]}"
82
+ end
83
+ end
149
84
 
150
- response.body
85
+ def weserv_attention(keep)
86
+ case keep.to_s
87
+ when "attention" then "a=attention"
88
+ when "entropy" then "a=entropy"
89
+ when "center", "centre" then "a=center"
151
90
  end
152
- rescue StandardError => e
153
- raise "Weserv request failed: #{e.message}"
154
91
  end
155
92
 
156
- def translate_quality_to_weserv(quality)
157
- quality
93
+ def append_weserv_watermark(operation, params)
94
+ parts = watermark_parts(operation)
95
+ params << "wm=#{encode_file_url(parts[:watermark_path])}"
96
+ params << "wmpos=#{compass_to_short(parts[:position])}"
97
+ params << "wmo=#{parts[:opacity]}" if parts[:opacity]
98
+ end
99
+
100
+ # Thin wrapper kept for test compatibility.
101
+ def translate_weserv_position(position)
102
+ compass_to_short(position)
158
103
  end
159
104
  end
160
105
  end
@@ -127,34 +127,14 @@ module JekyllImgFlow
127
127
  image_path = parts.shift
128
128
 
129
129
  # Parse operations as key:value pairs separated by commas or spaces
130
- operations = []
131
130
  remaining = parts.join(" ")
131
+ operation_pairs = split_operation_pairs(remaining)
132
132
 
133
- # Handle comma-separated: key:value,key:value
134
- operation_pairs = if remaining.include?(",")
135
- remaining.split(",")
136
- else
137
- # Handle space-separated: key:value key:value
138
- remaining.split(/\s+/)
139
- end
140
-
141
- operation_pairs.each do |pair|
142
- next if pair.empty?
143
-
144
- next unless pair.include?(":")
133
+ operations = operation_pairs.filter_map do |pair|
134
+ next if pair.empty? || !pair.include?(":")
145
135
 
146
136
  key, value = pair.split(":", 2)
147
- # Convert numeric values using simple string methods
148
- value_stripped = value.strip
149
- converted_value = if value_stripped.match?(/^\d+$/)
150
- value_stripped.to_i
151
- elsif value_stripped.match?(/^\d+\.\d+$/)
152
- value_stripped.to_f
153
- else
154
- value_stripped
155
- end
156
- # Store with converted value for downstream type correctness
157
- operations << "#{key.strip}:#{converted_value}"
137
+ "#{key.strip}:#{convert_value(value.strip)}"
158
138
  end
159
139
 
160
140
  {
@@ -163,6 +143,21 @@ module JekyllImgFlow
163
143
  }
164
144
  end
165
145
 
146
+ def split_operation_pairs(remaining)
147
+ # Handle comma-separated: key:value,key:value
148
+ return remaining.split(",") if remaining.include?(",")
149
+
150
+ # Handle space-separated: key:value key:value
151
+ remaining.split(/\s+/)
152
+ end
153
+
154
+ def convert_value(value)
155
+ return value.to_i if value.match?(/^\d+$/)
156
+ return value.to_f if value.match?(/^\d+\.\d+$/)
157
+
158
+ value
159
+ end
160
+
166
161
  private
167
162
 
168
163
  # Get all scannable content from site
@@ -33,6 +33,14 @@ module JekyllImgFlow
33
33
  end
34
34
  end
35
35
 
36
+ # Like get_image_dimensions but raises if dimensions cannot be read.
37
+ def get_original_dimensions(image_path)
38
+ width, height = get_image_dimensions(image_path)
39
+ raise ArgumentError, "Unable to determine original image dimensions" unless width && height
40
+
41
+ [width, height]
42
+ end
43
+
36
44
  def validate_positive_integer(value, name)
37
45
  return if value.nil?
38
46
 
@@ -47,6 +55,31 @@ module JekyllImgFlow
47
55
  # Config class already handles the fallback (cfg["quality"] || 85)
48
56
  @provider.config&.quality
49
57
  end
58
+
59
+ def validate_quality(value)
60
+ return @default_quality if value.nil?
61
+
62
+ quality = value.to_i
63
+ raise ArgumentError, "Invalid quality: '#{value}'. Must be between 1 and 100." unless quality.between?(1, 100)
64
+
65
+ quality
66
+ end
67
+
68
+ def validate_opacity(value, min: 0.0, max: 1.0)
69
+ raise ArgumentError, "Invalid opacity: '#{value}'. Must be between #{min} and #{max}." unless valid_numeric?(value)
70
+
71
+ opacity = value.to_f
72
+ raise ArgumentError, "Invalid opacity: '#{value}'. Must be between #{min} and #{max}." unless opacity.between?(min, max)
73
+
74
+ opacity
75
+ end
76
+
77
+ def valid_numeric?(value)
78
+ Float(value)
79
+ true
80
+ rescue ArgumentError, TypeError
81
+ false
82
+ end
50
83
  end
51
84
  end
52
85
  end