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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +661 -0
  3. data/README.md +214 -0
  4. data/lib/jekyll-imgflow/batch_manager.rb +263 -0
  5. data/lib/jekyll-imgflow/build_time_processor.rb +152 -0
  6. data/lib/jekyll-imgflow/config.rb +130 -0
  7. data/lib/jekyll-imgflow/filename_generator.rb +102 -0
  8. data/lib/jekyll-imgflow/helpers/http_downloader.rb +68 -0
  9. data/lib/jekyll-imgflow/hooks.rb +57 -0
  10. data/lib/jekyll-imgflow/html_generator.rb +335 -0
  11. data/lib/jekyll-imgflow/imgflow_tag.rb +247 -0
  12. data/lib/jekyll-imgflow/manifest_manager.rb +297 -0
  13. data/lib/jekyll-imgflow/operation_processor.rb +183 -0
  14. data/lib/jekyll-imgflow/parser.rb +199 -0
  15. data/lib/jekyll-imgflow/path_resolver.rb +130 -0
  16. data/lib/jekyll-imgflow/picture_tag_adaptor.rb +299 -0
  17. data/lib/jekyll-imgflow/picture_tag_preset_migrator.rb +210 -0
  18. data/lib/jekyll-imgflow/preset_manager.rb +149 -0
  19. data/lib/jekyll-imgflow/provider_registry.rb +106 -0
  20. data/lib/jekyll-imgflow/providers/base_provider.rb +198 -0
  21. data/lib/jekyll-imgflow/providers/flyimg.rb +187 -0
  22. data/lib/jekyll-imgflow/providers/imagemagick.rb +138 -0
  23. data/lib/jekyll-imgflow/providers/imgproxy.rb +164 -0
  24. data/lib/jekyll-imgflow/providers/libvips.rb +292 -0
  25. data/lib/jekyll-imgflow/providers/sharp.rb +213 -0
  26. data/lib/jekyll-imgflow/providers/weserv.rb +160 -0
  27. data/lib/jekyll-imgflow/tag_scanner.rb +227 -0
  28. data/lib/jekyll-imgflow/tags/base_tag.rb +52 -0
  29. data/lib/jekyll-imgflow/tags/crop_tag.rb +220 -0
  30. data/lib/jekyll-imgflow/tags/format_tag.rb +64 -0
  31. data/lib/jekyll-imgflow/tags/opacity_tag.rb +36 -0
  32. data/lib/jekyll-imgflow/tags/optimize_tag.rb +52 -0
  33. data/lib/jekyll-imgflow/tags/quality_tag.rb +31 -0
  34. data/lib/jekyll-imgflow/tags/resize_tag.rb +58 -0
  35. data/lib/jekyll-imgflow/tags/tag_registry.rb +70 -0
  36. data/lib/jekyll-imgflow/tags/watermark_tag.rb +51 -0
  37. data/lib/jekyll-imgflow/version.rb +5 -0
  38. data/lib/jekyll-imgflow.rb +30 -0
  39. 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