jekyll-imgflow 0.1.9 → 0.2.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 +24 -13
- data/lib/jekyll-imgflow/batch_manager.rb +10 -7
- data/lib/jekyll-imgflow/build_time_processor.rb +70 -61
- data/lib/jekyll-imgflow/config.rb +3 -6
- data/lib/jekyll-imgflow/filename_generator.rb +8 -10
- data/lib/jekyll-imgflow/generated_file_cleaner.rb +81 -0
- data/lib/jekyll-imgflow/hooks.rb +13 -6
- data/lib/jekyll-imgflow/html_generator.rb +14 -19
- data/lib/jekyll-imgflow/imgflow_tag.rb +84 -69
- data/lib/jekyll-imgflow/manifest_manager.rb +132 -53
- data/lib/jekyll-imgflow/operation_processor.rb +43 -22
- data/lib/jekyll-imgflow/picture_tag_adaptor.rb +1 -1
- data/lib/jekyll-imgflow/preset_manager.rb +58 -7
- data/lib/jekyll-imgflow/presets/gallery.yml +7 -0
- data/lib/jekyll-imgflow/presets/hero.yml +7 -0
- data/lib/jekyll-imgflow/presets/thumbnail.yml +7 -0
- data/lib/jekyll-imgflow/processing_stats.rb +80 -0
- data/lib/jekyll-imgflow/provider_registry.rb +18 -3
- data/lib/jekyll-imgflow/providers/base_provider.rb +12 -1
- data/lib/jekyll-imgflow/providers/flyimg.rb +2 -1
- data/lib/jekyll-imgflow/providers/imagemagick.rb +72 -3
- data/lib/jekyll-imgflow/providers/imgproxy.rb +2 -1
- data/lib/jekyll-imgflow/providers/libvips.rb +152 -100
- data/lib/jekyll-imgflow/providers/sharp.rb +2 -1
- data/lib/jekyll-imgflow/providers/weserv.rb +3 -2
- data/lib/jekyll-imgflow/tags/opacity_tag.rb +8 -1
- data/lib/jekyll-imgflow/tags/resize_tag.rb +2 -0
- data/lib/jekyll-imgflow/tags/watermark_tag.rb +8 -1
- data/lib/jekyll-imgflow/tasks.rb +129 -0
- data/lib/jekyll-imgflow/version.rb +1 -1
- data/lib/jekyll-imgflow.rb +2 -0
- metadata +24 -156
|
@@ -1,26 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
3
4
|
require "yaml"
|
|
4
5
|
|
|
5
6
|
module JekyllImgFlow
|
|
6
7
|
# PresetManager - translates YAML presets to tags:value format
|
|
7
8
|
# Handles tag overrides (user tags override preset tags)
|
|
8
9
|
# Passes combined markup to Parser for uniform validation flow
|
|
10
|
+
#
|
|
11
|
+
# Presets are loaded from two sources (user presets take precedence):
|
|
12
|
+
# 1. Site presets: _data/imgflow/presets/*.yml (user-defined, override built-ins)
|
|
13
|
+
# 2. Built-in presets: lib/jekyll-imgflow/presets/*.yml (shipped with the gem)
|
|
9
14
|
class PresetManager
|
|
15
|
+
BUILTIN_PRESETS_DIR = File.expand_path("presets", __dir__).freeze
|
|
16
|
+
|
|
10
17
|
def initialize(site, config)
|
|
11
18
|
@site = site
|
|
12
19
|
@config = config
|
|
13
20
|
@presets = load_presets
|
|
14
21
|
end
|
|
15
22
|
|
|
16
|
-
# Load presets from _data/imgflow/presets/
|
|
23
|
+
# Load presets from the site's _data/imgflow/presets/ directory,
|
|
24
|
+
# then merge in built-in presets from the gem. User presets override
|
|
25
|
+
# built-in presets of the same name.
|
|
17
26
|
def load_presets
|
|
18
|
-
presets =
|
|
19
|
-
|
|
27
|
+
presets = load_presets_from_dir(builtin_presets_dir)
|
|
28
|
+
presets.merge(load_presets_from_dir(site_presets_dir))
|
|
29
|
+
end
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
# Path to built-in presets shipped with the gem
|
|
32
|
+
def builtin_presets_dir
|
|
33
|
+
BUILTIN_PRESETS_DIR
|
|
34
|
+
end
|
|
22
35
|
|
|
23
|
-
|
|
36
|
+
# Path to user-defined presets in the site
|
|
37
|
+
def site_presets_dir
|
|
38
|
+
File.join(@site.source, "_data", "imgflow", "presets")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Load all preset YAML files from a directory
|
|
42
|
+
# @param dir [String] Directory containing *.yml preset files
|
|
43
|
+
# @return [Hash<String, Hash>] Preset name => preset data
|
|
44
|
+
def load_presets_from_dir(dir)
|
|
45
|
+
presets = {}
|
|
46
|
+
return presets unless dir && Dir.exist?(dir)
|
|
47
|
+
|
|
48
|
+
Dir.glob(File.join(dir, "*.yml")).each do |preset_file|
|
|
24
49
|
preset_name = File.basename(preset_file, ".yml")
|
|
25
50
|
begin
|
|
26
51
|
preset_data = YAML.safe_load_file(preset_file)
|
|
@@ -48,12 +73,38 @@ module JekyllImgFlow
|
|
|
48
73
|
@presets.key?(name.to_s)
|
|
49
74
|
end
|
|
50
75
|
|
|
51
|
-
# Get all available preset names
|
|
76
|
+
# Get all available preset names (user + built-in)
|
|
52
77
|
# @return [Array<String>] Array of preset names
|
|
53
78
|
def available_presets
|
|
54
79
|
@presets.keys
|
|
55
80
|
end
|
|
56
81
|
|
|
82
|
+
# Get names of built-in presets shipped with the gem
|
|
83
|
+
# @return [Array<String>] Array of built-in preset names
|
|
84
|
+
def builtin_preset_names
|
|
85
|
+
names = []
|
|
86
|
+
return names unless Dir.exist?(builtin_presets_dir)
|
|
87
|
+
|
|
88
|
+
Dir.glob(File.join(builtin_presets_dir, "*.yml")).each do |file|
|
|
89
|
+
names << File.basename(file, ".yml")
|
|
90
|
+
end
|
|
91
|
+
names
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check if a preset is a built-in (shipped with the gem)
|
|
95
|
+
# @param name [String] Preset name
|
|
96
|
+
# @return [Boolean] True if the preset is built-in
|
|
97
|
+
def builtin_preset?(name)
|
|
98
|
+
builtin_preset_names.include?(name.to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check if a preset is user-defined (in the site's _data/)
|
|
102
|
+
# @param name [String] Preset name
|
|
103
|
+
# @return [Boolean] True if the preset is user-defined
|
|
104
|
+
def user_preset?(name)
|
|
105
|
+
File.exist?(File.join(site_presets_dir, "#{name}.yml"))
|
|
106
|
+
end
|
|
107
|
+
|
|
57
108
|
# Build markup from preset (tags:value format)
|
|
58
109
|
# @param preset_name [String] Name of preset
|
|
59
110
|
# @param user_options [Hash] User-provided options that override preset values
|
|
@@ -143,7 +194,7 @@ module JekyllImgFlow
|
|
|
143
194
|
# @param tags [Hash] Tags in key => value format
|
|
144
195
|
# @return [String] Markup string
|
|
145
196
|
def tags_to_markup(tags)
|
|
146
|
-
tags.map { |key, value| "#{key}:#{value}" }.join(" ")
|
|
197
|
+
tags.map { |key, value| "#{key}:#{Shellwords.escape(value.to_s)}" }.join(" ")
|
|
147
198
|
end
|
|
148
199
|
end
|
|
149
200
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllImgFlow
|
|
4
|
+
# ProcessingStats — collects cache hit/miss, per-operation timing, and
|
|
5
|
+
# compression ratio metrics during a Jekyll build. Accessed by the
|
|
6
|
+
# performance benchmark to report beyond wall-clock time.
|
|
7
|
+
class ProcessingStats
|
|
8
|
+
attr_reader :cache_hits, :cache_misses, :operation_timings, :compression_ratios
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@cache_hits = 0
|
|
12
|
+
@cache_misses = 0
|
|
13
|
+
@operation_timings = Hash.new { |h, k| h[k] = 0.0 }
|
|
14
|
+
@compression_ratios = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record_cache_hit
|
|
19
|
+
@mutex.synchronize { @cache_hits += 1 }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record_cache_miss
|
|
23
|
+
@mutex.synchronize { @cache_misses += 1 }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Record wall-clock time for a specific operation type (:resize, :format, etc.)
|
|
27
|
+
def record_operation_time(type, seconds)
|
|
28
|
+
@mutex.synchronize { @operation_timings[type] += seconds }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Record compression ratio for a format (e.g. "webp" => 78.5 percent saved)
|
|
32
|
+
def record_compression_ratio(format, original_size, output_size)
|
|
33
|
+
return if original_size.zero?
|
|
34
|
+
|
|
35
|
+
ratio = ((original_size - output_size).to_f / original_size * 100).round(1)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@compression_ratios[format] ||= { ratios: [], count: 0 }
|
|
38
|
+
@compression_ratios[format][:ratios] << ratio
|
|
39
|
+
@compression_ratios[format][:count] += 1
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Average compression ratio per format
|
|
44
|
+
def average_compression_ratios
|
|
45
|
+
@compression_ratios.transform_values do |data|
|
|
46
|
+
(data[:ratios].sum / data[:count]).round(1) if data[:count].positive?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def total_processed
|
|
51
|
+
@cache_hits + @cache_misses
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def cache_hit_rate
|
|
55
|
+
return 0.0 if total_processed.zero?
|
|
56
|
+
|
|
57
|
+
(@cache_hits.to_f / total_processed * 100).round(1)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{
|
|
62
|
+
cache_hits: @cache_hits,
|
|
63
|
+
cache_misses: @cache_misses,
|
|
64
|
+
cache_hit_rate: cache_hit_rate,
|
|
65
|
+
total_processed: total_processed,
|
|
66
|
+
operation_timings: @operation_timings.transform_values { |v| v.round(3) },
|
|
67
|
+
compression_ratios: average_compression_ratios
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def reset
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@cache_hits = 0
|
|
74
|
+
@cache_misses = 0
|
|
75
|
+
@operation_timings.clear
|
|
76
|
+
@compression_ratios.clear
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -12,6 +12,9 @@ module JekyllImgFlow
|
|
|
12
12
|
self.class.discover_providers unless self.class.providers_discovered?
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Files in providers/ that are not provider classes (modules, helpers)
|
|
16
|
+
NON_PROVIDER_FILES = %w[base_provider.rb].freeze
|
|
17
|
+
|
|
15
18
|
# Dynamic provider discovery
|
|
16
19
|
def self.discover_providers
|
|
17
20
|
return if @providers_discovered
|
|
@@ -20,7 +23,8 @@ module JekyllImgFlow
|
|
|
20
23
|
providers_dir = File.join(File.dirname(__FILE__), "providers")
|
|
21
24
|
|
|
22
25
|
Dir.glob(File.join(providers_dir, "*.rb")).each do |file|
|
|
23
|
-
|
|
26
|
+
basename = File.basename(file)
|
|
27
|
+
next if NON_PROVIDER_FILES.include?(basename)
|
|
24
28
|
|
|
25
29
|
provider_name = File.basename(file, ".rb")
|
|
26
30
|
register_provider(provider_name)
|
|
@@ -72,9 +76,20 @@ module JekyllImgFlow
|
|
|
72
76
|
end
|
|
73
77
|
end
|
|
74
78
|
|
|
75
|
-
# Get current provider (first available from priority list)
|
|
79
|
+
# Get current provider (first available from priority list).
|
|
80
|
+
# If no provider is available, logs a warning and returns nil.
|
|
81
|
+
# The caller should check for nil and handle gracefully.
|
|
76
82
|
def current_provider
|
|
77
|
-
providers.find(&:available?)
|
|
83
|
+
provider = providers.find(&:available?)
|
|
84
|
+
return provider if provider
|
|
85
|
+
|
|
86
|
+
configured = @config.backend_priority.join(", ")
|
|
87
|
+
Jekyll.logger.warn "ImgFlow:",
|
|
88
|
+
"No available image provider found. " \
|
|
89
|
+
"Configured backends: #{configured}. " \
|
|
90
|
+
"Install one of: sharp, libvips, imagemagick, " \
|
|
91
|
+
"or start the Docker HTTP providers."
|
|
92
|
+
nil
|
|
78
93
|
end
|
|
79
94
|
|
|
80
95
|
# Get list of available provider names
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "English"
|
|
4
3
|
require "open3"
|
|
5
4
|
require "pathname"
|
|
6
5
|
|
|
@@ -158,6 +157,13 @@ module JekyllImgFlow
|
|
|
158
157
|
# Helper methods
|
|
159
158
|
protected
|
|
160
159
|
|
|
160
|
+
# Check if the input file is an SVG (vector format).
|
|
161
|
+
# SVGs require special handling: they have no fixed pixel dimensions,
|
|
162
|
+
# so providers must choose a rasterization size.
|
|
163
|
+
def svg?(input_path)
|
|
164
|
+
File.extname(input_path).downcase == ".svg"
|
|
165
|
+
end
|
|
166
|
+
|
|
161
167
|
def execute_command(command)
|
|
162
168
|
stdout, stderr, status = Open3.capture3(command)
|
|
163
169
|
raise "Command failed: #{command}\nError: #{stderr.strip}" unless status.success?
|
|
@@ -185,11 +191,16 @@ module JekyllImgFlow
|
|
|
185
191
|
end
|
|
186
192
|
|
|
187
193
|
# Provider capability methods
|
|
194
|
+
KNOWN_OPERATIONS = %i[crop format opacity optimize quality resize
|
|
195
|
+
watermark alpha_opacity].freeze
|
|
196
|
+
|
|
188
197
|
def unsupported_operations
|
|
189
198
|
[] # Default: no unsupported operations
|
|
190
199
|
end
|
|
191
200
|
|
|
192
201
|
def supports_operation?(operation)
|
|
202
|
+
return false unless KNOWN_OPERATIONS.include?(operation)
|
|
203
|
+
|
|
193
204
|
!unsupported_operations.include?(operation)
|
|
194
205
|
end
|
|
195
206
|
end
|
|
@@ -8,6 +8,10 @@ module JekyllImgFlow
|
|
|
8
8
|
module Providers
|
|
9
9
|
# ImageMagick provider implementation using the standardized tag interface
|
|
10
10
|
class Imagemagick < BaseProvider
|
|
11
|
+
# Cache rsvg delegate check across all instances (checked once per build)
|
|
12
|
+
@rsvg_available = nil
|
|
13
|
+
@svg_warning_shown = false
|
|
14
|
+
|
|
11
15
|
def available?
|
|
12
16
|
# Check if magick or convert CLI is available
|
|
13
17
|
_, _, status1 = Open3.capture3("which", "magick")
|
|
@@ -18,17 +22,30 @@ module JekyllImgFlow
|
|
|
18
22
|
def execute(input_path, output_path)
|
|
19
23
|
return if @operations.empty?
|
|
20
24
|
|
|
25
|
+
# Warn once per build about SVG performance with ImageMagick
|
|
26
|
+
warn_svg_performance if svg?(input_path) && !self.class.instance_variable_get(:@svg_warning_shown)
|
|
27
|
+
|
|
21
28
|
# Build single ImageMagick command with all operations combined
|
|
22
29
|
command = build_combined_imagemagick_command(input_path, output_path)
|
|
23
30
|
execute_command(command)
|
|
24
31
|
|
|
25
|
-
reset_operations
|
|
26
32
|
output_path
|
|
33
|
+
ensure
|
|
34
|
+
reset_operations
|
|
27
35
|
end
|
|
28
36
|
|
|
29
37
|
def build_combined_imagemagick_command(input_path, output_path)
|
|
30
|
-
# Start with base command
|
|
31
|
-
|
|
38
|
+
# Start with base command.
|
|
39
|
+
# For SVGs, set -density before the input so ImageMagick rasterizes
|
|
40
|
+
# at a reasonable resolution instead of the full viewBox (which can
|
|
41
|
+
# be 10000x8500 = 85M pixels, making each conversion take 10+ seconds).
|
|
42
|
+
# With the rsvg delegate installed, -density controls the render DPI.
|
|
43
|
+
# Without rsvg, ImageMagick uses its slow internal MSVG parser.
|
|
44
|
+
command_parts = if svg?(input_path)
|
|
45
|
+
["magick", "-density", svg_density.to_s, input_path.shellescape]
|
|
46
|
+
else
|
|
47
|
+
["magick", input_path.shellescape]
|
|
48
|
+
end
|
|
32
49
|
|
|
33
50
|
# Add all operations
|
|
34
51
|
@operations.each do |operation|
|
|
@@ -133,6 +150,58 @@ module JekyllImgFlow
|
|
|
133
150
|
# ImageMagick uses 1-100 directly, no translation needed
|
|
134
151
|
quality
|
|
135
152
|
end
|
|
153
|
+
|
|
154
|
+
# Choose a rasterization density (DPI) for SVG input.
|
|
155
|
+
# ImageMagick's internal SVG parser renders at the full viewBox size
|
|
156
|
+
# (e.g. 10000x8500 = 85M pixels) before applying -resize, which is
|
|
157
|
+
# extremely slow. Setting -density before the input tells the rsvg
|
|
158
|
+
# delegate to render at a lower resolution.
|
|
159
|
+
# We target ~2x the largest resize dimension for good quality,
|
|
160
|
+
# assuming a ~10 inch viewBox (common for SVGs). This avoids the
|
|
161
|
+
# massive internal canvas while preserving output quality.
|
|
162
|
+
# For a 400px output: density = 80px/in / 10in = 8 DPI → renders at
|
|
163
|
+
# ~800px instead of 10000px, giving ~10x speedup.
|
|
164
|
+
def svg_density
|
|
165
|
+
max_width = @operations.filter_map { |op| op[:type] == :resize && op[:width] }.max
|
|
166
|
+
# Default to 36 DPI if no resize (e.g. format-only conversion)
|
|
167
|
+
return 36 unless max_width
|
|
168
|
+
|
|
169
|
+
# Target 2x output width for quality. Assume ~10 inch viewBox.
|
|
170
|
+
# No upper cap — even 2000px output only needs 40 DPI.
|
|
171
|
+
(max_width * 2 / 10).to_i
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Check if ImageMagick has the rsvg delegate installed.
|
|
175
|
+
# Without it, ImageMagick uses its slow internal MSVG parser.
|
|
176
|
+
# Install with: macOS: `brew install librsvg`, Ubuntu: `apt install librsvg2-bin`
|
|
177
|
+
def rsvg_available?
|
|
178
|
+
return self.class.instance_variable_get(:@rsvg_available) unless self.class.instance_variable_get(:@rsvg_available).nil?
|
|
179
|
+
|
|
180
|
+
stdout, _, status = Open3.capture3("magick", "-list", "delegate")
|
|
181
|
+
result = status.success? && stdout.include?("rsvg-convert")
|
|
182
|
+
self.class.instance_variable_set(:@rsvg_available, result)
|
|
183
|
+
result
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Warn once per build about SVG performance with ImageMagick.
|
|
187
|
+
# Suggests installing rsvg-convert or using a different provider.
|
|
188
|
+
def warn_svg_performance
|
|
189
|
+
return if self.class.instance_variable_get(:@svg_warning_shown)
|
|
190
|
+
|
|
191
|
+
self.class.instance_variable_set(:@svg_warning_shown, true)
|
|
192
|
+
if rsvg_available?
|
|
193
|
+
Jekyll.logger.info "ImgFlow:",
|
|
194
|
+
"ImageMagick processing SVG with rsvg delegate " \
|
|
195
|
+
"(density=#{svg_density})."
|
|
196
|
+
else
|
|
197
|
+
Jekyll.logger.warn "ImgFlow:",
|
|
198
|
+
"ImageMagick is processing SVGs without the rsvg " \
|
|
199
|
+
"delegate — this is VERY slow. Install librsvg " \
|
|
200
|
+
"(macOS: `brew install librsvg`, " \
|
|
201
|
+
"Ubuntu: `apt install librsvg2-bin`) or use a " \
|
|
202
|
+
"different provider (sharp/libvips) for SVGs."
|
|
203
|
+
end
|
|
204
|
+
end
|
|
136
205
|
end
|
|
137
206
|
end
|
|
138
207
|
end
|