sevk 0.1.0 → 1.1.0

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.
@@ -1,57 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rouge'
4
+ require 'json'
5
+ require 'strscan'
6
+
7
+ require_relative 'document_parser'
8
+ require_relative 'html_document'
9
+ require_relative 'number_formatting'
10
+ require_relative 'render_limits'
11
+ require_relative 'style_helpers'
12
+ require_relative 'syntax_highlighting'
13
+ require_relative 'tag_scanner'
14
+ require_relative 'template_engine'
15
+
3
16
  module Sevk
4
17
  module Markup
5
- # Font configuration
6
18
  class FontConfig
7
19
  attr_accessor :id, :name, :url
8
20
 
9
- def initialize(id: "", name: "", url: "")
21
+ def initialize(id: '', name: '', url: '')
10
22
  @id = id
11
23
  @name = name
12
24
  @url = url
13
25
  end
14
26
  end
15
27
 
16
- # Head settings for email generation
17
28
  class EmailHeadSettings
18
- attr_accessor :title, :preview_text, :styles, :fonts
29
+ attr_accessor :title, :preview_text, :styles, :fonts, :lang, :dir
19
30
 
20
31
  def initialize
21
- @title = ""
22
- @preview_text = ""
23
- @styles = ""
32
+ @title = ''
33
+ @preview_text = ''
34
+ @styles = ''
24
35
  @fonts = []
36
+ @lang = ''
37
+ @dir = ''
25
38
  end
26
39
  end
27
40
 
28
- # Parsed email content
29
- class ParsedEmailContent
30
- attr_accessor :body, :head_settings
31
-
32
- def initialize
33
- @body = ""
34
- @head_settings = EmailHeadSettings.new
35
- end
36
- end
37
-
38
- # Sevk Markup Renderer
39
- # Converts Sevk markup to email-compatible HTML using regex-based parsing (like Node.js)
40
41
  class Renderer
42
+ include DocumentParser
43
+ include HtmlDocument
44
+ include NumberFormatting
45
+ include RenderLimits
46
+ include StyleHelpers
47
+ include SyntaxHighlighting
48
+ include TagScanner
49
+ include TemplateEngine
50
+
41
51
  def initialize
42
52
  @head_settings = EmailHeadSettings.new
43
53
  end
44
54
 
55
+ # Converts sevk markup into complete, email-compatible HTML.
45
56
  def render(markup)
46
- # Parse head settings from markup
57
+ @head_settings = EmailHeadSettings.new
47
58
  parse_head_settings(markup)
48
-
49
- # Normalize markup
50
- markup = normalize_markup(markup)
51
-
52
- # Process markup using regex
53
- processed = process_markup(markup)
54
-
59
+ body_content = extract_body_content(markup)
60
+ normalized = normalize_markup(body_content)
61
+ processed = process_markup(normalized)
55
62
  generate_html(processed)
56
63
  end
57
64
 
@@ -60,205 +67,276 @@ module Sevk
60
67
  def normalize_markup(content)
61
68
  result = content
62
69
 
