jekyll-imgflow 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +661 -0
  3. data/README.md +214 -0
  4. data/lib/jekyll-imgflow/batch_manager.rb +263 -0
  5. data/lib/jekyll-imgflow/build_time_processor.rb +152 -0
  6. data/lib/jekyll-imgflow/config.rb +130 -0
  7. data/lib/jekyll-imgflow/filename_generator.rb +102 -0
  8. data/lib/jekyll-imgflow/helpers/http_downloader.rb +68 -0
  9. data/lib/jekyll-imgflow/hooks.rb +57 -0
  10. data/lib/jekyll-imgflow/html_generator.rb +335 -0
  11. data/lib/jekyll-imgflow/imgflow_tag.rb +247 -0
  12. data/lib/jekyll-imgflow/manifest_manager.rb +297 -0
  13. data/lib/jekyll-imgflow/operation_processor.rb +183 -0
  14. data/lib/jekyll-imgflow/parser.rb +199 -0
  15. data/lib/jekyll-imgflow/path_resolver.rb +130 -0
  16. data/lib/jekyll-imgflow/picture_tag_adaptor.rb +299 -0
  17. data/lib/jekyll-imgflow/picture_tag_preset_migrator.rb +210 -0
  18. data/lib/jekyll-imgflow/preset_manager.rb +149 -0
  19. data/lib/jekyll-imgflow/provider_registry.rb +106 -0
  20. data/lib/jekyll-imgflow/providers/base_provider.rb +198 -0
  21. data/lib/jekyll-imgflow/providers/flyimg.rb +187 -0
  22. data/lib/jekyll-imgflow/providers/imagemagick.rb +138 -0
  23. data/lib/jekyll-imgflow/providers/imgproxy.rb +164 -0
  24. data/lib/jekyll-imgflow/providers/libvips.rb +292 -0
  25. data/lib/jekyll-imgflow/providers/sharp.rb +213 -0
  26. data/lib/jekyll-imgflow/providers/weserv.rb +160 -0
  27. data/lib/jekyll-imgflow/tag_scanner.rb +227 -0
  28. data/lib/jekyll-imgflow/tags/base_tag.rb +52 -0
  29. data/lib/jekyll-imgflow/tags/crop_tag.rb +220 -0
  30. data/lib/jekyll-imgflow/tags/format_tag.rb +64 -0
  31. data/lib/jekyll-imgflow/tags/opacity_tag.rb +36 -0
  32. data/lib/jekyll-imgflow/tags/optimize_tag.rb +52 -0
  33. data/lib/jekyll-imgflow/tags/quality_tag.rb +31 -0
  34. data/lib/jekyll-imgflow/tags/resize_tag.rb +58 -0
  35. data/lib/jekyll-imgflow/tags/tag_registry.rb +70 -0
  36. data/lib/jekyll-imgflow/tags/watermark_tag.rb +51 -0
  37. data/lib/jekyll-imgflow/version.rb +5 -0
  38. data/lib/jekyll-imgflow.rb +30 -0
  39. metadata +261 -0
@@ -0,0 +1,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