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.
- checksums.yaml +4 -4
- data/README.md +496 -66
- 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 +506 -14
- data/lib/przn/echoes_client.rb +20 -0
- data/lib/przn/file_watcher.rb +69 -0
- data/lib/przn/image_util.rb +65 -2
- data/lib/przn/kitty_text.rb +2 -1
- data/lib/przn/mermaid_renderer.rb +88 -0
- data/lib/przn/parser.rb +417 -35
- 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 +2411 -305
- data/lib/przn/screenshot_pdf_exporter.rb +173 -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 +11 -5
- data/sample/ruby-logo-LICENSE +15 -0
- data/sample/ruby.jpg +0 -0
- data/sample/ruby.png +0 -0
- data/sample/sample.md +285 -28
- 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,13 +19,22 @@ 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
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `{: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: {: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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
241
|
-
#
|
|
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+)="([^"]+)
|
|
245
|
-
attrs[key.to_sym] =
|
|
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(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
segments
|
|
280
|
-
elsif scanner.scan(/\{::
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
286
|
-
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/}')
|
|
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('<')
|
|
289
626
|
segments << [:text, '<']
|
|
290
627
|
elsif scanner.scan('>')
|
|
@@ -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 `<` / `>` /
|
|
653
|
+
# `&` 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
|
-
|
|
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
|