jekyll-imgflow 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +661 -0
- data/README.md +214 -0
- data/lib/jekyll-imgflow/batch_manager.rb +263 -0
- data/lib/jekyll-imgflow/build_time_processor.rb +152 -0
- data/lib/jekyll-imgflow/config.rb +130 -0
- data/lib/jekyll-imgflow/filename_generator.rb +102 -0
- data/lib/jekyll-imgflow/helpers/http_downloader.rb +68 -0
- data/lib/jekyll-imgflow/hooks.rb +57 -0
- data/lib/jekyll-imgflow/html_generator.rb +335 -0
- data/lib/jekyll-imgflow/imgflow_tag.rb +247 -0
- data/lib/jekyll-imgflow/manifest_manager.rb +297 -0
- data/lib/jekyll-imgflow/operation_processor.rb +183 -0
- data/lib/jekyll-imgflow/parser.rb +199 -0
- data/lib/jekyll-imgflow/path_resolver.rb +130 -0
- data/lib/jekyll-imgflow/picture_tag_adaptor.rb +299 -0
- data/lib/jekyll-imgflow/picture_tag_preset_migrator.rb +210 -0
- data/lib/jekyll-imgflow/preset_manager.rb +149 -0
- data/lib/jekyll-imgflow/provider_registry.rb +106 -0
- data/lib/jekyll-imgflow/providers/base_provider.rb +198 -0
- data/lib/jekyll-imgflow/providers/flyimg.rb +187 -0
- data/lib/jekyll-imgflow/providers/imagemagick.rb +138 -0
- data/lib/jekyll-imgflow/providers/imgproxy.rb +164 -0
- data/lib/jekyll-imgflow/providers/libvips.rb +292 -0
- data/lib/jekyll-imgflow/providers/sharp.rb +213 -0
- data/lib/jekyll-imgflow/providers/weserv.rb +160 -0
- data/lib/jekyll-imgflow/tag_scanner.rb +227 -0
- data/lib/jekyll-imgflow/tags/base_tag.rb +52 -0
- data/lib/jekyll-imgflow/tags/crop_tag.rb +220 -0
- data/lib/jekyll-imgflow/tags/format_tag.rb +64 -0
- data/lib/jekyll-imgflow/tags/opacity_tag.rb +36 -0
- data/lib/jekyll-imgflow/tags/optimize_tag.rb +52 -0
- data/lib/jekyll-imgflow/tags/quality_tag.rb +31 -0
- data/lib/jekyll-imgflow/tags/resize_tag.rb +58 -0
- data/lib/jekyll-imgflow/tags/tag_registry.rb +70 -0
- data/lib/jekyll-imgflow/tags/watermark_tag.rb +51 -0
- data/lib/jekyll-imgflow/version.rb +5 -0
- data/lib/jekyll-imgflow.rb +30 -0
- metadata +261 -0
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
# Weserv provider implementation using the standardized tag interface
|
|
11
|
+
class Weserv < BaseProvider
|
|
12
|
+
TIMEOUT = 10
|
|
13
|
+
|
|
14
|
+
def available?
|
|
15
|
+
# Check if weserv service is running
|
|
16
|
+
return false unless @config.respond_to?(:weserv_url) && @config.weserv_url
|
|
17
|
+
|
|
18
|
+
check_http_service(@config.weserv_url)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execute(input_path, output_path)
|
|
22
|
+
return if @operations.empty?
|
|
23
|
+
|
|
24
|
+
# Build single Weserv URL with all operations combined
|
|
25
|
+
url = build_combined_weserv_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_weserv_url(input_path)
|
|
34
|
+
raise "weserv_url not set" unless @config.weserv_url
|
|
35
|
+
|
|
36
|
+
encoded_url = encode_file_url(input_path)
|
|
37
|
+
params = ["url=#{URI.encode_www_form_component(encoded_url)}"]
|
|
38
|
+
|
|
39
|
+
# Add all operations to single URL
|
|
40
|
+
@operations.each do |operation|
|
|
41
|
+
case operation[:type]
|
|
42
|
+
when :resize
|
|
43
|
+
# Tags now provide complete calculated values
|
|
44
|
+
params << if operation[:height]
|
|
45
|
+
"w=#{operation[:width]}&h=#{operation[:height]}&fit=fill"
|
|
46
|
+
else
|
|
47
|
+
"w=#{operation[:width]}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
when :crop
|
|
51
|
+
opts = operation[:options]
|
|
52
|
+
op_params = operation[:params] || {}
|
|
53
|
+
|
|
54
|
+
# Check for keep parameter (smartcrop support)
|
|
55
|
+
keep = opts[:keep] || op_params[:keep] || op_params[:position]
|
|
56
|
+
|
|
57
|
+
if operation[:ratio] && keep && SMARTCROP_POSITIONS.include?(keep.to_s)
|
|
58
|
+
# Use smartcrop (WeServ supports smartcrop)
|
|
59
|
+
crop_width = opts[:calculated_width]
|
|
60
|
+
crop_height = opts[:calculated_height]
|
|
61
|
+
|
|
62
|
+
# Add smartcrop parameters according to WeServ docs
|
|
63
|
+
params << "crop=#{crop_width}x#{crop_height}"
|
|
64
|
+
params << "a=smart" # smart attention
|
|
65
|
+
|
|
66
|
+
# Map keep parameter to WeServ attention type
|
|
67
|
+
case keep.to_s
|
|
68
|
+
when "attention" then params << "a=attention"
|
|
69
|
+
when "entropy" then params << "a=entropy"
|
|
70
|
+
when "center", "centre" then params << "a=center"
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
# Use basic cropping
|
|
74
|
+
if operation[:ratio]
|
|
75
|
+
crop_x = opts[:calculated_x]
|
|
76
|
+
crop_y = opts[:calculated_y]
|
|
77
|
+
crop_width = opts[:calculated_width]
|
|
78
|
+
crop_height = opts[:calculated_height]
|
|
79
|
+
else
|
|
80
|
+
crop_x = opts[:x]
|
|
81
|
+
crop_y = opts[:y]
|
|
82
|
+
crop_width = opts[:width]
|
|
83
|
+
crop_height = opts[:height]
|
|
84
|
+
end
|
|
85
|
+
params << "cx=#{crop_x}&cy=#{crop_y}&cw=#{crop_width}&ch=#{crop_height}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
when :quality
|
|
89
|
+
weserv_quality = translate_quality_to_weserv(operation[:quality])
|
|
90
|
+
params << "q=#{weserv_quality}"
|
|
91
|
+
|
|
92
|
+
when :format
|
|
93
|
+
# Assume validated input from tags
|
|
94
|
+
params << "output=#{operation[:format]}"
|
|
95
|
+
|
|
96
|
+
when :watermark
|
|
97
|
+
watermark_path = operation[:watermark_path]
|
|
98
|
+
position = operation[:options][:position]
|
|
99
|
+
opacity = operation[:options][:opacity]
|
|
100
|
+
|
|
101
|
+
# Weserv watermark parameters
|
|
102
|
+
params << "wm=#{encode_file_url(watermark_path)}"
|
|
103
|
+
params << "wmpos=#{translate_weserv_position(position)}"
|
|
104
|
+
params << "wmo=#{opacity}" if opacity
|
|
105
|
+
|
|
106
|
+
when :alpha_opacity
|
|
107
|
+
opacity = operation[:opacity]
|
|
108
|
+
params << "alpha=#{(opacity * 255).round}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
query_string = params.join("&")
|
|
113
|
+
"#{@config.weserv_url}/?#{query_string}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def translate_weserv_position(position)
|
|
119
|
+
# Translate compass directions to Weserv position format
|
|
120
|
+
case position
|
|
121
|
+
when "northwest" then "tl"
|
|
122
|
+
when "northeast" then "tr"
|
|
123
|
+
when "southwest" then "bl"
|
|
124
|
+
when "southeast" then "br"
|
|
125
|
+
when "center" then "c"
|
|
126
|
+
else position
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fetch_and_save(url, output_path)
|
|
131
|
+
response = fetch_with_timeout(url)
|
|
132
|
+
raise "Weserv request failed" if response.empty?
|
|
133
|
+
|
|
134
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
135
|
+
File.write(output_path, response)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fetch_with_timeout(url)
|
|
139
|
+
uri = URI(url)
|
|
140
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
141
|
+
use_ssl: uri.scheme == "https",
|
|
142
|
+
open_timeout: TIMEOUT,
|
|
143
|
+
read_timeout: TIMEOUT) do |http|
|
|
144
|
+
request = Net::HTTP::Get.new(uri)
|
|
145
|
+
response = http.request(request)
|
|
146
|
+
|
|
147
|
+
raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
148
|
+
|
|
149
|
+
response.body
|
|
150
|
+
end
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
raise "Weserv request failed: #{e.message}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def translate_quality_to_weserv(quality)
|
|
156
|
+
quality
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllImgFlow
|
|
4
|
+
# Scans Jekyll content for {% imgflow %} and {% picture %} tags
|
|
5
|
+
# Uses ManifestManager as single source of truth
|
|
6
|
+
class TagScanner
|
|
7
|
+
def initialize(site, config, manifest = nil)
|
|
8
|
+
@site = site
|
|
9
|
+
@config = config
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Scan all content files for image tags
|
|
14
|
+
# Returns summary from ManifestManager instead of building own hash
|
|
15
|
+
def scan_all_content
|
|
16
|
+
# Scan posts, pages, and documents
|
|
17
|
+
scannable_content.each do |item|
|
|
18
|
+
scan_content_item(item)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Return manifest summary if available, otherwise empty hash
|
|
22
|
+
@manifest ? @manifest.get_all_versions : {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Scan a single content item
|
|
26
|
+
# Note: With ManifestManager as single source of truth, this just discovers tags
|
|
27
|
+
# Actual tracking happens in ManifestManager during ImgflowTag rendering
|
|
28
|
+
def scan_content_item(item)
|
|
29
|
+
content = item.content
|
|
30
|
+
|
|
31
|
+
# Define tag patterns to scan for
|
|
32
|
+
tag_patterns = {
|
|
33
|
+
"imgflow" => "{% imgflow",
|
|
34
|
+
"picture" => "{% picture"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Use reusable scanner - just for discovery/validation
|
|
38
|
+
scan_tags_from_content(content, tag_patterns) do |_tag_name, markup|
|
|
39
|
+
# Tags are discovered but not tracked here
|
|
40
|
+
# ManifestManager handles all tracking during actual rendering
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
nil # No longer returns requirements hash
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Scan raw content string and return array of tag info (for testing/direct use)
|
|
47
|
+
def scan_content(content)
|
|
48
|
+
tags = []
|
|
49
|
+
|
|
50
|
+
# Define tag patterns to scan for
|
|
51
|
+
tag_patterns = {
|
|
52
|
+
"imgflow" => "{% imgflow"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Use reusable scanner
|
|
56
|
+
scan_tags_from_content(content, tag_patterns) do |_tag_name, markup|
|
|
57
|
+
parsed = parse_tag_markup_simple(markup)
|
|
58
|
+
tags << parsed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
tags
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Find all imgflow tags in content
|
|
65
|
+
def find_imgflow_tags(content)
|
|
66
|
+
scan_content(content)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Find all picture tags in content
|
|
70
|
+
def find_picture_tags(content)
|
|
71
|
+
tags = []
|
|
72
|
+
|
|
73
|
+
# Define tag patterns to scan for
|
|
74
|
+
tag_patterns = {
|
|
75
|
+
"picture" => "{% picture"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Use reusable scanner
|
|
79
|
+
scan_tags_from_content(content, tag_patterns) do |_tag_name, markup|
|
|
80
|
+
parsed = parse_tag_markup_simple(markup)
|
|
81
|
+
tags << parsed
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
tags
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Extract operations from tag markup - returns raw strings only
|
|
88
|
+
def extract_operations(markup)
|
|
89
|
+
parsed = parse_tag_markup_simple(markup)
|
|
90
|
+
parsed[:operations] # Just return array of ["width:800", "quality:90"]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Determine if operations represent default or specialized version
|
|
94
|
+
def determine_version_type(operations)
|
|
95
|
+
determine_type(operations)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Generic tag scanner - eliminates duplication
|
|
99
|
+
def scan_tags_from_content(content, tag_patterns)
|
|
100
|
+
content_lines = content.split("\n")
|
|
101
|
+
content_lines.each do |line|
|
|
102
|
+
tag_patterns.each do |tag_name, tag_prefix|
|
|
103
|
+
next unless line.include?(tag_prefix)
|
|
104
|
+
|
|
105
|
+
markup = extract_markup_from_line(line, tag_prefix)
|
|
106
|
+
yield tag_name, markup if markup
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Extract markup from line - reusable method
|
|
112
|
+
def extract_markup_from_line(line, tag_prefix)
|
|
113
|
+
start_idx = line.index(tag_prefix)
|
|
114
|
+
return unless start_idx
|
|
115
|
+
|
|
116
|
+
start_idx += tag_prefix.length
|
|
117
|
+
end_idx = line.index("%}", start_idx)
|
|
118
|
+
return unless start_idx && end_idx
|
|
119
|
+
|
|
120
|
+
line[start_idx..(end_idx - 1)].strip
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Simple parsing without regex gymnastics
|
|
124
|
+
def parse_tag_markup_simple(markup)
|
|
125
|
+
# Split by whitespace first to get image path
|
|
126
|
+
parts = markup.split(/\s+/)
|
|
127
|
+
image_path = parts.shift
|
|
128
|
+
|
|
129
|
+
# Parse operations as key:value pairs separated by commas or spaces
|
|
130
|
+
operations = []
|
|
131
|
+
remaining = parts.join(" ")
|
|
132
|
+
|
|
133
|
+
# Handle comma-separated: key:value,key:value
|
|
134
|
+
operation_pairs = if remaining.include?(",")
|
|
135
|
+
remaining.split(",")
|
|
136
|
+
else
|
|
137
|
+
# Handle space-separated: key:value key:value
|
|
138
|
+
remaining.split(/\s+/)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
operation_pairs.each do |pair|
|
|
142
|
+
next if pair.empty?
|
|
143
|
+
|
|
144
|
+
next unless pair.include?(":")
|
|
145
|
+
|
|
146
|
+
key, value = pair.split(":", 2)
|
|
147
|
+
# Convert numeric values using simple string methods
|
|
148
|
+
value_stripped = value.strip
|
|
149
|
+
converted_value = if value_stripped.match?(/^\d+$/)
|
|
150
|
+
value_stripped.to_i
|
|
151
|
+
elsif value_stripped.match?(/^\d+\.\d+$/)
|
|
152
|
+
value_stripped.to_f
|
|
153
|
+
else
|
|
154
|
+
value_stripped
|
|
155
|
+
end
|
|
156
|
+
# Store with converted value for downstream type correctness
|
|
157
|
+
operations << "#{key.strip}:#{converted_value}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
image: image_path, # Use :image key for test compatibility
|
|
162
|
+
operations: operations # Return as array of strings
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Get all scannable content from site
|
|
169
|
+
def scannable_content
|
|
170
|
+
items = []
|
|
171
|
+
items.concat(@site.posts.docs) if @site.posts
|
|
172
|
+
items.concat(@site.pages)
|
|
173
|
+
|
|
174
|
+
# Add documents from collections
|
|
175
|
+
@site.collections.each_value do |collection|
|
|
176
|
+
items.concat(collection.docs)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
items
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# NOTE: process_tag removed - ManifestManager handles all tracking
|
|
183
|
+
# Tags are discovered during scanning but tracking happens during rendering
|
|
184
|
+
|
|
185
|
+
def parse_tag_markup(markup)
|
|
186
|
+
parts = markup.split(/\s+/)
|
|
187
|
+
image_path = parts.shift
|
|
188
|
+
operations = {}
|
|
189
|
+
|
|
190
|
+
parts.each do |part|
|
|
191
|
+
next unless part.include?(":")
|
|
192
|
+
|
|
193
|
+
key, value = part.split(":", 2)
|
|
194
|
+
operations[key.to_sym] = parse_value(value)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
image_path: image_path,
|
|
199
|
+
operations: operations
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def parse_value(value)
|
|
204
|
+
value = value.tr("'\"", "")
|
|
205
|
+
return value.to_i if value.match?(/^\d+$/)
|
|
206
|
+
return value.to_f if value.match?(/^\d+\.\d+$/)
|
|
207
|
+
|
|
208
|
+
value
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Determine if operations match default configuration
|
|
212
|
+
def determine_type(operations)
|
|
213
|
+
# Check if operations match any default size + format combination
|
|
214
|
+
default_sizes = @config.sizes.values
|
|
215
|
+
default_formats = @config.formats
|
|
216
|
+
|
|
217
|
+
# If width matches a default size and format is default, it's default type
|
|
218
|
+
return "default" if operations[:width] && default_sizes.include?(operations[:width]) && (operations[:format].nil? || default_formats.include?(operations[:format]))
|
|
219
|
+
|
|
220
|
+
# If only format is specified and it's a default format, it's default
|
|
221
|
+
return "default" if operations.keys == [:format] && default_formats.include?(operations[:format])
|
|
222
|
+
|
|
223
|
+
# Otherwise, it's specialized
|
|
224
|
+
"specialized"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fastimage"
|
|
4
|
+
|
|
5
|
+
module JekyllImgFlow
|
|
6
|
+
module Tags
|
|
7
|
+
# Base class for all ImgFlow tags
|
|
8
|
+
class BaseTag
|
|
9
|
+
def initialize(provider)
|
|
10
|
+
@provider = provider
|
|
11
|
+
@config = provider.config
|
|
12
|
+
@default_quality = default_quality_from_config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def process(input_path, output_path, options = {})
|
|
16
|
+
raise NotImplementedError, "Provider must implement #{self.class.name}#process"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
protected
|
|
20
|
+
|
|
21
|
+
def ensure_output_dir(output_path)
|
|
22
|
+
dir = File.dirname(output_path)
|
|
23
|
+
FileUtils.mkdir_p(dir)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_image_dimensions(image_path)
|
|
27
|
+
return [nil, nil] unless File.exist?(image_path)
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
FastImage.size(image_path)
|
|
31
|
+
rescue FastImage::UnknownImageType, FastImage::ImageFetchError
|
|
32
|
+
[nil, nil]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_positive_integer(value, name)
|
|
37
|
+
return if value.nil?
|
|
38
|
+
|
|
39
|
+
integer_value = value.to_i
|
|
40
|
+
raise ArgumentError, "Invalid #{name}: '#{value}'. Must be a positive integer." if integer_value <= 0 || value.to_s != integer_value.to_s
|
|
41
|
+
|
|
42
|
+
integer_value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def default_quality_from_config
|
|
46
|
+
# Get default quality from provider's config
|
|
47
|
+
# Config class already handles the fallback (cfg["quality"] || 85)
|
|
48
|
+
@provider.config&.quality
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_tag"
|
|
4
|
+
|
|
5
|
+
module JekyllImgFlow
|
|
6
|
+
module Tags
|
|
7
|
+
# Crop tag - processes image cropping (validation handled by Parser)
|
|
8
|
+
class CropTag < BaseTag
|
|
9
|
+
def process(input_path, output_path, options = {})
|
|
10
|
+
ensure_output_dir(output_path)
|
|
11
|
+
|
|
12
|
+
# Validate that crop information is provided
|
|
13
|
+
ratio = options[:ratio] || options[:aspect_ratio]
|
|
14
|
+
has_pixel_crop = options[:width] || options[:height]
|
|
15
|
+
|
|
16
|
+
unless ratio || has_pixel_crop
|
|
17
|
+
raise ArgumentError,
|
|
18
|
+
"Crop operation requires ratio or at least one dimension (width or height)"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if ratio
|
|
22
|
+
# Handle aspect ratio cropping (e.g., "16:9", "4:3")
|
|
23
|
+
validate_ratio(ratio)
|
|
24
|
+
handle_aspect_ratio_crop(input_path, output_path, ratio, options)
|
|
25
|
+
else
|
|
26
|
+
# Handle pixel-based cropping with flexible dimensions
|
|
27
|
+
validate_positive_integer(options[:width], "width") if options[:width]
|
|
28
|
+
validate_positive_integer(options[:height], "height") if options[:height]
|
|
29
|
+
validate_positive_integer(options[:x], "x") if options[:x]
|
|
30
|
+
validate_positive_integer(options[:y], "y") if options[:y]
|
|
31
|
+
handle_pixel_crop(input_path, output_path, options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@provider.execute(input_path, output_path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Handle aspect ratio cropping
|
|
40
|
+
def handle_aspect_ratio_crop(input_path, _output_path, ratio, options)
|
|
41
|
+
# Calculate optimal crop dimensions for the aspect ratio
|
|
42
|
+
calculated_options = calculate_aspect_ratio_dimensions(input_path, ratio, options)
|
|
43
|
+
|
|
44
|
+
# Add keep parameter for smartcrop (JPT compatibility)
|
|
45
|
+
if options[:keep] && %w[attention entropy center
|
|
46
|
+
centre].include?(options[:keep].to_s)
|
|
47
|
+
calculated_options[:keep] = options[:keep]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Pass ratio and calculated options to provider (uniform interface)
|
|
51
|
+
@provider.crop(ratio, calculated_options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Handle flexible pixel-based cropping
|
|
55
|
+
def handle_pixel_crop(input_path, _output_path, options)
|
|
56
|
+
# Get original image dimensions
|
|
57
|
+
original_width, original_height = get_original_dimensions(input_path)
|
|
58
|
+
|
|
59
|
+
# Parse dimensions (support pixels and percentages)
|
|
60
|
+
crop_width = parse_dimension(options[:width], original_width)
|
|
61
|
+
crop_height = parse_dimension(options[:height], original_height)
|
|
62
|
+
|
|
63
|
+
# At least one dimension must be specified
|
|
64
|
+
raise ArgumentError, "At least width or height must be specified for cropping" unless crop_width || crop_height
|
|
65
|
+
|
|
66
|
+
# Calculate missing dimension if only one provided
|
|
67
|
+
if crop_width && !crop_height
|
|
68
|
+
crop_height = original_height # Keep original height
|
|
69
|
+
elsif crop_height && !crop_width
|
|
70
|
+
crop_width = original_width # Keep original width
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Parse positions with smart defaults - always provide values
|
|
74
|
+
crop_x = parse_position_with_default(options[:x], crop_width, original_width)
|
|
75
|
+
crop_y = parse_position_with_default(options[:y], crop_height, original_height)
|
|
76
|
+
|
|
77
|
+
# Validate crop area is not bigger than original
|
|
78
|
+
validate_crop_dimensions(crop_x, crop_y, crop_width, crop_height, original_width,
|
|
79
|
+
original_height)
|
|
80
|
+
|
|
81
|
+
# Build crop options with keep parameter support
|
|
82
|
+
crop_options = {
|
|
83
|
+
x: crop_x,
|
|
84
|
+
y: crop_y,
|
|
85
|
+
width: crop_width,
|
|
86
|
+
height: crop_height
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Add keep parameter for smartcrop (JPT compatibility)
|
|
90
|
+
if options[:keep] && %w[attention entropy center
|
|
91
|
+
centre].include?(options[:keep].to_s)
|
|
92
|
+
crop_options[:keep] = options[:keep]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Execute crop with calculated dimensions
|
|
96
|
+
@provider.crop(nil, crop_options)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Get original image dimensions using FastImage
|
|
100
|
+
def get_original_dimensions(input_path)
|
|
101
|
+
require "fastimage"
|
|
102
|
+
original_width, original_height = FastImage.size(input_path)
|
|
103
|
+
|
|
104
|
+
raise ArgumentError, "Unable to determine original image dimensions" unless original_width && original_height
|
|
105
|
+
|
|
106
|
+
[original_width, original_height]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Parse dimension (supports pixels and percentages)
|
|
110
|
+
def parse_dimension(value, original_size)
|
|
111
|
+
return if value.nil?
|
|
112
|
+
|
|
113
|
+
if value.to_s.end_with?("%")
|
|
114
|
+
# Percentage-based: "50%" → original_size * 0.5
|
|
115
|
+
percentage = value.to_f / 100
|
|
116
|
+
(original_size * percentage).round
|
|
117
|
+
else
|
|
118
|
+
# Absolute pixels: "800" → 800
|
|
119
|
+
value.to_i
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Parse position with smart defaults
|
|
124
|
+
def parse_position(value, crop_size, original_size)
|
|
125
|
+
return if value.nil?
|
|
126
|
+
|
|
127
|
+
if value.to_s == "center"
|
|
128
|
+
# Center the crop
|
|
129
|
+
((original_size - crop_size) / 2).round
|
|
130
|
+
else
|
|
131
|
+
# Absolute position
|
|
132
|
+
value.to_i
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Parse position with default to center if not specified
|
|
137
|
+
def parse_position_with_default(value, crop_size, original_size)
|
|
138
|
+
return ((original_size - crop_size) / 2).round if value.nil?
|
|
139
|
+
|
|
140
|
+
if value.to_s == "center"
|
|
141
|
+
# Center the crop
|
|
142
|
+
((original_size - crop_size) / 2).round
|
|
143
|
+
else
|
|
144
|
+
# Absolute position
|
|
145
|
+
value.to_i
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Validate crop dimensions don't exceed original image
|
|
150
|
+
def validate_crop_dimensions(crop_x, crop_y, crop_width, crop_height, original_width,
|
|
151
|
+
original_height)
|
|
152
|
+
if crop_x.negative? || crop_y.negative?
|
|
153
|
+
raise ArgumentError,
|
|
154
|
+
"Crop position cannot be negative"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
return if crop_x + crop_width <= original_width && crop_y + crop_height <= original_height
|
|
158
|
+
|
|
159
|
+
raise ArgumentError, "Crop area exceeds original image dimensions"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Calculate optimal dimensions for aspect ratio cropping
|
|
163
|
+
def calculate_aspect_ratio_dimensions(input_path, ratio, _options)
|
|
164
|
+
original_width, original_height = get_original_dimensions(input_path)
|
|
165
|
+
|
|
166
|
+
# Parse ratio like "16:9"
|
|
167
|
+
ratio_width, ratio_height = ratio.split(":").map(&:to_f)
|
|
168
|
+
target_ratio = ratio_width / ratio_height
|
|
169
|
+
original_ratio = original_width.to_f / original_height
|
|
170
|
+
|
|
171
|
+
# Calculate crop dimensions
|
|
172
|
+
if original_ratio > target_ratio
|
|
173
|
+
# Image is wider than target ratio - crop width
|
|
174
|
+
calculated_height = original_height
|
|
175
|
+
calculated_width = (calculated_height * target_ratio).round
|
|
176
|
+
calculated_x = ((original_width - calculated_width) / 2).round
|
|
177
|
+
calculated_y = 0
|
|
178
|
+
else
|
|
179
|
+
# Image is taller than target ratio - crop height
|
|
180
|
+
calculated_width = original_width
|
|
181
|
+
calculated_height = (calculated_width / target_ratio).round
|
|
182
|
+
calculated_x = 0
|
|
183
|
+
calculated_y = ((original_height - calculated_height) / 2).round
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
{
|
|
187
|
+
calculated_width: calculated_width,
|
|
188
|
+
calculated_height: calculated_height,
|
|
189
|
+
calculated_x: calculated_x,
|
|
190
|
+
calculated_y: calculated_y
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Validate ratio format (e.g., "16:9")
|
|
195
|
+
def validate_ratio(ratio)
|
|
196
|
+
return ratio if ratio.nil?
|
|
197
|
+
|
|
198
|
+
# Convert symbol to string
|
|
199
|
+
ratio_str = ratio.to_s
|
|
200
|
+
|
|
201
|
+
# Validate format
|
|
202
|
+
unless ratio_str.match?(/\A\d+:\d+\z/)
|
|
203
|
+
raise ArgumentError,
|
|
204
|
+
"Invalid ratio: '#{ratio}'. Must be in format 'width:height' (e.g., '16:9')."
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Validate numeric values
|
|
208
|
+
width, height = ratio_str.split(":").map(&:to_i)
|
|
209
|
+
if width <= 0 || height <= 0
|
|
210
|
+
raise ArgumentError,
|
|
211
|
+
"Invalid ratio: '#{ratio}'. Width and height must be positive numbers."
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
ratio_str
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# validate_positive_integer inherited from BaseTag
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|