przn 0.4.0 → 0.6.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.
data/lib/przn/parser.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
3
4
  require 'strscan'
4
5
 
5
6
  module Przn
@@ -18,13 +19,22 @@ module Przn
18
19
  }.freeze
19
20
 
20
21
  NAMED_COLORS = {
21
- 'red' => 31, 'green' => 32, 'yellow' => 33, 'blue' => 34,
22
- 'magenta' => 35, 'cyan' => 36, 'white' => 37,
23
- 'bright_red' => 91, 'bright_green' => 92, 'bright_yellow' => 93,
24
- 'bright_blue' => 94, 'bright_magenta' => 95, 'bright_cyan' => 96,
25
- 'bright_white' => 97
22
+ 'black' => 30, 'red' => 31, 'green' => 32, 'yellow' => 33,
23
+ 'blue' => 34, 'magenta' => 35, 'cyan' => 36, 'white' => 37,
24
+ 'bright_black' => 90, 'bright_red' => 91, 'bright_green' => 92,
25
+ 'bright_yellow' => 93, 'bright_blue' => 94, 'bright_magenta' => 95,
26
+ 'bright_cyan' => 96, 'bright_white' => 97
26
27
  }.freeze
27
28
 
29
+ # HTML-ish attribute, three accepted value forms:
30
+ # key="value" key='value' key=bareword
31
+ # The unquoted token excludes whitespace, `=`, `<`, `>`, `"`, `'` and
32
+ # backtick, matching the spirit of HTML5's unquoted-attribute grammar.
33
+ # `/` is intentionally NOT excluded so paths like `src=path/to/file`
34
+ # work — which means self-closing tags need a space before `/>` when
35
+ # the last attribute is unquoted (`<img src=foo.png />`).
36
+ ATTR_RE_SRC = '[\w-]+=(?:"[^"]*"|\'[^\']*\'|[^\s=<>"\'`]+)'
37
+
28
38
  module_function
29
39
 
30
40
  def parse(markdown)
@@ -55,6 +65,7 @@ module Przn
55
65
 
56
66
  def parse_slide(raw)
57
67
  blocks = []
68
+ slide_attrs = {}
58
69
  lines = raw.lines
59
70
  i = 0
60
71
 
@@ -62,6 +73,13 @@ module Przn
62
73
  line = lines[i]
63
74
 
64
75
  case line
76
+ # <!-- ... --> block (single or multi line) — skip entirely.
77
+ # Sibling of the kramdown `{::comment} ... {:/comment}` form.
78
+ when /\A\s*<!--/
79
+ # Same line? Skip just this line.
80
+ i += 1 unless line.match?(/-->/)
81
+ i += 1 while i < lines.size && !lines[i].match?(/-->/)
82
+
65
83
  # {::comment} ... {:/comment} block — skip entirely
66
84
  when /\A\s*\{::comment\}/
67
85
  i += 1
@@ -74,18 +92,38 @@ module Przn
74
92
  # Block alignment, XML form: <center>content</center> or <right>content</right>
75
93
  when /\A\s*<(center|right)>(.*)<\/\1>\s*\z/
76
94
  blocks << {type: :align, align: Regexp.last_match(1).to_sym}
77
- blocks << {type: :paragraph, content: Regexp.last_match(2)}
95
+ content = Regexp.last_match(2).dup
96
+ blocks << {type: :wait} if strip_leading_wait!(content)
97
+ blocks << {type: :paragraph, content: content}
78
98
 
79
99
  # Slide background (Echoes OSC 7772):
80
100
  # <bg color="#..."/> — solid (bg-color)
81
101
  # <bg from="#..." to="#..." angle="N"/> — linear gradient (bg-gradient)
