jekyll-imgflow 0.1.10 → 0.3.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 +22 -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 +6 -7
- 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 +19 -5
- data/lib/jekyll-imgflow/html_generator.rb +22 -20
- data/lib/jekyll-imgflow/imgflow_tag.rb +86 -70
- data/lib/jekyll-imgflow/manifest_manager.rb +132 -53
- data/lib/jekyll-imgflow/modal_assets.rb +107 -0
- data/lib/jekyll-imgflow/operation_processor.rb +43 -22
- data/lib/jekyll-imgflow/parser.rb +1 -1
- 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 +3 -0
- metadata +25 -157
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "filename_generator"
|
|
4
|
+
require "benchmark"
|
|
4
5
|
|
|
5
6
|
module JekyllImgFlow
|
|
6
7
|
# OperationProcessor - processes image operations using providers
|
|
7
8
|
# Handles both single operations and batch operations
|
|
8
9
|
class OperationProcessor
|
|
10
|
+
attr_accessor :stats
|
|
11
|
+
|
|
9
12
|
def initialize(provider, path_resolver, manifest = nil, config = nil)
|
|
10
13
|
@provider = provider
|
|
11
14
|
@path_resolver = path_resolver
|
|
12
15
|
@filename_generator = FilenameGenerator.new
|
|
13
16
|
@manifest = manifest
|
|
14
17
|
@config = config
|
|
18
|
+
@stats = ProcessingStats.new
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# Process a single operation on an image
|
|
@@ -41,6 +45,7 @@ module JekyllImgFlow
|
|
|
41
45
|
def process_operation(original_name, operation, input_path, page_path = nil)
|
|
42
46
|
type = operation[:type]
|
|
43
47
|
params = operation[:params]
|
|
48
|
+
file_digest = operation[:file_digest] || @filename_generator.file_digest(input_path)
|
|
44
49
|
|
|
45
50
|
# Determine version type
|
|
46
51
|
version_type = determine_version_type(params)
|
|
@@ -58,17 +63,37 @@ module JekyllImgFlow
|
|
|
58
63
|
# Ensure output directory exists before processing
|
|
59
64
|
FileUtils.mkdir_p(File.dirname(actual_output_path))
|
|
60
65
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
66
|
+
# Skip processing if the output file already exists and is up-to-date.
|
|
67
|
+
# This handles legacy manifests, corrupted manifests, and any case where
|
|
68
|
+
# optimized files exist on disk but the manifest is out of sync — the
|
|
69
|
+
# version is registered in the manifest without re-running the provider.
|
|
70
|
+
if !operation[:force_processing] && output_up_to_date?(input_path, actual_output_path)
|
|
71
|
+
Jekyll.logger.debug "⏭️ ImgFlow: Output exists and up-to-date, " \
|
|
72
|
+
"skipping provider call for #{original_name}"
|
|
73
|
+
@stats.record_cache_hit
|
|
74
|
+
elsif AnimatedGifDetector.animated?(input_path)
|
|
75
|
+
# Animated GIFs must not be resized or converted — the operation
|
|
76
|
+
# would destroy the animation. Copy the original file as-is so the
|
|
77
|
+
# manifest can track it and HTML references stay valid.
|
|
65
78
|
Jekyll.logger.warn "🖼️ ImgFlow: Skipping resize for animated GIF " \
|
|
66
79
|
"'#{original_name}' — copying original as-is to " \
|
|
67
80
|
"preserve animation."
|
|
68
81
|
FileUtils.cp(input_path, actual_output_path)
|
|
82
|
+
@stats.record_cache_miss
|
|
69
83
|
else
|
|
70
84
|
# Process the operation (create the image)
|
|
71
|
-
|
|
85
|
+
elapsed = Benchmark.measure do
|
|
86
|
+
process_single_operation(type, input_path, actual_output_path, params)
|
|
87
|
+
end
|
|
88
|
+
@stats.record_operation_time(type, elapsed.real)
|
|
89
|
+
@stats.record_cache_miss
|
|
90
|
+
|
|
91
|
+
# Record compression ratio if we have the original size
|
|
92
|
+
if File.file?(input_path) && File.file?(actual_output_path)
|
|
93
|
+
format = params[:format] || File.extname(input_path).delete(".")
|
|
94
|
+
@stats.record_compression_ratio(format.to_s, File.size(input_path),
|
|
95
|
+
File.size(actual_output_path))
|
|
96
|
+
end
|
|
72
97
|
end
|
|
73
98
|
|
|
74
99
|
# Register in manifest
|
|
@@ -76,14 +101,14 @@ module JekyllImgFlow
|
|
|
76
101
|
# Store relative path (with leading /) for manifest storage
|
|
77
102
|
relative_path = "/#{@path_resolver.resolve_relative_output_path(filename, subdir)}"
|
|
78
103
|
|
|
79
|
-
provider_name = @provider
|
|
104
|
+
provider_name = @provider&.class&.provider_name || "unknown"
|
|
80
105
|
@manifest.register_version(
|
|
81
106
|
original_name,
|
|
82
107
|
relative_path,
|
|
83
108
|
params,
|
|
84
109
|
version_type,
|
|
85
110
|
page_path,
|
|
86
|
-
|
|
111
|
+
file_digest,
|
|
87
112
|
provider_name
|
|
88
113
|
)
|
|
89
114
|
end
|
|
@@ -170,26 +195,22 @@ module JekyllImgFlow
|
|
|
170
195
|
# @param output_path [String] Path to output image
|
|
171
196
|
# @param operations [Hash] Operations to apply
|
|
172
197
|
# @return [Boolean] True if processing needed
|
|
173
|
-
def needs_processing?(input_path, output_path,
|
|
198
|
+
def needs_processing?(input_path, output_path, _operations = nil)
|
|
174
199
|
# If output doesn't exist, needs processing
|
|
175
200
|
return true unless File.exist?(output_path)
|
|
176
201
|
|
|
177
202
|
# If input is newer than output, needs processing
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
# Check if operations changed by comparing cache key
|
|
181
|
-
# stored alongside the output file
|
|
182
|
-
cache_key = @filename_generator.generate_cache_key(operations)
|
|
183
|
-
cache_file = "#{output_path}.cache_key"
|
|
184
|
-
|
|
185
|
-
return true unless File.exist?(cache_file)
|
|
186
|
-
|
|
187
|
-
stored_key = File.read(cache_file).strip
|
|
188
|
-
return true if stored_key != cache_key
|
|
189
|
-
|
|
190
|
-
# No cache key file - needs processing to create it
|
|
203
|
+
File.mtime(input_path) > File.mtime(output_path)
|
|
204
|
+
end
|
|
191
205
|
|
|
192
|
-
|
|
206
|
+
# Check if the output file exists and the input is not newer than the
|
|
207
|
+
# output. Used to skip provider calls when the optimized file is already
|
|
208
|
+
# on disk (e.g. legacy manifest, manifest out of sync).
|
|
209
|
+
# @param input_path [String] Path to input image
|
|
210
|
+
# @param output_path [String] Path to output image
|
|
211
|
+
# @return [Boolean] True if output exists and is up-to-date
|
|
212
|
+
def output_up_to_date?(input_path, output_path)
|
|
213
|
+
File.file?(output_path) && File.mtime(input_path) <= File.mtime(output_path)
|
|
193
214
|
end
|
|
194
215
|
|
|
195
216
|
# Build operation structure from params hash
|
|
@@ -9,7 +9,7 @@ module JekyllImgFlow
|
|
|
9
9
|
# Define valid operation parameters and attribute types
|
|
10
10
|
OPERATION_PARAMS = %i[width height ratio aspect_ratio quality format formats
|
|
11
11
|
optimize level watermark opacity position keep preset].freeze
|
|
12
|
-
HTML_ATTRIBUTES = %i[alt class title loading].freeze
|
|
12
|
+
HTML_ATTRIBUTES = %i[alt class title loading link modal].freeze
|
|
13
13
|
|
|
14
14
|
# Simple operation mappings for single-parameter operations
|
|
15
15
|
SIMPLE_OPERATIONS = {
|
|
@@ -43,7 +43,7 @@ module JekyllImgFlow
|
|
|
43
43
|
# @return [Hash] Translation result with markup and attributes
|
|
44
44
|
def translate_to_imgflow(picture_markup)
|
|
45
45
|
# Extract content between {% picture ... %}
|
|
46
|
-
match = picture_markup.match(/\{%\s*picture\s+(.+?)
|
|
46
|
+
match = picture_markup.match(/\{%\s*picture\s+(.+?)%\}/)
|
|
47
47
|
return { markup: "", attributes: {} } unless match
|
|
48
48
|
|
|
49
49
|
content = match[1].strip
|
|
@@ -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
|