przn 0.1.5
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +156 -0
- data/Rakefile +12 -0
- data/default_theme.yml +16 -0
- data/exe/przn +44 -0
- data/lib/przn/controller.rb +73 -0
- data/lib/przn/image_util.rb +85 -0
- data/lib/przn/kitty_text.rb +27 -0
- data/lib/przn/parser.rb +266 -0
- data/lib/przn/pdf_exporter.rb +546 -0
- data/lib/przn/presentation.rb +37 -0
- data/lib/przn/renderer.rb +611 -0
- data/lib/przn/slide.rb +11 -0
- data/lib/przn/terminal.rb +72 -0
- data/lib/przn/theme.rb +41 -0
- data/lib/przn/version.rb +5 -0
- data/lib/przn.rb +34 -0
- data/sample/sample.md +45 -0
- data/sig/przn.rbs +4 -0
- metadata +78 -0
data/lib/przn/parser.rb
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'strscan'
|
|
4
|
+
|
|
5
|
+
module Przn
|
|
6
|
+
module Parser
|
|
7
|
+
# Size names → Kitty text sizing scale (1-7)
|
|
8
|
+
SIZE_SCALES = {
|
|
9
|
+
'xx-small' => 1,
|
|
10
|
+
'x-small' => 1,
|
|
11
|
+
'small' => 2,
|
|
12
|
+
'large' => 3,
|
|
13
|
+
'x-large' => 4,
|
|
14
|
+
'xx-large' => 5,
|
|
15
|
+
'xxx-large' => 6,
|
|
16
|
+
'xxxx-large' => 7,
|
|
17
|
+
'1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7,
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
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,
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def parse(markdown)
|
|
31
|
+
slides = split_slides(markdown)
|
|
32
|
+
Presentation.new(slides.map { |raw| parse_slide(raw) })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Split on h1 headings (Rabbit-compatible)
|
|
36
|
+
def split_slides(markdown)
|
|
37
|
+
chunks = []
|
|
38
|
+
current = +""
|
|
39
|
+
in_fence = false
|
|
40
|
+
|
|
41
|
+
markdown.each_line do |line|
|
|
42
|
+
if line.match?(/\A\s*```/)
|
|
43
|
+
in_fence = !in_fence
|
|
44
|
+
current << line
|
|
45
|
+
elsif !in_fence && line.match?(/\A#\s/)
|
|
46
|
+
chunks << current unless current.strip.empty?
|
|
47
|
+
current = +line
|
|
48
|
+
else
|
|
49
|
+
current << line
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
chunks << current unless current.strip.empty?
|
|
53
|
+
chunks
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_slide(raw)
|
|
57
|
+
blocks = []
|
|
58
|
+
lines = raw.lines
|
|
59
|
+
i = 0
|
|
60
|
+
|
|
61
|
+
while i < lines.size
|
|
62
|
+
line = lines[i]
|
|
63
|
+
|
|
64
|
+
case line
|
|
65
|
+
# {::comment} ... {:/comment} block — skip entirely
|
|
66
|
+
when /\A\s*\{::comment\}/
|
|
67
|
+
i += 1
|
|
68
|
+
i += 1 while i < lines.size && !lines[i].match?(/\{:\/comment\}/)
|
|
69
|
+
|
|
70
|
+
# Block alignment: {:.center} or {:.right}
|
|
71
|
+
when /\A\s*\{:\.(\w+)\}\s*\z/
|
|
72
|
+
blocks << {type: :align, align: Regexp.last_match(1).to_sym}
|
|
73
|
+
|
|
74
|
+
# Fenced code block
|
|
75
|
+
when /\A\s*```(\w*)\s*\z/
|
|
76
|
+
lang = Regexp.last_match(1)
|
|
77
|
+
lang = nil if lang.empty?
|
|
78
|
+
code_lines = []
|
|
79
|
+
i += 1
|
|
80
|
+
while i < lines.size && !lines[i].match?(/\A\s*```\s*\z/)
|
|
81
|
+
code_lines << lines[i]
|
|
82
|
+
i += 1
|
|
83
|
+
end
|
|
84
|
+
# Check for kramdown IAL on next line: {: lang="ruby"}
|
|
85
|
+
if (i + 1) < lines.size && lines[i + 1]&.match?(/\A\s*\{:/)
|
|
86
|
+
i += 1
|
|
87
|
+
if lines[i].match(/lang="(\w+)"/)
|
|
88
|
+
lang = Regexp.last_match(1)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
blocks << {type: :code_block, content: code_lines.join, language: lang}
|
|
92
|
+
|
|
93
|
+
# Indented code block (4 spaces)
|
|
94
|
+
when /\A {4}(.*)$/
|
|
95
|
+
code_lines = [Regexp.last_match(1)]
|
|
96
|
+
while (i + 1) < lines.size && lines[i + 1].match?(/\A {4}/)
|
|
97
|
+
i += 1
|
|
98
|
+
code_lines << lines[i].sub(/\A {4}/, '')
|
|
99
|
+
end
|
|
100
|
+
# Check for kramdown IAL: {: lang="ruby"}
|
|
101
|
+
lang = nil
|
|
102
|
+
if (i + 1) < lines.size && lines[i + 1]&.match?(/\A\s*\{:/)
|
|
103
|
+
i += 1
|
|
104
|
+
if lines[i].match(/lang="(\w+)"/)
|
|
105
|
+
lang = Regexp.last_match(1)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
blocks << {type: :code_block, content: code_lines.join("\n") + "\n", language: lang}
|
|
109
|
+
|
|
110
|
+
# h1 (slide title)
|
|
111
|
+
when /\A#\s+(.*)/
|
|
112
|
+
blocks << {type: :heading, level: 1, content: Regexp.last_match(1).strip}
|
|
113
|
+
|
|
114
|
+
# h2-h6 (sub-headings within slide)
|
|
115
|
+
when /\A(\#{2,6})\s+(.*)/
|
|
116
|
+
level = Regexp.last_match(1).size
|
|
117
|
+
text = Regexp.last_match(2).strip
|
|
118
|
+
blocks << {type: :heading, level: level, content: text}
|
|
119
|
+
|
|
120
|
+
# Block quote
|
|
121
|
+
when /\A>\s?(.*)/
|
|
122
|
+
quote_lines = [Regexp.last_match(1)]
|
|
123
|
+
while (i + 1) < lines.size && (m = lines[i + 1].match(/\A>\s?(.*)/))
|
|
124
|
+
i += 1
|
|
125
|
+
quote_lines << m[1]
|
|
126
|
+
end
|
|
127
|
+
blocks << {type: :blockquote, content: quote_lines.join("\n")}
|
|
128
|
+
|
|
129
|
+
# Table
|
|
130
|
+
when /\A\|/
|
|
131
|
+
table_lines = [line.strip]
|
|
132
|
+
while (i + 1) < lines.size && lines[i + 1].match?(/\A\|/)
|
|
133
|
+
i += 1
|
|
134
|
+
table_lines << lines[i].strip
|
|
135
|
+
end
|
|
136
|
+
blocks << parse_table(table_lines)
|
|
137
|
+
|
|
138
|
+
# Unordered list (* or - item)
|
|
139
|
+
when /\A[*\-]\s+(.*)/
|
|
140
|
+
items = []
|
|
141
|
+
while i < lines.size && (lines[i].match?(/\A[*\-]\s+/) || lines[i].match?(/\A {2,}[*\-]\s+/) || lines[i].match?(/\A {2,}\S/))
|
|
142
|
+
if lines[i].match(/\A(\s*)[*\-]\s+(.*)/)
|
|
143
|
+
depth = Regexp.last_match(1).size / 2
|
|
144
|
+
items << {text: Regexp.last_match(2), depth: depth}
|
|
145
|
+
elsif lines[i].match(/\A {2,}(\S.*)/)
|
|
146
|
+
# Continuation line
|
|
147
|
+
items.last[:text] << " " << Regexp.last_match(1) if items.last
|
|
148
|
+
else
|
|
149
|
+
break
|
|
150
|
+
end
|
|
151
|
+
i += 1
|
|
152
|
+
end
|
|
153
|
+
i -= 1
|
|
154
|
+
blocks << {type: :unordered_list, items: items}
|
|
155
|
+
|
|
156
|
+
# Ordered list
|
|
157
|
+
when /\A(\s*)\d+\.\s+(.*)/
|
|
158
|
+
items = []
|
|
159
|
+
while i < lines.size && lines[i].match?(/\A\s*\d+\.\s+/)
|
|
160
|
+
lines[i].match(/\A(\s*)\d+\.\s+(.*)/)
|
|
161
|
+
depth = Regexp.last_match(1).size / 3
|
|
162
|
+
items << {text: Regexp.last_match(2), depth: depth}
|
|
163
|
+
i += 1
|
|
164
|
+
end
|
|
165
|
+
i -= 1
|
|
166
|
+
blocks << {type: :ordered_list, items: items}
|
|
167
|
+
|
|
168
|
+
# Image: {:attrs}
|
|
169
|
+
when /\A!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]*)")?\)(.*)/
|
|
170
|
+
alt = Regexp.last_match(1)
|
|
171
|
+
path = Regexp.last_match(2)
|
|
172
|
+
title = Regexp.last_match(3)
|
|
173
|
+
rest = Regexp.last_match(4).strip
|
|
174
|
+
attrs = {}
|
|
175
|
+
if rest.match(/\{([^}]+)\}/)
|
|
176
|
+
parse_image_attrs(Regexp.last_match(1), attrs)
|
|
177
|
+
elsif rest.match(/\{(.+)/) || ((i + 1) < lines.size && lines[i + 1]&.match?(/\A\s*\{/))
|
|
178
|
+
attr_str = rest.sub(/\A\{:?\s*/, '')
|
|
179
|
+
while !attr_str.include?('}') && (i + 1) < lines.size
|
|
180
|
+
i += 1
|
|
181
|
+
attr_str << " " << lines[i].strip
|
|
182
|
+
end
|
|
183
|
+
attr_str = attr_str.sub(/\}\s*\z/, '')
|
|
184
|
+
parse_image_attrs(attr_str, attrs)
|
|
185
|
+
end
|
|
186
|
+
blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
|
|
187
|
+
|
|
188
|
+
# Definition list: term on one line, : definition on next
|
|
189
|
+
when /\A(\S.*)\s*\z/
|
|
190
|
+
if (i + 1) < lines.size && lines[i + 1].match?(/\A:\s{3}/)
|
|
191
|
+
term = Regexp.last_match(1).strip
|
|
192
|
+
i += 1
|
|
193
|
+
definition_lines = []
|
|
194
|
+
while i < lines.size && lines[i].match?(/\A:\s{3}(.*)|\A {4}(.*)/)
|
|
195
|
+
if lines[i].match(/\A:\s{3}(.*)/)
|
|
196
|
+
definition_lines << Regexp.last_match(1)
|
|
197
|
+
elsif lines[i].match(/\A {4}(.*)/)
|
|
198
|
+
definition_lines << Regexp.last_match(1)
|
|
199
|
+
end
|
|
200
|
+
i += 1
|
|
201
|
+
end
|
|
202
|
+
i -= 1
|
|
203
|
+
blocks << {type: :definition_list, term: term, definition: definition_lines.join("\n")}
|
|
204
|
+
else
|
|
205
|
+
blocks << {type: :paragraph, content: Regexp.last_match(1).strip}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
when /\A\s*\z/
|
|
209
|
+
blocks << {type: :blank}
|
|
210
|
+
|
|
211
|
+
else
|
|
212
|
+
blocks << {type: :paragraph, content: line.strip}
|
|
213
|
+
end
|
|
214
|
+
i += 1
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
Slide.new(blocks)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def parse_image_attrs(str, attrs)
|
|
221
|
+
str = str.sub(/\A:?\s*/, '')
|
|
222
|
+
str.scan(/([\w-]+)=['"]([^'"]*)['"]/) do |key, value|
|
|
223
|
+
attrs[key.tr('-', '_')] = value
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def parse_table(lines)
|
|
228
|
+
rows = []
|
|
229
|
+
lines.each do |line|
|
|
230
|
+
next if line.match?(/\A\|[-|:\s]+\|\s*\z/) # separator row
|
|
231
|
+
|
|
232
|
+
cells = line.split('|').map(&:strip).reject(&:empty?)
|
|
233
|
+
rows << cells
|
|
234
|
+
end
|
|
235
|
+
{type: :table, header: rows.first, rows: rows.drop(1)}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Parse inline text with Rabbit-compatible markup
|
|
239
|
+
def parse_inline(text)
|
|
240
|
+
segments = []
|
|
241
|
+
scanner = StringScanner.new(text)
|
|
242
|
+
|
|
243
|
+
until scanner.eos?
|
|
244
|
+
if scanner.scan(/\{::tag\s+name="([^"]+)"\}(.*?)\{:\/tag\}/)
|
|
245
|
+
segments << [:tag, scanner[2], scanner[1]]
|
|
246
|
+
elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
|
|
247
|
+
segments << [:note, scanner[1]]
|
|
248
|
+
elsif scanner.scan(/\{::wait\/\}/)
|
|
249
|
+
# skip wait markers in inline text
|
|
250
|
+
elsif scanner.scan(/`([^`]+)`/)
|
|
251
|
+
segments << [:code, scanner[1]]
|
|
252
|
+
elsif scanner.scan(/\*\*(.+?)\*\*/)
|
|
253
|
+
segments << [:bold, scanner[1]]
|
|
254
|
+
elsif scanner.scan(/\*(.+?)\*/)
|
|
255
|
+
segments << [:italic, scanner[1]]
|
|
256
|
+
elsif scanner.scan(/~~(.+?)~~/)
|
|
257
|
+
segments << [:strikethrough, scanner[1]]
|
|
258
|
+
else
|
|
259
|
+
segments << [:text, scanner.scan(/[^`*~{]+|./)]
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
segments
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|