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