jekyll-imgflow 0.3.3 → 0.4.1

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,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "pathname"
5
+ require "fileutils"
5
6
 
6
7
  module JekyllImgFlow
7
8
  module Providers
@@ -10,6 +11,15 @@ module JekyllImgFlow
10
11
  # Valid smartcrop position values
11
12
  SMARTCROP_POSITIONS = %w[attention entropy center centre].freeze
12
13
 
14
+ # Short compass → provider position mapping used by HTTP providers.
15
+ COMPASS_TO_SHORT = {
16
+ "northwest" => "tl",
17
+ "northeast" => "tr",
18
+ "southwest" => "bl",
19
+ "southeast" => "br",
20
+ "center" => "c"
21
+ }.freeze
22
+
13
23
  attr_accessor :config
14
24
 
15
25
  def initialize(config = {})
@@ -17,6 +27,91 @@ module JekyllImgFlow
17
27
  @operations = []
18
28
  end
19
29
 
30
+ # Operation lookup helpers — used by all providers to avoid
31
+ # repeating `@operations.find { |op| op[:type] == :xxx }`.
32
+ def find_op(type)
33
+ @operations.find { |op| op[:type] == type }
34
+ end
35
+
36
+ def op?(type)
37
+ @operations.any? { |op| op[:type] == type }
38
+ end
39
+
40
+ # Normalize a crop operation into a geometry hash.
41
+ # Returns { smartcrop:, keep:, x:, y:, width:, height: } where
42
+ # smartcrop is true when ratio + keep + valid smartcrop position.
43
+ def crop_geometry(operation)
44
+ opts = operation[:options] || {}
45
+ params = operation[:params] || {}
46
+ keep = opts[:keep] || params[:keep] || params[:position]
47
+ smartcrop = operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
48
+ crop_coords(operation, opts).merge(smartcrop: smartcrop, keep: keep)
49
+ end
50
+
51
+ # Coordinates for a crop operation: ratio crops use calculated values,
52
+ # explicit crops use x/y/width/height options (defaulting x/y to 0).
53
+ def crop_coords(operation, opts)
54
+ if operation[:ratio]
55
+ { x: opts[:calculated_x], y: opts[:calculated_y],
56
+ width: opts[:calculated_width], height: opts[:calculated_height] }
57
+ else
58
+ { x: opts[:x] || 0, y: opts[:y] || 0,
59
+ width: opts[:width], height: opts[:height] }
60
+ end
61
+ end
62
+
63
+ # Extract watermark operation parts into a hash.
64
+ def watermark_parts(operation)
65
+ {
66
+ watermark_path: operation[:watermark_path],
67
+ position: operation[:options][:position],
68
+ opacity: operation[:options][:opacity]
69
+ }
70
+ end
71
+
72
+ # Shared compass → short position mapping (tl/tr/bl/br/c).
73
+ # HTTP providers (imgproxy, weserv, flyimg) all use this mapping.
74
+ def compass_to_short(position)
75
+ COMPASS_TO_SHORT[position.to_s] || position.to_s
76
+ end
77
+
78
+ # Input format extension (lowercased, no dot) for format preservation.
79
+ def input_format_ext(input_path)
80
+ File.extname(input_path).delete(".").downcase
81
+ end
82
+
83
+ # Convert a 0..1 opacity float to a 0..255 integer (used by all
84
+ # providers that encode alpha as an 8-bit value).
85
+ def alpha_byte_value(opacity)
86
+ (opacity * 255).round
87
+ end
88
+
89
+ # Generate a temporary file path by replacing the extension of
90
+ # +input_path+ with +suffix+ (e.g. temp_path("a.jpg", "tmp_base.jpg")
91
+ # → "a.tmp_base.jpg").
92
+ def temp_path(input_path, suffix)
93
+ input_path.gsub(/\.[^.]+$/, ".#{suffix}")
94
+ end
95
+
96
+ # Map a smartcrop +keep+ value to the libvips interestingness string
97
+ # used by Sharp and LibVips smartcrop/thumbnail --crop operations.
98
+ def smartcrop_interestingness(keep)
99
+ case keep.to_s
100
+ when "entropy" then "entropy"
101
+ when "center", "centre" then "centre"
102
+ else "attention"
103
+ end
104
+ end
105
+
106
+ # Check whether any of the given CLI commands is available on PATH.
107
+ # Used by CLI providers (sharp, libvips, imagemagick).
108
+ def cli_available?(*commands)
109
+ commands.any? do |cmd|
110
+ _, _, status = Open3.capture3("which", cmd)
111
+ status.success?
112
+ end
113
+ end
114
+
20
115
  # Check if this provider is available (must be implemented by subclasses)
21
116
  def available?
22
117
  # Default: not available unless subclass implements actual check
@@ -55,17 +150,7 @@ module JekyllImgFlow
55
150
 
