sparx 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.
@@ -0,0 +1,71 @@
1
+ module Sparx
2
+ MIME_TYPES = {'webp' => 'image/webp','avif' => 'image/avif','jpg' => 'image/jpeg','jpeg' => 'image/jpeg','png' => 'image/png','gif' => 'image/gif','svg' => 'image/svg+xml'}.freeze
3
+ CONTAINER_SPECS = {
4
+ details: {
5
+ pattern: /\+\[(.*?)\]\{/, placeholder: "DETAILSPLACEHOLDER",
6
+ build_html: ->(matches, content, citations, numbered_citations) {
7
+ summary = process_all_inline_elements(matches[:summary], citations, numbered_citations)
8
+ "<details><summary>#{summary}</summary>#{content}</details>"
9
+ },extract_params: ->(match) { {summary: match[1]} }
10
+ },
11
+ section: {
12
+ pattern: /\$\[([^\]]+)\]\{/, # Change this to accept any characters in []
13
+ placeholder: "SECTIONPLACEHOLDER",
14
+ build_html: ->(matches, content, citations, numbered_citations) {
15
+ id = escape_html_attr(matches[:id]) # Escape the ID
16
+ "<section id=\"#{id}\">#{content}</section>"
17
+ },
18
+ extract_params: ->(match) { {id: match[1]} }
19
+ },
20
+ blockquote: {
21
+ pattern: />(?:\[([^\]]+)\])?\{/, placeholder: "BLOCKQUOTEPLACEHOLDER",
22
+ build_html: ->(matches, content, citations, numbered_citations) {
23
+ cite_attr = matches[:cite] ? " cite=\"#{escape_html_attr(matches[:cite])}\"" : ""
24
+ "<blockquote#{cite_attr}>#{content}</blockquote>"
25
+ },extract_params: ->(match) { {cite: match[1]} }
26
+ },
27
+ div_class: {
28
+ pattern: /\.([a-zA-Z0-9_-]+)\{/, placeholder: "DIVPLACEHOLDER",
29
+ build_html: ->(matches, content, citations, numbered_citations) {
30
+ "<div class=\"#{matches[:class_name]}\">#{content}</div>"
31
+ },extract_params: ->(match) { {class_name: match[1]} }
32
+ },
33
+ aside: {
34
+ pattern: /~\{/, placeholder: "ASIDEPLACEHOLDER",
35
+ build_html: ->(matches, content, citations, numbered_citations) {
36
+ "<aside>#{content}</aside>"
37
+ },extract_params: ->(match) { {} }
38
+ },
39
+ figure: {
40
+ pattern: /f\[(.*?)\]\{/, placeholder: "FIGUREPLACEHOLDER",
41
+ build_html: ->(matches, content, citations, numbered_citations) {
42
+ caption = process_all_inline_elements(matches[:caption], citations, numbered_citations)
43
+ "<figure>#{content}<figcaption>#{caption}</figcaption></figure>"
44
+ },extract_params: ->(match) { {caption: match[1]} }
45
+ }
46
+ }.freeze
47
+ LIST_SPECS = {
48
+ ul: {
49
+ simple_pattern: /^\s*(-+)\s+(.+)$/,complex_pattern: /^\s*(-+)\{$/, placeholder: "ULPLACEHOLDER",
50
+ build_html: ->(items) {build_nested_list(items, 'ul')},
51
+ extract_item_params: ->(match, content = nil) {{level: match[1].length, content: content || match[2]}}
52
+ },
53
+ ol: {
54
+ simple_pattern: /^\s*(\++)\s+(.+)$/,complex_pattern: /^\s*(\++)\{$/, placeholder: "OLPLACEHOLDER",
55
+ build_html: ->(items) {build_nested_list(items, 'ol')},
56
+ extract_item_params: ->(match, content = nil) {{level: match[1].length, content: content || match[2]}}
57
+ },
58
+ dl: {
59
+ simple_pattern: /^\s*(:+)([^:]+):\s*(.*)$/,complex_pattern: /^\s*(:+)([^:]+):\{$/, placeholder: "DLPLACEHOLDER",
60
+ build_html: ->(items) {build_nested_definition_list(items)},
61
+ extract_item_params: ->(match, content = nil) {
62
+ {level: match[1].length, term: match[2].strip, description: (content ? content : match[3].strip)}
63
+ }
64
+ }
65
+ }.freeze
66
+
67
+
68
+ def self.global_counters
69
+ @global_counters ||= {}
70
+ end
71
+ end
@@ -0,0 +1,217 @@
1
+ module Sparx
2
+ def self.process_all_containers(text, citations, numbered_citations)
3
+ original_text = text.dup
4
+ all_caches = {}
5
+ text = process_responsive_images(text, citations, numbered_citations)
6
+ CONTAINER_SPECS.each do |type, spec|
7
+ result = process_container_type(text, spec, citations, numbered_citations, type)
8
+ text = result[:text]
9
+ all_caches[type] = result[:cache]
10
+ end
11
+ LIST_SPECS.each do |type, spec|
12
+ result = process_list_type(text, spec, citations, numbered_citations, type)
13
+ text = result[:text]
14
+ all_caches[type] = result[:cache]
15
+ end
16
+ text = parse_styles(text, citations, numbered_citations)
17
+ text = wrap_paragraphs_if_needed(text, original_text)
18
+ max_iterations = 10
19
+ iteration = 0
20
+ while iteration < max_iterations
21
+ replacements_made = false
22
+ (LIST_SPECS.keys + CONTAINER_SPECS.keys).reverse.each do |type|
23
+ all_caches[type]&.each do |placeholder, html|
24
+ if text.include?(placeholder)
25
+ text = text.gsub(placeholder, html)
26
+ replacements_made = true
27
+ end
28
+ end
29
+ end
30
+ break unless replacements_made
31
+ iteration += 1
32
+ end
33
+ text
34
+ end
35
+ def self.process_container_type(text, spec, citations, numbered_citations, type)
36
+ container_cache = {}
37
+ @global_counters[spec[:placeholder]] ||= 0
38
+ while match = text.match(spec[:pattern])
39
+ start_pos = match.begin(0)
40
+ content_start = match.end(0)
41
+ end_pos = find_matching_brace(text, content_start)
42
+ content = text[content_start...end_pos].strip
43
+ params = spec[:extract_params].call(match)
44
+ processed_content = process_all_containers(content, citations, numbered_citations)
45
+ html = spec[:build_html].call(params, processed_content, citations, numbered_citations)
46
+ placeholder = "#{spec[:placeholder]}#{@global_counters[spec[:placeholder]]}END"
47
+ @global_counters[spec[:placeholder]] += 1
48
+ container_cache[placeholder] = html
49
+ text = text[0...start_pos] + placeholder + text[end_pos + 1..-1]
50
+ end
51
+ { text: text, cache: container_cache }
52
+ end
53
+ def self.process_list_type(text, spec, citations, numbered_citations, type)
54
+ cache = {}
55
+ @global_counters[spec[:placeholder]] ||= 0
56
+ lines = text.split("\n")
57
+ result_lines = []
58
+ i = 0
59
+ complex_just_closed = false
60
+ while i < lines.length
61
+ line = lines[i]
62
+ simple_match = line.match(spec[:simple_pattern])
63
+ complex_match = line.match(spec[:complex_pattern])
64
+ if simple_match || complex_match
65
+ items = []
66
+ while i < lines.length
67
+ current_line = lines[i]
68
+ if type == :dl
69
+ if !current_line.strip.empty? && !current_line.match(spec[:simple_pattern]) && !current_line.match(spec[:complex_pattern])
70
+ break
71
+ end
72
+ if current_line.strip.empty?
73
+ if i + 1 < lines.length
74
+ next_line = lines[i + 1]
75
+ if next_line.match(spec[:simple_pattern]) || next_line.match(spec[:complex_pattern])
76
+ i += 1
77
+ next
78
+ else
79
+ break
80
+ end
81
+ else
82
+ break
83
+ end
84
+ end
85
+ end
86
+ if match = current_line.match(spec[:complex_pattern])
87
+ i += 1
88
+ content_lines = []
89
+ brace_count = 1
90
+ while i < lines.length && brace_count > 0
91
+ content_line = lines[i]
92
+ content_line.each_char do |char|
93
+ brace_count += 1 if char == '{'
94
+ brace_count -= 1 if char == '}'
95
+ end
96
+ if brace_count > 0
97
+ content_lines << content_line
98
+ else
99
+ close_pos = content_line.rindex('}')
100
+ content_lines << content_line[0...close_pos] if close_pos && close_pos > 0
101
+ end
102
+ i += 1
103
+ end
104
+ content = content_lines.join("\n").strip
105
+ original_content = content.dup
106
+ processed_content = process_all_containers(content, citations, numbered_citations)
107
+ if original_content.match?(/\n\s*\n/)
108
+ processed_content = processed_content
109
+ else
110
+ unless processed_content.match(/\A<(ul|ol|table|blockquote|pre|h\d|div|details|section|img|dl|aside|figure)/)
111
+ processed_content = "<p>#{processed_content}</p>"
112
+ end
113
+ end
114
+ params = spec[:extract_item_params].call(match, processed_content)
115
+ original_level = params[:level]
116
+ if type == :dl && complex_just_closed
117
+ params[:level] = 1
118
+ if original_level == 1
119
+ complex_just_closed = false
120
+ end
121
+ end
122
+ params[:closed] = true
123
+ if type == :dl
124
+ params[:term] = process_all_inline_elements(params[:term], citations, numbered_citations)
125
+ end
126
+ items << params
127
+ complex_just_closed = true
128
+ elsif match = current_line.match(spec[:simple_pattern])
129
+ params = spec[:extract_item_params].call(match)
130
+ original_level = params[:level]
131
+ if type == :dl && complex_just_closed
132
+ params[:level] = 1
133
+ if original_level == 1
134
+ complex_just_closed = false
135
+ end
136
+ end
137
+ if type == :dl
138
+ params[:term] = process_all_inline_elements(params[:term], citations, numbered_citations)
139
+ params[:description] = process_all_inline_elements(params[:description], citations, numbered_citations)
140
+ else
141
+ params[:content] = process_all_inline_elements(params[:content], citations, numbered_citations)
142
+ end
143
+ items << params
144
+ i += 1
145
+ else
146
+ break
147
+ end
148
+ end
149
+ if items.any?
150
+ html = spec[:build_html].call(items)
151
+ placeholder = "#{spec[:placeholder]}#{@global_counters[spec[:placeholder]]}END"
152
+ @global_counters[spec[:placeholder]] += 1
153
+ cache[placeholder] = html
154
+ result_lines << placeholder
155
+ i -= 1
156
+ else
157
+ result_lines << line
158
+ end
159
+ else
160
+ result_lines << line
161
+ end
162
+ i += 1
163
+ end
164
+ { text: result_lines.join("\n"), cache: cache }
165
+ end
166
+ def self.find_matching_brace(text, start_pos)
167
+ brace_count = 1
168
+ pos = start_pos
169
+ while pos < text.length && brace_count > 0
170
+ if text[pos] == '{'
171
+ brace_count += 1
172
+ elsif text[pos] == '}'
173
+ brace_count -= 1
174
+ end
175
+ pos += 1
176
+ end
177
+ pos - 1
178
+ end
179
+ def self.build_nested_list(items, list_type);return "" if items.empty?;html, _ = build_list_recursive(items, 0, list_type, 1);html;end
180
+ def self.build_list_recursive(items, start_index, list_type, expected_level)
181
+ return ["", start_index] if start_index >= items.length
182
+ html = "<#{list_type}>"
183
+ i = start_index
184
+ while i < items.length
185
+ item = items[i]
186
+ if item[:level] == expected_level
187
+ html += "<li>#{item[:content]}"
188
+ if i + 1 < items.length && items[i + 1][:level] > expected_level
189
+ nested_html, new_i = build_list_recursive(items, i + 1, list_type, expected_level + 1)
190
+ html += nested_html
191
+ i = new_i - 1
192
+ end
193
+ html += "</li>";i += 1;elsif item[:level] < expected_level;break;else;i += 1;end
194
+ end
195
+ html += "</#{list_type}>";[html, i]
196
+ end
197
+ def self.build_nested_definition_list(items);return "" if items.empty?;html, _ = build_definition_list_recursive(items, 0, 1);html;end
198
+ def self.build_definition_list_recursive(items, start_index, expected_level)
199
+ return ["", start_index] if start_index >= items.length
200
+ html = "<dl>";i = start_index
201
+ while i < items.length
202
+ item = items[i]
203
+ if item[:level] == expected_level
204
+ html += "<dt>#{item[:term]}</dt>"
205
+ html += "<dd>#{item[:description]}"
206
+ if !item[:closed] && i + 1 < items.length && items[i + 1][:level] > expected_level
207
+ nested_html, new_i = build_definition_list_recursive(items, i + 1, expected_level + 1)
208
+ html += nested_html
209
+ i = new_i - 1
210
+ end
211
+ html += "</dd>"
212
+ i += 1
213
+ elsif item[:level] < expected_level;break;else;i += 1;end
214
+ end
215
+ html += "</dl>";[html, i]
216
+ end
217
+ end
@@ -0,0 +1,267 @@
1
+ module Sparx
2
+ def self.process_srcset_with_expansion(srcset_parts, media_attr, url_prefix, citations)
3
+ expanded_items = []
4
+ srcset_parts.each do |part|
5
+ if part.match(/^(.+\{[^}]+\})(.*)$/)
6
+ path_with_braces, suffix = $1, $2.strip
7
+ width_descriptor = suffix.match(/^(\d+w)/) ? $1 : nil
8
+ if path_with_braces.match(/^(.+)\{([^}]+)\}$/)
9
+ base_path, extensions = $1, $2.split(',').map(&:strip)
10
+ extensions.each do |ext|
11
+ expanded_items << {path: "#{base_path}#{ext}", width: width_descriptor, ext: ext}
12
+ end
13
+ end
14
+ elsif match = part.match(/^([^\s]+)\s*(\d+w)?$/)
15
+ path, width, ext = match[1], match[2], File.extname(match[1]).sub('.', '')
16
+ expanded_items << {path: path, width: width, ext: ext}
17
+ end
18
+ end
19
+ grouped = expanded_items.group_by { |item| item[:ext] }
20
+ sources = []
21
+ grouped.each do |ext, items|
22
+ srcset_value = items.map { |item|
23
+ full_src = resolve_image_src(item[:path], url_prefix, citations)
24
+ item[:width] ? "#{full_src} #{item[:width]}" : full_src
25
+ }.join(', ')
26
+ sources << build_source_tag(srcset_value, infer_type(ext), media_attr)
27
+ end
28
+ sources
29
+ end
30
+ def self.expand_source(path_str, media_attr, url_prefix, citations)
31
+ sources = []
32
+ if path_str.match(/^(.+)\{([^}]+)\}(.*)$/)
33
+ base_path, extensions, suffix = $1, $2.split(',').map(&:strip), $3
34
+ extensions.each do |ext|
35
+ full_path = "#{base_path}#{ext}#{suffix}"
36
+ actual_path = full_path.split(/\s+/).first
37
+ width_descriptor = full_path.match(/\s+(\d+w)/) ? $1 : nil
38
+ full_src = resolve_image_src(actual_path, url_prefix, citations)
39
+ srcset_value = width_descriptor ? "#{full_src} #{width_descriptor}" : full_src
40
+ sources << build_source_tag(srcset_value, infer_type(ext), media_attr)
41
+ end
42
+ else
43
+ actual_path = path_str.split(/\s+/).first
44
+ width_descriptor = path_str.match(/\s+(\d+w)/) ? $1 : nil
45
+ full_src = resolve_image_src(actual_path, url_prefix, citations)
46
+ ext = File.extname(actual_path).sub('.', '')
47
+ srcset_value = width_descriptor ? "#{full_src} #{width_descriptor}" : full_src
48
+ sources << build_source_tag(srcset_value, infer_type(ext), media_attr)
49
+ end
50
+ sources
51
+ end
52
+ def self.process_responsive_images(text, citations, numbered_citations)
53
+ lines = text.split("\n")
54
+ result_lines = []
55
+ i = 0
56
+ @global_counters['PICTUREPLACEHOLDER'] ||= 0
57
+ picture_cache = {}
58
+ while i < lines.length
59
+ line = lines[i]
60
+ if line.match(/^\s*src\[([^\]]+)\](.+)$/)
61
+ src_elements = []
62
+ start_i = i
63
+ while i < lines.length && lines[i].match(/^\s*src\[([^\]]+)\](.+)$/)
64
+ src_elements << lines[i]
65
+ i += 1
66
+ break if i >= lines.length
67
+ next_line = lines[i]
68
+ if next_line.strip.empty?
69
+ if i + 1 < lines.length
70
+ following = lines[i + 1]
71
+ break unless following.match(/^\s*src\[/) || following.match(/^\s*([*\/-]*)i\[/)
72
+ else
73
+ break
74
+ end
75
+ elsif !next_line.match(/^\s*src\[/) && !next_line.match(/^\s*([*\/-]*)i\[/)
76
+ break
77
+ end
78
+ end
79
+ img_line_index = i
80
+ img_line_index = i + 1 if i < lines.length && lines[i].strip.empty?
81
+ if img_line_index < lines.length && lines[img_line_index].match(/^\s*([*\/-]*)i\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*?)\](@[a-zA-Z0-9_-]+)?([^\s\[\]=^]+)(?:=(\d+x\d+))?/)
82
+ img_match = lines[img_line_index].match(/^\s*([*\/-]*)i\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*?)\](@[a-zA-Z0-9_-]+)?([^\s\[\]=^]+)(?:=(\d+x\d+))?/)
83
+ prefix, inner, url_prefix, src, dimensions = img_match[1], img_match[2], img_match[3], img_match[4], img_match[5]
84
+ sources = []
85
+ src_elements.each do |src_line|
86
+ src_match = src_line.match(/^\s*src\[([^\]]+)\](.+)$/)
87
+ condition, path_and_srcset = src_match[1], src_match[2].strip
88
+ local_url_prefix = url_prefix
89
+ if path_and_srcset.match(/^(@[a-zA-Z0-9_-]+)(.+)$/)
90
+ local_url_prefix = $1
91
+ path_and_srcset = $2
92
+ end
93
+ media_attr = parse_media_condition(condition)
94
+ if path_and_srcset.include?('|')
95
+ sources.concat(process_srcset_with_expansion(path_and_srcset.split('|').map(&:strip), media_attr, local_url_prefix, citations))
96
+ else
97
+ sources.concat(expand_source(path_and_srcset, media_attr, local_url_prefix, citations))
98
+ end
99
+ end
100
+ parts = inner.split('|', 2)
101
+ alt, title = parts[0] || "", parts[1]
102
+ full_src = resolve_image_src(src, url_prefix, citations)
103
+ img_attrs = "src=\"#{full_src}\" alt=\"#{escape_html_attr(alt)}\""
104
+ img_attrs += " title=\"#{escape_html_attr(title)}\"" if title && !title.empty?
105
+ img_attrs += " width=\"#{dimensions.split('x')[0]}\" height=\"#{dimensions.split('x')[1]}\"" if dimensions
106
+ picture_html = "<picture>\n#{sources.map { |s| " #{s}" }.join("\n")}\n <img #{img_attrs}>\n</picture>"
107
+ picture_html = apply_formatting_prefixes(picture_html, prefix)
108
+ placeholder = "PICTUREPLACEHOLDER#{@global_counters['PICTUREPLACEHOLDER']}END"
109
+ @global_counters['PICTUREPLACEHOLDER'] += 1
110
+ picture_cache[placeholder] = picture_html
111
+ result_lines << placeholder
112
+ i = img_line_index + 1
113
+ else
114
+ result_lines.concat(src_elements)
115
+ i = start_i + src_elements.length
116
+ end
117
+ else
118
+ result_lines << line
119
+ i += 1
120
+ end
121
+ end
122
+ text = result_lines.join("\n")
123
+ picture_cache.each { |placeholder, html| text = text.gsub(placeholder, html) }
124
+ text
125
+ end
126
+ def self.parse_media_condition(condition)
127
+ return "(min-width: #{$1}px)" if condition.match(/^>(\d+)px$/)
128
+ return "(max-width: #{$1}px)" if condition.match(/^<(\d+)px$/)
129
+ nil
130
+ end
131
+
132
+
133
+ def self.infer_type(extension)
134
+ MIME_TYPES[extension.downcase]
135
+ end
136
+ def self.build_source_tag(srcset, type_attr, media_attr)
137
+ attrs = ["srcset=\"#{srcset}\""]
138
+ attrs << "type=\"#{type_attr}\"" if type_attr
139
+ attrs << "media=\"#{media_attr}\"" if media_attr
140
+ "<source #{attrs.join(' ')}>"
141
+ end
142
+ def self.process_images(content, citations, numbered_citations, recursive_processor = nil)
143
+ content.gsub(/([*\/-]*)i\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*?)\](@[a-zA-Z0-9_-]+)?([^\n]+)/) do
144
+ prefix = $1
145
+ inner = $2
146
+ url_prefix = $3
147
+ remainder = $4.strip
148
+
149
+ # Extract dimensions if present
150
+ dimensions = nil
151
+ if remainder =~ /(.+)=(\d+x\d+)$/
152
+ remainder = $1.strip
153
+ dimensions = $2
154
+ end
155
+
156
+ # CHECK FOR JAVASCRIPT PROTOCOL IN IMAGE SRC
157
+ if remainder =~ /^javascript:/i
158
+ # Block javascript: protocol in images
159
+ next apply_formatting_prefixes("[IMAGE BLOCKED - UNSAFE PROTOCOL]", prefix)
160
+ end
161
+
162
+ if remainder.include?('|')
163
+ srcset_items = remainder.split('|').map(&:strip)
164
+ first_src = srcset_items[0].gsub(/\s+\d+w$/, '')
165
+ src_value = resolve_image_src(first_src, url_prefix, citations)
166
+ srcset_value = srcset_items.map { |item|
167
+ if item.match(/^([^\s]+)(?:\s+(\d+w))?$/)
168
+ path = $1
169
+ descriptor = $2
170
+ full_path = resolve_image_src(path, url_prefix, citations)
171
+ descriptor ? "#{full_path} #{descriptor}" : full_path
172
+ end
173
+ }.compact.join(', ')
174
+
175
+ # ESCAPE THE IMAGE ATTRIBUTES (security fix)
176
+ parts = inner.split('|', 2)
177
+ alt = escape_html_content(parts[0] || "")
178
+ title = parts[1] ? escape_html_content(parts[1]) : nil
179
+
180
+ img_attrs = %Q(src="#{src_value}" srcset="#{srcset_value}" alt="#{alt}")
181
+ img_attrs += %Q( title="#{title}") if title
182
+ img_attrs += %Q( width="#{dimensions.split('x')[0]}" height="#{dimensions.split('x')[1]}") if dimensions
183
+ apply_formatting_prefixes("<img #{img_attrs}>", prefix)
184
+ else
185
+ src = remainder
186
+
187
+ # ESCAPE THE IMAGE ATTRIBUTES (security fix)
188
+ parts = inner.split('|', 2)
189
+ alt = escape_html_content(parts[0] || "")
190
+ title = parts[1] ? escape_html_content(parts[1]) : nil
191
+
192
+ full_src = resolve_image_src(src, url_prefix, citations)
193
+ img_attrs = %Q(src="#{full_src}" alt="#{alt}")
194
+ img_attrs += %Q( title="#{title}") if title
195
+ img_attrs += %Q( width="#{dimensions.split('x')[0]}" height="#{dimensions.split('x')[1]}") if dimensions
196
+ apply_formatting_prefixes("<img #{img_attrs}>", prefix)
197
+ end
198
+ end
199
+ end
200
+ def self.resolve_image_src(src, url_prefix, citations)
201
+ # Block dangerous protocols in images
202
+ if src =~ /^(javascript|data|vbscript):/i
203
+ return "[BLOCKED]"
204
+ end
205
+
206
+ return src.start_with?('/') ? src[1..-1] : src unless url_prefix
207
+ prefix_id = url_prefix[1..-1]
208
+ return src.start_with?('/') ? src[1..-1] : src unless citations[prefix_id] && citations[prefix_id][:url]
209
+
210
+ base_url = citations[prefix_id][:url]
211
+
212
+ # Block dangerous protocols in citation URLs too
213
+ if base_url =~ /^(javascript|data|vbscript):/i
214
+ return "[BLOCKED]"
215
+ end
216
+ if base_url.end_with?('/') && src.start_with?('/')
217
+ base_url + src[1..-1]
218
+ elsif !base_url.end_with?('/') && !src.start_with?('/')
219
+ base_url + '/' + src
220
+ else
221
+ base_url + src
222
+ end
223
+ end
224
+ def self.process_images(content, citations, numbered_citations, recursive_processor = nil)
225
+ content.gsub(/([*\/-]*)i\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*?)\](@[a-zA-Z0-9_-]+)?([^\n]+)/) do
226
+ prefix = $1
227
+ inner = $2
228
+ url_prefix = $3
229
+ remainder = $4.strip
230
+ dimensions = nil
231
+ if remainder =~ /(.+)=(\d+x\d+)$/
232
+ remainder = $1.strip
233
+ dimensions = $2
234
+ end
235
+ if remainder.include?('|')
236
+ srcset_items = remainder.split('|').map(&:strip)
237
+ first_src = srcset_items[0].gsub(/\s+\d+w$/, '')
238
+ src_value = resolve_image_src(first_src, url_prefix, citations)
239
+ srcset_value = srcset_items.map { |item|
240
+ if item.match(/^([^\s]+)(?:\s+(\d+w))?$/)
241
+ path = $1
242
+ descriptor = $2
243
+ full_path = resolve_image_src(path, url_prefix, citations)
244
+ descriptor ? "#{full_path} #{descriptor}" : full_path
245
+ end
246
+ }.compact.join(', ')
247
+ parts = inner.split('|', 2)
248
+ alt = parts[0] || ""
249
+ title = parts[1]
250
+ img_attrs = %Q(src="#{src_value}" srcset="#{srcset_value}" alt="#{escape_html_attr(alt)}")
251
+ img_attrs += %Q( title="#{escape_html_attr(title)}") if title && !title.empty?
252
+ img_attrs += %Q( width="#{dimensions.split('x')[0]}" height="#{dimensions.split('x')[1]}") if dimensions
253
+ apply_formatting_prefixes("<img #{img_attrs}>", prefix)
254
+ else
255
+ src = remainder
256
+ parts = inner.split('|', 2)
257
+ alt = parts[0] || ""
258
+ title = parts[1]
259
+ full_src = resolve_image_src(src, url_prefix, citations)
260
+ img_attrs = %Q(src="#{full_src}" alt="#{escape_html_attr(alt)}")
261
+ img_attrs += %Q( title="#{escape_html_attr(title)}") if title && !title.empty?
262
+ img_attrs += %Q( width="#{dimensions.split('x')[0]}" height="#{dimensions.split('x')[1]}") if dimensions
263
+ apply_formatting_prefixes("<img #{img_attrs}>", prefix)
264
+ end
265
+ end
266
+ end
267
+ end