jekyll-imgflow 0.3.2 → 0.3.3
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 +11 -2
- data/lib/jekyll-imgflow/build_time_processor.rb +3 -2
- data/lib/jekyll-imgflow/config.rb +25 -18
- data/lib/jekyll-imgflow/filename_generator.rb +2 -0
- data/lib/jekyll-imgflow/html_generator.rb +6 -6
- data/lib/jekyll-imgflow/imgflow_tag.rb +86 -52
- data/lib/jekyll-imgflow/manifest_manager.rb +64 -57
- data/lib/jekyll-imgflow/operation_processor.rb +44 -64
- data/lib/jekyll-imgflow/picture_tag_adaptor.rb +113 -106
- data/lib/jekyll-imgflow/providers/base_provider.rb +1 -0
- data/lib/jekyll-imgflow/providers/flyimg.rb +1 -1
- data/lib/jekyll-imgflow/providers/libvips.rb +75 -80
- data/lib/jekyll-imgflow/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d59e73749066617a2c71322c3c80c5161ea847872a7864cf65c9133a8a315e24
|
|
4
|
+
data.tar.gz: 861a4df0428da9bdd3221b89838ebb4d85d598172465569b5cdc28e14f243ede
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aae0d3bb14c346d432881a29844b1221225599dd77767d087faccc14023f0d527fcc36c4eeb0e5a1df4fa9a87f86276ea42388e2364f7158f12af8ba0512d459
|
|
7
|
+
data.tar.gz: ba868a71465128a9f6f7259ad2852b7b16093b30496fd9a2e9d9f259e811908b2541b3f14a002475201a26576236921f76dbd52782797facb2d4a27e496c81de
|
data/README.md
CHANGED
|
@@ -6,6 +6,8 @@ A [Jekyll](https://jekyllrb.com/) plugin for automatic image optimization with m
|
|
|
6
6
|
|
|
7
7
|
[](https://deepwiki.com/gundestrup/jekyll-imgflow)
|
|
8
8
|
[](https://github.com/gundestrup/jekyll-imgflow)
|
|
9
|
+
[](https://semgrep.dev/)
|
|
10
|
+
[](https://www.codefactor.io/repository/github/gundestrup/jekyll-imgflow)
|
|
9
11
|
[](https://codecov.io/gh/gundestrup/jekyll-imgflow)
|
|
10
12
|
[](https://github.com/gundestrup/jekyll-imgflow-vscode)
|
|
11
13
|
[](LICENSE)
|
|
@@ -202,8 +204,15 @@ bundle install
|
|
|
202
204
|
# Download test images
|
|
203
205
|
rake download_test_images
|
|
204
206
|
|
|
205
|
-
# Run
|
|
206
|
-
rake
|
|
207
|
+
# Run the CI-equivalent checks locally
|
|
208
|
+
rake ci
|
|
209
|
+
|
|
210
|
+
# Run the local Semgrep Pro rules directly
|
|
211
|
+
semgrep scan --pro --config .semgrep.yml --error lib/
|
|
212
|
+
|
|
213
|
+
# Sync the repository scan and findings with the Semgrep dashboard
|
|
214
|
+
# (requires semgrep login or SEMGREP_APP_TOKEN)
|
|
215
|
+
semgrep ci
|
|
207
216
|
```
|
|
208
217
|
|
|
209
218
|
### Key Files
|
|
@@ -78,8 +78,9 @@ module JekyllImgFlow
|
|
|
78
78
|
results = @batch_manager.process_all
|
|
79
79
|
register_skipped_tasks(@batch_manager.completed)
|
|
80
80
|
|
|
81
|
-
#
|
|
82
|
-
|
|
81
|
+
# Persist newly processed defaults; unchanged builds keep page-usage resets in memory
|
|
82
|
+
# until post_write saves the final stable manifest.
|
|
83
|
+
@manifest.save unless results[:completed].zero? && results[:failed].zero?
|
|
83
84
|
|
|
84
85
|
Jekyll.logger.info "✅ BuildTimeProcessor complete: #{results[:completed]} completed, #{results[:failed]} failed"
|
|
85
86
|
|
|
@@ -28,33 +28,40 @@ module JekyllImgFlow
|
|
|
28
28
|
:optimize_qualities, :fallback_format, :image_modal
|
|
29
29
|
|
|
30
30
|
def initialize(site)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
cfg
|
|
34
|
-
|
|
35
|
-
@
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@
|
|
41
|
-
@
|
|
31
|
+
@site = site
|
|
32
|
+
cfg = merged_config
|
|
33
|
+
assign_core_config(cfg)
|
|
34
|
+
assign_provider_config(cfg)
|
|
35
|
+
@optimize_qualities = cfg["optimize_qualities"] || default_optimize_qualities
|
|
36
|
+
validate_backend_priority!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def assign_core_config(cfg)
|
|
40
|
+
@originals = cfg["originals"] || DEFAULT_ORIGINALS
|
|
41
|
+
@output = cfg["output"] || DEFAULT_OUTPUT
|
|
42
|
+
@input_formats = cfg["input_formats"] || DEFAULT_INPUT_FORMATS
|
|
43
|
+
@sizes = cfg["sizes"] || DEFAULT_SIZES
|
|
44
|
+
@formats = cfg["formats"] || DEFAULT_FORMATS
|
|
45
|
+
@quality = cfg["quality"] || DEFAULT_QUALITY
|
|
42
46
|
@backend_priority = cfg["backend_priority"] || DEFAULT_BACKEND_PRIORITY
|
|
43
|
-
@fallback_format
|
|
44
|
-
@image_modal
|
|
47
|
+
@fallback_format = cfg["fallback_format"] || DEFAULT_FALLBACK_FORMAT
|
|
48
|
+
@image_modal = cfg.fetch("image_modal", DEFAULT_IMAGE_MODAL)
|
|
49
|
+
end
|
|
45
50
|
|
|
51
|
+
def assign_provider_config(cfg)
|
|
46
52
|
@imgproxy_url = cfg["imgproxy_url"]
|
|
47
|
-
@weserv_url
|
|
48
|
-
@flyimg_url
|
|
49
|
-
|
|
53
|
+
@weserv_url = cfg["weserv_url"]
|
|
54
|
+
@flyimg_url = cfg["flyimg_url"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def default_optimize_qualities
|
|
58
|
+
{
|
|
50
59
|
"low" => 30,
|
|
51
60
|
"medium" => 50, # Uses default quality
|
|
52
61
|
"high" => 85, # Uses default quality
|
|
53
62
|
"maximum" => 95,
|
|
54
63
|
"default" => 75
|
|
55
64
|
}
|
|
56
|
-
|
|
57
|
-
validate_backend_priority!
|
|
58
65
|
end
|
|
59
66
|
|
|
60
67
|
def cache_dir
|
|
@@ -83,6 +83,7 @@ module JekyllImgFlow
|
|
|
83
83
|
def generate_jpt_hash(original_path, operations)
|
|
84
84
|
# Get filename digest (MD5 of filename) - exactly like JPT
|
|
85
85
|
filename = File.basename(original_path)
|
|
86
|
+
# nosemgrep: ruby.lang.security.weak-hashes-md5.weak-hashes-md5 -- Jekyll Picture Tag filename compatibility, not security.
|
|
86
87
|
file_digest = Digest::MD5.hexdigest(filename)
|
|
87
88
|
|
|
88
89
|
# Build settings array (like Jekyll Picture Tag)
|
|
@@ -94,6 +95,7 @@ module JekyllImgFlow
|
|
|
94
95
|
]
|
|
95
96
|
|
|
96
97
|
# Generate MD5 hash of settings (9 chars like JPT)
|
|
98
|
+
# nosemgrep: ruby.lang.security.weak-hashes-md5.weak-hashes-md5 -- Jekyll Picture Tag filename compatibility, not security.
|
|
97
99
|
Digest::MD5.hexdigest(settings.join)[0..8]
|
|
98
100
|
end
|
|
99
101
|
end
|
|
@@ -76,15 +76,15 @@ module JekyllImgFlow
|
|
|
76
76
|
return default_attributes if attributes.nil? || attributes.empty?
|
|
77
77
|
|
|
78
78
|
{
|
|
79
|
-
img: attributes
|
|
80
|
-
picture: attributes
|
|
81
|
-
source: attributes
|
|
82
|
-
a: attributes
|
|
83
|
-
parent: attributes
|
|
79
|
+
img: attributes.fetch(:img, {}),
|
|
80
|
+
picture: attributes.fetch(:picture, {}),
|
|
81
|
+
source: attributes.fetch(:source, {}),
|
|
82
|
+
a: attributes.fetch(:a, {}),
|
|
83
|
+
parent: attributes.fetch(:parent, {}),
|
|
84
84
|
alt: attributes[:alt],
|
|
85
85
|
link: attributes[:link],
|
|
86
86
|
modal: attributes[:modal],
|
|
87
|
-
modal_results: attributes
|
|
87
|
+
modal_results: attributes.fetch(:modal_results, [])
|
|
88
88
|
}
|
|
89
89
|
end
|
|
90
90
|
|
|
@@ -73,46 +73,48 @@ module Jekyll
|
|
|
73
73
|
return "" unless parsed[:image_path]
|
|
74
74
|
|
|
75
75
|
site = context.registers[:site]
|
|
76
|
-
|
|
76
|
+
input_path = resolve_input_path(parsed[:image_path], site, components[:config])
|
|
77
|
+
original_name = original_image_name(input_path, site, components[:config])
|
|
78
|
+
page_path = page_identifier(context.registers[:page])
|
|
79
|
+
results, modal_results = process_image_variants(components, parsed, original_name, input_path,
|
|
80
|
+
page_path)
|
|
81
|
+
render_processed_results(results, modal_results, parsed, site, context)
|
|
82
|
+
end
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
input_path = resolve_image_path(
|
|
84
|
+
def resolve_input_path(image_path, site, config)
|
|
85
|
+
input_path = resolve_image_path(image_path, site, config)
|
|
80
86
|
raise ArgumentError, "Input image file not found: #{input_path}" unless File.file?(input_path)
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
originals_dir = File.join(site.source, components[:config].originals)
|
|
85
|
-
original_name = input_path.sub("#{originals_dir}/", "")
|
|
88
|
+
input_path
|
|
89
|
+
end
|
|
86
90
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
else
|
|
92
|
-
"unknown"
|
|
93
|
-
end
|
|
91
|
+
def original_image_name(input_path, site, config)
|
|
92
|
+
originals_dir = File.join(site.source, config.originals)
|
|
93
|
+
input_path.sub("#{originals_dir}/", "")
|
|
94
|
+
end
|
|
94
95
|
|
|
96
|
+
def page_identifier(page)
|
|
97
|
+
(page && (page["path"] || page["url"] || page["name"])) || "unknown"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def process_image_variants(components, parsed, original_name, input_path, page_path)
|
|
95
101
|
operations = parsed[:operations]
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
process_modal_variants(components, operations.first, parsed, original_name,
|
|
107
|
-
input_path, page_path)
|
|
108
|
-
end
|
|
102
|
+
return [[input_path], []] if operations.empty?
|
|
103
|
+
|
|
104
|
+
operation = operations.first
|
|
105
|
+
results = process_variants(components, operation, original_name, input_path, page_path)
|
|
106
|
+
modal_results = process_modal_variants(components, operation, parsed, original_name,
|
|
107
|
+
input_path, page_path)
|
|
108
|
+
[results, modal_results]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def render_processed_results(results, modal_results, parsed, site, context)
|
|
109
112
|
relative_results = results.uniq.map { |result| relative_result_path(result, site) }
|
|
110
113
|
relative_modal_results = modal_results.uniq.map do |result|
|
|
111
114
|
relative_result_path(result, site)
|
|
112
115
|
end
|
|
113
116
|
parsed = parsed.merge(markup_format: "picture") if relative_results.length > 1
|
|
114
|
-
|
|
115
|
-
generate_html(relative_results, parsed, context)
|
|
117
|
+
generate_html(relative_results, parsed.merge(modal_results: relative_modal_results), context)
|
|
116
118
|
end
|
|
117
119
|
|
|
118
120
|
def process_modal_variants(components, operation, parsed, original_name, input_path, page_path)
|
|
@@ -123,18 +125,22 @@ module Jekyll
|
|
|
123
125
|
return [] unless original_width
|
|
124
126
|
|
|
125
127
|
params = operation[:params].dup
|
|
126
|
-
formats =
|
|
127
|
-
|
|
128
|
+
formats = modal_formats(params, config)
|
|
129
|
+
params.delete(:formats)
|
|
128
130
|
modal_width = [original_width, config.sizes.values.max].min
|
|
129
|
-
|
|
130
131
|
formats.map do |format|
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
process_variant(components, operation, params.merge(width: modal_width, format: format),
|
|
133
|
+
original_name, input_path, page_path)
|
|
133
134
|
end
|
|
134
135
|
rescue FastImage::ImageFetchError, FastImage::UnknownImageType
|
|
135
136
|
[]
|
|
136
137
|
end
|
|
137
138
|
|
|
139
|
+
def modal_formats(params, config)
|
|
140
|
+
formats = Array(params[:formats] || params[:format])
|
|
141
|
+
formats.empty? ? config.formats : formats
|
|
142
|
+
end
|
|
143
|
+
|
|
138
144
|
def modal_requested?(parsed, config)
|
|
139
145
|
return false if %w[direct_url naked_srcset].include?(parsed[:markup_format])
|
|
140
146
|
|
|
@@ -164,35 +170,63 @@ module Jekyll
|
|
|
164
170
|
|
|
165
171
|
def process_variant(components, operation, params, original_name, input_path, page_path)
|
|
166
172
|
config = components[:config]
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
173
|
+
apply_default_params(params, config)
|
|
174
|
+
variant = variant_context(components, params, original_name, input_path, config)
|
|
175
|
+
|
|
176
|
+
if variant_cached?(components, original_name, params, variant[:version_type],
|
|
177
|
+
variant[:digest], variant[:output_path])
|
|
178
|
+
return cached_variant(components, original_name, params, variant[:version_type],
|
|
179
|
+
variant[:digest], variant[:output_path], page_path)
|
|
170
180
|
end
|
|
171
181
|
|
|
182
|
+
process_variant_output(components, operation, params, variant, original_name, input_path,
|
|
183
|
+
page_path)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def variant_context(components, params, original_name, input_path, config)
|
|
172
187
|
digest = components[:filename_generator].file_digest(input_path)
|
|
173
|
-
variant = operation.merge(params: params, file_digest: digest)
|
|
174
|
-
subdir = File.dirname(original_name)
|
|
175
|
-
subdir = nil if subdir == "."
|
|
176
188
|
filename = components[:filename_generator].generate_filename(input_path, params)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return output_path
|
|
185
|
-
end
|
|
189
|
+
subdir = output_subdir(original_name)
|
|
190
|
+
{
|
|
191
|
+
digest: digest,
|
|
192
|
+
output_path: components[:path_resolver].resolve_source_output_path(filename, subdir),
|
|
193
|
+
version_type: determine_version_type(params, config)
|
|
194
|
+
}
|
|
195
|
+
end
|
|
186
196
|
|
|
197
|
+
def process_variant_output(components, operation, params, variant, original_name, input_path,
|
|
198
|
+
page_path)
|
|
187
199
|
unless components[:operation_processor]
|
|
188
200
|
Jekyll.logger.warn "ImgFlow:", "No image provider available — " \
|
|
189
201
|
"rendering original without optimization."
|
|
190
202
|
return input_path
|
|
191
203
|
end
|
|
192
204
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
205
|
+
operation_params = operation.merge(params: params, file_digest: variant[:digest],
|
|
206
|
+
force_processing: true)
|
|
207
|
+
components[:operation_processor].process_operation(original_name, operation_params, input_path,
|
|
208
|
+
page_path)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def apply_default_params(params, config)
|
|
212
|
+
return unless determine_version_type(params, config) == :default
|
|
213
|
+
|
|
214
|
+
params[:format] ||= config.formats.first
|
|
215
|
+
params[:quality] ||= config.quality
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def output_subdir(original_name)
|
|
219
|
+
File.dirname(original_name).then { |dir| dir == "." ? nil : dir }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def variant_cached?(components, original_name, params, version_type, digest, output_path)
|
|
223
|
+
components[:manifest].version_exists?(original_name, params, version_type, digest) && File.file?(output_path)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def cached_variant(components, original_name, params, version_type, _digest, output_path, page_path)
|
|
227
|
+
components[:stats]&.record_cache_hit
|
|
228
|
+
components[:manifest].update_page_usage(original_name, params, version_type, page_path)
|
|
229
|
+
output_path
|
|
196
230
|
end
|
|
197
231
|
|
|
198
232
|
def relative_result_path(result, site)
|
|
@@ -25,32 +25,35 @@ module JekyllImgFlow
|
|
|
25
25
|
|
|
26
26
|
# Load existing manifest or create new one
|
|
27
27
|
def load_manifest(path = @manifest_path)
|
|
28
|
-
|
|
29
|
-
begin
|
|
30
|
-
data = JSON.parse(File.read(path))
|
|
31
|
-
|
|
32
|
-
# Handle new format with provider at top level
|
|
33
|
-
if data.is_a?(Hash) && data.key?("images")
|
|
34
|
-
@cached_provider = data["provider"]
|
|
35
|
-
images = data["images"] || {}
|
|
36
|
-
else
|
|
37
|
-
# Old format - just image data
|
|
38
|
-
@cached_provider = nil
|
|
39
|
-
images = data
|
|
40
|
-
end
|
|
28
|
+
return empty_manifest unless path && File.exist?(path)
|
|
41
29
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
30
|
+
load_manifest_file(path)
|
|
31
|
+
rescue JSON::ParserError => e
|
|
32
|
+
Jekyll.logger.warn "ImgFlow: Corrupt manifest file, starting fresh: #{e.message}"
|
|
33
|
+
empty_manifest
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load_manifest_file(path)
|
|
37
|
+
data = JSON.parse(File.read(path))
|
|
38
|
+
images = manifest_images(data)
|
|
39
|
+
migrate_legacy_manifest(images)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def manifest_images(data)
|
|
43
|
+
if data.is_a?(Hash) && data.key?("images")
|
|
44
|
+
@cached_provider = data["provider"]
|
|
45
|
+
data["images"] || {}
|
|
48
46
|
else
|
|
49
47
|
@cached_provider = nil
|
|
50
|
-
|
|
48
|
+
data
|
|
51
49
|
end
|
|
52
50
|
end
|
|
53
51
|
|
|
52
|
+
def empty_manifest
|
|
53
|
+
@cached_provider = nil
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
|
|
54
57
|
# Detect and migrate legacy manifest entries (pre-0.1.11 format).
|
|
55
58
|
# Old entries have operations like {"width": 400} without format/quality
|
|
56
59
|
# and no file_digest. Clearing them lets the build re-register existing
|
|
@@ -232,51 +235,55 @@ module JekyllImgFlow
|
|
|
232
235
|
# Register a new image version
|
|
233
236
|
def register_version(original_name, output_path, operations, type, page_path,
|
|
234
237
|
file_digest = nil, provider = nil)
|
|
238
|
+
image = manifest_image(original_name, file_digest)
|
|
239
|
+
image["file_digest"] = file_digest if file_digest
|
|
240
|
+
versions = image["versions"][type.to_s] ||= []
|
|
241
|
+
normalized = normalize_operations(operations)
|
|
242
|
+
page_paths = normalize_page_paths(page_path)
|
|
243
|
+
version = versions.find { |entry| same_operations?(entry["operations"], normalized) }
|
|
244
|
+
|
|
245
|
+
if version
|
|
246
|
+
update_version(version, output_path, page_paths, file_digest, provider)
|
|
247
|
+
else
|
|
248
|
+
versions << new_version(output_path, normalized, type, page_paths, file_digest, provider)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def manifest_image(original_name, file_digest)
|
|
235
253
|
@manifest[original_name] ||= {
|
|
236
254
|
"versions" => { "default" => [], "specialized" => [] },
|
|
237
|
-
"file_digest" => file_digest
|
|
255
|
+
"file_digest" => file_digest
|
|
238
256
|
}
|
|
257
|
+
end
|
|
239
258
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
versions = @manifest[original_name]["versions"][type.to_s] ||= []
|
|
244
|
-
normalized_operations = normalize_operations(operations)
|
|
245
|
-
|
|
246
|
-
# Find or create version entry
|
|
247
|
-
version = versions.find { |v| same_operations?(v["operations"], normalized_operations) }
|
|
248
|
-
|
|
249
|
-
# Normalize page_path to array
|
|
250
|
-
page_paths = if page_path.is_a?(Array)
|
|
251
|
-
page_path
|
|
252
|
-
else
|
|
253
|
-
(page_path ? [page_path] : [])
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
if version
|
|
257
|
-
# Add pages to used_on list if not already present
|
|
258
|
-
version["used_on"] ||= []
|
|
259
|
-
page_paths.each do |path|
|
|
260
|
-
version["used_on"] << path if path && !version["used_on"].include?(path)
|
|
261
|
-
end
|
|
262
|
-
version["output"] = output_path
|
|
263
|
-
version["file_digest"] = file_digest if file_digest
|
|
264
|
-
# Update provider if provided
|
|
265
|
-
version["provider"] = provider if provider
|
|
259
|
+
def normalize_page_paths(page_path)
|
|
260
|
+
if page_path.is_a?(Array)
|
|
261
|
+
page_path
|
|
266
262
|
else
|
|
267
|
-
|
|
268
|
-
versions << {
|
|
269
|
-
"output" => output_path,
|
|
270
|
-
"operations" => normalized_operations,
|
|
271
|
-
"type" => type.to_s,
|
|
272
|
-
"used_on" => page_paths,
|
|
273
|
-
"created_at" => Time.now.to_i,
|
|
274
|
-
"file_digest" => file_digest,
|
|
275
|
-
"provider" => provider
|
|
276
|
-
}
|
|
263
|
+
(page_path ? [page_path] : [])
|
|
277
264
|
end
|
|
278
265
|
end
|
|
279
266
|
|
|
267
|
+
def update_version(version, output_path, page_paths, file_digest, provider)
|
|
268
|
+
version["used_on"] ||= []
|
|
269
|
+
page_paths.each { |path| version["used_on"] << path if path && !version["used_on"].include?(path) }
|
|
270
|
+
version["output"] = output_path
|
|
271
|
+
version["file_digest"] = file_digest if file_digest
|
|
272
|
+
version["provider"] = provider if provider
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def new_version(output_path, operations, type, page_paths, file_digest, provider)
|
|
276
|
+
{
|
|
277
|
+
"output" => output_path,
|
|
278
|
+
"operations" => operations,
|
|
279
|
+
"type" => type.to_s,
|
|
280
|
+
"used_on" => page_paths,
|
|
281
|
+
"created_at" => Time.now.to_i,
|
|
282
|
+
"file_digest" => file_digest,
|
|
283
|
+
"provider" => provider
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
|
|
280
287
|
# Check if image version is default type
|
|
281
288
|
def default_version?(original_name, operations)
|
|
282
289
|
return false unless @manifest[original_name]
|
|
@@ -43,84 +43,64 @@ module JekyllImgFlow
|
|
|
43
43
|
# @param page_path [String] Optional page path for manifest tracking
|
|
44
44
|
# @return [String] Path to processed image
|
|
45
45
|
def process_operation(original_name, operation, input_path, page_path = nil)
|
|
46
|
-
type = operation[:type]
|
|
47
46
|
params = operation[:params]
|
|
48
47
|
file_digest = operation[:file_digest] || @filename_generator.file_digest(input_path)
|
|
49
|
-
|
|
50
|
-
# Determine version type
|
|
51
48
|
version_type = determine_version_type(params)
|
|
52
|
-
|
|
53
|
-
# Preserve original directory structure under output
|
|
54
|
-
subdir = File.dirname(original_name)
|
|
55
|
-
subdir = nil if subdir == "."
|
|
56
|
-
|
|
57
|
-
# Generate filename using FilenameGenerator (JPT compatible)
|
|
58
49
|
filename = @filename_generator.generate_filename(input_path, params)
|
|
50
|
+
subdir = output_subdir(original_name)
|
|
51
|
+
output_path = @path_resolver.resolve_source_output_path(filename, subdir)
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
59
53
|
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
process_output(operation, original_name, input_path, output_path, params)
|
|
55
|
+
register_manifest(original_name, filename, params, version_type, page_path, file_digest, subdir)
|
|
56
|
+
register_jekyll_static_file(output_path)
|
|
57
|
+
output_path
|
|
58
|
+
end
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
def output_subdir(original_name)
|
|
61
|
+
subdir = File.dirname(original_name)
|
|
62
|
+
subdir == "." ? nil : subdir
|
|
63
|
+
end
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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}"
|
|
65
|
+
def process_output(operation, original_name, input_path, output_path, params)
|
|
66
|
+
if !operation[:force_processing] && output_up_to_date?(input_path, output_path)
|
|
67
|
+
Jekyll.logger.debug "⏭️ ImgFlow: Output exists and up-to-date, skipping provider call for #{original_name}"
|
|
73
68
|
@stats.record_cache_hit
|
|
74
69
|
elsif AnimatedGifDetector.animated?(input_path)
|
|
75
|
-
|
|
76
|
-
# would destroy the animation. Copy the original file as-is so the
|
|
77
|
-
# manifest can track it and HTML references stay valid.
|
|
78
|
-
Jekyll.logger.warn "🖼️ ImgFlow: Skipping resize for animated GIF " \
|
|
79
|
-
"'#{original_name}' — copying original as-is to " \
|
|
80
|
-
"preserve animation."
|
|
81
|
-
FileUtils.cp(input_path, actual_output_path)
|
|
82
|
-
@stats.record_cache_miss
|
|
70
|
+
copy_animated_image(original_name, input_path, output_path)
|
|
83
71
|
else
|
|
84
|
-
|
|
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
|
+
process_image(operation[:type], input_path, output_path, params)
|
|
97
73
|
end
|
|
74
|
+
end
|
|
98
75
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
76
|
+
def copy_animated_image(original_name, input_path, output_path)
|
|
77
|
+
Jekyll.logger.warn "🖼️ ImgFlow: Skipping resize for animated GIF '#{original_name}' — " \
|
|
78
|
+
"copying original as-is to preserve animation."
|
|
79
|
+
FileUtils.cp(input_path, output_path)
|
|
80
|
+
@stats.record_cache_miss
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def process_image(type, input_path, output_path, params)
|
|
84
|
+
elapsed = Benchmark.measure { process_single_operation(type, input_path, output_path, params) }
|
|
85
|
+
@stats.record_operation_time(type, elapsed.real)
|
|
86
|
+
@stats.record_cache_miss
|
|
87
|
+
record_compression_ratio(input_path, output_path, params)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def record_compression_ratio(input_path, output_path, params)
|
|
91
|
+
return unless File.file?(input_path) && File.file?(output_path)
|
|
92
|
+
|
|
93
|
+
format = params[:format] || File.extname(input_path).delete(".")
|
|
94
|
+
@stats.record_compression_ratio(format.to_s, File.size(input_path), File.size(output_path))
|
|
95
|
+
end
|
|
115
96
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# during render via imgflow tags) are not picked up by Jekyll's static
|
|
119
|
-
# file reader, which runs before pre_render. This would leave _site
|
|
120
|
-
# without optimized images until a second build.
|
|
121
|
-
register_jekyll_static_file(actual_output_path)
|
|
97
|
+
def register_manifest(original_name, filename, params, version_type, page_path, file_digest, subdir)
|
|
98
|
+
return unless @manifest
|
|
122
99
|
|
|
123
|
-
|
|
100
|
+
relative_path = "/#{@path_resolver.resolve_relative_output_path(filename, subdir)}"
|
|
101
|
+
provider_name = @provider&.class&.provider_name || "unknown"
|
|
102
|
+
@manifest.register_version(original_name, relative_path, params, version_type, page_path,
|
|
103
|
+
file_digest, provider_name)
|
|
124
104
|
end
|
|
125
105
|
|
|
126
106
|
# Register a generated file as a Jekyll::StaticFile so Jekyll copies it
|
|
@@ -16,26 +16,19 @@ module JekyllImgFlow
|
|
|
16
16
|
result = translate_to_imgflow(picture_markup)
|
|
17
17
|
return "" if result[:markup].empty?
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# Add HTML attributes
|
|
23
|
-
tag_parts << "alt:\"#{result[:attributes][:alt]}\"" if result[:attributes][:alt]
|
|
24
|
-
|
|
25
|
-
if result[:attributes][:img]&.any?
|
|
26
|
-
result[:attributes][:img].each do |name, value|
|
|
27
|
-
tag_parts << "img-#{name}:\"#{value}\""
|
|
28
|
-
end
|
|
29
|
-
end
|
|
19
|
+
"{% imgflow #{[result[:markup], *translated_attributes(result[:attributes]), '%}'].join(' ')}"
|
|
20
|
+
end
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
22
|
+
def translated_attributes(attributes)
|
|
23
|
+
parts = []
|
|
24
|
+
parts << "alt:\"#{attributes[:alt]}\"" if attributes[:alt]
|
|
25
|
+
parts.concat(prefixed_attributes(attributes[:img], "img"))
|
|
26
|
+
parts.concat(prefixed_attributes(attributes[:picture], "picture"))
|
|
27
|
+
parts
|
|
28
|
+
end
|
|
36
29
|
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
def prefixed_attributes(attributes, prefix)
|
|
31
|
+
attributes.to_h.map { |name, value| "#{prefix}-#{name}:\"#{value}\"" }
|
|
39
32
|
end
|
|
40
33
|
|
|
41
34
|
# Convert Picture Tag markup to ImgFlow markup with HTML attributes
|
|
@@ -57,17 +50,9 @@ module JekyllImgFlow
|
|
|
57
50
|
categorized = categorize_arguments(args)
|
|
58
51
|
return { markup: "", attributes: {} } unless categorized[:image]
|
|
59
52
|
|
|
60
|
-
|
|
61
|
-
imgflow_parts = []
|
|
62
|
-
imgflow_parts << categorized[:image]
|
|
63
|
-
imgflow_parts += translate_media_queries(categorized[:media_queries])
|
|
64
|
-
imgflow_parts += translate_operations(categorized[:operations])
|
|
65
|
-
imgflow_parts << "formats:webp,jpg" unless formats?(imgflow_parts)
|
|
66
|
-
imgflow_parts << "markup:#{categorized[:markup_format]}" if categorized[:markup_format] && categorized[:markup_format] != "auto"
|
|
67
|
-
|
|
68
|
-
# Step 3: Assemble result
|
|
53
|
+
imgflow_parts = translated_parts(categorized)
|
|
69
54
|
{
|
|
70
|
-
markup: imgflow_parts.
|
|
55
|
+
markup: imgflow_parts.join(" "),
|
|
71
56
|
attributes: categorized[:html_attributes],
|
|
72
57
|
markup_format: categorized[:markup_format]
|
|
73
58
|
}
|
|
@@ -75,6 +60,16 @@ module JekyllImgFlow
|
|
|
75
60
|
|
|
76
61
|
private
|
|
77
62
|
|
|
63
|
+
def translated_parts(categorized)
|
|
64
|
+
parts = [categorized[:image]]
|
|
65
|
+
parts.concat(translate_media_queries(categorized[:media_queries]))
|
|
66
|
+
parts.concat(translate_operations(categorized[:operations]))
|
|
67
|
+
parts << "formats:webp,jpg" unless formats?(parts)
|
|
68
|
+
format = categorized[:markup_format]
|
|
69
|
+
parts << "markup:#{format}" if format && format != "auto"
|
|
70
|
+
parts.compact
|
|
71
|
+
end
|
|
72
|
+
|
|
78
73
|
def extract_picture_content(markup)
|
|
79
74
|
offset = 0
|
|
80
75
|
while (opening = markup.index("{%", offset))
|
|
@@ -105,37 +100,43 @@ module JekyllImgFlow
|
|
|
105
100
|
# @param content [String] Raw content
|
|
106
101
|
# @return [Array] Parsed arguments
|
|
107
102
|
def parse_arguments(content)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
103
|
+
state = { args: [], current: +"", in_quotes: false, quote_char: nil }
|
|
104
|
+
content.each_char { |char| consume_argument_character(state, char) }
|
|
105
|
+
state[:args] << state[:current].dup unless state[:current].empty?
|
|
106
|
+
state[:args]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def consume_argument_character(state, char)
|
|
110
|
+
if quote_character?(char)
|
|
111
|
+
consume_quote(state, char)
|
|
112
|
+
elsif char == " " && !state[:in_quotes]
|
|
113
|
+
append_argument(state)
|
|
114
|
+
else
|
|
115
|
+
state[:current] << char
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def quote_character?(char)
|
|
120
|
+
["\"", "'"].include?(char)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def consume_quote(state, char)
|
|
124
|
+
if !state[:in_quotes]
|
|
125
|
+
state[:in_quotes] = true
|
|
126
|
+
state[:quote_char] = char
|
|
127
|
+
elsif char == state[:quote_char]
|
|
128
|
+
state[:in_quotes] = false
|
|
129
|
+
state[:quote_char] = nil
|
|
130
|
+
else
|
|
131
|
+
state[:current] << char
|
|
135
132
|
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def append_argument(state)
|
|
136
|
+
return if state[:current].empty?
|
|
136
137
|
|
|
137
|
-
args << current.dup
|
|
138
|
-
|
|
138
|
+
state[:args] << state[:current].dup
|
|
139
|
+
state[:current] = +""
|
|
139
140
|
end
|
|
140
141
|
|
|
141
142
|
# Categorize arguments into types
|
|
@@ -149,59 +150,65 @@ module JekyllImgFlow
|
|
|
149
150
|
html_attributes: default_html_attributes,
|
|
150
151
|
markup_format: nil
|
|
151
152
|
}
|
|
153
|
+
index = 0
|
|
154
|
+
index = categorize_argument(args, result, index) while index < args.length
|
|
155
|
+
result
|
|
156
|
+
end
|
|
152
157
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
def categorize_argument(args, result, index)
|
|
159
|
+
arg = args[index]
|
|
160
|
+
return parse_alt_argument(args, result, index) if arg == "--alt"
|
|
161
|
+
return parse_link_argument(args, result, index) if arg == "--link"
|
|
162
|
+
return parse_element_arguments(args, result, index) if arg.match?(/^--(img|picture|source|a|parent)$/)
|
|
163
|
+
return parse_media_argument(args, result, index) if arg.match?(/(mobile|tablet|desktop):/)
|
|
156
164
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
i += 1
|
|
161
|
-
alt_parts = []
|
|
162
|
-
while i < args.length && !args[i].start_with?("--")
|
|
163
|
-
alt_parts << args[i]
|
|
164
|
-
i += 1
|
|
165
|
-
end
|
|
166
|
-
result[:html_attributes][:alt] = strip_quotes(alt_parts.join(" "))
|
|
167
|
-
next
|
|
168
|
-
when /^--link$/
|
|
169
|
-
result[:html_attributes][:link] = strip_quotes(args[i + 1]) if i + 1 < args.length
|
|
170
|
-
i += 2
|
|
171
|
-
next
|
|
172
|
-
when /^--(img|picture|source|a|parent)$/
|
|
173
|
-
element = ::Regexp.last_match(1).to_sym
|
|
174
|
-
i += 1
|
|
175
|
-
while i < args.length && !args[i].start_with?("--")
|
|
176
|
-
parse_element_attribute(args[i], result[:html_attributes][element])
|
|
177
|
-
i += 1
|
|
178
|
-
end
|
|
179
|
-
next
|
|
180
|
-
when /(mobile|tablet|desktop):/
|
|
181
|
-
device = ::Regexp.last_match(1)
|
|
182
|
-
i += 1
|
|
183
|
-
if i < args.length && args[i].include?(".")
|
|
184
|
-
image = args[i]
|
|
185
|
-
i += 1
|
|
186
|
-
crop = parse_crop_from_arg(args[i]) if i < args.length && args[i] =~ /^\d+:\d+/
|
|
187
|
-
i += 1 if crop
|
|
188
|
-
result[:media_queries][device] = { image: image, crop: crop }
|
|
189
|
-
end
|
|
190
|
-
next
|
|
191
|
-
when /^(auto|data_auto|picture|img)$/
|
|
192
|
-
result[:markup_format] = arg
|
|
193
|
-
when /\./
|
|
194
|
-
# Image path (has extension, no colon)
|
|
195
|
-
result[:image] ||= arg unless arg.include?(":")
|
|
196
|
-
else
|
|
197
|
-
# Operation argument
|
|
198
|
-
result[:operations] << arg unless arg.start_with?("--")
|
|
199
|
-
end
|
|
165
|
+
categorize_simple_argument(arg, result)
|
|
166
|
+
index + 1
|
|
167
|
+
end
|
|
200
168
|
|
|
201
|
-
|
|
202
|
-
|
|
169
|
+
def parse_alt_argument(args, result, index)
|
|
170
|
+
values, next_index = values_until_option(args, index + 1)
|
|
171
|
+
result[:html_attributes][:alt] = strip_quotes(values.join(" "))
|
|
172
|
+
next_index
|
|
173
|
+
end
|
|
203
174
|
|
|
204
|
-
|
|
175
|
+
def parse_link_argument(args, result, index)
|
|
176
|
+
result[:html_attributes][:link] = strip_quotes(args[index + 1]) if args[index + 1]
|
|
177
|
+
index + 2
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def parse_element_arguments(args, result, index)
|
|
181
|
+
element = args[index].match(/^--(img|picture|source|a|parent)$/)[1].to_sym
|
|
182
|
+
values, next_index = values_until_option(args, index + 1)
|
|
183
|
+
values.each { |value| parse_element_attribute(value, result[:html_attributes][element]) }
|
|
184
|
+
next_index
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def values_until_option(args, index)
|
|
188
|
+
start = index
|
|
189
|
+
index += 1 while index < args.length && !args[index].start_with?("--")
|
|
190
|
+
[args[start...index], index]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def parse_media_argument(args, result, index)
|
|
194
|
+
device = args[index].match(/(mobile|tablet|desktop):/)[1]
|
|
195
|
+
image = args[index + 1]
|
|
196
|
+
return index + 1 unless image&.include?(".")
|
|
197
|
+
|
|
198
|
+
crop = parse_crop_from_arg(args[index + 2])
|
|
199
|
+
result[:media_queries][device] = { image: image, crop: crop }
|
|
200
|
+
index + 2 + (crop ? 1 : 0)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def categorize_simple_argument(arg, result)
|
|
204
|
+
case arg
|
|
205
|
+
when /^(auto|data_auto|picture|img)$/
|
|
206
|
+
result[:markup_format] = arg
|
|
207
|
+
when /\./
|
|
208
|
+
result[:image] ||= arg unless arg.include?(":")
|
|
209
|
+
else
|
|
210
|
+
result[:operations] << arg unless arg.start_with?("--")
|
|
211
|
+
end
|
|
205
212
|
end
|
|
206
213
|
|
|
207
214
|
# Translate media queries to ImgFlow crop syntax
|
|
@@ -165,6 +165,7 @@ module JekyllImgFlow
|
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
def execute_command(command)
|
|
168
|
+
# nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- provider commands shell-escape paths; pipelines are intentional for Sharp watermark operations.
|
|
168
169
|
stdout, stderr, status = Open3.capture3(command)
|
|
169
170
|
raise "Command failed: #{command}\nError: #{stderr.strip}" unless status.success?
|
|
170
171
|
|
|
@@ -31,24 +31,32 @@ module JekyllImgFlow
|
|
|
31
31
|
# Each sub-array is passed directly to Open3.capture3 (no shell).
|
|
32
32
|
# Returns Array[Array[String]].
|
|
33
33
|
def build_vips_commands(input_path, output_path)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
flags = operation_flags
|
|
35
|
+
operation_builder = select_operation_builder(flags)
|
|
36
|
+
operation_builder.call(input_path, output_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def select_operation_builder(flags)
|
|
40
|
+
return ->(input, output) { build_watermark_pipeline(input, output, flags[:crop], flags[:resize]) } if flags[:watermark]
|
|
41
|
+
return ->(input, output) { build_alpha_pipeline(input, output, flags[:crop], flags[:resize]) } if flags[:alpha]
|
|
42
|
+
return ->(input, output) { build_sequential_crop_resize(input, output) } if flags[:crop] && flags[:resize]
|
|
43
|
+
return ->(input, output) { [build_resize_command(input, output)] } if flags[:resize]
|
|
44
|
+
return ->(input, output) { crop_commands(input, output) } if flags[:crop]
|
|
45
|
+
|
|
46
|
+
->(input, output) { [build_copy_command(input, output)] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def operation_flags
|
|
50
|
+
{
|
|
51
|
+
crop: @operations.any? { |op| op[:type] == :crop },
|
|
52
|
+
resize: @operations.any? { |op| op[:type] == :resize },
|
|
53
|
+
watermark: @operations.any? { |op| op[:type] == :watermark },
|
|
54
|
+
alpha: @operations.any? { |op| op[:type] == :alpha_opacity }
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def crop_commands(input_path, output_path)
|
|
59
|
+
svg?(input_path) ? build_svg_crop_pipeline(input_path, output_path) : [build_crop_command(input_path, output_path)]
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
# Convenience wrapper for backward compatibility with tests.
|
|
@@ -64,22 +72,8 @@ module JekyllImgFlow
|
|
|
64
72
|
end
|
|
65
73
|
|
|
66
74
|
def build_alpha_pipeline(input_path, output_path, has_crop, has_resize)
|
|
67
|
-
commands = []
|
|
68
75
|
temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
|
|
69
|
-
|
|
70
|
-
if has_crop && has_resize
|
|
71
|
-
commands.concat(build_sequential_crop_resize(input_path, temp_path))
|
|
72
|
-
base_path = temp_path
|
|
73
|
-
elsif has_resize
|
|
74
|
-
commands << build_resize_command(input_path, temp_path)
|
|
75
|
-
base_path = temp_path
|
|
76
|
-
elsif has_crop
|
|
77
|
-
commands << build_crop_command(input_path, temp_path)
|
|
78
|
-
base_path = temp_path
|
|
79
|
-
else
|
|
80
|
-
base_path = input_path
|
|
81
|
-
end
|
|
82
|
-
|
|
76
|
+
commands, base_path = build_base_pipeline(input_path, temp_path, has_crop, has_resize)
|
|
83
77
|
commands << build_alpha_command(base_path, output_path)
|
|
84
78
|
commands << [:cleanup, temp_path] unless base_path == input_path
|
|
85
79
|
commands
|
|
@@ -87,39 +81,32 @@ module JekyllImgFlow
|
|
|
87
81
|
|
|
88
82
|
def build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
|
|
89
83
|
temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
|
|
84
|
+
commands, base_path = build_base_pipeline(input_path, temp_path, has_crop, has_resize)
|
|
85
|
+
base_path, commands = apply_watermark_alpha(base_path, input_path, commands)
|
|
86
|
+
commands.concat(build_composite_commands(base_path, output_path))
|
|
87
|
+
cleanup_paths(commands, input_path, temp_path, base_path)
|
|
88
|
+
commands
|
|
89
|
+
end
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if has_crop
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
commands << build_resize_command(input_path, temp_path)
|
|
99
|
-
base_path = temp_path
|
|
100
|
-
elsif has_crop
|
|
101
|
-
commands << build_crop_command(input_path, temp_path)
|
|
102
|
-
base_path = temp_path
|
|
103
|
-
else
|
|
104
|
-
base_path = input_path
|
|
105
|
-
end
|
|
91
|
+
def build_base_pipeline(input_path, temp_path, has_crop, has_resize)
|
|
92
|
+
return [build_sequential_crop_resize(input_path, temp_path), temp_path] if has_crop && has_resize
|
|
93
|
+
return [[build_resize_command(input_path, temp_path)], temp_path] if has_resize
|
|
94
|
+
return [[build_crop_command(input_path, temp_path)], temp_path] if has_crop
|
|
95
|
+
|
|
96
|
+
[[], input_path]
|
|
97
|
+
end
|
|
106
98
|
|
|
107
|
-
|
|
99
|
+
def apply_watermark_alpha(base_path, input_path, commands)
|
|
108
100
|
alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
|
|
109
|
-
|
|
110
|
-
alpha_temp = input_path.gsub(/\.[^.]+$/, ".tmp_alpha.jpg")
|
|
111
|
-
commands << build_alpha_command(base_path, alpha_temp)
|
|
112
|
-
base_path = alpha_temp
|
|
113
|
-
end
|
|
101
|
+
return [base_path, commands] unless alpha_op
|
|
114
102
|
|
|
115
|
-
|
|
116
|
-
commands
|
|
103
|
+
alpha_temp = input_path.gsub(/\.[^.]+$/, ".tmp_alpha.jpg")
|
|
104
|
+
[alpha_temp, commands << build_alpha_command(base_path, alpha_temp)]
|
|
105
|
+
end
|
|
117
106
|
|
|
118
|
-
|
|
107
|
+
def cleanup_paths(commands, input_path, temp_path, base_path)
|
|
119
108
|
temp_files = [temp_path, base_path == input_path ? nil : base_path].compact
|
|
120
|
-
temp_files.each { |
|
|
121
|
-
|
|
122
|
-
commands
|
|
109
|
+
temp_files.each { |file| commands << [:cleanup, file] }
|
|
123
110
|
end
|
|
124
111
|
|
|
125
112
|
def build_composite_commands(base_path, output_path)
|
|
@@ -169,6 +156,9 @@ module JekyllImgFlow
|
|
|
169
156
|
# Get an image dimension (width or height) using vips header.
|
|
170
157
|
# Returns integer or 0 if vips is unavailable.
|
|
171
158
|
def vips_image_dimension(image_path, field)
|
|
159
|
+
return 0 unless %w[width height].include?(field)
|
|
160
|
+
|
|
161
|
+
# nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- Open3 receives an argument array and field is allowlisted.
|
|
172
162
|
stdout, _, status = Open3.capture3("vips", "header", image_path, field)
|
|
173
163
|
return 0 unless status.success?
|
|
174
164
|
|
|
@@ -230,27 +220,31 @@ module JekyllImgFlow
|
|
|
230
220
|
opts = crop_op[:options] || {}
|
|
231
221
|
params = crop_op[:params] || {}
|
|
232
222
|
keep = opts[:keep] || params[:keep] || params[:position]
|
|
223
|
+
return build_smartcrop_command(crop_op, opts, keep, input_path, output_path) if smartcrop?(crop_op, keep)
|
|
233
224
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
225
|
+
build_extract_command(crop_op, opts, input_path, output_path)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def smartcrop?(crop_op, keep)
|
|
229
|
+
crop_op[:ratio] && keep && %w[attention entropy center centre].include?(keep.to_s)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def build_smartcrop_command(_crop_op, opts, keep, input_path, output_path)
|
|
233
|
+
interestingness = %w[center centre].include?(keep.to_s) ? "centre" : keep.to_s
|
|
234
|
+
interestingness = "attention" unless %w[entropy centre].include?(interestingness)
|
|
235
|
+
["vips", "smartcrop", input_path, output_path,
|
|
236
|
+
opts[:calculated_width].to_s, opts[:calculated_height].to_s,
|
|
237
|
+
"--interesting=#{interestingness}"]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def build_extract_command(crop_op, opts, input_path, output_path)
|
|
241
|
+
ratio = crop_op[:ratio]
|
|
242
|
+
x = ratio ? opts[:calculated_x] : (opts[:x] || 0)
|
|
243
|
+
y = ratio ? opts[:calculated_y] : (opts[:y] || 0)
|
|
244
|
+
width = ratio ? opts[:calculated_width] : opts[:width]
|
|
245
|
+
height = ratio ? opts[:calculated_height] : opts[:height]
|
|
246
|
+
["vips", "extract_area", input_path, output_path,
|
|
247
|
+
x.to_s, y.to_s, width.to_s, height.to_s]
|
|
254
248
|
end
|
|
255
249
|
|
|
256
250
|
# SVG crop: use `vips thumbnail` with --crop to crop during thumbnailing.
|
|
@@ -323,6 +317,7 @@ module JekyllImgFlow
|
|
|
323
317
|
Jekyll.logger.debug "LibVips command: #{command.join(' ')}"
|
|
324
318
|
|
|
325
319
|
# Execute command array directly (no shell)
|
|
320
|
+
# nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- command is an argument array; no shell is invoked.
|
|
326
321
|
stdout, stderr, status = Open3.capture3(*command)
|
|
327
322
|
output = "#{stdout}#{stderr}"
|
|
328
323
|
success = status.success?
|