82
- when /\A\s*<bg((?:\s+\w+="[^"]+")*)\s*\/>\s*\z/
102
+ # Attribute values may be double-quoted, single-quoted, or
103
+ # unquoted (HTML5-ish — see ATTR_RE_SRC).
104
+ when %r{\A\s*<bg((?:\s+#{ATTR_RE_SRC})*)\s*/>\s*\z}o
83
105
  blocks << {type: :bg, attrs: parse_xml_attrs(Regexp.last_match(1))}
84
106
 
85
- # Fenced code block
86
- when /\A\s*```(\w*)\s*\z/
107
+ # Absolute-position text:
108
+ # <at x="N" y="N">content</at>
109
+ # {::at x="N" y="N"}content{:/at}
110
+ # Content can include inline markup (size, color, font, bold, …).
111
+ when %r{\A\s*<at((?:\s+#{ATTR_RE_SRC})+)\s*>(.*)</at>\s*\z}o
112
+ blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
113
+ when %r{\A\s*\{::at((?:\s+#{ATTR_RE_SRC})+)\}(.*)\{:/at\}\s*\z}o
114
+ blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
115
+
116
+ # Fenced code block. Optional kramdown-style IAL right after the
117
+ # language lets one block override the theme's per-attribute
118
+ # code defaults — typically `size` to shrink a long snippet to
119
+ # one scale, or `family` / `color` / `bg` for a one-off look:
120
+ # ```ruby {size=1}
121
+ # ```ruby {size: small, family: Menlo}
122
+ when /\A\s*```(\w*)\s*(?:\{([^}]*)\})?\s*\z/
87
123
  lang = Regexp.last_match(1)
88
124
  lang = nil if lang.empty?
125
+ ial_inner = Regexp.last_match(2)
126
+ attrs = ial_inner ? parse_ial_attrs(ial_inner) : nil
89
127
  code_lines = []
90
128
  i += 1
91
129
  while i < lines.size && !lines[i].match?(/\A\s*```\s*\z/)
@@ -98,8 +136,33 @@ module Przn
98
136
  if lines[i].match(/lang="(\w+)"/)
99
137
  lang = Regexp.last_match(1)
100
138
  end
139
+ # Closing-line IAL can also carry per-block style overrides,
140
+ # merged WITHOUT clobbering the opener IAL (opener wins).
141
+ trailing = parse_ial_attrs(lines[i][/\{([^}]*)\}/, 1] || '')
142
+ attrs = trailing.merge(attrs || {}) unless trailing.empty?
143
+ end
144
+ # ```mermaid is special-cased into its own block type — the
145
+ # renderer shells out to mmdc to produce a PNG and places it
146
+ # as an inline image. Per-block IAL (`{height=70%}`, etc.)
147
+ # rides through on attrs to size the placement.
148
+ block = if lang == 'mermaid'
149
+ {type: :mermaid, content: code_lines.join}
150
+ else
151
+ {type: :code_block, content: code_lines.join, language: lang}
152
+ end
153
+ block[:attrs] = attrs if attrs && !attrs.empty?
154
+ # Per-line highlight stepping: `{lines=1-2|3|4}` parses pipe-
155
+ # separated step groups and emits one synthetic `<wait/>` per
156
+ # extra group so the step counter advances naturally. The
157
+ # renderer dims lines outside the active group via the
158
+ # OSC 7772 cell-alpha pipeline.
159
+ if attrs && (raw = attrs[:lines]) && (steps = parse_line_steps(raw))
160
+ block[:line_steps] = steps
161
+ blocks << block
162
+ (steps.size - 1).times { blocks << {type: :wait} }
163
+ else
164
+ blocks << block
101
165
  end
102
- blocks << {type: :code_block, content: code_lines.join, language: lang}
103
166
 
104
167
  # Indented code block (4 spaces)
105
168
  when /\A {4}(.*)$/
@@ -118,9 +181,98 @@ module Przn
118
181
  end
119
182
  blocks << {type: :code_block, content: code_lines.join("\n") + "\n", language: lang}
120
183
 
121
- # h1 (slide title)
184
+ # h1 (slide title) — also accepts a trailing IAL with slide-level
185
+ # metadata: `# Title {layout=name}` / `{:layout=name}` / `{layout: name}`.
186
+ # The IAL is lifted off and stashed on slide_attrs; the heading
187
+ # content keeps just the title text.
122
188
  when /\A#\s+(.*)/
123
- blocks << {type: :heading, level: 1, content: Regexp.last_match(1).strip}
189
+ heading_body, ial_attrs = extract_h1_ial(Regexp.last_match(1).strip)
190
+ blocks << {type: :heading, level: 1, content: heading_body}
191
+ slide_attrs.merge!(ial_attrs)
192
+
193
+ # Slot break inside a layout-driven slide. `<slot/>` advances to
194
+ # the next slot; `<slot name="right"/>` jumps to that named slot.
195
+ # Outside a layout, the block is a render-time no-op.
196
+ when %r{\A\s*<slot((?:\s+#{ATTR_RE_SRC})*)\s*/>\s*\z}o
197
+ attrs = parse_xml_attrs(Regexp.last_match(1))
198
+ blocks << {type: :slot, name: attrs[:name]}
199
+
200
+ # Step boundary for incremental reveals (Keynote-style "builds").
201
+ # A `<wait/>` on its own line divides the slide's block sequence
202
+ # into a group; the renderer renders all blocks up to the current
203
+ # step and *reserves* layout space for the rest. Pressing Space
204
+ # advances by one step before flipping to the next slide. A
205
+ # leading `<wait/>` on a list item or paragraph (handled at the
206
+ # respective push sites) is the same boundary scoped one notch
207
+ # finer. Mid-text `<wait/>` is still silently dropped in
208
+ # `parse_inline` — splitting a sentence mid-flow has no defined
209
+ # rendering semantics yet.
210
+ when %r{\A\s*<wait\s*/>\s*\z}, %r{\A\s*<wait>\s*</wait>\s*\z}, %r{\A\s*\{::wait/\}\s*\z}
211
+ blocks << {type: :wait}
212
+
213
+ # Action targeting a previously-id'd block. The renderer
214
+ # accumulates these by step into an "effective state" map
215
+ # (`{id => override_attrs}`) so when the target block renders
216
+ # at the current step it picks up the new x / y / etc. instead
217
+ # of its original attrs. `target=` is required; other attrs
218
+ # ride through as overrides — typically x / y / z / opacity
219
+ # but anything the target's renderer reads can be moved.
220
+ when %r{\A\s*<action((?:\s+#{ATTR_RE_SRC})*)\s*/>\s*\z}o
221
+ attrs = parse_xml_attrs(Regexp.last_match(1))
222
+ if attrs[:target]
223
+ block = {type: :action, attrs: attrs}
224
+ # `duration="500ms"` / `"0.5s"` / plain `"500"` turns the
225
+ # snap into a smooth interpolation. Unparseable values
226
+ # silently fall back to instant — keeps a typo from
227
+ # crashing the deck. See `parse_duration_ms`.
228
+ if attrs[:duration] && (ms = parse_duration_ms(attrs[:duration]))
229
+ block[:duration_ms] = ms
230
+ end
231
+ blocks << block
232
+ end
233
+
234
+ # Cross-slide content reference. `<ref id="x"/>` re-renders the
235
+ # block whose original declaration carries `id="x"` (anywhere
236
+ # in the deck) at the ref's position. Same-tag attrs on the
237
+ # `<ref>` (typically x / y) override the source's, so the same
238
+ # callout can appear in a new spot without copy-pasting its
239
+ # content. Missing id is silently dropped, matching `<action>`.
240
+ when %r{\A\s*<ref((?:\s+#{ATTR_RE_SRC})*)\s*/>\s*\z}o
241
+ attrs = parse_xml_attrs(Regexp.last_match(1))
242
+ blocks << {type: :ref, attrs: attrs} if attrs[:id]
243
+
244
+ # Composite container — wraps an arbitrary sequence of child
245
+ # blocks under one id. `<ref id="x"/>` resolves a group source
246
+ # the same way it resolves any other id'd block, replaying the
247
+ # whole bundle on another slide. The opening and closing tags
248
+ # must each be alone on their own line (matches the line-level
249
+ # parsing model the rest of the block-level dispatch uses). A
250
+ # missing close tag or missing id is silently dropped — the
251
+ # next h1 / end-of-input ends the implicit scan.
252
+ when %r{\A\s*<group((?:\s+#{ATTR_RE_SRC})*)\s*>\s*\z}o
253
+ group_attrs = parse_xml_attrs(Regexp.last_match(1))
254
+ depth = 1
255
+ scan = i + 1
256
+ while scan < lines.size
257
+ if lines[scan] =~ %r{\A\s*<group((?:\s+#{ATTR_RE_SRC})*)\s*>\s*\z}o
258
+ depth += 1
259
+ elsif lines[scan] =~ %r{\A\s*</group>\s*\z}
260
+ depth -= 1
261
+ break if depth == 0
262
+ end
263
+ scan += 1
264
+ end
265
+ if depth == 0 && group_attrs[:id]
266
+ # Recurse on the inner lines via parse_slide so every existing
267
+ # block-level construct (paragraph, <at>, <img>, shapes, even
268
+ # nested <group>) works inside without duplicating the dispatch.
269
+ # Throw away any incidental h1 IAL the inner content yields —
270
+ # it belongs to the outer slide, not the group.
271
+ inner_text = lines[(i + 1)...scan].join
272
+ children = parse_slide(inner_text).blocks
273
+ blocks << {type: :group, attrs: group_attrs, children: children}
274
+ i = scan # outer `i += 1` lands on the line after </group>
275
+ end
124
276
 
125
277
  # h2-h6 (sub-headings within slide)
126
278
  when /\A(\#{2,6})\s+(.*)/
@@ -152,7 +304,10 @@ module Przn
152
304
  while i < lines.size && (lines[i].match?(/\A[*\-]\s+/) || lines[i].match?(/\A {2,}[*\-]\s+/) || lines[i].match?(/\A {2,}\S/))
153
305
  if lines[i].match(/\A(\s*)[*\-]\s+(.*)/)
154
306
  depth = Regexp.last_match(1).size / 2
155
- items << {text: Regexp.last_match(2), depth: depth}
307
+ text = Regexp.last_match(2).dup
308
+ item = {text: text, depth: depth}
309
+ item[:wait] = true if strip_leading_wait!(text)
310
+ items << item
156
311
  elsif lines[i].match(/\A {2,}(\S.*)/)
157
312
  # Continuation line
158
313
  items.last[:text] << ' ' << Regexp.last_match(1) if items.last
@@ -170,12 +325,45 @@ module Przn
170
325
  while i < lines.size && lines[i].match?(/\A\s*\d+\.\s+/)
171
326
  lines[i].match(/\A(\s*)\d+\.\s+(.*)/)
172
327
  depth = Regexp.last_match(1).size / 3
173
- items << {text: Regexp.last_match(2), depth: depth}
328
+ text = Regexp.last_match(2).dup
329
+ item = {text: text, depth: depth}
330
+ item[:wait] = true if strip_leading_wait!(text)
331
+ items << item
174
332
  i += 1
175
333
  end
176
334
  i -= 1
177
335
  blocks << {type: :ordered_list, items: items}
178
336
 
337
+ # Shape primitives — Keynote-style "Shapes and Lines". Each tag is
338
+ # self-closing with the shape's geometry attrs (cells, or `N%` of
339
+ # the terminal w/h) plus optional SVG paint attrs (fill, stroke,
340
+ # stroke-width, opacity, …). The renderer composes each block into
341
+ # a tiny self-contained SVG document shipped via the Kitty
342
+ # Graphics Protocol — Echoes content-sniffs the payload and
343
+ # rasterizes it through its native CoreGraphics fast path
344
+ # (sub-millisecond for path-only SVGs, which these always are).
345
+ when %r{\A\s*<(rect|circle|ellipse|line|polyline|polygon|arrow|path)((?:\s+#{ATTR_RE_SRC})*)\s*/>\s*\z}o
346
+ kind = Regexp.last_match(1).to_sym
347
+ attrs = parse_xml_attrs(Regexp.last_match(2)).transform_keys(&:to_s)
348
+ blocks << {type: :shape, kind: kind, attrs: attrs}
349
+
350
+ # Image, XML form: <img src="path" alt="..." title="..." {:attrs}/>
351
+ # Equivalent to the markdown `![alt](src "title"){:attrs}` form below
352
+ # — emits the same `:image` block so the renderer handles both
353
+ # identically. `src` is required; all other attributes pass through
354
+ # to `block[:attrs]` (string-keyed, matching markdown's IAL parse) so
355
+ # `relative_height`, `width`, etc. work the same way.
356
+ when %r{\A\s*<img((?:\s+#{ATTR_RE_SRC})+)\s*/>\s*\z}o
357
+ raw = parse_xml_attrs(Regexp.last_match(1))
358
+ path = raw.delete(:src)
359
+ if path
360
+ alt = raw.delete(:alt).to_s
361
+ title = raw.delete(:title)
362
+ attrs = raw.transform_keys(&:to_s)
363
+ normalize_image_attrs!(attrs)
364
+ blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
365
+ end
366
+
179
367
  # Image: ![alt](path "title"){:attrs}
180
368
  when /\A!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]*)")?\)(.*)/
181
369
  alt = Regexp.last_match(1)
@@ -194,6 +382,7 @@ module Przn
194
382
  attr_str = attr_str.sub(/\}\s*\z/, '')
195
383
  parse_image_attrs(attr_str, attrs)
196
384
  end
385
+ normalize_image_attrs!(attrs)
197
386
  blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
198
387
 
199
388
  # Definition list: term on one line, : definition on next
@@ -213,19 +402,83 @@ module Przn
213
402
  i -= 1
214
403
  blocks << {type: :definition_list, term: term, definition: definition_lines.join("\n")}
215
404
  else
216
- blocks << {type: :paragraph, content: Regexp.last_match(1).strip}
405
+ content = Regexp.last_match(1).strip
406
+ blocks << {type: :wait} if strip_leading_wait!(content)
407
+ blocks << {type: :paragraph, content: content}
217
408
  end
218
409
 
219
410
  when /\A\s*\z/
220
411
  blocks << {type: :blank}
221
412
 
222
413
  else
223
- blocks << {type: :paragraph, content: line.strip}
414
+ content = line.strip
415
+ blocks << {type: :wait} if strip_leading_wait!(content)
416
+ blocks << {type: :paragraph, content: content}
224
417
  end
225
418
  i += 1
226
419
  end
227
420
 
228
- Slide.new(blocks)
421
+ hoist_fragment_attrs!(blocks)
422
+ layout = slide_attrs.delete(:layout)
423
+ Slide.new(blocks, layout: layout, attrs: slide_attrs)
424
+ end
425
+
426
+ # Pull `fragment="..."` off the attrs of `<at>` / `<img>` / shape
427
+ # blocks and stash it on the block itself as `:fragment`. Auto-
428
+ # generate an `id=` when none is present so the renderer's effective-
429
+ # state machinery can target the block. Counter is per-slide; cross-
430
+ # slide id collisions are fine because each slide computes its own
431
+ # effective_state independently.
432
+ def hoist_fragment_attrs!(blocks)
433
+ counter = 0
434
+ blocks.each do |block|
435
+ next unless %i[at image shape].include?(block[:type])
436
+ attrs = block[:attrs] or next
437
+ fragment = attrs.delete(:fragment) || attrs.delete('fragment')
438
+ next unless fragment
439
+ block[:fragment] = fragment.to_s
440
+ unless attrs[:id] || attrs['id']
441
+ # Match the attr's existing key style — `:at` uses symbols,
442
+ # `:image` and `:shape` use strings (their parsers run
443
+ # `transform_keys(&:to_s)`).
444
+ key = block[:type] == :at ? :id : 'id'
445
+ attrs[key] = "__frag_#{counter}"
446
+ counter += 1
447
+ end
448
+ end
449
+ end
450
+
451
+ # Pull a trailing IAL block off an h1's content. Accepted spellings:
452
+ # `Title {layout=name}` — plain HTML-attr style
453
+ # `Title {:layout=name}` — kramdown IAL (leading colon marker)
454
+ # `Title {layout: name}` — YAML / JSON flow style (colon separator)
455
+ # `=` and `:` are interchangeable separators. Values can be unquoted,
456
+ # single-quoted, or double-quoted; unquoted excludes `,` and `}` so
457
+ # multi-attr forms like `{layout: two-column, foo: bar}` parse cleanly.
458
+ # If no key=value pairs are found inside the braces we leave the title
459
+ # untouched — `# What about {curlies}?` shouldn't be munched.
460
+ def extract_h1_ial(content)
461
+ return [content, {}] unless content =~ /\A(.*?)\s*\{([^}]*)\}\s*\z/
462
+ body = Regexp.last_match(1)
463
+ inner = Regexp.last_match(2)
464
+
465
+ attrs = parse_ial_attrs(inner)
466
+ return [content, {}] if attrs.empty?
467
+ [body, attrs]
468
+ end
469
+
470
+ # Scan the inside of an IAL brace block for key=value / key: value
471
+ # pairs and return a symbol-keyed hash. Accepts quoted (single /
472
+ # double) and unquoted values; ignores a leading kramdown `:` so
473
+ # `{:layout=name}` and `{layout=name}` parse the same. Empty inner
474
+ # → {} (caller decides whether that's a parse failure or a no-op).
475
+ def parse_ial_attrs(inner)
476
+ stripped = inner.to_s.sub(/\A:?\s*/, '')
477
+ attrs = {}
478
+ stripped.scan(/(\w+)\s*[=:]\s*(?:"([^"]*)"|'([^']*)'|([^\s=:<>"',}]+))/) do |key, dq, sq, uq|
479
+ attrs[key.to_sym] = dq || sq || uq
480
+ end
481
+ attrs
229
482
  end
230
483
 
231
484
  # HTML4-style <font face="..." size="..." color="..."> attributes.
@@ -237,16 +490,71 @@ module Przn
237
490
  attrs.slice(:face, :size, :color)
238
491
  end
239
492
 
240
- # Generic attribute scanner — `key="value"` pairs, returned as a hash with
241
- # symbolized keys. Doesn't validate which keys are allowed; callers slice.
493
+ # Parse a code-fence `lines=` spec into an array of line-number Sets
494
+ # — one Set per pipe-separated step group. Inside a group, single
495
+ # integers and `a-b` ranges separate by commas (quoted IAL value
496
+ # required when commas are present, since `,` is the multi-attr
497
+ # separator at the IAL level). Returns nil for malformed input so a
498
+ # typo in the deck doesn't crash parsing.
499
+ #
500
+ # "1-2|3|4" → [Set[1, 2], Set[3], Set[4]]
501
+ # "1-2,4|3" → [Set[1, 2, 4], Set[3]]
502
+ # "all" / "" / garbage → nil
503
+ def parse_line_steps(spec)
504
+ return nil if spec.nil?
505
+ str = spec.to_s.strip
506
+ return nil if str.empty? || str == 'all' || str == '*'
507
+
508
+ groups = str.split('|').map do |group|
509
+ set = group.split(',').each_with_object(Set.new) do |chunk, acc|
510
+ chunk = chunk.strip
511
+ next if chunk.empty?
512
+ if (m = chunk.match(/\A(\d+)\s*-\s*(\d+)\z/))
513
+ lo, hi = m[1].to_i, m[2].to_i
514
+ (lo..hi).each { |n| acc << n }
515
+ elsif chunk =~ /\A\d+\z/
516
+ acc << chunk.to_i
517
+ else
518
+ return nil
519
+ end
520
+ end
521
+ return nil if set.empty?
522
+ set
523
+ end
524
+ groups.empty? ? nil : groups
525
+ end
526
+
527
+ # Generic attribute scanner — three value forms accepted:
528
+ # key="value" key='value' key=bareword
529
+ # Returns a hash with symbolized keys. Doesn't validate which keys
530
+ # are allowed; callers slice.
242
531
  def parse_xml_attrs(str)
243
532
  attrs = {}
244
- str.scan(/(\w+)="([^"]+)"/) do |key, value|
245
- attrs[key.to_sym] = value
533
+ str.scan(/([\w-]+)=(?:"([^"]*)"|'([^']*)'|([^\s=<>"'`]+))/) do |key, dq, sq, uq|
534
+ attrs[key.to_sym] = dq || sq || uq
246
535
  end
247
536
  attrs
248
537
  end
249
538
 
539
+ # Parse a `duration=` value off `<action>` into milliseconds as a
540
+ # Float. Accepts:
541
+ # "500ms" → 500.0
542
+ # "0.5s" → 500.0
543
+ # "500" → 500.0 (unit-less = ms)
544
+ # Returns nil for anything unparseable so a typo lets the action
545
+ # quietly fall back to the snap (v2) behavior.
546
+ def parse_duration_ms(raw)
547
+ return nil if raw.nil?
548
+ s = raw.to_s.strip
549
+ if (m = s.match(/\A(\d+(?:\.\d+)?)ms\z/))
550
+ m[1].to_f
551
+ elsif (m = s.match(/\A(\d+(?:\.\d+)?)s\z/))
552
+ m[1].to_f * 1000.0
553
+ elsif (m = s.match(/\A(\d+(?:\.\d+)?)\z/))
554
+ m[1].to_f
555
+ end
556
+ end
557
+
250
558
  def parse_image_attrs(str, attrs)
251
559
  str = str.sub(/\A:?\s*/, '')
252
560
  str.scan(/([\w-]+)=['"]([^'"]*)['"]/) do |key, value|
@@ -254,6 +562,22 @@ module Przn
254
562
  end
255
563
  end
256
564
 
565
+ # Rewrite `height="N%"` / `width="N%"` into the canonical
566
+ # `relative_height="N"` / `relative_width="N"` the renderer reads.
567
+ # Values without a `%` suffix pass through unchanged (and are
568
+ # ignored downstream); an explicit `relative_*` already on the
569
+ # block wins so authors can mix forms without surprise.
570
+ def normalize_image_attrs!(attrs)
571
+ if (h = attrs['height']) && (m = h.match(/\A(\d+)%\z/))
572
+ attrs.delete('height')
573
+ attrs['relative_height'] ||= m[1]
574
+ end
575
+ if (w = attrs['width']) && (m = w.match(/\A(\d+)%\z/))
576
+ attrs.delete('width')
577
+ attrs['relative_width'] ||= m[1]
578
+ end
579
+ end
580
+
257
581
  def parse_table(lines)
258
582
  rows = []
259
583
  lines.each do |line|
@@ -271,20 +595,33 @@ module Przn
271
595
  scanner = StringScanner.new(text)
272
596
 
273
597
  until scanner.eos?
274
- if scanner.scan(/\{::tag\s+name="([^"]+)"\}(.*?)\{:\/tag\}/)
275
- segments << [:tag, scanner[2], scanner[1]]
276
- elsif scanner.scan(/<size=([^>\s]+)>(.*?)<\/size>/)
277
- segments << [:tag, scanner[2], scanner[1]]
278
- elsif scanner.scan(/<font((?:\s+\w+="[^"]+")+)\s*>(.*?)<\/font>/)
279
- segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
280
- elsif scanner.scan(/\{::font((?:\s+\w+="[^"]+")+)\}(.*?)\{:\/font\}/)
281
- segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
282
- elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
283
- segments << [:note, scanner[1]]
598
+ if scanner.scan(/<size=(?:"([^"]*)"|'([^']*)'|([^>\s]+))>(.*?)<\/size>/)
599
+ value = scanner[1] || scanner[2] || scanner[3]
600
+ push_with_breaks(segments, scanner[4]) { |chunk| [:tag, chunk, value] }
601
+ elsif scanner.scan(/<color=(?:"([^"]*)"|'([^']*)'|([^>\s]+))>(.*?)<\/color>/)
602
+ value = scanner[1] || scanner[2] || scanner[3]
603
+ push_with_breaks(segments, scanner[4]) { |chunk| [:tag, chunk, value] }
604
+ elsif scanner.scan(/\{::tag\s+name="([^"]+)"\}(.*?)\{:\/tag\}/)
605
+ # Rabbit-compatible kramdown spelling; covers both size and color.
606
+ push_with_breaks(segments, scanner[2]) { |chunk| [:tag, chunk, scanner[1]] }
607
+ elsif scanner.scan(%r{<font((?:\s+#{ATTR_RE_SRC})+)\s*>(.*?)</font>}o)
608
+ font_attrs = parse_font_attrs(scanner[1])
609
+ push_with_breaks(segments, scanner[2]) { |chunk| [:font, chunk, font_attrs] }
610
+ elsif scanner.scan(%r{\{::font((?:\s+#{ATTR_RE_SRC})+)\}(.*?)\{:/font\}}o)
611
+ font_attrs = parse_font_attrs(scanner[1])
612
+ push_with_breaks(segments, scanner[2]) { |chunk| [:font, chunk, font_attrs] }
284
613
  elsif scanner.scan(/<note>(.*?)<\/note>/)
285
- segments << [:note, scanner[1]]
286
- elsif scanner.scan('{::wait/}') || scanner.scan(/<wait\s*\/>/)
614
+ push_with_breaks(segments, scanner[1]) { |chunk| [:note, chunk] }
615
+ elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
616
+ push_with_breaks(segments, scanner[1]) { |chunk| [:note, chunk] }
617
+ elsif scanner.scan(/<wait\s*\/>/) || scanner.scan('{::wait/}')
287
618
  # skip wait markers in inline text
619
+ elsif scanner.scan(/<br\s*\/?>/i)
620
+ # Force a line break inside the current paragraph. All three
621
+ # spellings — `<br>`, `<br/>`, `<br />` — collapse to a
622
+ # `:break` sentinel that wrap_segments closes the current
623
+ # line on.
624
+ segments << [:break]
288
625
  elsif scanner.scan('&lt;')
289
626
  segments << [:text, '<']
290
627
  elsif scanner.scan('&gt;')
@@ -297,14 +634,59 @@ module Przn
297
634
  segments << [:bold, scanner[1]]
298
635
  elsif scanner.scan(/\*(.+?)\*/)
299
636
  segments << [:italic, scanner[1]]
637
+ # CommonMark spelling of bold / italic via underscores. Word-boundary
638
+ # guards (`(?<!\w)` / `(?!\w)`) keep `snake_case` and `foo__bar__baz`
639
+ # identifiers literal — only flanking underscores count.
640
+ elsif scanner.scan(/(?<!\w)__(.+?)__(?!\w)/)
641
+ segments << [:bold, scanner[1]]
642
+ elsif scanner.scan(/(?<!\w)_(.+?)_(?!\w)/)
643
+ segments << [:italic, scanner[1]]
300
644
  elsif scanner.scan(/~~(.+?)~~/)
301
645
  segments << [:strikethrough, scanner[1]]
302
646
  else
303
- segments << [:text, scanner.scan(/[^`*~{<&]+|./)]
647
+ segments << [:text, scanner.scan(/[^`*~_{<&]+|./)]
648
+ end
649
+ end
650
+
651
+ # Coalesce adjacent :text segments. The scanner has to bail to a
652
+ # single-character `.` when it sees `&` so the `&lt;` / `&gt;` /
653
+ # `&amp;` entity matches can run on the next iteration, which
654
+ # leaves a bare `&` as its own segment and fragments the
655
+ # surrounding text. Merging them back together means one OSC 66
656
+ # multicell sequence per typeset run — important for h1 titles
657
+ # under a proportional font, where Echoes pads each run
658
+ # independently and stray segments become visible gaps.
659
+ segments.each_with_object([]) do |seg, acc|
660
+ if seg[0] == :text && acc.last && acc.last[0] == :text
661
+ acc.last[1] = acc.last[1] + seg[1]
662
+ else
663
+ acc << seg
304
664
  end
305
665
  end
666
+ end
306
667
 
307
- segments
668
+ # Push a styled segment to `segments`, splitting its inner content
669
+ # on `<br>` so a `<br>` written inside a `<size>` / `<color>` /
670
+ # `<font>` / `<note>` etc. wrapper closes the current line the same
671
+ # way it would inside plain text. Without this, the `.*?` inside
672
+ # the wrapper regex captures `<br>` literally and the styled
673
+ # segment carries it as text.
674
+ # Leading `<wait/>` (or `{::wait/}`) on a list item or paragraph.
675
+ # When matched, mutate the source text to drop the marker plus any
676
+ # trailing whitespace before the real content; callers attach a
677
+ # step-boundary flag to the surrounding block.
678
+ LEADING_WAIT_RE = %r{\A(?:<wait\s*/>|<wait>\s*</wait>|\{::wait/\})\s*}
679
+ def strip_leading_wait!(text)
680
+ !!text.sub!(LEADING_WAIT_RE, '')
681
+ end
682
+
683
+ BR_RE = /<br\s*\/?>/i
684
+ def push_with_breaks(segments, content)
685
+ chunks = content.split(BR_RE, -1)
686
+ chunks.each_with_index do |chunk, i|
687
+ segments << yield(chunk) unless chunk.empty?
688
+ segments << [:break] if i < chunks.size - 1
689
+ end
308
690
  end
309
691
  end
310
692
  end