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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllImgFlow
4
+ # Adaptor that translates Jekyll Picture Tag syntax to ImgFlow syntax
5
+ # Simple tag-to-tag translation with values
6
+ class PictureTagAdaptor
7
+ def initialize(site, config)
8
+ @site = site
9
+ @config = config
10
+ end
11
+
12
+ # Convert Picture Tag to ImgFlow tag markup that can be used in templates
13
+ # @param picture_markup [String] Picture Tag markup
14
+ # @return [String] ImgFlow tag markup with attributes
15
+ def to_imgflow_tag(picture_markup)
16
+ result = translate_to_imgflow(picture_markup)
17
+ return "" if result[:markup].empty?
18
+
19
+ # Build ImgFlow tag with attributes
20
+ tag_parts = ["{% imgflow", result[:markup]]
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
30
+
31
+ if result[:attributes][:picture]&.any?
32
+ result[:attributes][:picture].each do |name, value|
33
+ tag_parts << "picture-#{name}:\"#{value}\""
34
+ end
35
+ end
36
+
37
+ tag_parts << "%}"
38
+ tag_parts.join(" ")
39
+ end
40
+
41
+ # Convert Picture Tag markup to ImgFlow markup with HTML attributes
42
+ # @param picture_markup [String] Picture Tag markup like "{% picture hero image.jpg 16:9 --alt Text %}"
43
+ # @return [Hash] Translation result with markup and attributes
44
+ def translate_to_imgflow(picture_markup)
45
+ # Extract content between {% picture ... %}
46
+ match = picture_markup.match(/\{%\s*picture\s+(.+?)\s*%}/)
47
+ return { markup: "", attributes: {} } unless match
48
+
49
+ content = match[1].strip
50
+ return { markup: "", attributes: {} } if content.empty?
51
+
52
+ # Parse arguments
53
+ args = parse_arguments(content)
54
+ return { markup: "", attributes: {} } if args.empty?
55
+
56
+ # Step 1: Categorize arguments by type
57
+ categorized = categorize_arguments(args)
58
+ return { markup: "", attributes: {} } unless categorized[:image]
59
+
60
+ # Step 2: Translate each category to ImgFlow syntax
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
69
+ {
70
+ markup: imgflow_parts.compact.join(" "),
71
+ attributes: categorized[:html_attributes],
72
+ markup_format: categorized[:markup_format]
73
+ }
74
+ end
75
+
76
+ private
77
+
78
+ # Parse arguments handling quoted paths and attributes
79
+ # @param content [String] Raw content
80
+ # @return [Array] Parsed arguments
81
+ def parse_arguments(content)
82
+ args = []
83
+ current = +"" # Create unfrozen string
84
+ in_quotes = false
85
+ quote_char = nil
86
+
87
+ content.dup.each_char do |char|
88
+ case char
89
+ when '"', "'"
90
+ if !in_quotes
91
+ in_quotes = true
92
+ quote_char = char
93
+ elsif char == quote_char
94
+ in_quotes = false
95
+ quote_char = nil
96
+ else
97
+ current << char
98
+ end
99
+ when " "
100
+ if in_quotes
101
+ current << char
102
+ elsif !current.empty?
103
+ args << current.dup
104
+ current = +""
105
+ end
106
+ else
107
+ current << char
108
+ end
109
+ end
110
+
111
+ args << current.dup unless current.empty?
112
+ args
113
+ end
114
+
115
+ # Categorize arguments into types
116
+ # @param args [Array] All parsed arguments
117
+ # @return [Hash] Categorized arguments
118
+ def categorize_arguments(args)
119
+ result = {
120
+ image: nil,
121
+ media_queries: {},
122
+ operations: [],
123
+ html_attributes: default_html_attributes,
124
+ markup_format: nil
125
+ }
126
+
127
+ i = 0
128
+ while i < args.length
129
+ arg = args[i]
130
+
131
+ case arg
132
+ when /^--alt$/
133
+ # Collect alt text until next --
134
+ i += 1
135
+ alt_parts = []
136
+ while i < args.length && !args[i].start_with?("--")
137
+ alt_parts << args[i]
138
+ i += 1
139
+ end
140
+ result[:html_attributes][:alt] = strip_quotes(alt_parts.join(" "))
141
+ next
142
+ when /^--link$/
143
+ result[:html_attributes][:link] = strip_quotes(args[i + 1]) if i + 1 < args.length
144
+ i += 2
145
+ next
146
+ when /^--(img|picture|source|a|parent)$/
147
+ element = ::Regexp.last_match(1).to_sym
148
+ i += 1
149
+ while i < args.length && !args[i].start_with?("--")
150
+ parse_element_attribute(args[i], result[:html_attributes][element])
151
+ i += 1
152
+ end
153
+ next
154
+ when /(mobile|tablet|desktop):/
155
+ device = ::Regexp.last_match(1)
156
+ i += 1
157
+ if i < args.length && args[i].include?(".")
158
+ image = args[i]
159
+ i += 1
160
+ crop = parse_crop_from_arg(args[i]) if i < args.length && args[i] =~ /^\d+:\d+/
161
+ i += 1 if crop
162
+ result[:media_queries][device] = { image: image, crop: crop }
163
+ end
164
+ next
165
+ when /^(auto|data_auto|picture|img)$/
166
+ result[:markup_format] = arg
167
+ when /\./
168
+ # Image path (has extension, no colon)
169
+ result[:image] ||= arg unless arg.include?(":")
170
+ else
171
+ # Operation argument
172
+ result[:operations] << arg unless arg.start_with?("--")
173
+ end
174
+
175
+ i += 1
176
+ end
177
+
178
+ result
179
+ end
180
+
181
+ # Translate media queries to ImgFlow crop syntax
182
+ # @param media_queries [Hash] Media queries by device
183
+ # @return [Array<String>] Translated crop operations
184
+ def translate_media_queries(media_queries)
185
+ return [] if media_queries.empty?
186
+
187
+ # Use mobile crop as primary (most important for responsive)
188
+ primary = media_queries["mobile"] || media_queries.values.first
189
+ return [] unless primary && primary[:crop]
190
+
191
+ parts = []
192
+ parts << "ratio:#{primary[:crop][:ratio]}" if primary[:crop][:ratio]
193
+ parts << "keep:#{primary[:crop][:keep]}" if primary[:crop][:keep]
194
+ parts
195
+ end
196
+
197
+ # Default HTML attributes structure
198
+ def default_html_attributes
199
+ {
200
+ img: {},
201
+ picture: {},
202
+ source: {},
203
+ a: {},
204
+ parent: {},
205
+ alt: nil,
206
+ link: nil
207
+ }
208
+ end
209
+
210
+ # Parse element attribute (class=value or boolean)
211
+ # @param attr_string [String] Attribute string
212
+ # @param attrs_hash [Hash] Hash to store parsed attributes
213
+ def parse_element_attribute(attr_string, attrs_hash)
214
+ case attr_string
215
+ when /^(\w+(?:-\w+)*)=(["'])(.+?)\2$/
216
+ attrs_hash[::Regexp.last_match(1)] = ::Regexp.last_match(3)
217
+ when /^(\w+(?:-\w+)*)=([^"\s]+)$/
218
+ attrs_hash[::Regexp.last_match(1)] = ::Regexp.last_match(2)
219
+ when /^(\w+(?:-\w+)*)$/
220
+ attrs_hash[::Regexp.last_match(1)] = true
221
+ end
222
+ end
223
+
224
+ # Translate operation arguments to ImgFlow syntax
225
+ # @param operations [Array<String>] Operation arguments
226
+ # @return [Array<String>] Translated operations
227
+ def translate_operations(operations)
228
+ operations.map do |arg|
229
+ case arg
230
+ when /^\d+:\d+\s+(center|entropy|top|bottom|left|right|smart)$/
231
+ parts = arg.split
232
+ ["ratio:#{parts[0]}", "keep:#{parts[1]}"]
233
+ when /^\d+:\d+$/
234
+ "ratio:#{arg}"
235
+ when /^(center|entropy|top|bottom|left|right|smart)$/
236
+ "keep:#{arg}"
237
+ when /^[a-zA-Z][a-zA-Z0-9_-]*$/
238
+ "preset:#{arg}"
239
+ when /^\d+$/
240
+ "width:#{arg}"
241
+ end
242
+ end.flatten.compact
243
+ end
244
+
245
+ # Parse crop from argument string
246
+ # @param arg [String] Argument like "16:9" or "16:9 center" or "16:9 attention"
247
+ # @return [Hash, nil] Crop info with ratio and keep parameter
248
+ def parse_crop_from_arg(arg)
249
+ return unless arg
250
+
251
+ parts = arg.split
252
+ crop = {}
253
+
254
+ if /^\d+:\d+$/.match?(parts[0])
255
+ crop[:ratio] = parts[0]
256
+ # Default keep to center if not specified
257
+ crop[:keep] = parts[1] || "center"
258
+ end
259
+
260
+ crop.empty? ? nil : crop
261
+ end
262
+
263
+ # Parse crop information from crop string (for tests)
264
+ # @param crop_string [String] Crop string like "16:9" or "16:9 center"
265
+ # @return [Hash] Parsed crop info with ratio and keep
266
+ def parse_crop(crop_string)
267
+ parse_crop_from_arg(crop_string) || {}
268
+ end
269
+
270
+ # Strip quotes from string
271
+ # @param str [String] String that may have quotes
272
+ # @return [String] String without surrounding quotes
273
+ def strip_quotes(str)
274
+ return "" if str.nil? || str.empty?
275
+
276
+ str = str[1..-2] if str.start_with?('"') && str.end_with?('"')
277
+ str = str[1..-2] if str.start_with?("'") && str.end_with?("'")
278
+ str
279
+ end
280
+
281
+ # Check if formats are already specified
282
+ # @param parts [Array<String>] ImgFlow parts
283
+ # @return [Boolean] True if formats already specified
284
+ def formats?(parts)
285
+ parts.any? { |part| part&.include?("formats:") }
286
+ end
287
+
288
+ # Determine primary crop from multiple crop options (for tests)
289
+ # @param crops [Hash] Hash of crops by breakpoint
290
+ # @return [Hash] Primary crop info
291
+ def determine_primary_crop(crops)
292
+ return crops["mobile"] if crops["mobile"]
293
+ return crops["tablet"] if crops["tablet"]
294
+ return crops["default"] if crops["default"]
295
+
296
+ crops.values.first
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module JekyllImgFlow
7
+ # Migrates Jekyll Picture Tag presets to ImgFlow YAML preset format
8
+ class PictureTagPresetMigrator
9
+ PICTURE_TAG_CONFIG = "_data/picture.yml"
10
+ IMGFLOW_PRESETS_DIR = "_data/imgflow/presets"
11
+
12
+ def initialize(site_source)
13
+ @site_source = site_source
14
+ @picture_config_path = File.join(site_source, PICTURE_TAG_CONFIG)
15
+ @imgflow_presets_dir = File.join(site_source, IMGFLOW_PRESETS_DIR)
16
+ end
17
+
18
+ # Migrate all Picture Tag presets to ImgFlow format
19
+ # @return [Hash] Migration results
20
+ def migrate_presets
21
+ results = {
22
+ migrated: [],
23
+ skipped: [],
24
+ errors: []
25
+ }
26
+
27
+ return results unless File.exist?(@picture_config_path)
28
+
29
+ picture_config = load_picture_config
30
+ return results unless picture_config["presets"]
31
+
32
+ # Ensure ImgFlow presets directory exists
33
+ FileUtils.mkdir_p(@imgflow_presets_dir)
34
+
35
+ picture_config["presets"].each do |preset_name, preset_data|
36
+ imgflow_preset = convert_preset_to_imgflow(preset_name, preset_data)
37
+
38
+ if imgflow_preset
39
+ save_imgflow_preset(preset_name, imgflow_preset)
40
+ results[:migrated] << preset_name
41
+ else
42
+ results[:skipped] << { name: preset_name, reason: "No convertible operations" }
43
+ end
44
+ rescue StandardError => e
45
+ results[:errors] << { name: preset_name, error: e.message }
46
+ end
47
+
48
+ results
49
+ end
50
+
51
+ # Get migration summary without actually migrating
52
+ # @return [Hash] Preview of what would be migrated
53
+ def preview_migration
54
+ results = {
55
+ found: [],
56
+ convertible: [],
57
+ non_convertible: []
58
+ }
59
+
60
+ return results unless File.exist?(@picture_config_path)
61
+
62
+ picture_config = load_picture_config
63
+ return results unless picture_config["presets"]
64
+
65
+ picture_config["presets"].each do |preset_name, preset_data|
66
+ results[:found] << preset_name
67
+
68
+ if convertible_operations?(preset_data)
69
+ imgflow_preset = convert_preset_to_imgflow(preset_name, preset_data)
70
+ results[:convertible] << { name: preset_name, preview: imgflow_preset }
71
+ else
72
+ results[:non_convertible] << { name: preset_name,
73
+ reason: "No convertible operations" }
74
+ end
75
+ end
76
+
77
+ results
78
+ end
79
+
80
+ private
81
+
82
+ # Load Picture Tag configuration
83
+ # @return [Hash] Picture Tag config
84
+ def load_picture_config
85
+ YAML.safe_load_file(@picture_config_path) || {}
86
+ end
87
+
88
+ # Check if preset has operations that can be converted
89
+ # @param preset_data [Hash] Picture Tag preset data
90
+ # @return [Boolean] True if convertible
91
+ def convertible_operations?(preset_data)
92
+ convertible_keys = %w[widths base_width width height quality formats crop gravity]
93
+ convertible_keys.any? { |key| preset_data[key] }
94
+ end
95
+
96
+ # Convert Picture Tag preset to ImgFlow YAML format
97
+ # @param preset_name [String] Preset name
98
+ # @param preset_data [Hash] Picture Tag preset data
99
+ # @return [Hash, nil] ImgFlow preset data or nil if not convertible
100
+ def convert_preset_to_imgflow(preset_name, preset_data)
101
+ operations = []
102
+
103
+ # Handle multi-width presets - use the largest width as primary
104
+ if preset_data["widths"]
105
+ max_width = Array(preset_data["widths"]).max
106
+ operations << { "resize" => { "width" => max_width } }
107
+ elsif preset_data["base_width"]
108
+ # For pixel-ratio presets, use base_width
109
+ operations << { "resize" => { "width" => preset_data["base_width"] } }
110
+ elsif preset_data["width"]
111
+ # Direct width setting
112
+ operations << { "resize" => { "width" => preset_data["width"] } }
113
+ end
114
+
115
+ # Handle height (less common in Picture Tag)
116
+ if preset_data["height"]
117
+ if operations.any? && operations.last["resize"]
118
+ # Add height to existing resize operation
119
+ operations.last["resize"]["height"] = preset_data["height"]
120
+ else
121
+ operations << { "resize" => { "height" => preset_data["height"] } }
122
+ end
123
+ end
124
+
125
+ # Convert quality (if specified)
126
+ operations << { "quality" => { "quality" => preset_data["quality"] } } if preset_data["quality"]
127
+
128
+ # Convert formats - Picture Tag uses 'original', ImgFlow uses actual format
129
+ if preset_data["formats"]
130
+ formats = Array(preset_data["formats"]).map do |format|
131
+ case format
132
+ when "original"
133
+ "jpg" # Default to jpg for 'original'
134
+ else
135
+ format
136
+ end
137
+ end
138
+ operations << { "format" => { "formats" => formats } }
139
+ end
140
+
141
+ # Convert crop (aspect ratio) - handle both ratio and aspect_ratio with precedence
142
+ ratio_value = preset_data["ratio"] || preset_data["aspect_ratio"] || preset_data["crop"]
143
+ operations << { "crop" => { "ratio" => ratio_value } } if ratio_value
144
+
145
+ # Convert gravity/position - handle both position and gravity with precedence
146
+ position_value = preset_data["position"] || preset_data["gravity"]
147
+ if position_value
148
+ if operations.any? { |op| op["crop"] }
149
+ # Add to existing crop operation
150
+ crop_op = operations.find { |op| op["crop"] }
151
+ crop_op["crop"]["position"] = position_value
152
+ else
153
+ # Create new crop operation
154
+ operations << { "crop" => { "position" => position_value } }
155
+ end
156
+ end
157
+
158
+ return if operations.empty?
159
+
160
+ {
161
+ "name" => preset_name,
162
+ "description" => generate_description(preset_name, preset_data),
163
+ "operations" => operations,
164
+ "picture_tag_metadata" => {
165
+ "original_widths" => preset_data["widths"],
166
+ "original_base_width" => preset_data["base_width"],
167
+ "original_pixel_ratios" => preset_data["pixel_ratios"],
168
+ "original_sizes" => preset_data["sizes"],
169
+ "dimension_attributes" => preset_data["dimension_attributes"]
170
+ }.compact
171
+ }
172
+ end
173
+
174
+ # Generate description for ImgFlow preset
175
+ # @param preset_name [String] Preset name
176
+ # @param preset_data [Hash] Original preset data
177
+ # @return [String] Description
178
+ def generate_description(preset_name, preset_data)
179
+ operations = []
180
+
181
+ if preset_data["widths"]
182
+ operations << "widths: #{Array(preset_data['widths']).join(',')}"
183
+ operations << "using max width: #{Array(preset_data['widths']).max}"
184
+ elsif preset_data["base_width"]
185
+ operations << "base_width: #{preset_data['base_width']}"
186
+ operations << "pixel_ratios: #{Array(preset_data['pixel_ratios']).join(',')}"
187
+ elsif preset_data["width"]
188
+ operations << "width: #{preset_data['width']}"
189
+ end
190
+
191
+ operations << "height: #{preset_data['height']}" if preset_data["height"]
192
+ operations << "quality: #{preset_data['quality']}" if preset_data["quality"]
193
+ operations << "formats: #{Array(preset_data['formats']).join(',')}" if preset_data["formats"]
194
+ operations << "crop: #{preset_data['crop']}" if preset_data["crop"]
195
+ operations << "gravity: #{preset_data['gravity']}" if preset_data["gravity"]
196
+
197
+ "Migrated from Picture Tag preset '#{preset_name}'. #{operations.join(', ')}"
198
+ end
199
+
200
+ # Save ImgFlow preset to YAML file
201
+ # @param preset_name [String] Preset name
202
+ # @param preset_data [Hash] ImgFlow preset data
203
+ def save_imgflow_preset(preset_name, preset_data)
204
+ filename = "#{preset_name}.yml"
205
+ filepath = File.join(@imgflow_presets_dir, filename)
206
+
207
+ File.write(filepath, YAML.dump(preset_data))
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module JekyllImgFlow
6
+ # PresetManager - translates YAML presets to tags:value format
7
+ # Handles tag overrides (user tags override preset tags)
8
+ # Passes combined markup to Parser for uniform validation flow
9
+ class PresetManager
10
+ def initialize(site, config)
11
+ @site = site
12
+ @config = config
13
+ @presets = load_presets
14
+ end
15
+
16
+ # Load presets from _data/imgflow/presets/
17
+ def load_presets
18
+ presets = {}
19
+ presets_dir = File.join(@site.source, "_data", "imgflow", "presets")
20
+
21
+ return presets unless Dir.exist?(presets_dir)
22
+
23
+ Dir.glob(File.join(presets_dir, "*.yml")).each do |preset_file|
24
+ preset_name = File.basename(preset_file, ".yml")
25
+ begin
26
+ preset_data = YAML.safe_load_file(preset_file)
27
+ presets[preset_name] = preset_data
28
+ rescue Psych::SyntaxError => e
29
+ Jekyll.logger.warn "ImgFlow: Invalid YAML in preset file #{preset_file}: #{e.message}"
30
+ # Skip invalid preset files
31
+ end
32
+ end
33
+
34
+ presets
35
+ end
36
+
37
+ # Get a preset by name
38
+ # @param name [String] Preset name
39
+ # @return [Hash, nil] Preset data or nil if not found
40
+ def get_preset(name)
41
+ @presets[name.to_s]
42
+ end
43
+
44
+ # Check if a preset exists
45
+ # @param name [String] Preset name
46
+ # @return [Boolean] True if preset exists
47
+ def preset_exists?(name)
48
+ @presets.key?(name.to_s)
49
+ end
50
+
51
+ # Get all available preset names
52
+ # @return [Array<String>] Array of preset names
53
+ def available_presets
54
+ @presets.keys
55
+ end
56
+
57
+ # Build markup from preset (tags:value format)
58
+ # @param preset_name [String] Name of preset
59
+ # @param user_options [Hash] User-provided options that override preset values
60
+ # @return [String] Markup string in tags:value format
61
+ def build_markup_from_preset(preset_name, user_options = {})
62
+ preset = get_preset(preset_name)
63
+ return "" unless preset
64
+
65
+ # Convert YAML operations to tags:value format
66
+ preset_tags = yaml_to_tags(preset)
67
+
68
+ # Merge with user options (user options override preset)
69
+ merged_tags = merge_tags(preset_tags, user_options)
70
+
71
+ # Convert to markup string
72
+ tags_to_markup(merged_tags)
73
+ end
74
+
75
+ private
76
+
77
+ # Convert YAML operations to tags hash
78
+ # Note: Multiple operations of same type will have last one's values
79
+ # This matches the override behavior where user tags override preset tags
80
+ # @param preset [Hash] Preset data from YAML
81
+ # @return [Hash] Tags in key => value format
82
+ def yaml_to_tags(preset)
83
+ tags = {}
84
+ preset_operations = preset["operations"] || []
85
+
86
+ preset_operations.each do |op|
87
+ next unless op.is_a?(Hash)
88
+
89
+ op.each do |op_type, params|
90
+ next unless params.is_a?(Hash)
91
+
92
+ # Store operation type for operations that don't have specific params
93
+ # This allows Parser to detect the operation type
94
+ case op_type.to_s
95
+ when "resize"
96
+ # Resize params: width, height (both optional, at least one required)
97
+ tags[:width] = params["width"] if params["width"]
98
+ tags[:height] = params["height"] if params["height"]
99
+ when "crop"
100
+ # Crop params: ratio or width/height
101
+ tags[:ratio] = params["ratio"] if params["ratio"]
102
+ tags[:aspect_ratio] = params["aspect_ratio"] if params["aspect_ratio"]
103
+ tags[:width] = params["width"] if params["width"] && !tags[:width]
104
+ tags[:height] = params["height"] if params["height"] && !tags[:height]
105
+ when "format"
106
+ # Format params: format or formats
107
+ if params["formats"]
108
+ tag_value = params["formats"].is_a?(Array) ? params["formats"].join(",") : params["formats"]
109
+ tags[:formats] = tag_value
110
+ elsif params["format"]
111
+ tags[:format] = params["format"]
112
+ end
113
+ when "quality"
114
+ tags[:quality] = params["quality"] if params["quality"]
115
+ when "optimize"
116
+ tags[:optimize] = true
117
+ tags[:level] = params["level"] if params["level"]
118
+ when "opacity"
119
+ tags[:opacity] = params["opacity"] if params["opacity"]
120
+ else
121
+ # Generic handling for unknown operations
122
+ params.each do |key, value|
123
+ tag_value = value.is_a?(Array) ? value.join(",") : value
124
+ tags[key.to_sym] = tag_value
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ tags
131
+ end
132
+
133
+ # Merge preset tags with user options (user options override)
134
+ # @param preset_tags [Hash] Tags from preset
135
+ # @param user_options [Hash] User-provided options
136
+ # @return [Hash] Merged tags
137
+ def merge_tags(preset_tags, user_options)
138
+ # User options override preset tags
139
+ preset_tags.merge(user_options)
140
+ end
141
+
142
+ # Convert tags hash to markup string
143
+ # @param tags [Hash] Tags in key => value format
144
+ # @return [String] Markup string
145
+ def tags_to_markup(tags)
146
+ tags.map { |key, value| "#{key}:#{value}" }.join(" ")
147
+ end
148
+ end
149
+ end