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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "providers/base_provider"
|
|
4
|
+
|
|
5
|
+
module JekyllImgFlow
|
|
6
|
+
class ProviderRegistry
|
|
7
|
+
# Providers that don't work programmatically (manual use only)
|
|
8
|
+
MANUAL_ONLY_PROVIDERS = [].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
self.class.discover_providers unless self.class.providers_discovered?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Dynamic provider discovery
|
|
16
|
+
def self.discover_providers
|
|
17
|
+
return if @providers_discovered
|
|
18
|
+
|
|
19
|
+
@provider_registry ||= {}
|
|
20
|
+
providers_dir = File.join(File.dirname(__FILE__), "providers")
|
|
21
|
+
|
|
22
|
+
Dir.glob(File.join(providers_dir, "*.rb")).each do |file|
|
|
23
|
+
next if File.basename(file) == "base_provider.rb"
|
|
24
|
+
|
|
25
|
+
provider_name = File.basename(file, ".rb")
|
|
26
|
+
register_provider(provider_name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@providers_discovered = true
|
|
30
|
+
Jekyll.logger.debug "ImgFlow:", "Discovered #{@provider_registry.size} providers"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.register_provider(name)
|
|
34
|
+
class_name = name.split("_").map(&:capitalize).join
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
require_relative "providers/#{name}"
|
|
38
|
+
provider_class = JekyllImgFlow::Providers.const_get(class_name)
|
|
39
|
+
@provider_registry[name.to_sym] = provider_class
|
|
40
|
+
Jekyll.logger.debug "ImgFlow:", "Registered provider: #{class_name}"
|
|
41
|
+
rescue NameError => e
|
|
42
|
+
Jekyll.logger.warn "ImgFlow:", "Failed to load provider #{name}: #{e.message}"
|
|
43
|
+
rescue LoadError => e
|
|
44
|
+
Jekyll.logger.warn "ImgFlow:", "Failed to require provider #{name}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.provider_registry
|
|
49
|
+
@provider_registry ||= {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.providers_discovered?
|
|
53
|
+
@providers_discovered ||= false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get all providers that are currently available (running/installed)
|
|
57
|
+
def self.get_available_providers(config)
|
|
58
|
+
registry = new(config)
|
|
59
|
+
registry.providers.select(&:available?)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get all providers with their availability status
|
|
63
|
+
def self.get_all_providers_with_status(config)
|
|
64
|
+
registry = new(config)
|
|
65
|
+
registry.providers.map do |provider|
|
|
66
|
+
{
|
|
67
|
+
name: provider.class.name.split("::").last,
|
|
68
|
+
class: provider.class,
|
|
69
|
+
instance: provider,
|
|
70
|
+
available: provider.available?
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get current provider (first available from priority list)
|
|
76
|
+
def current_provider
|
|
77
|
+
providers.find(&:available?)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get list of available provider names
|
|
81
|
+
def available_providers
|
|
82
|
+
available = providers.select(&:available?)
|
|
83
|
+
available.map! { |p| p.class.name.split("::").last }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def providers
|
|
87
|
+
@providers ||= @config.backend_priority.filter_map do |name|
|
|
88
|
+
# Skip manual-only providers (they don't have programmatic APIs)
|
|
89
|
+
if MANUAL_ONLY_PROVIDERS.include?(name)
|
|
90
|
+
Jekyll.logger.debug "ImgFlow:",
|
|
91
|
+
"Skipping '#{name}' - manual use only (no programmatic API)"
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Dynamic provider loading from registry
|
|
96
|
+
provider_class = self.class.provider_registry[name.to_sym]
|
|
97
|
+
if provider_class
|
|
98
|
+
provider_class.new(@config)
|
|
99
|
+
else
|
|
100
|
+
Jekyll.logger.warn "ImgFlow:", "Unknown provider '#{name}' - not found in registry"
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module JekyllImgFlow
|
|
8
|
+
module Providers
|
|
9
|
+
# Base provider class - all providers must inherit from this
|
|
10
|
+
class BaseProvider
|
|
11
|
+
# Valid smartcrop position values
|
|
12
|
+
SMARTCROP_POSITIONS = %w[attention entropy center centre].freeze
|
|
13
|
+
|
|
14
|
+
attr_accessor :config
|
|
15
|
+
|
|
16
|
+
def initialize(config = {})
|
|
17
|
+
@config = config
|
|
18
|
+
@operations = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Check if this provider is available (must be implemented by subclasses)
|
|
22
|
+
def available?
|
|
23
|
+
# Default: not available unless subclass implements actual check
|
|
24
|
+
# This ensures providers explicitly check their availability
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Helper method for HTTP providers to check service availability
|
|
29
|
+
def check_http_service(url)
|
|
30
|
+
return false unless url
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
require "net/http"
|
|
34
|
+
require "uri"
|
|
35
|
+
|
|
36
|
+
uri = URI(url)
|
|
37
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
38
|
+
http.use_ssl = uri.scheme == "https"
|
|
39
|
+
http.read_timeout = 2
|
|
40
|
+
http.open_timeout = 2
|
|
41
|
+
|
|
42
|
+
request = Net::HTTP::Get.new(uri)
|
|
43
|
+
http.request(request)
|
|
44
|
+
|
|
45
|
+
# Consider available if we get any response (even 404 means service is running)
|
|
46
|
+
true
|
|
47
|
+
rescue StandardError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Helper method for HTTP providers to construct HTTP URLs from file paths
|
|
53
|
+
def encode_file_url(file_path)
|
|
54
|
+
# If already a URL, return as-is
|
|
55
|
+
return file_path if file_path.start_with?("http://", "https://")
|
|
56
|
+
|
|
57
|
+
# For HTTP providers, construct proper HTTP URL
|
|
58
|
+
site = @config.site
|
|
59
|
+
|
|
60
|
+
# Get the relative path from site source using Pathname
|
|
61
|
+
source_path = Pathname.new(site.source)
|
|
62
|
+
file_pathname = Pathname.new(file_path)
|
|
63
|
+
relative_path = if file_pathname.absolute? && file_path.to_s.start_with?(site.source)
|
|
64
|
+
file_pathname.relative_path_from(source_path).to_s
|
|
65
|
+
elsif file_path.start_with?("/")
|
|
66
|
+
file_path[1..]
|
|
67
|
+
else
|
|
68
|
+
file_path
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# URL-encode each path segment to handle spaces and special characters
|
|
72
|
+
encoded_path = relative_path.split("/").map do |segment|
|
|
73
|
+
URI.encode_www_form_component(segment)
|
|
74
|
+
end.join("/")
|
|
75
|
+
|
|
76
|
+
# Construct HTTP URL for the file
|
|
77
|
+
# Use Jekyll server URL (localhost:4000 by default for development)
|
|
78
|
+
# In production, this would be the actual site URL from config
|
|
79
|
+
base_url = site.config["url"] || "http://localhost:4000"
|
|
80
|
+
baseurl = site.config["baseurl"] || ""
|
|
81
|
+
|
|
82
|
+
"#{base_url}#{baseurl}/#{encoded_path}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Operation collection methods - these should NOT execute immediately
|
|
86
|
+
def resize(width, height, options = {})
|
|
87
|
+
@operations << { type: :resize, width: width, height: height,
|
|
88
|
+
options: options }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def crop(ratio_or_width, height_val = nil, x_val = nil, y_val = nil)
|
|
92
|
+
if height_val.is_a?(Hash)
|
|
93
|
+
@operations << { type: :crop, ratio: ratio_or_width, options: height_val }
|
|
94
|
+
elsif height_val
|
|
95
|
+
options = { width: ratio_or_width, height: height_val, x: x_val, y: y_val }
|
|
96
|
+
@operations << options.merge(type: :crop, ratio: nil, options: options)
|
|
97
|
+
else
|
|
98
|
+
@operations << { type: :crop, ratio: ratio_or_width, options: {} }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def quality=(quality)
|
|
103
|
+
@operations << { type: :quality, quality: quality }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def convert_format(format)
|
|
107
|
+
@operations << { type: :format, format: format }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def quality(value)
|
|
111
|
+
self.quality = value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def format(value)
|
|
115
|
+
convert_format(value)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def optimize(level = :medium)
|
|
119
|
+
@operations << { type: :optimize, level: level }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def opacity(value)
|
|
123
|
+
self.alpha_opacity = value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add_watermark(watermark_path, options = {})
|
|
127
|
+
@operations << { type: :watermark, watermark_path: watermark_path,
|
|
128
|
+
options: options }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def alpha_opacity=(opacity)
|
|
132
|
+
@operations << { type: :alpha_opacity, opacity: opacity }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def watermark(watermark_path, options = {})
|
|
136
|
+
add_watermark(watermark_path, options)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def supports_operation?(operation)
|
|
140
|
+
self.class.supports_operation?(operation)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Final execution method - providers must implement this
|
|
144
|
+
def execute(input_path, output_path)
|
|
145
|
+
raise NotImplementedError, "Provider must implement execute method"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Reset operations for next processing
|
|
149
|
+
def reset_operations
|
|
150
|
+
@operations = []
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Get current operations (for testing/debugging)
|
|
154
|
+
def operations
|
|
155
|
+
@operations.dup
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Helper methods
|
|
159
|
+
protected
|
|
160
|
+
|
|
161
|
+
def execute_command(command)
|
|
162
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
163
|
+
raise "Command failed: #{command}\nError: #{stderr.strip}" unless status.success?
|
|
164
|
+
|
|
165
|
+
stdout.strip
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def get_image_dimensions(image_path)
|
|
169
|
+
# Use FastImage for better performance and reliability
|
|
170
|
+
require "fastimage"
|
|
171
|
+
dimensions = FastImage.size(image_path)
|
|
172
|
+
|
|
173
|
+
unless dimensions
|
|
174
|
+
raise ArgumentError,
|
|
175
|
+
"Unable to read image dimensions for #{image_path}. The file may be corrupted, empty, or in an unsupported format."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
dimensions
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class << self
|
|
182
|
+
# Provider name for identification (override in subclasses)
|
|
183
|
+
def provider_name
|
|
184
|
+
name.split("::").last.downcase
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Provider capability methods
|
|
188
|
+
def unsupported_operations
|
|
189
|
+
[] # Default: no unsupported operations
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def supports_operation?(operation)
|
|
193
|
+
!unsupported_operations.include?(operation)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require_relative "base_provider"
|
|
7
|
+
|
|
8
|
+
module JekyllImgFlow
|
|
9
|
+
module Providers
|
|
10
|
+
# Flyimg provider implementation using the standardized tag interface
|
|
11
|
+
class Flyimg < BaseProvider
|
|
12
|
+
TIMEOUT = 10
|
|
13
|
+
|
|
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)
|
|
19
|
+
end
|
|
20
|
+
|
|
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
|
+
reset_operations
|
|
30
|
+
output_path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build_combined_flyimg_url(input_path)
|
|
34
|
+
raise "flyimg_url not set" unless @config.flyimg_url
|
|
35
|
+
|
|
36
|
+
encoded_url = encode_file_url(input_path)
|
|
37
|
+
options = []
|
|
38
|
+
|
|
39
|
+
# Add all operations to single options string
|
|
40
|
+
@operations.each do |operation|
|
|
41
|
+
case operation[:type]
|
|
42
|
+
when :resize
|
|
43
|
+
# Tags now provide complete calculated values.
|
|
44
|
+
# c_1 forces the image to fill the exact width x height area.
|
|
45
|
+
# pns_0 allows upscaling when the source is smaller than the target
|
|
46
|
+
# (Flyimg's pns/preserve-natural-size defaults to 1, blocking enlarge).
|
|
47
|
+
options << "w_#{operation[:width]}"
|
|
48
|
+
options << "pns_0"
|
|
49
|
+
if operation[:height]
|
|
50
|
+
options << "h_#{operation[:height]}"
|
|
51
|
+
options << "c_1"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
when :crop
|
|
55
|
+
opts = operation[:options]
|
|
56
|
+
params = operation[:params] || {}
|
|
57
|
+
|
|
58
|
+
# Check for keep parameter (smartcrop support)
|
|
59
|
+
keep = opts[:keep] || params[:keep] || params[:position]
|
|
60
|
+
|
|
61
|
+
if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
|
|
62
|
+
# Use smartcrop (Flyimg's smc option picks the best crop area)
|
|
63
|
+
crop_width = opts[:calculated_width]
|
|
64
|
+
crop_height = opts[:calculated_height]
|
|
65
|
+
|
|
66
|
+
options << "w_#{crop_width}"
|
|
67
|
+
options << "h_#{crop_height}"
|
|
68
|
+
options << "c_1"
|
|
69
|
+
options << "smc_1"
|
|
70
|
+
else
|
|
71
|
+
# Use basic cropping via extract (top-left / bottom-right coordinates)
|
|
72
|
+
if operation[:ratio]
|
|
73
|
+
crop_x = opts[:calculated_x]
|
|
74
|
+
crop_y = opts[:calculated_y]
|
|
75
|
+
crop_width = opts[:calculated_width]
|
|
76
|
+
crop_height = opts[:calculated_height]
|
|
77
|
+
else
|
|
78
|
+
crop_x = opts[:x]
|
|
79
|
+
crop_y = opts[:y]
|
|
80
|
+
crop_width = opts[:width]
|
|
81
|
+
crop_height = opts[:height]
|
|
82
|
+
end
|
|
83
|
+
options << "e_1"
|
|
84
|
+
options << "p1x_#{crop_x}"
|
|
85
|
+
options << "p1y_#{crop_y}"
|
|
86
|
+
options << "p2x_#{crop_x + crop_width}"
|
|
87
|
+
options << "p2y_#{crop_y + crop_height}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
when :quality
|
|
91
|
+
flyimg_quality = translate_quality_to_flyimg(operation[:quality])
|
|
92
|
+
options << "q_#{flyimg_quality}"
|
|
93
|
+
|
|
94
|
+
when :format
|
|
95
|
+
# Assume validated input from tags
|
|
96
|
+
options << "o_#{operation[:format]}"
|
|
97
|
+
|
|
98
|
+
when :watermark
|
|
99
|
+
watermark_path = operation[:watermark_path]
|
|
100
|
+
position = operation[:options][:position]
|
|
101
|
+
opacity = operation[:options][:opacity]
|
|
102
|
+
|
|
103
|
+
# Flyimg watermark parameters
|
|
104
|
+
encoded_watermark = encode_file_url(watermark_path)
|
|
105
|
+
position_param = translate_flyimg_position(position)
|
|
106
|
+
opacity_param = (opacity * 100).round
|
|
107
|
+
options << "wm_#{encoded_watermark}_#{position_param}_#{opacity_param}"
|
|
108
|
+
|
|
109
|
+
when :alpha_opacity
|
|
110
|
+
opacity = operation[:opacity]
|
|
111
|
+
options << "a_#{(opacity * 255).round}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Preserve format when no explicit format operation is requested.
|
|
116
|
+
# Without this, chained HTTP requests (e.g. quality-only steps after a
|
|
117
|
+
# prior format conversion) would lose the previously converted format.
|
|
118
|
+
unless @operations.any? { |op| op[:type] == :format }
|
|
119
|
+
input_ext = File.extname(input_path).delete(".").downcase
|
|
120
|
+
options << "o_#{map_format(input_ext)}" unless input_ext.empty?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Build combined URL: base/upload/options/options,.../encoded_url
|
|
124
|
+
options_string = options.join(",")
|
|
125
|
+
"#{@config.flyimg_url}/upload/#{options_string}/#{encoded_url}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def translate_flyimg_position(position)
|
|
131
|
+
# Translate compass directions to Flyimg position format
|
|
132
|
+
case position
|
|
133
|
+
when "northwest" then "tl"
|
|
134
|
+
when "northeast" then "tr"
|
|
135
|
+
when "southwest" then "bl"
|
|
136
|
+
when "southeast" then "br"
|
|
137
|
+
when "center" then "c"
|
|
138
|
+
else position
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def map_format(format)
|
|
143
|
+
case format&.downcase
|
|
144
|
+
when "jpeg", "jpg"
|
|
145
|
+
"jpg"
|
|
146
|
+
when "png"
|
|
147
|
+
"png"
|
|
148
|
+
when "webp"
|
|
149
|
+
"webp"
|
|
150
|
+
when "gif"
|
|
151
|
+
"gif"
|
|
152
|
+
else
|
|
153
|
+
format&.downcase || format
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def fetch_and_save(url, output_path)
|
|
158
|
+
response = fetch_with_timeout(url)
|
|
159
|
+
raise "Flyimg request failed" if response.empty?
|
|
160
|
+
|
|
161
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
162
|
+
File.write(output_path, response)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def fetch_with_timeout(url)
|
|
166
|
+
uri = URI(url)
|
|
167
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
168
|
+
use_ssl: uri.scheme == "https",
|
|
169
|
+
open_timeout: TIMEOUT,
|
|
170
|
+
read_timeout: TIMEOUT) do |http|
|
|
171
|
+
request = Net::HTTP::Get.new(uri)
|
|
172
|
+
response = http.request(request)
|
|
173
|
+
|
|
174
|
+
raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
175
|
+
|
|
176
|
+
response.body
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
raise "Flyimg request failed: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def translate_quality_to_flyimg(quality)
|
|
183
|
+
quality
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
# ImageMagick provider implementation using the standardized tag interface
|
|
10
|
+
class Imagemagick < BaseProvider
|
|
11
|
+
def available?
|
|
12
|
+
# Check if magick or convert CLI is available
|
|
13
|
+
_, _, status1 = Open3.capture3("which", "magick")
|
|
14
|
+
_, _, status2 = Open3.capture3("which", "convert")
|
|
15
|
+
status1.success? || status2.success?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def execute(input_path, output_path)
|
|
19
|
+
return if @operations.empty?
|
|
20
|
+
|
|
21
|
+
# Build single ImageMagick command with all operations combined
|
|
22
|
+
command = build_combined_imagemagick_command(input_path, output_path)
|
|
23
|
+
execute_command(command)
|
|
24
|
+
|
|
25
|
+
reset_operations
|
|
26
|
+
output_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_combined_imagemagick_command(input_path, output_path)
|
|
30
|
+
# Start with base command
|
|
31
|
+
command_parts = ["magick", input_path.shellescape]
|
|
32
|
+
|
|
33
|
+
# Add all operations
|
|
34
|
+
@operations.each do |operation|
|
|
35
|
+
case operation[:type]
|
|
36
|
+
when :resize
|
|
37
|
+
# Tags now provide complete calculated values
|
|
38
|
+
command_parts << "-resize" << if operation[:height]
|
|
39
|
+
"#{operation[:width]}x#{operation[:height]}!"
|
|
40
|
+
else
|
|
41
|
+
operation[:width].to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
when :crop
|
|
45
|
+
opts = operation[:options]
|
|
46
|
+
params = operation[:params] || {}
|
|
47
|
+
|
|
48
|
+
# Check for keep parameter (ImageMagick doesn't support smartcrop, but we handle it gracefully)
|
|
49
|
+
keep = opts[:keep] || params[:keep] || params[:position]
|
|
50
|
+
|
|
51
|
+
if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
|
|
52
|
+
# ImageMagick doesn't support smartcrop, but we handle the keep parameter
|
|
53
|
+
# Use center gravity as a reasonable fallback for smartcrop requests
|
|
54
|
+
crop_width = opts[:calculated_width]
|
|
55
|
+
crop_height = opts[:calculated_height]
|
|
56
|
+
crop_x = opts[:calculated_x]
|
|
57
|
+
crop_y = opts[:calculated_y]
|
|
58
|
+
|
|
59
|
+
# Use gravity center for smartcrop-like behavior
|
|
60
|
+
crop_spec = "#{crop_width}x#{crop_height}+#{crop_x}+#{crop_y}"
|
|
61
|
+
command_parts << "-gravity" << "center"
|
|
62
|
+
else
|
|
63
|
+
# Use basic cropping
|
|
64
|
+
if operation[:ratio]
|
|
65
|
+
crop_x = opts[:calculated_x]
|
|
66
|
+
crop_y = opts[:calculated_y]
|
|
67
|
+
crop_width = opts[:calculated_width]
|
|
68
|
+
crop_height = opts[:calculated_height]
|
|
69
|
+
else
|
|
70
|
+
crop_x = opts[:x]
|
|
71
|
+
crop_y = opts[:y]
|
|
72
|
+
crop_width = opts[:width]
|
|
73
|
+
crop_height = opts[:height]
|
|
74
|
+
end
|
|
75
|
+
crop_spec = "#{crop_width}x#{crop_height}+#{crop_x}+#{crop_y}"
|
|
76
|
+
end
|
|
77
|
+
command_parts << "-crop" << crop_spec
|
|
78
|
+
|
|
79
|
+
when :quality
|
|
80
|
+
magick_quality = translate_quality_to_imagemagick(operation[:quality])
|
|
81
|
+
command_parts << "-quality" << magick_quality.to_s
|
|
82
|
+
|
|
83
|
+
when :format
|
|
84
|
+
# Assume validated input from tags
|
|
85
|
+
# Format is handled by output filename extension
|
|
86
|
+
# Quality is set separately if needed
|
|
87
|
+
unless @operations.any? { |op| op[:type] == :quality }
|
|
88
|
+
default_quality = @config&.quality || raise("No quality configured")
|
|
89
|
+
command_parts << "-quality" << default_quality.to_s
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
when :watermark
|
|
93
|
+
watermark_path = operation[:watermark_path]
|
|
94
|
+
position = operation[:options][:position]
|
|
95
|
+
|
|
96
|
+
# Translate compass directions to ImageMagick gravity format
|
|
97
|
+
gravity = translate_position(position)
|
|
98
|
+
|
|
99
|
+
# Add watermark as composite operation
|
|
100
|
+
command_parts << watermark_path.shellescape
|
|
101
|
+
command_parts << "-gravity" << gravity
|
|
102
|
+
command_parts << "-composite"
|
|
103
|
+
|
|
104
|
+
when :alpha_opacity
|
|
105
|
+
opacity = operation[:opacity]
|
|
106
|
+
alpha_value = (opacity * 100).round
|
|
107
|
+
command_parts << "-alpha" << "set"
|
|
108
|
+
command_parts << "-channel" << "A"
|
|
109
|
+
command_parts << "-evaluate" << "multiply" << "#{alpha_value}%"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Add output filename
|
|
114
|
+
command_parts << output_path.shellescape
|
|
115
|
+
|
|
116
|
+
command_parts.join(" ")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def translate_position(position)
|
|
120
|
+
case position
|
|
121
|
+
when "northwest" then "NorthWest"
|
|
122
|
+
when "northeast" then "NorthEast"
|
|
123
|
+
when "southwest" then "SouthWest"
|
|
124
|
+
when "southeast" then "SouthEast"
|
|
125
|
+
when "center" then "Center"
|
|
126
|
+
else position
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def translate_quality_to_imagemagick(quality)
|
|
133
|
+
# ImageMagick uses 1-100 directly, no translation needed
|
|
134
|
+
quality
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|