przn 0.5.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,11 +19,11 @@ 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
 
28
29
  # HTML-ish attribute, three accepted value forms:
@@ -32,7 +33,7 @@ module Przn
32
33
  # `/` is intentionally NOT excluded so paths like `src=path/to/file`
33
34
  # work — which means self-closing tags need a space before `/>` when
34
35
  # the last attribute is unquoted (`<img src=foo.png />`).
35
- ATTR_RE_SRC = '\w+=(?:"[^"]*"|\'[^\']*\'|[^\s=<>"\'`]+)'
36
+ ATTR_RE_SRC = '[\w-]+=(?:"[^"]*"|\'[^\']*\'|[^\s=<>"\'`]+)'
36
37
 
37
38
  module_function
38
39
 
@@ -64,6 +65,7 @@ module Przn
64
65
 
65
66
  def parse_slide(raw)
66
67
  blocks = []
68
+ slide_attrs = {}
67
69
  lines = raw.lines
68
70
  i = 0
69
71
 
@@ -71,6 +73,13 @@ module Przn
71
73
  line = lines[i]
72
74
 
73
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
+
74
83
  # {::comment} ... {:/comment} block — skip entirely
75
84
  when /\A\s*\{::comment\}/
76
85
  i += 1
@@ -83,7 +92,9 @@ module Przn
83
92
  # Block alignment, XML form: <center>content</center> or <right>content</right>
84
93
  when /\A\s*<(center|right)>(.*)<\/\1>\s*\z/
85
94
  blocks << {type: :align, align: Regexp.last_match(1).to_sym}
86
- 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}
87
98
 
88
99
  # Slide background (Echoes OSC 7772):
89
100
  # <bg color="#..."/> — solid (bg-color)