63
- # Replace <link> with <sevk-link>
64
- if result.include?("<link")
65
- result = result.gsub(/<link\s+href=/i, "<sevk-link href=")
66
- result = result.gsub("</link>", "</sevk-link>")
67
- end
70
+ result = result.gsub(/<link(\s+(?:[\w-]+=(?:"[^"]*"|'[^']*')\s+)*)href=/i, '<sevk-link\1href=')
71
+ result = result.gsub(%r{</link>}i, '</sevk-link>')
68
72
 
69
- unless result.include?("<sevk-email") || result.include?("<email") || result.include?("<mail")
70
- result = "<mail><body>#{result}</body></mail>"
71
- end
73
+ result = "<mail><body>#{result}</body></mail>" unless result.match?(/<(?:sevk-email|email|mail)(?=[\s>])/i)
72
74
 
73
75
  result
74
76
  end
75
77
 
76
- def parse_head_settings(markup)
77
- # Extract title
78
- if (match = markup.match(/<title[^>]*>([\s\S]*?)<\/title>/i))
79
- @head_settings.title = match[1].strip
80
- end
81
-
82
- # Extract preview
83
- if (match = markup.match(/<preview[^>]*>([\s\S]*?)<\/preview>/i))
84
- @head_settings.preview_text = match[1].strip
85
- end
78
+ def process_markup(content)
79
+ @gap_rules = []
80
+ @codeblock_budget = 0
81
+ @scan_work = 0
82
+ assert_markup_depth(content)
86
83
 
87
- # Extract styles
88
- if (match = markup.match(/<style[^>]*>([\s\S]*?)<\/style>/i))
89
- @head_settings.styles = match[1].strip
84
+ result = process_tag(content, 'block') do |attrs, inner|
85
+ process_block_tag(attrs, inner)
90
86
  end
91
87
 
92
- # Extract fonts
93
- markup.scan(/<font[^>]*name=["']([^"']*)["'][^>]*url=["']([^"']*)["'][^>]*\/?>/i).each_with_index do |(name, url), i|
94
- @head_settings.fonts << FontConfig.new(id: "font-#{i}", name: name, url: url)
88
+ result = replace_self_closing(result, 'block', self_close_only: true) do |attrs_str|
89
+ process_block_tag(parse_attributes(attrs_str))
95
90
  end
96
- end
91
+ assert_markup_depth(result)
97
92
 
98
- def process_markup(content)
99
- result = content
93
+ result = result.gsub(/<link(\s+(?:[\w-]+=(?:"[^"]*"|'[^']*')\s+)*)href=/i, '<sevk-link\1href=')
94
+ result = result.gsub(%r{</link>}i, '</sevk-link>')
100
95
 
101
- # Process section tags
102
- result = process_tag(result, "section") do |attrs, inner|
96
+ # Section: padding and text-align go on td (Outlook-friendly), rest on outer table.
97
+ result = process_tag(result, 'section') do |attrs, inner|
103
98
  style = extract_all_style_attributes(attrs)
104
- style_str = style_to_string(style)
105
- %(<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{style_str}">
106
- <tbody>
107
- <tr>
108
- <td>#{inner}</td>
109
- </tr>
110
- </tbody>
111
- </table>)
99
+ td_style = {}
100
+ td_style['padding'] = style.delete('padding') if style.key?('padding')
101
+ td_style['text-align'] = style.delete('text-align') if style.key?('text-align')
102
+ # Tables default to border-collapse:collapse via the global reset,
103
+ # which disables border-radius. Flip to separate when any corner
104
+ # radius is set so the rounded border actually renders.
105
+ radius_keys = %w[
106
+ border-radius border-top-left-radius border-top-right-radius
107
+ border-bottom-left-radius border-bottom-right-radius
108
+ ]
109
+ style['border-collapse'] = 'separate' if radius_keys.any? { |key| style.key?(key) }
110
+
111
+ [
112
+ %(<table align="center" width="100%" border="0" cellPadding="0" ),
113
+ %(cellSpacing="0" role="presentation" style="#{style_to_string(style)}">\n),
114
+ "<tbody>\n",
115
+ "<tr>\n",
116
+ %(<td style="#{style_to_string(td_style)}">#{inner}</td>\n),
117
+ "</tr>\n",
118
+ "</tbody>\n",
119
+ '</table>'
120
+ ].join
112
121
  end
113
122
 
114
- # Process row tags
115
- result = process_tag(result, "row") do |attrs, inner|
123
+ result = process_tag(result, 'column') do |attrs, inner|
116
124
  style = extract_all_style_attributes(attrs)
125
+ style['vertical-align'] ||= 'top'
117
126
  style_str = style_to_string(style)
118
- %(<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{style_str}">
119
- <tbody style="width:100%">
120
- <tr style="width:100%">#{inner}</tr>
121
- </tbody>
122
- </table>)
127
+ %(<td class="sevk-column" style="#{style_str}">#{inner}</td>)
123
128
  end
124
129
 
125
- # Process column tags
126
- result = process_tag(result, "column") do |attrs, inner|
130
+ row_counter = 0
131
+ result = process_tag(result, 'row') do |attrs, inner|
132
+ gap = attrs['gap'] || '0'
127
133
  style = extract_all_style_attributes(attrs)
134
+ style.delete('gap')
128
135
  style_str = style_to_string(style)
129
- %(<td style="#{style_str}">#{inner}</td>)
136
+ gap_px = gap.sub('px', '')
137
+ gap_num = parse_px(gap_px)
138
+ row_id = "sevk-row-#{row_counter}"
139
+ row_counter += 1
140
+
141
+ processed_inner = inner
142
+ column_count = inner.scan('class="sevk-column"').length
143
+ if column_count > 1
144
+ equal_width = "#{(100 / column_count).to_i}%"
145
+ processed_inner = processed_inner.gsub(/<td class="sevk-column" style="([^"]*)"/) do
146
+ existing_style = Regexp.last_match(1)
147
+ if existing_style.include?('width:')
148
+ %(<td class="sevk-column" style="#{existing_style}")
149
+ else
150
+ %(<td class="sevk-column" style="width:#{equal_width};#{existing_style}")
151
+ end
152
+ end
153
+ end
154
+
155
+ if gap_num.positive?
156
+ spacer_td = [
157
+ %(</td><td class="sevk-gap" ),
158
+ %(style="width:#{gap_px}px;min-width:#{gap_px}px" width="#{gap_px}"></td>),
159
+ %(<td class="sevk-column")
160
+ ].join
161
+ processed_inner = processed_inner.gsub(%r{</td>\s*<td class="sevk-column"}, spacer_td)
162
+ @gap_rules << [
163
+ ".#{row_id} .sevk-gap{display:none !important;}",
164
+ ".#{row_id} > tbody > tr > td.sevk-column{display:block !important;",
165
+ "width:100% !important;margin-bottom:#{gap_px}px !important;}",
166
+ ".#{row_id} > tbody > tr > td.sevk-column:last-of-type{margin-bottom:0 !important;}"
167
+ ].join
168
+ end
169
+
170
+ [
171
+ %(<table align="center" width="100%" border="0" cellPadding="0" ),
172
+ %(cellSpacing="0" role="presentation" class="sevk-row-table #{row_id}" style="#{style_str}">\n),
173
+ %(<tbody style="width:100%">\n),
174
+ %(<tr style="width:100%">#{processed_inner}</tr>\n),
175
+ "</tbody>\n",
176
+ '</table>'
177
+ ].join
130
178
  end
131
179
 
132
- # Process container tags
133
- result = process_tag(result, "container") do |attrs, inner|
180
+ result = process_tag(result, 'container') do |attrs, inner|
134
181
  style = extract_all_style_attributes(attrs)
135
- style_str = style_to_string(style)
136
- %(<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{style_str}">
137
- <tbody>
138
- <tr style="width:100%">
139
- <td>#{inner}</td>
140
- </tr>
141
- </tbody>
142
- </table>)
182
+ td_style = {}
183
+ table_style = {}
184
+
185
+ visual_keys = %w[
186
+ background-color background-image background-size background-position background-repeat
187
+ border border-top border-right border-bottom border-left border-color border-width border-style
188
+ border-radius border-top-left-radius border-top-right-radius
189
+ border-bottom-left-radius border-bottom-right-radius
190
+ padding padding-top padding-right padding-bottom padding-left
191
+ ]
192
+ style.each do |key, value|
193
+ if visual_keys.include?(key)
194
+ td_style[key] = value
195
+ else
196
+ table_style[key] = value
197
+ end
198
+ end
199
+
200
+ has_border_radius = td_style['border-radius'] || td_style['border-top-left-radius'] ||
201
+ td_style['border-top-right-radius'] || td_style['border-bottom-left-radius'] ||
202
+ td_style['border-bottom-right-radius']
203
+ table_style['border-collapse'] = 'separate' if has_border_radius
204
+
205
+ if table_style['width'] && table_style['width'] != '100%' && table_style['width'] != 'auto'
206
+ table_style['max-width'] ||= table_style['width']
207
+ table_style['width'] = '100%'
208
+ end
209
+
210
+ table_style_str = style_to_string(table_style)
211
+ td_style_str = style_to_string(td_style)
212
+ # No `align="center"` on container: centering is opt-in via the
213
+ # user's markup (e.g. `margin="0 auto"`). Editor preview and the
214
+ # rendered email follow the same "no implicit centering" contract.
215
+ [
216
+ %(<table width="100%" border="0" cellPadding="0" cellSpacing="0" ),
217
+ %(role="presentation" style="#{table_style_str}">\n),
218
+ "<tbody>\n",
219
+ %(<tr style="width:100%">\n),
220
+ %(<td style="#{td_style_str}">#{inner}</td>\n),
221
+ "</tr>\n",
222
+ "</tbody>\n",
223
+ '</table>'
224
+ ].join
143
225
  end
144
226
 
145
- # Process heading tags
146
- result = process_tag(result, "heading") do |attrs, inner|
147
- level = attrs["level"] || "1"
227
+ result = process_tag(result, 'heading') do |attrs, inner|
228
+ level = attrs['level'].to_s.empty? ? '1' : attrs['level']
148
229
  style = extract_all_style_attributes(attrs)
230
+ style['margin'] ||= '0'
149
231
  style_str = style_to_string(style)
150
232
  %(<h#{level} style="#{style_str}">#{inner}</h#{level}>)
151
233
  end
152
234
 
153
- # Process paragraph tags
154
- result = process_tag(result, "paragraph") do |attrs, inner|
235
+ result = process_tag(result, 'paragraph') do |attrs, inner|
155
236
  style = extract_all_style_attributes(attrs)
237
+ style['margin'] ||= '0'
156
238
  style_str = style_to_string(style)
157
239
  %(<p style="#{style_str}">#{inner}</p>)
158
240
  end
159
241
 
160
- # Process text tags
161
- result = process_tag(result, "text") do |attrs, inner|
242
+ result = process_tag(result, 'text') do |attrs, inner|
162
243
  style = extract_all_style_attributes(attrs)
163
244
  style_str = style_to_string(style)
164
245
  %(<span style="#{style_str}">#{inner}</span>)
165
246
  end
166
247
 
167
- # Process button tags with MSO compatibility
168
- result = process_tag(result, "button") do |attrs, inner|
248
+ result = process_tag(result, 'button') do |attrs, inner|
169
249
  process_button(attrs, inner)
170
250
  end
171
251
 
172
- # Process image tags
173
- result = result.gsub(/<image([^>]*)\/?>/i) do
174
- attrs = parse_attributes(Regexp.last_match(1) || "")
175
- src = attrs["src"] || ""
176
- alt = attrs["alt"] || ""
177
- width = attrs["width"]
178
- height = attrs["height"]
252
+ result = result.gsub(%r{<image([^>]*)/?>}i) do
253
+ attrs = parse_attributes(Regexp.last_match(1) || '')
254
+ src = attrs['src'] || ''
255
+ alt = attrs['alt'] || ''
256
+ width = attrs['width']
257
+ height = attrs['height']
179
258
 
180
259
  style = extract_all_style_attributes(attrs)
181
- style["outline"] ||= "none"
182
- style["border"] ||= "none"
183
- style["text-decoration"] ||= "none"
260
+ style['vertical-align'] ||= 'middle'
261
+ style['max-width'] ||= '100%'
184
262
 
185
263
  style_str = style_to_string(style)
186
- width_attr = width ? %( width="#{width}") : ""
187
- height_attr = height ? %( height="#{height}") : ""
264
+ width_value = width.to_s.sub('px', '')
265
+ height_value = height.to_s.sub('px', '')
266
+ width_attr = width_value.empty? ? '' : %( width="#{width_value}")
267
+ height_attr = height_value.empty? ? '' : %( height="#{height_value}")
188
268
 
189
269
  %(<img src="#{src}" alt="#{alt}"#{width_attr}#{height_attr} style="#{style_str}" />)
190
270
  end
191
271
 
192
- # Process divider tags
193
- result = result.gsub(/<divider([^>]*)\/?>/i) do
194
- attrs = parse_attributes(Regexp.last_match(1) || "")
272
+ result = result.gsub(%r{<divider([^>]*)/?>}i) do
273
+ attrs = parse_attributes(Regexp.last_match(1) || '')
195
274
  style = extract_all_style_attributes(attrs)
196
275
  style_str = style_to_string(style)
197
- class_attr = attrs["class"] || attrs["className"]
198
- class_str = class_attr ? %( class="#{class_attr}") : ""
276
+ class_str = class_attribute(attrs)
199
277
  %(<hr style="#{style_str}"#{class_str} />)
200
278
  end
201
279
 
202
- # Process link tags
203
- result = process_tag(result, "sevk-link") do |attrs, inner|
204
- href = attrs["href"] || "#"
205
- target = attrs["target"] || "_blank"
280
+ result = result.gsub(%r{</divider>}i, '')
281
+ result = result.gsub(%r{</image>}i, '')
282
+
283
+ result = process_tag(result, 'sevk-link') do |attrs, inner|
284
+ href = attrs['href'].to_s.empty? ? '#' : attrs['href']
285
+ target = attrs['target'].to_s.empty? ? '_blank' : attrs['target']
206
286
  style = extract_all_style_attributes(attrs)
207
287
  style_str = style_to_string(style)
208
288
  %(<a href="#{href}" target="#{target}" style="#{style_str}">#{inner}</a>)
209
289
  end
210
290
 
211
- # Process list tags
212
- result = process_tag(result, "list") do |attrs, inner|
213
- list_type = attrs["type"] || "unordered"
214
- tag = list_type == "ordered" ? "ol" : "ul"
291
+ result = process_tag(result, 'list') do |attrs, inner|
292
+ list_type = attrs['type'] || 'unordered'
293
+ tag = list_type == 'ordered' ? 'ol' : 'ul'
215
294
  style = extract_all_style_attributes(attrs)
216
- style["list-style-type"] = attrs["list-style-type"] if attrs["list-style-type"]
295
+ style['margin'] ||= '0'
296
+ style['list-style-type'] = attrs['list-style-type'] if attrs['list-style-type']
217
297
  style_str = style_to_string(style)
218
- class_attr = attrs["class"] || attrs["className"]
219
- class_str = class_attr ? %( class="#{class_attr}") : ""
298
+ class_str = class_attribute(attrs)
220
299
  %(<#{tag} style="#{style_str}"#{class_str}>#{inner}</#{tag}>)
221
300
  end
222
301
 
223
- # Process list item tags
224
- result = process_tag(result, "li") do |attrs, inner|
302
+ result = process_tag(result, 'li') do |attrs, inner|
225
303
  style = extract_all_style_attributes(attrs)
226
304
  style_str = style_to_string(style)
227
- class_attr = attrs["class"] || attrs["className"]
228
- class_str = class_attr ? %( class="#{class_attr}") : ""
305
+ class_str = class_attribute(attrs)
229
306
  %(<li style="#{style_str}"#{class_str}>#{inner}</li>)
230
307
  end
231
308
 
232
- # Process codeblock tags
233
- result = process_tag(result, "codeblock") do |attrs, inner|
234
- style = extract_all_style_attributes(attrs)
235
- style["width"] ||= "100%"
236
- style["box-sizing"] ||= "border-box"
237
- style_str = style_to_string(style)
238
- escaped = inner.gsub("<", "&lt;").gsub(">", "&gt;")
239
- %(<pre style="#{style_str}"><code>#{escaped}</code></pre>)
309
+ result = process_tag(result, 'codeblock') do |attrs, inner|
310
+ process_codeblock(attrs, inner)
311
+ end
312
+
313
+ stray_closing_tags = %w[container section row column heading paragraph text button sevk-link]
314
+ stray_closing_tags.each do |tag|
315
+ result = result.gsub(%r{</#{tag}>}i, '')
240
316
  end
241
317
 
242
- # Clean up wrapper tags
243
318
  wrapper_patterns = [
244
- /<sevk-email[^>]*>/i, /<\/sevk-email>/i,
245
- /<sevk-body[^>]*>/i, /<\/sevk-body>/i,
246
- /<email[^>]*>/i, /<\/email>/i,
247
- /<mail[^>]*>/i, /<\/mail>/i,
248
- /<body[^>]*>/i, /<\/body>/i
319
+ /<sevk-email[^>]*>/i, %r{</sevk-email>}i,
320
+ /<sevk-body[^>]*>/i, %r{</sevk-body>}i,
321
+ /<email[^>]*>/i, %r{</email>}i,
322
+ /<mail[^>]*>/i, %r{</mail>}i,
323
+ /<body[^>]*>/i, %r{</body>}i
249
324
  ]
250
325
  wrapper_patterns.each do |pattern|
251
- result = result.gsub(pattern, "")
326
+ result = result.gsub(pattern, '')
252
327
  end
253
328
 
254
329
  result.strip
255
330
  end
256
331
 
332
+ # Outlook button padding hack ported from react-email's Button component
333
+ # (https://github.com/resend/react-email/blob/main/packages/components/src/button.tsx).
334
+ # Uses mso-padding-alt:0, hairspace runs scaled by mso-font-width, and
335
+ # mso-text-raise to get Outlook to honour horizontal/vertical padding.
257
336
  def process_button(attrs, inner)
258
- href = attrs["href"] || "#"
337
+ href = attrs['href'].to_s.empty? ? '#' : attrs['href']
259
338
  style = extract_all_style_attributes(attrs)
260
339
 
261
- # Parse padding
262
340
  padding_top, padding_right, padding_bottom, padding_left = parse_padding(style)
263
341
 
264
342
  y = padding_top + padding_bottom
@@ -268,247 +346,100 @@ module Sevk
268
346
  pr_font_width, pr_space_count = compute_font_width_and_space_count(padding_right)
269
347
 
270
348
  button_style = {
271
- "line-height" => "100%",
272
- "text-decoration" => "none",
273
- "display" => "inline-block",
274
- "max-width" => "100%",
275
- "mso-padding-alt" => "0px"
349
+ 'line-height' => '100%',
350
+ 'text-decoration' => 'none',
351
+ 'display' => 'inline-block',
352
+ 'max-width' => '100%',
353
+ 'mso-padding-alt' => '0px'
276
354
  }
277
355
 
278
- # Merge with extracted styles
279
356
  button_style.merge!(style)
280
357
 
281
- # Override padding with parsed values
282
- button_style["padding-top"] = "#{padding_top}px"
283
- button_style["padding-right"] = "#{padding_right}px"
284
- button_style["padding-bottom"] = "#{padding_bottom}px"
285
- button_style["padding-left"] = "#{padding_left}px"
358
+ button_style['padding-top'] = "#{padding_top}px"
359
+ button_style['padding-right'] = "#{padding_right}px"
360
+ button_style['padding-bottom'] = "#{padding_bottom}px"
361
+ button_style['padding-left'] = "#{padding_left}px"
286
362
 
287
363
  style_str = style_to_string(button_style)
288
364
 
289
- left_mso_spaces = "&#8202;" * pl_space_count
290
- right_mso_spaces = "&#8202;" * pr_space_count
291
-
292
- %(<a href="#{href}" target="_blank" style="#{style_str}"><!--[if mso]><i style="mso-font-width:#{(pl_font_width * 100).round}%;mso-text-raise:#{text_raise}" hidden>#{left_mso_spaces}</i><![endif]--><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:#{px_to_pt(padding_bottom)}">#{inner}</span><!--[if mso]><i style="mso-font-width:#{(pr_font_width * 100).round}%" hidden>#{right_mso_spaces}&#8203;</i><![endif]--></a>)
293
- end
294
-
295
- def parse_padding(style)
296
- if style["padding"]
297
- parts = style["padding"].split
298
- case parts.length
299
- when 1
300
- val = parse_px(parts[0])
301
- [val, val, val, val]
302
- when 2
303
- vertical = parse_px(parts[0])
304
- horizontal = parse_px(parts[1])
305
- [vertical, horizontal, vertical, horizontal]
306
- when 4
307
- [parse_px(parts[0]), parse_px(parts[1]), parse_px(parts[2]), parse_px(parts[3])]
308
- else
309
- [0, 0, 0, 0]
310
- end
311
- else
312
- [
313
- parse_px(style["padding-top"] || "0"),
314
- parse_px(style["padding-right"] || "0"),
315
- parse_px(style["padding-bottom"] || "0"),
316
- parse_px(style["padding-left"] || "0")
317
- ]
318
- end
319
- end
320
-
321
- def parse_px(s)
322
- s.to_s.gsub("px", "").to_i
365
+ left_mso_spaces = '&#8202;' * pl_space_count
366
+ right_mso_spaces = '&#8202;' * pr_space_count
367
+
368
+ [
369
+ %(<a href="#{href}" target="_blank" style="#{style_str}">),
370
+ %(<!--[if mso]><i style="mso-font-width:#{(pl_font_width * 100).round}%;),
371
+ %(mso-text-raise:#{text_raise}" hidden>#{left_mso_spaces}</i><![endif]-->),
372
+ %(<span style="max-width:100%;display:inline-block;line-height:120%;),
373
+ %(mso-padding-alt:0px;mso-text-raise:#{px_to_pt(padding_bottom)}">#{inner}</span>),
374
+ %(<!--[if mso]><i style="mso-font-width:#{(pr_font_width * 100).round}%" hidden>),
375
+ %(#{right_mso_spaces}&#8203;</i><![endif]--></a>)
376
+ ].join
323
377
  end
324
378
 
325
- def px_to_pt(px)
326
- (px * 3) / 4
327
- end
328
-
329
- def compute_font_width_and_space_count(expected_width)
330
- return [0, 0] if expected_width == 0
331
-
332
- smallest_space_count = 0
333
- max_font_width = 5.0
334
-
335
- loop do
336
- required_font_width = if smallest_space_count > 0
337
- expected_width.to_f / smallest_space_count / 2.0
338
- else
339
- Float::INFINITY
340
- end
341
-
342
- return [required_font_width, smallest_space_count] if required_font_width <= max_font_width
343
-
344
- smallest_space_count += 1
345
- end
346
- end
347
-
348
- def process_tag(content, tag_name)
379
+ def process_tag(content, tag_name, &processor)
349
380
  result = content
350
- open_pattern = /<#{tag_name}([^>]*)>/i
351
- close_tag = "</#{tag_name}>"
352
- open_tag_start = "<#{tag_name}"
353
-
354
- max_iterations = 10_000
355
- iterations = 0
381
+ patterns = tag_patterns(tag_name)
356
382
 
357
- while iterations < max_iterations
358
- iterations += 1
383
+ MAX_TAG_ITERATIONS.times do
384
+ charge_scan_work(result.bytesize)
385
+ replacements = collect_replacements(result, patterns, &processor)
386
+ break if replacements.empty?
359
387
 
360
- # Find all opening tags
361
- matches = result.to_enum(:scan, open_pattern).map { Regexp.last_match }
362
- break if matches.empty?
388
+ rebuilt = rebuild_result(result, replacements)
389
+ break if rebuilt == result
363
390
 
364
- processed = false
365
-
366
- # Find the innermost tag (one that has no nested same tags)
367
- matches.reverse_each do |match|
368
- start = match.begin(0)
369
- inner_start = match.end(0)
370
- attrs_str = match[1]
371
-
372
- # Find the next close tag after this opening tag
373
- close_pos = result.downcase.index(close_tag.downcase, inner_start)
374
- next unless close_pos
375
-
376
- inner = result[inner_start...close_pos]
377
-
378
- # Check if there's another opening tag inside
379
- next if inner.downcase.include?(open_tag_start.downcase)
380
-
381
- # This is an innermost tag, process it
382
- attrs = parse_attributes(attrs_str)
383
- replacement = yield(attrs, inner)
384
- end_pos = close_pos + close_tag.length
385
-
386
- result = result[0...start] + replacement + result[end_pos..]
387
- processed = true
388
- break
389
- end
390
-
391
- break unless processed
391
+ result = rebuilt
392
392
  end
393
393
 
394
394
  result
395
395
  end
396
396
 
397
- def parse_attributes(attrs_str)
398
- attrs = {}
399
- attrs_str.scan(/([\w-]+)=["']([^"']*)["']/) do |key, value|
400
- attrs[key] = value
401
- end
402
- attrs
403
- end
404
-
405
- def extract_all_style_attributes(attrs)
406
- style = {}
397
+ def collect_replacements(result, patterns, &processor)
398
+ scanner = StringScanner.new(result)
399
+ replacements = []
400
+ consumed_until = 0
407
401
 
408
- # Typography attributes
409
- if attrs["text-color"]
410
- style["color"] = attrs["text-color"]
411
- elsif attrs["color"]
412
- style["color"] = attrs["color"]
413
- end
414
- style["background-color"] = attrs["background-color"] if attrs["background-color"]
415
- style["font-size"] = attrs["font-size"] if attrs["font-size"]
416
- style["font-family"] = attrs["font-family"] if attrs["font-family"]
417
- style["font-weight"] = attrs["font-weight"] if attrs["font-weight"]
418
- style["line-height"] = attrs["line-height"] if attrs["line-height"]
419
- style["text-align"] = attrs["text-align"] if attrs["text-align"]
420
- style["text-decoration"] = attrs["text-decoration"] if attrs["text-decoration"]
421
-
422
- # Dimensions
423
- style["width"] = attrs["width"] if attrs["width"]
424
- style["height"] = attrs["height"] if attrs["height"]
425
- style["max-width"] = attrs["max-width"] if attrs["max-width"]
426
- style["min-height"] = attrs["min-height"] if attrs["min-height"]
427
-
428
- # Spacing - Padding
429
- if attrs["padding"]
430
- style["padding"] = attrs["padding"]
431
- else
432
- style["padding-top"] = attrs["padding-top"] if attrs["padding-top"]
433
- style["padding-right"] = attrs["padding-right"] if attrs["padding-right"]
434
- style["padding-bottom"] = attrs["padding-bottom"] if attrs["padding-bottom"]
435
- style["padding-left"] = attrs["padding-left"] if attrs["padding-left"]
436
- end
402
+ while scanner.skip_until(patterns.open)
403
+ start = scanner.pos - scanner.matched_size
404
+ next if start < consumed_until
437
405
 
438
- # Spacing - Margin
439
- if attrs["margin"]
440
- style["margin"] = attrs["margin"]
441
- else
442
- style["margin-top"] = attrs["margin-top"] if attrs["margin-top"]
443
- style["margin-right"] = attrs["margin-right"] if attrs["margin-right"]
444
- style["margin-bottom"] = attrs["margin-bottom"] if attrs["margin-bottom"]
445
- style["margin-left"] = attrs["margin-left"] if attrs["margin-left"]
446
- end
406
+ attrs_str = scanner[1]
407
+ inner_start = scanner.pos
408
+ close_pos = find_close(result, patterns, inner_start)
409
+ charge_scan_work((close_pos || result.bytesize) - inner_start)
410
+ next unless close_pos
447
411
 
448
- # Borders
449
- if attrs["border"]
450
- style["border"] = attrs["border"]
451
- else
452
- style["border-top"] = attrs["border-top"] if attrs["border-top"]
453
- style["border-right"] = attrs["border-right"] if attrs["border-right"]
454
- style["border-bottom"] = attrs["border-bottom"] if attrs["border-bottom"]
455
- style["border-left"] = attrs["border-left"] if attrs["border-left"]
456
- style["border-color"] = attrs["border-color"] if attrs["border-color"]
457
- style["border-width"] = attrs["border-width"] if attrs["border-width"]
458
- style["border-style"] = attrs["border-style"] if attrs["border-style"]
459
- end
412
+ inner = result.byteslice(inner_start, close_pos - inner_start)
413
+ next if inner.match?(patterns.nested)
460
414
 
461
- # Border Radius
462
- if attrs["border-radius"]
463
- style["border-radius"] = attrs["border-radius"]
464
- else
465
- style["border-top-left-radius"] = attrs["border-top-left-radius"] if attrs["border-top-left-radius"]
466
- style["border-top-right-radius"] = attrs["border-top-right-radius"] if attrs["border-top-right-radius"]
467
- style["border-bottom-left-radius"] = attrs["border-bottom-left-radius"] if attrs["border-bottom-left-radius"]
468
- style["border-bottom-right-radius"] = attrs["border-bottom-right-radius"] if attrs["border-bottom-right-radius"]
415
+ consumed_until = close_pos + patterns.close_length
416
+ replacements << [start, consumed_until, processor.call(parse_attributes(attrs_str), inner)]
469
417
  end
470
418
 
471
- style
419
+ replacements
472
420
  end
473
421
 
474
- def style_to_string(style)
475
- style.map { |k, v| "#{k}:#{v}" }.join(";")
422
+ def find_close(result, patterns, inner_start)
423
+ scanner = StringScanner.new(result)
424
+ scanner.pos = inner_start
425
+ scanner.skip_until(patterns.close) ? scanner.pos - patterns.close_length : nil
476
426
  end
477
427
 
478
- def generate_html(content)
479
- title = @head_settings.title.empty? ? "" : "<title>#{@head_settings.title}</title>"
480
-
481
- font_links = @head_settings.fonts.map do |font|
482
- %(<link href="#{CGI.escapeHTML(font.url)}" rel="stylesheet" type="text/css" />)
483
- end.join("\n")
484
-
485
- styles = @head_settings.styles.empty? ? "" : %(<style type="text/css">#{@head_settings.styles}</style>)
486
-
487
- preview_text = ""
488
- unless @head_settings.preview_text.empty?
489
- preview_text = %(<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">#{CGI.escapeHTML(@head_settings.preview_text)}</div>)
428
+ def rebuild_result(result, replacements)
429
+ rebuilt = +''
430
+ cursor = 0
431
+ replacements.each do |r_start, r_end, r_text|
432
+ rebuilt << result.byteslice(cursor, r_start - cursor) << r_text
433
+ cursor = r_end
434
+ if rebuilt.bytesize > MAX_RENDER_OUTPUT
435
+ raise ArgumentError, "Email markup expands beyond the maximum size of #{MAX_RENDER_OUTPUT} bytes"
436
+ end
490
437
  end
491
-
492
- <<~HTML
493
- <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
494
- <html lang="en" dir="ltr">
495
- <head>
496
- <meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
497
- <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
498
- #{title}
499
- #{font_links}
500
- #{styles}
501
- </head>
502
- <body style="margin:0;padding:0;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;background-color:#ffffff">
503
- #{preview_text}
504
- #{content}
505
- </body>
506
- </html>
507
- HTML
438
+ rebuilt << result.byteslice(cursor, result.bytesize - cursor)
508
439
  end
509
440
  end
510
441
 
511
- # Module-level render method
442
+ # Converts sevk markup into complete, email-compatible HTML.
512
443
  def self.render(markup)
513
444
  Renderer.new.render(markup)
514
445
  end