56
151
  # For HTTP providers, construct proper HTTP URL
57
152
  site = @config.site
58
-
59
- # Get the relative path from site source using Pathname
60
- source_path = Pathname.new(site.source)
61
- file_pathname = Pathname.new(file_path)
62
- relative_path = if file_pathname.absolute? && file_path.to_s.start_with?(site.source)
63
- file_pathname.relative_path_from(source_path).to_s
64
- elsif file_path.start_with?("/")
65
- file_path[1..]
66
- else
67
- file_path
68
- end
153
+ relative_path = relative_file_path(file_path, site)
69
154
 
70
155
  # URL-encode each path segment to handle spaces and special characters
71
156
  encoded_path = relative_path.split("/").map do |segment|
@@ -81,6 +166,18 @@ module JekyllImgFlow
81
166
  "#{base_url}#{baseurl}/#{encoded_path}"
82
167
  end
83
168
 
169
+ # Get the relative path from site source using Pathname
170
+ def relative_file_path(file_path, site)
171
+ file_pathname = Pathname.new(file_path)
172
+ if file_pathname.absolute? && file_path.to_s.start_with?(site.source)
173
+ file_pathname.relative_path_from(Pathname.new(site.source)).to_s
174
+ elsif file_path.start_with?("/")
175
+ file_path[1..]
176
+ else
177
+ file_path
178
+ end
179
+ end
180
+
84
181
  # Operation collection methods - these should NOT execute immediately
85
182
  def resize(width, height, options = {})