@@ -102,10 +113,17 @@ module Przn
102
113
  when %r{\A\s*\{::at((?:\s+#{ATTR_RE_SRC})+)\}(.*)\{:/at\}\s*\z}o
103
114
  blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
104
115
 
105
- # Fenced code block
106
- when /\A\s*```(\w*)\s*\z/
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/
107
123
  lang = Regexp.last_match(1)
108
124
  lang = nil if lang.empty?
125
+ ial_inner = Regexp.last_match(2)
126
+ attrs = ial_inner ? parse_ial_attrs(ial_inner) : nil
109
127
  code_lines = []
110
128
  i += 1
111
129
  while i < lines.size && !lines[i].match?(/\A\s*```\s*\z/)
@@ -118,8 +136,33 @@ module Przn
118
136
  if lines[i].match(/lang="(\w+)"/)
119
137
  lang = Regexp.last_match(1)
120
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
121
165
  end
122
- blocks << {type: :code_block, content: code_lines.join, language: lang}
123
166
 
124
167
  # Indented code block (4 spaces)
125
168
  when /\A {4}(.*)$/
@@ -138,9 +181,98 @@ module Przn
138
181
  end
139
182
  blocks << {type: :code_block, content: code_lines.join("\n") + "\n", language: lang}
140
183
 
141
- # 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.
142
188
  when /\A#\s+(.*)/
143
- 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
144
276
 
145
277
  # h2-h6 (sub-headings within slide)
146
278
  when /\A(\#{2,6})\s+(.*)/
@@ -172,7 +304,10 @@ module Przn
172
304
  while i < lines.size && (lines[i].match?(/\A[*\-]\s+/) || lines[i].match?(/\A {2,}[*\-]\s+/) || lines[i].match?(/\A {2,}\S/))
173
305
  if lines[i].match(/\A(\s*)[*\-]\s+(.*)/)
174
306
  depth = Regexp.last_match(1).size / 2
175
- 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
176
311
  elsif lines[i].match(/\A {2,}(\S.*)/)
177
312
  # Continuation line
178
313
  items.last[:text] << ' ' << Regexp.last_match(1) if items.last
@@ -190,12 +325,28 @@ module Przn
190
325
  while i < lines.size && lines[i].match?(/\A\s*\d+\.\s+/)
191
326
  lines[i].match(/\A(\s*)\d+\.\s+(.*)/)
192
327
  depth = Regexp.last_match(1).size / 3
193
- 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
194
332
  i += 1
195
333
  end
196
334
  i -= 1
197
335
  blocks << {type: :ordered_list, items: items}
198
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
+
199
350
  # Image, XML form: <img src="path" alt="..." title="..." {:attrs}/>
200
351
  # Equivalent to the markdown `![alt](src "title"){:attrs}` form below
201
352
  # — emits the same `:image` block so the renderer handles both
@@ -251,19 +402,83 @@ module Przn
251
402
  i -= 1
252
403
  blocks << {type: :definition_list, term: term, definition: definition_lines.join("\n")}
253
404
  else
254
- 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}
255
408
  end
256
409
 
257
410
  when /\A\s*\z/
258
411
  blocks << {type: :blank}
259
412
 
260
413
  else
261
- 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}
262
417
  end
263
418
  i += 1
264
419
  end
265
420
 
266
- 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
267
482
  end
268
483
 
269
484
  # HTML4-style <font face="..." size="..." color="..."> attributes.
@@ -275,18 +490,71 @@ module Przn
275
490
  attrs.slice(:face, :size, :color)
276
491
  end
277
492
 
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
+
278
527
  # Generic attribute scanner — three value forms accepted:
279
528
  # key="value" key='value' key=bareword
280
529
  # Returns a hash with symbolized keys. Doesn't validate which keys
281
530
  # are allowed; callers slice.
282
531
  def parse_xml_attrs(str)
283
532
  attrs = {}
284
- str.scan(/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s=<>"'`]+))/) do |key, dq, sq, uq|
533
+ str.scan(/([\w-]+)=(?:"([^"]*)"|'([^']*)'|([^\s=<>"'`]+))/) do |key, dq, sq, uq|
285
534
  attrs[key.to_sym] = dq || sq || uq
286
535
  end
287
536
  attrs
288
537
  end
289
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
+
290
558
  def parse_image_attrs(str, attrs)
291
559
  str = str.sub(/\A:?\s*/, '')
292
560
  str.scan(/([\w-]+)=['"]([^'"]*)['"]/) do |key, value|
@@ -327,20 +595,33 @@ module Przn
327
595
  scanner = StringScanner.new(text)
328
596
 
329
597
  until scanner.eos?
330
- if scanner.scan(/\{::tag\s+name="([^"]+)"\}(.*?)\{:\/tag\}/)
331
- segments << [:tag, scanner[2], scanner[1]]
332
- elsif scanner.scan(/<size=([^>\s]+)>(.*?)<\/size>/)
333
- segments << [:tag, scanner[2], 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]] }
334
607
  elsif scanner.scan(%r{<font((?:\s+#{ATTR_RE_SRC})+)\s*>(.*?)</font>}o)
335
- segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
608
+ font_attrs = parse_font_attrs(scanner[1])
609
+ push_with_breaks(segments, scanner[2]) { |chunk| [:font, chunk, font_attrs] }
336
610
  elsif scanner.scan(%r{\{::font((?:\s+#{ATTR_RE_SRC})+)\}(.*?)\{:/font\}}o)
337
- segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
338
- elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
339
- segments << [:note, scanner[1]]
611
+ font_attrs = parse_font_attrs(scanner[1])
612
+ push_with_breaks(segments, scanner[2]) { |chunk| [:font, chunk, font_attrs] }
340
613
  elsif scanner.scan(/<note>(.*?)<\/note>/)
341
- segments << [:note, scanner[1]]
342
- 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/}')
343
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]
344
625
  elsif scanner.scan('&lt;')
345
626
  segments << [:text, '<']
346
627
  elsif scanner.scan('&gt;')
@@ -353,10 +634,17 @@ module Przn
353
634
  segments << [:bold, scanner[1]]
354
635
  elsif scanner.scan(/\*(.+?)\*/)
355
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]]
356
644
  elsif scanner.scan(/~~(.+?)~~/)
357
645
  segments << [:strikethrough, scanner[1]]
358
646
  else
359
- segments << [:text, scanner.scan(/[^`*~{<&]+|./)]
647
+ segments << [:text, scanner.scan(/[^`*~_{<&]+|./)]
360
648
  end
361
649
  end
362
650
 
@@ -376,5 +664,29 @@ module Przn
376
664
  end
377
665
  end
378
666
  end
667
+
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
690
+ end
379
691
  end
380
692
  end