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.
- checksums.yaml +4 -4
- data/README.md +33 -2
- data/lib/jekyll-imgflow/build_time_processor.rb +3 -2
- data/lib/jekyll-imgflow/config.rb +25 -18
- data/lib/jekyll-imgflow/filename_generator.rb +2 -0
- data/lib/jekyll-imgflow/html_generator.rb +6 -6
- data/lib/jekyll-imgflow/imgflow_tag.rb +86 -52
- data/lib/jekyll-imgflow/manifest_manager.rb +64 -57
- data/lib/jekyll-imgflow/operation_processor.rb +44 -64
- data/lib/jekyll-imgflow/parser.rb +32 -25
- data/lib/jekyll-imgflow/picture_tag_adaptor.rb +113 -106
- data/lib/jekyll-imgflow/picture_tag_preset_migrator.rb +73 -69
- data/lib/jekyll-imgflow/preset_manager.rb +42 -38
- data/lib/jekyll-imgflow/providers/base_provider.rb +199 -0
- data/lib/jekyll-imgflow/providers/flyimg.rb +72 -142
- data/lib/jekyll-imgflow/providers/imagemagick.rb +57 -97
- data/lib/jekyll-imgflow/providers/imgproxy.rb +53 -126
- data/lib/jekyll-imgflow/providers/libvips.rb +105 -138
- data/lib/jekyll-imgflow/providers/sharp.rb +57 -100
- data/lib/jekyll-imgflow/providers/weserv.rb +69 -124
- data/lib/jekyll-imgflow/tag_scanner.rb +19 -24
- data/lib/jekyll-imgflow/tags/base_tag.rb +33 -0
- data/lib/jekyll-imgflow/tags/crop_tag.rb +24 -47
- data/lib/jekyll-imgflow/tags/opacity_tag.rb +1 -20
- data/lib/jekyll-imgflow/tags/optimize_tag.rb +0 -9
- data/lib/jekyll-imgflow/tags/quality_tag.rb +0 -11
- data/lib/jekyll-imgflow/tags/resize_tag.rb +18 -11
- data/lib/jekyll-imgflow/tags/watermark_tag.rb +0 -17
- data/lib/jekyll-imgflow/version.rb +1 -1
- metadata +1 -1
|
@@ -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,86 @@ 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
|
+
coords = if operation[:ratio]
|
|
49
|
+
{ x: opts[:calculated_x], y: opts[:calculated_y],
|
|
50
|
+
width: opts[:calculated_width], height: opts[:calculated_height] }
|
|
51
|
+
else
|
|
52
|
+
{ x: opts[:x] || 0, y: opts[:y] || 0,
|
|
53
|
+
width: opts[:width], height: opts[:height] }
|
|
54
|
+
end
|
|
55
|
+
coords.merge(smartcrop: smartcrop, keep: keep)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Extract watermark operation parts into a hash.
|
|
59
|
+
def watermark_parts(operation)
|
|
60
|
+
{
|
|
61
|
+
watermark_path: operation[:watermark_path],
|
|
62
|
+
position: operation[:options][:position],
|
|
63
|
+
opacity: operation[:options][:opacity]
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Shared compass → short position mapping (tl/tr/bl/br/c).
|
|
68
|
+
# HTTP providers (imgproxy, weserv, flyimg) all use this mapping.
|
|
69
|
+
def compass_to_short(position)
|
|
70
|
+
COMPASS_TO_SHORT[position.to_s] || position.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Input format extension (lowercased, no dot) for format preservation.
|
|
74
|
+
def input_format_ext(input_path)
|
|
75
|
+
File.extname(input_path).delete(".").downcase
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Convert a 0..1 opacity float to a 0..255 integer (used by all
|
|
79
|
+
# providers that encode alpha as an 8-bit value).
|
|
80
|
+
def alpha_byte_value(opacity)
|
|
81
|
+
(opacity * 255).round
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Generate a temporary file path by replacing the extension of
|
|
85
|
+
# +input_path+ with +suffix+ (e.g. temp_path("a.jpg", "tmp_base.jpg")
|
|
86
|
+
# → "a.tmp_base.jpg").
|
|
87
|
+
def temp_path(input_path, suffix)
|
|
88
|
+
input_path.gsub(/\.[^.]+$/, ".#{suffix}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Map a smartcrop +keep+ value to the libvips interestingness string
|
|
92
|
+
# used by Sharp and LibVips smartcrop/thumbnail --crop operations.
|
|
93
|
+
def smartcrop_interestingness(keep)
|
|
94
|
+
case keep.to_s
|
|
95
|
+
when "entropy" then "entropy"
|
|
96
|
+
when "center", "centre" then "centre"
|
|
97
|
+
else "attention"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check whether any of the given CLI commands is available on PATH.
|
|
102
|
+
# Used by CLI providers (sharp, libvips, imagemagick).
|
|
103
|
+
def cli_available?(*commands)
|
|
104
|
+
commands.any? do |cmd|
|
|
105
|
+
_, _, status = Open3.capture3("which", cmd)
|
|
106
|
+
status.success?
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
20
110
|
# Check if this provider is available (must be implemented by subclasses)
|
|
21
111
|
def available?
|
|
22
112
|
# Default: not available unless subclass implements actual check
|
|
@@ -165,6 +255,7 @@ module JekyllImgFlow
|
|
|
165
255
|
end
|
|
166
256
|
|
|
167
257
|
def execute_command(command)
|
|
258
|
+
# nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- provider commands shell-escape paths; pipelines are intentional for Sharp watermark operations.
|
|
168
259
|
stdout, stderr, status = Open3.capture3(command)
|
|
169
260
|
raise "Command failed: #{command}\nError: #{stderr.strip}" unless status.success?
|
|
170
261
|
|
|
@@ -205,5 +296,113 @@ module JekyllImgFlow
|
|
|
205
296
|
end
|
|
206
297
|
end
|
|
207
298
|
end
|
|
299
|
+
|
|
300
|
+
# Shared base for HTTP-based providers (imgproxy, weserv, flyimg).
|
|
301
|
+
# Subclasses must implement `service_url`, `build_combined_url`, and
|
|
302
|
+
# define a `TIMEOUT` constant. They may optionally override
|
|
303
|
+
# `provider_label` for error messages.
|
|
304
|
+
class HttpBase < BaseProvider
|
|
305
|
+
def available?
|
|
306
|
+
url = service_url
|
|
307
|
+
return false unless url
|
|
308
|
+
|
|
309
|
+
check_http_service(url)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def execute(input_path, output_path)
|
|
313
|
+
return if @operations.empty?
|
|
314
|
+
|
|
315
|
+
url = build_combined_url(input_path)
|
|
316
|
+
fetch_and_save(url, output_path)
|
|
317
|
+
output_path
|
|
318
|
+
ensure
|
|
319
|
+
reset_operations
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
protected
|
|
323
|
+
|
|
324
|
+
# Subclasses override to return the configured service URL or nil.
|
|
325
|
+
def service_url
|
|
326
|
+
raise NotImplementedError
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Subclasses override to build the full request URL.
|
|
330
|
+
def build_combined_url(_input_path)
|
|
331
|
+
raise NotImplementedError
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Label used in error messages (defaults to class name).
|
|
335
|
+
def provider_label
|
|
336
|
+
self.class.name.split("::").last
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def timeout
|
|
340
|
+
self.class::TIMEOUT
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def fetch_and_save(url, output_path)
|
|
344
|
+
response = fetch_with_timeout(url)
|
|
345
|
+
raise "#{provider_label} request failed" if response.empty?
|
|
346
|
+
|
|
347
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
348
|
+
File.write(output_path, response)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def fetch_with_timeout(url)
|
|
352
|
+
uri = URI(url)
|
|
353
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
354
|
+
use_ssl: uri.scheme == "https",
|
|
355
|
+
open_timeout: timeout,
|
|
356
|
+
read_timeout: timeout) do |http|
|
|
357
|
+
request = Net::HTTP::Get.new(uri)
|
|
358
|
+
response = http.request(request)
|
|
359
|
+
|
|
360
|
+
raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
361
|
+
|
|
362
|
+
response.body
|
|
363
|
+
end
|
|
364
|
+
rescue StandardError => e
|
|
365
|
+
raise "#{provider_label} request failed: #{e.message}"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Shared base for CLI-based providers (sharp, imagemagick, libvips).
|
|
370
|
+
# Subclasses implement `build_commands(input_path, output_path)` to
|
|
371
|
+
# return an array of command entries (strings or arrays), and
|
|
372
|
+
# `run_command(cmd)` to execute a single entry.
|
|
373
|
+
class CliBase < BaseProvider
|
|
374
|
+
def execute(input_path, output_path)
|
|
375
|
+
return if @operations.empty?
|
|
376
|
+
|
|
377
|
+
before_execute(input_path)
|
|
378
|
+
commands = build_commands(input_path, output_path)
|
|
379
|
+
Array(commands).each { |cmd| run_command(cmd) }
|
|
380
|
+
output_path
|
|
381
|
+
ensure
|
|
382
|
+
reset_operations
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
protected
|
|
386
|
+
|
|
387
|
+
# Hook for subclasses to perform pre-execution work (e.g. SVG warnings).
|
|
388
|
+
def before_execute(_input_path); end
|
|
389
|
+
|
|
390
|
+
# Subclasses override to return command(s) to execute.
|
|
391
|
+
# May return a single command or an array of commands.
|
|
392
|
+
def build_commands(_input_path, _output_path)
|
|
393
|
+
raise NotImplementedError
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Execute a single command. Default uses the shell-based
|
|
397
|
+
# `execute_command` from BaseProvider. Subclasses that use
|
|
398
|
+
# argument arrays (no shell) should override this.
|
|
399
|
+
def run_command(cmd)
|
|
400
|
+
if cmd.is_a?(Array) && cmd.first == :cleanup
|
|
401
|
+
FileUtils.rm_f(cmd[1])
|
|
402
|
+
return
|
|
403
|
+
end
|
|
404
|
+
execute_command(cmd)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
208
407
|
end
|
|
209
408
|
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 <
|
|
12
|
-
TIMEOUT =
|
|
10
|
+
class Flyimg < HttpBase
|
|
11
|
+
TIMEOUT = 60
|
|
13
12
|
|
|
14
|
-
def
|
|
15
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
35
|
-
raise "flyimg_url not set" unless
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
options
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
"#{@config.flyimg_url}/upload/#{options_string}/#{encoded_url}"
|
|
60
|
+
options << "h_#{operation[:height]}"
|
|
61
|
+
options << "c_1"
|
|
127
62
|
end
|
|
128
63
|
|
|
129
|
-
|
|
64
|
+
def append_flyimg_crop(operation, options)
|
|
65
|
+
geo = crop_geometry(operation)
|
|
130
66
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
@@ -7,31 +7,22 @@ require_relative "base_provider"
|
|
|
7
7
|
module JekyllImgFlow
|
|
8
8
|
module Providers
|
|
9
9
|
# ImageMagick provider implementation using the standardized tag interface
|
|
10
|
-
class Imagemagick <
|
|
10
|
+
class Imagemagick < CliBase
|
|
11
11
|
# Cache rsvg delegate check across all instances (checked once per build)
|
|
12
12
|
@rsvg_available = nil
|
|
13
13
|
@svg_warning_shown = false
|
|
14
14
|
|
|
15
15
|
def available?
|
|
16
|
-
|
|
17
|
-
_, _, status1 = Open3.capture3("which", "magick")
|
|
18
|
-
_, _, status2 = Open3.capture3("which", "convert")
|
|
19
|
-
status1.success? || status2.success?
|
|
16
|
+
cli_available?("magick", "convert")
|
|
20
17
|
end
|
|
21
18
|
|
|
22
|
-
def
|
|
23
|
-
return if @operations.empty?
|
|
24
|
-
|
|
19
|
+
def before_execute(input_path)
|
|
25
20
|
# Warn once per build about SVG performance with ImageMagick
|
|
26
21
|
warn_svg_performance if svg?(input_path) && !self.class.instance_variable_get(:@svg_warning_shown)
|
|
22
|
+
end
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
execute_command(command)
|
|
31
|
-
|
|
32
|
-
output_path
|
|
33
|
-
ensure
|
|
34
|
-
reset_operations
|
|
24
|
+
def build_commands(input_path, output_path)
|
|
25
|
+
build_combined_imagemagick_command(input_path, output_path)
|
|
35
26
|
end
|
|
36
27
|
|
|
37
28
|
def build_combined_imagemagick_command(input_path, output_path)
|
|
@@ -47,85 +38,7 @@ module JekyllImgFlow
|
|
|
47
38
|
["magick", input_path.shellescape]
|
|
48
39
|
end
|
|
49
40
|
|
|
50
|
-
|
|
51
|
-
@operations.each do |operation|
|
|
52
|
-
case operation[:type]
|
|
53
|
-
when :resize
|
|
54
|
-
# Tags now provide complete calculated values
|
|
55
|
-
command_parts << "-resize" << if operation[:height]
|
|
56
|
-
"#{operation[:width]}x#{operation[:height]}!"
|
|
57
|
-
else
|
|
58
|
-
operation[:width].to_s
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
when :crop
|
|
62
|
-
opts = operation[:options]
|
|
63
|
-
params = operation[:params] || {}
|
|
64
|
-
|
|
65
|
-
# Check for keep parameter (ImageMagick doesn't support smartcrop, but we handle it gracefully)
|
|
66
|
-
keep = opts[:keep] || params[:keep] || params[:position]
|
|
67
|
-
|
|
68
|
-
if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
|
|
69
|
-
# ImageMagick doesn't support smartcrop, but we handle the keep parameter
|
|
70
|
-
# Use center gravity as a reasonable fallback for smartcrop requests
|
|
71
|
-
crop_width = opts[:calculated_width]
|
|
72
|
-
crop_height = opts[:calculated_height]
|
|
73
|
-
crop_x = opts[:calculated_x]
|
|
74
|
-
crop_y = opts[:calculated_y]
|
|
75
|
-
|
|
76
|
-
# Use gravity center for smartcrop-like behavior
|
|
77
|
-
crop_spec = "#{crop_width}x#{crop_height}+#{crop_x}+#{crop_y}"
|
|
78
|
-
command_parts << "-gravity" << "center"
|
|
79
|
-
else
|
|
80
|
-
# Use basic cropping
|
|
81
|
-
if operation[:ratio]
|
|
82
|
-
crop_x = opts[:calculated_x]
|
|
83
|
-
crop_y = opts[:calculated_y]
|
|
84
|
-
crop_width = opts[:calculated_width]
|
|
85
|
-
crop_height = opts[:calculated_height]
|
|
86
|
-
else
|
|
87
|
-
crop_x = opts[:x]
|
|
88
|
-
crop_y = opts[:y]
|
|
89
|
-
crop_width = opts[:width]
|
|
90
|
-
crop_height = opts[:height]
|
|
91
|
-
end
|
|
92
|
-
crop_spec = "#{crop_width}x#{crop_height}+#{crop_x}+#{crop_y}"
|
|
93
|
-
end
|
|
94
|
-
command_parts << "-crop" << crop_spec
|
|
95
|
-
|
|
96
|
-
when :quality
|
|
97
|
-
magick_quality = translate_quality_to_imagemagick(operation[:quality])
|
|
98
|
-
command_parts << "-quality" << magick_quality.to_s
|
|
99
|
-
|
|
100
|
-
when :format
|
|
101
|
-
# Assume validated input from tags
|
|
102
|
-
# Format is handled by output filename extension
|
|
103
|
-
# Quality is set separately if needed
|
|
104
|
-
unless @operations.any? { |op| op[:type] == :quality }
|
|
105
|
-
default_quality = @config&.quality || raise("No quality configured")
|
|
106
|
-
command_parts << "-quality" << default_quality.to_s
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
when :watermark
|
|
110
|
-
watermark_path = operation[:watermark_path]
|
|
111
|
-
position = operation[:options][:position]
|
|
112
|
-
|
|
113
|
-
# Translate compass directions to ImageMagick gravity format
|
|
114
|
-
gravity = translate_position(position)
|
|
115
|
-
|
|
116
|
-
# Add watermark as composite operation
|
|
117
|
-
command_parts << watermark_path.shellescape
|
|
118
|
-
command_parts << "-gravity" << gravity
|
|
119
|
-
command_parts << "-composite"
|
|
120
|
-
|
|
121
|
-
when :alpha_opacity
|
|
122
|
-
opacity = operation[:opacity]
|
|
123
|
-
alpha_value = (opacity * 100).round
|
|
124
|
-
command_parts << "-alpha" << "set"
|
|
125
|
-
command_parts << "-channel" << "A"
|
|
126
|
-
command_parts << "-evaluate" << "multiply" << "#{alpha_value}%"
|
|
127
|
-
end
|
|
128
|
-
end
|
|
41
|
+
@operations.each { |operation| append_imagemagick_operation(operation, command_parts) }
|
|
129
42
|
|
|
130
43
|
# Add output filename
|
|
131
44
|
command_parts << output_path.shellescape
|
|
@@ -146,9 +59,56 @@ module JekyllImgFlow
|
|
|
146
59
|
|
|
147
60
|
private
|
|
148
61
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
62
|
+
def append_imagemagick_operation(operation, command_parts)
|
|
63
|
+
case operation[:type]
|
|
64
|
+
when :resize
|
|
65
|
+
command_parts << "-resize" << if operation[:height]
|
|
66
|
+
"#{operation[:width]}x#{operation[:height]}!"
|
|
67
|
+
else
|
|
68
|
+
operation[:width].to_s
|
|
69
|
+
end
|
|
70
|
+
when :crop
|
|
71
|
+
append_imagemagick_crop(operation, command_parts)
|
|
72
|
+
when :quality
|
|
73
|
+
command_parts << "-quality" << operation[:quality].to_s
|
|
74
|
+
when :format
|
|
75
|
+
# Format is handled by output filename extension.
|
|
76
|
+
# Quality is set separately if needed.
|
|
77
|
+
unless op?(:quality)
|
|
78
|
+
default_quality = @config&.quality || raise("No quality configured")
|
|
79
|
+
command_parts << "-quality" << default_quality.to_s
|
|
80
|
+
end
|
|
81
|
+
when :watermark
|
|
82
|
+
append_imagemagick_watermark(operation, command_parts)
|
|
83
|
+
when :alpha_opacity
|
|
84
|
+
alpha_value = (operation[:opacity] * 100).round
|
|
85
|
+
command_parts << "-alpha" << "set"
|
|
86
|
+
command_parts << "-channel" << "A"
|
|
87
|
+
command_parts << "-evaluate" << "multiply" << "#{alpha_value}%"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def append_imagemagick_crop(operation, command_parts)
|
|
92
|
+
geo = crop_geometry(operation)
|
|
93
|
+
|
|
94
|
+
if geo[:smartcrop]
|
|
95
|
+
# ImageMagick doesn't support smartcrop, but we handle the keep parameter
|
|
96
|
+
# Use center gravity as a reasonable fallback for smartcrop requests
|
|
97
|
+
command_parts << "-gravity" << "center"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
crop_spec = "#{geo[:width]}x#{geo[:height]}+#{geo[:x]}+#{geo[:y]}"
|
|
101
|
+
command_parts << "-crop" << crop_spec
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def append_imagemagick_watermark(operation, command_parts)
|
|
105
|
+
parts = watermark_parts(operation)
|
|
106
|
+
gravity = translate_position(parts[:position])
|
|
107
|
+
|
|
108
|
+
# Add watermark as composite operation
|
|
109
|
+
command_parts << parts[:watermark_path].shellescape
|
|
110
|
+
command_parts << "-gravity" << gravity
|
|
111
|
+
command_parts << "-composite"
|
|
152
112
|
end
|
|
153
113
|
|
|
154
114
|
# Choose a rasterization density (DPI) for SVG input.
|