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.
- checksums.yaml +4 -4
- data/README.md +467 -82
- data/default_theme.yml +98 -18
- data/exe/przn +8 -2
- data/lib/przn/audience_link.rb +3 -3
- data/lib/przn/code_highlighter.rb +165 -0
- data/lib/przn/controller.rb +505 -14
- data/lib/przn/echoes_client.rb +20 -0
- data/lib/przn/file_watcher.rb +69 -0
- data/lib/przn/image_util.rb +55 -2
- data/lib/przn/kitty_text.rb +2 -1
- data/lib/przn/mermaid_renderer.rb +88 -0
- data/lib/przn/parser.rb +341 -29
- data/lib/przn/prawn_pdf_exporter.rb +133 -15
- data/lib/przn/presentation.rb +43 -0
- data/lib/przn/presenter_renderer.rb +22 -8
- data/lib/przn/renderer.rb +2382 -343
- data/lib/przn/screenshot_pdf_exporter.rb +172 -5
- data/lib/przn/slide.rb +4 -2
- data/lib/przn/terminal.rb +10 -0
- data/lib/przn/theme.rb +85 -10
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +10 -5
- data/sample/ruby-logo-LICENSE +15 -0
- data/sample/ruby.jpg +0 -0
- data/sample/ruby.png +0 -0
- data/sample/sample.md +279 -38
- data/sample/shapes.md +55 -0
- metadata +91 -77
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
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
|
-
'
|
|
22
|
-
'magenta' => 35, 'cyan' => 36, 'white' => 37,
|
|
23
|
-
'
|
|
24
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `{: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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
|
342
|
-
elsif scanner.scan(
|
|
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('<')
|
|
345
626
|
segments << [:text, '<']
|
|
346
627
|
elsif scanner.scan('>')
|
|
@@ -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
|