86
183
  @operations << { type: :resize, width: width, height: height,
@@ -206,5 +303,113 @@ module JekyllImgFlow
206
303
  end
207
304
  end
208
305
  end
306
+
307
+ # Shared base for HTTP-based providers (imgproxy, weserv, flyimg).
308
+ # Subclasses must implement `service_url`, `build_combined_url`, and
309
+ # define a `TIMEOUT` constant. They may optionally override
310
+ # `provider_label` for error messages.
311
+ class HttpBase < BaseProvider
312
+ def available?
313
+ url = service_url
314
+ return false unless url
315
+
316
+ check_http_service(url)
317
+ end
318
+
319
+ def execute(input_path, output_path)
320
+ return if @operations.empty?
321
+
322
+ url = build_combined_url(input_path)
323
+ fetch_and_save(url, output_path)
324
+ output_path
325
+ ensure
326
+ reset_operations
327
+ end
328
+
329
+ protected
330
+
331
+ # Subclasses override to return the configured service URL or nil.
332
+ def service_url
333
+ raise NotImplementedError
334
+ end
335
+
336
+ # Subclasses override to build the full request URL.
337
+ def build_combined_url(_input_path)
338
+ raise NotImplementedError
339
+ end
340
+
341
+ # Label used in error messages (defaults to class name).
342
+ def provider_label
343
+ self.class.name.split("::").last
344
+ end
345
+
346
+ def timeout
347
+ self.class::TIMEOUT
348
+ end
349
+
350
+ def fetch_and_save(url, output_path)
351
+ response = fetch_with_timeout(url)
352
+ raise "#{provider_label} request failed" if response.empty?
353
+
354
+ FileUtils.mkdir_p(File.dirname(output_path))
355
+ File.write(output_path, response)
356
+ end
357
+
358
+ def fetch_with_timeout(url)
359
+ uri = URI(url)
360
+ Net::HTTP.start(uri.host, uri.port,
361
+ use_ssl: uri.scheme == "https",
362
+ open_timeout: timeout,
363
+ read_timeout: timeout) do |http|
364
+ request = Net::HTTP::Get.new(uri)
365
+ response = http.request(request)
366
+
367
+ raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
368
+
369
+ response.body
370
+ end
371
+ rescue StandardError => e
372
+ raise "#{provider_label} request failed: #{e.message}"
373
+ end
374
+ end
375
+
376
+ # Shared base for CLI-based providers (sharp, imagemagick, libvips).
377
+ # Subclasses implement `build_commands(input_path, output_path)` to
378
+ # return an array of command entries (strings or arrays), and
379
+ # `run_command(cmd)` to execute a single entry.
380
+ class CliBase < BaseProvider
381
+ def execute(input_path, output_path)
382
+ return if @operations.empty?
383
+
384
+ before_execute(input_path)
385
+ commands = build_commands(input_path, output_path)
386
+ Array(commands).each { |cmd| run_command(cmd) }
387
+ output_path
388
+ ensure
389
+ reset_operations
390
+ end
391
+
392
+ protected
393
+
394
+ # Hook for subclasses to perform pre-execution work (e.g. SVG warnings).
395
+ def before_execute(_input_path); end
396
+
397
+ # Subclasses override to return command(s) to execute.
398
+ # May return a single command or an array of commands.
399
+ def build_commands(_input_path, _output_path)
400
+ raise NotImplementedError
401
+ end
402
+
403
+ # Execute a single command. Default uses the shell-based
404
+ # `execute_command` from BaseProvider. Subclasses that use
405
+ # argument arrays (no shell) should override this.
406
+ def run_command(cmd)
407
+ if cmd.is_a?(Array) && cmd.first == :cleanup
408
+ FileUtils.rm_f(cmd[1])
409
+ return
410
+ end
411
+ execute_command(cmd)
412
+ end
413
+ end
209
414
  end
210
415
  end
@@ -2,144 +2,98 @@
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
  # Flyimg provider implementation using the standardized tag interface
11
- class Flyimg < BaseProvider
10
+ class Flyimg < HttpBase
12
11
  TIMEOUT = 60
13
12
 
14
- def available?
15
- # Check if flyimg service is running
16
- return false unless @config.respond_to?(:flyimg_url) && @config.flyimg_url
17
-
18
- check_http_service(@config.flyimg_url)
13
+ def service_url
14
+ @config.respond_to?(:flyimg_url) ? @config.flyimg_url : nil
19
15
  end
20
16
 
21
- def execute(input_path, output_path)
22
- return if @operations.empty?
23
-
24
- # Build single Flyimg URL with all operations combined
25
- url = build_combined_flyimg_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_flyimg_url(input_path)
18
+ build_combined_url(input_path)
32
19
  end
33
20
 
34
- def build_combined_flyimg_url(input_path)
35
- raise "flyimg_url not set" unless @config.flyimg_url
21
+ def build_combined_url(input_path)
22
+ raise "flyimg_url not set" unless service_url
36
23
 
37
24
  encoded_url = encode_file_url(input_path)
38
25
  options = []
39
26
 
40
- # Add all operations to single options string
41
- @operations.each do |operation|
42
- case operation[:type]
43
- when :resize
44
- # Tags now provide complete calculated values.
45
- # c_1 forces the image to fill the exact width x height area.
46
- # pns_0 allows upscaling when the source is smaller than the target
47
- # (Flyimg's pns/preserve-natural-size defaults to 1, blocking enlarge).
48
- options << "w_#{operation[:width]}"
49
- options << "pns_0"
50
- if operation[:height]
51
- options << "h_#{operation[:height]}"
52
- options << "c_1"
53
- end
54
-
55
- when :crop
56
- opts = operation[:options]
57
- params = operation[:params] || {}
58
-
59
- # Check for keep parameter (smartcrop support)
60
- keep = opts[:keep] || params[:keep] || params[:position]
61
-
62
- if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
63
- # Use smartcrop (Flyimg's smc option picks the best crop area)
64
- crop_width = opts[:calculated_width]
65
- crop_height = opts[:calculated_height]
66
-
67
- options << "w_#{crop_width}"
68
- options << "h_#{crop_height}"
69
- options << "c_1"
70
- options << "smc_1"
71
- else
72
- # Use basic cropping via extract (top-left / bottom-right coordinates)
73
- if operation[:ratio]
74
- crop_x = opts[:calculated_x]
75
- crop_y = opts[:calculated_y]
76
- crop_width = opts[:calculated_width]
77
- crop_height = opts[:calculated_height]
78
- else
79
- crop_x = opts[:x]
80
- crop_y = opts[:y]
81
- crop_width = opts[:width]
82
- crop_height = opts[:height]
83
- end
84
- options << "e_1"
85
- options << "p1x_#{crop_x}"
86
- options << "p1y_#{crop_y}"
87
- options << "p2x_#{crop_x + crop_width}"
88
- options << "p2y_#{crop_y + crop_height}"
89
- end
90
-
91
- when :quality
92
- flyimg_quality = translate_quality_to_flyimg(operation[:quality])
93
- options << "q_#{flyimg_quality}"
94
-
95
- when :format
96
- # Assume validated input from tags
97
- options << "o_#{operation[:format]}"
98
-
99
- when :watermark
100
- watermark_path = operation[:watermark_path]
101
- position = operation[:options][:position]
102
- opacity = operation[:options][:opacity]
103
-
104
- # Flyimg watermark parameters
105
- encoded_watermark = encode_file_url(watermark_path)
106
- position_param = translate_flyimg_position(position)
107
- opacity_param = (opacity * 100).round
108
- options << "wm_#{encoded_watermark}_#{position_param}_#{opacity_param}"
109
-
110
- when :alpha_opacity
111
- opacity = operation[:opacity]
112
- options << "a_#{(opacity * 255).round}"
113
- end
114
- end
27
+ @operations.each { |operation| append_flyimg_operation(operation, options) }
28
+
29
+ preserve_input_format(input_path, options)
30
+ "#{service_url}/upload/#{options.join(',')}/#{encoded_url}"
31
+ end
115
32
 
116
- # Preserve format when no explicit format operation is requested.
117
- # Without this, chained HTTP requests (e.g. quality-only steps after a
118
- # prior format conversion) would lose the previously converted format.
119
- unless @operations.any? { |op| op[:type] == :format }
120
- input_ext = File.extname(input_path).delete(".").downcase
121
- options << "o_#{map_format(input_ext)}" unless input_ext.empty?
33
+ private
34
+
35
+ def append_flyimg_operation(operation, options)
36
+ case operation[:type]
37
+ when :resize
38
+ append_flyimg_resize(operation, options)
39
+ when :crop
40
+ append_flyimg_crop(operation, options)
41
+ when :quality
42
+ options << "q_#{operation[:quality]}"
43
+ when :format
44
+ options << "o_#{operation[:format]}"
45
+ when :watermark
46
+ append_flyimg_watermark(operation, options)
47
+ when :alpha_opacity
48
+ options << "a_#{alpha_byte_value(operation[:opacity])}"
122
49
  end
50
+ end
51
+
52
+ def append_flyimg_resize(operation, options)
53
+ # c_1 forces the image to fill the exact width x height area.
54
+ # pns_0 allows upscaling when the source is smaller than the target
55
+ # (Flyimg's pns/preserve-natural-size defaults to 1, blocking enlarge).
56
+ options << "w_#{operation[:width]}"
57
+ options << "pns_0"
58
+ return unless operation[:height]
123
59
 
124
- # Build combined URL: base/upload/options/options,.../encoded_url
125
- options_string = options.join(",")
126
- "#{@config.flyimg_url}/upload/#{options_string}/#{encoded_url}"
60
+ options << "h_#{operation[:height]}"
61
+ options << "c_1"
127
62
  end
128
63
 
129
- private
64
+ def append_flyimg_crop(operation, options)
65
+ geo = crop_geometry(operation)
130
66
 
131
- def translate_flyimg_position(position)
132
- # Translate compass directions to Flyimg position format
133
- case position
134
- when "northwest" then "tl"
135
- when "northeast" then "tr"
136
- when "southwest" then "bl"
137
- when "southeast" then "br"
138
- when "center" then "c"
139
- else position
67
+ if geo[:smartcrop]
68
+ options << "w_#{geo[:width]}"
69
+ options << "h_#{geo[:height]}"
70
+ options << "c_1"
71
+ options << "smc_1"
72
+ else
73
+ # Use basic cropping via extract (top-left / bottom-right coordinates)
74
+ options << "e_1"
75
+ options << "p1x_#{geo[:x]}"
76
+ options << "p1y_#{geo[:y]}"
77
+ options << "p2x_#{geo[:x] + geo[:width]}"
78
+ options << "p2y_#{geo[:y] + geo[:height]}"
140
79
  end
141
80
  end
142
81
 
82
+ def append_flyimg_watermark(operation, options)
83
+ parts = watermark_parts(operation)
84
+ encoded_watermark = encode_file_url(parts[:watermark_path])
85
+ position_param = compass_to_short(parts[:position])
86
+ opacity_param = (parts[:opacity] * 100).round
87
+ options << "wm_#{encoded_watermark}_#{position_param}_#{opacity_param}"
88
+ end
89
+
90
+ def preserve_input_format(input_path, options)
91
+ return if op?(:format)
92
+
93
+ ext = input_format_ext(input_path)
94
+ options << "o_#{map_format(ext)}" unless ext.empty?
95
+ end
96
+
143
97
  def map_format(format)
144
98
  case format&.downcase
145
99
  when "jpeg", "jpg"
@@ -155,33 +109,9 @@ module JekyllImgFlow
155
109
  end
156
110
  end
157
111
 
158
- def fetch_and_save(url, output_path)
159
- response = fetch_with_timeout(url)
160
- raise "Flyimg request failed" if response.empty?
161
-
162
- FileUtils.mkdir_p(File.dirname(output_path))
163
- File.write(output_path, response)
164
- end
165
-
166
- def fetch_with_timeout(url)
167
- uri = URI(url)
168
- Net::HTTP.start(uri.host, uri.port,
169
- use_ssl: uri.scheme == "https",
170
- open_timeout: TIMEOUT,
171
- read_timeout: TIMEOUT) do |http|
172
- request = Net::HTTP::Get.new(uri)
173
- response = http.request(request)
174
-
175
- raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
176
-
177
- response.body
178
- end
179
- rescue StandardError => e
180
- raise "Flyimg request failed: #{e.message}"
181
- end
182
-
183
- def translate_quality_to_flyimg(quality)
184
- quality
112
+ # Thin wrapper kept for test compatibility.
113
+ def translate_flyimg_position(position)
114
+ compass_to_short(position)
185
115
  end
186
116
  end
187
117
  end