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
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przn
|
|
4
|
+
class Renderer
|
|
5
|
+
ANSI = {
|
|
6
|
+
bold: "\e[1m",
|
|
7
|
+
italic: "\e[3m",
|
|
8
|
+
reverse: "\e[7m",
|
|
9
|
+
strikethrough: "\e[9m",
|
|
10
|
+
dim: "\e[2m",
|
|
11
|
+
cyan: "\e[36m",
|
|
12
|
+
gray_bg: "\e[48;5;236m",
|
|
13
|
+
reset: "\e[0m",
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
DEFAULT_SCALE = 2
|
|
17
|
+
|
|
18
|
+
def initialize(terminal, base_dir: '.', theme: nil)
|
|
19
|
+
@terminal = terminal
|
|
20
|
+
@base_dir = base_dir
|
|
21
|
+
@theme = theme
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render(slide, current:, total:)
|
|
25
|
+
@terminal.clear
|
|
26
|
+
w = @terminal.width
|
|
27
|
+
h = @terminal.height
|
|
28
|
+
|
|
29
|
+
row = if current == 0
|
|
30
|
+
content_height = calculate_height(slide.blocks, w)
|
|
31
|
+
usable_height = h - 1
|
|
32
|
+
[(usable_height - content_height) / 2 + 1, 1].max
|
|
33
|
+
else
|
|
34
|
+
2
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
pending_align = nil
|
|
38
|
+
slide.blocks.each do |block|
|
|
39
|
+
if block[:type] == :align
|
|
40
|
+
pending_align = block[:align]
|
|
41
|
+
else
|
|
42
|
+
row = render_block(block, w, row, align: pending_align)
|
|
43
|
+
pending_align = nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
status = " #{current + 1} / #{total} "
|
|
48
|
+
@terminal.move_to(h, w - status.size)
|
|
49
|
+
@terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
|
|
50
|
+
|
|
51
|
+
@terminal.flush
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def render_block(block, width, row, align: nil)
|
|
57
|
+
case block[:type]
|
|
58
|
+
when :heading then render_heading(block, width, row)
|
|
59
|
+
when :paragraph then render_paragraph(block, width, row, align: align)
|
|
60
|
+
when :code_block then render_code_block(block, width, row)
|
|
61
|
+
when :unordered_list then render_unordered_list(block, width, row)
|
|
62
|
+
when :ordered_list then render_ordered_list(block, width, row)
|
|
63
|
+
when :definition_list then render_definition_list(block, width, row)
|
|
64
|
+
when :blockquote then render_blockquote(block, width, row)
|
|
65
|
+
when :table then render_table(block, width, row)
|
|
66
|
+
when :image then render_image(block, width, row)
|
|
67
|
+
when :blank then row + DEFAULT_SCALE
|
|
68
|
+
else row + 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_heading(block, width, row)
|
|
73
|
+
text = block[:content]
|
|
74
|
+
|
|
75
|
+
if block[:level] == 1
|
|
76
|
+
scale = KittyText::HEADING_SCALES[1]
|
|
77
|
+
visible_width = display_width(text) * scale
|
|
78
|
+
pad = [(width - visible_width) / 2, 0].max
|
|
79
|
+
@terminal.move_to(row, pad + 1)
|
|
80
|
+
@terminal.write "#{ANSI[:bold]}#{KittyText.sized(text, s: scale)}#{ANSI[:reset]}"
|
|
81
|
+
row + scale + 4
|
|
82
|
+
else
|
|
83
|
+
left = content_left(width)
|
|
84
|
+
prefix = "・"
|
|
85
|
+
prefix_w = display_width(prefix)
|
|
86
|
+
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
87
|
+
segments = Parser.parse_inline(text)
|
|
88
|
+
wrapped = wrap_segments(segments, max_w)
|
|
89
|
+
|
|
90
|
+
wrapped.each_with_index do |line_segs, li|
|
|
91
|
+
@terminal.move_to(row, left)
|
|
92
|
+
if li == 0
|
|
93
|
+
@terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
94
|
+
else
|
|
95
|
+
@terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
96
|
+
end
|
|
97
|
+
row += DEFAULT_SCALE
|
|
98
|
+
end
|
|
99
|
+
row
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render_paragraph(block, width, row, align: nil)
|
|
104
|
+
text = block[:content]
|
|
105
|
+
scale = max_inline_scale(text) || DEFAULT_SCALE
|
|
106
|
+
left = content_left(width)
|
|
107
|
+
|
|
108
|
+
if align
|
|
109
|
+
vis = visible_width_scaled(text, scale)
|
|
110
|
+
left = compute_pad(width, vis, align)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
max_w = max_text_width(width, left, scale)
|
|
114
|
+
segments = Parser.parse_inline(text)
|
|
115
|
+
wrapped = wrap_segments(segments, max_w)
|
|
116
|
+
|
|
117
|
+
wrapped.each do |line_segs|
|
|
118
|
+
@terminal.move_to(row, left + 1)
|
|
119
|
+
@terminal.write render_segments_scaled(line_segs, scale)
|
|
120
|
+
row += scale
|
|
121
|
+
end
|
|
122
|
+
row
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def render_code_block(block, width, row)
|
|
126
|
+
code_lines = block[:content].lines.map(&:chomp)
|
|
127
|
+
return row + DEFAULT_SCALE if code_lines.empty?
|
|
128
|
+
|
|
129
|
+
left = content_left(width)
|
|
130
|
+
max_content_w = max_text_width(width, left, DEFAULT_SCALE) - 4
|
|
131
|
+
max_len = code_lines.map { |l| display_width(l) }.max
|
|
132
|
+
box_content_w = [max_len, max_content_w].min
|
|
133
|
+
|
|
134
|
+
code_lines.each do |code_line|
|
|
135
|
+
truncated = truncate_to_width(code_line, box_content_w)
|
|
136
|
+
padded = pad_to_width(truncated, box_content_w)
|
|
137
|
+
@terminal.move_to(row, left + 1)
|
|
138
|
+
@terminal.write "#{ANSI[:gray_bg]}#{KittyText.sized(" #{padded} ", s: DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
139
|
+
row += DEFAULT_SCALE
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
row
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def render_unordered_list(block, width, row)
|
|
146
|
+
left = content_left(width)
|
|
147
|
+
block[:items].each do |item|
|
|
148
|
+
depth = item[:depth] || 0
|
|
149
|
+
indent = " " * depth
|
|
150
|
+
prefix = "#{indent}・"
|
|
151
|
+
prefix_w = display_width(prefix)
|
|
152
|
+
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
153
|
+
|
|
154
|
+
segments = Parser.parse_inline(item[:text])
|
|
155
|
+
wrapped = wrap_segments(segments, max_w)
|
|
156
|
+
|
|
157
|
+
wrapped.each_with_index do |line_segs, li|
|
|
158
|
+
@terminal.move_to(row, left)
|
|
159
|
+
if li == 0
|
|
160
|
+
@terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
161
|
+
else
|
|
162
|
+
@terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
163
|
+
end
|
|
164
|
+
row += DEFAULT_SCALE
|
|
165
|
+
end
|
|
166
|
+
row += 1
|
|
167
|
+
end
|
|
168
|
+
row
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def render_ordered_list(block, width, row)
|
|
172
|
+
left = content_left(width)
|
|
173
|
+
block[:items].each_with_index do |item, i|
|
|
174
|
+
depth = item[:depth] || 0
|
|
175
|
+
indent = " " * depth
|
|
176
|
+
prefix = "#{indent}#{i + 1}. "
|
|
177
|
+
prefix_w = display_width(prefix)
|
|
178
|
+
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
179
|
+
|
|
180
|
+
segments = Parser.parse_inline(item[:text])
|
|
181
|
+
wrapped = wrap_segments(segments, max_w)
|
|
182
|
+
|
|
183
|
+
wrapped.each_with_index do |line_segs, li|
|
|
184
|
+
@terminal.move_to(row, left)
|
|
185
|
+
if li == 0
|
|
186
|
+
@terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
187
|
+
else
|
|
188
|
+
@terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
189
|
+
end
|
|
190
|
+
row += DEFAULT_SCALE
|
|
191
|
+
end
|
|
192
|
+
row += 1
|
|
193
|
+
end
|
|
194
|
+
row
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def render_definition_list(block, width, row)
|
|
198
|
+
left = content_left(width)
|
|
199
|
+
max_w = max_text_width(width, left, DEFAULT_SCALE)
|
|
200
|
+
|
|
201
|
+
segments = Parser.parse_inline(block[:term])
|
|
202
|
+
wrapped = wrap_segments(segments, max_w)
|
|
203
|
+
wrapped.each do |line_segs|
|
|
204
|
+
@terminal.move_to(row, left)
|
|
205
|
+
@terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
206
|
+
row += DEFAULT_SCALE
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def_max_w = [max_w - 4, 1].max
|
|
210
|
+
block[:definition].each_line do |line|
|
|
211
|
+
segments = Parser.parse_inline(line.chomp)
|
|
212
|
+
wrapped = wrap_segments(segments, def_max_w)
|
|
213
|
+
wrapped.each do |line_segs|
|
|
214
|
+
@terminal.move_to(row, left + 4)
|
|
215
|
+
@terminal.write render_segments_scaled(line_segs, DEFAULT_SCALE)
|
|
216
|
+
row += DEFAULT_SCALE
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
row
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def render_blockquote(block, width, row)
|
|
223
|
+
left = content_left(width)
|
|
224
|
+
prefix = "| "
|
|
225
|
+
prefix_w = display_width(prefix)
|
|
226
|
+
max_w = max_text_width(width, left + 1, DEFAULT_SCALE) - prefix_w
|
|
227
|
+
|
|
228
|
+
block[:content].each_line do |line|
|
|
229
|
+
text = line.chomp
|
|
230
|
+
segments = [[:text, text]]
|
|
231
|
+
wrapped = wrap_segments(segments, max_w)
|
|
232
|
+
|
|
233
|
+
wrapped.each_with_index do |line_segs, li|
|
|
234
|
+
@terminal.move_to(row, left + 1)
|
|
235
|
+
p = li == 0 ? prefix : " " * prefix_w
|
|
236
|
+
@terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
237
|
+
row += DEFAULT_SCALE
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
row
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def render_table(block, width, row)
|
|
244
|
+
left = content_left(width)
|
|
245
|
+
all_rows = [block[:header]] + block[:rows]
|
|
246
|
+
col_widths = Array.new(block[:header]&.size || 0, 0)
|
|
247
|
+
all_rows.each do |cells|
|
|
248
|
+
cells&.each_with_index do |cell, ci|
|
|
249
|
+
col_widths[ci] = [col_widths[ci] || 0, display_width(cell)].max
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
all_rows.each_with_index do |cells, ri|
|
|
254
|
+
next unless cells
|
|
255
|
+
|
|
256
|
+
@terminal.move_to(row, left)
|
|
257
|
+
line = cells.each_with_index.map { |cell, ci|
|
|
258
|
+
pad_to_width(cell, col_widths[ci] || 0)
|
|
259
|
+
}.join(" | ")
|
|
260
|
+
if ri == 0
|
|
261
|
+
@terminal.write "#{ANSI[:bold]}#{KittyText.sized(line, s: DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
262
|
+
else
|
|
263
|
+
@terminal.write KittyText.sized(line, s: DEFAULT_SCALE)
|
|
264
|
+
end
|
|
265
|
+
row += DEFAULT_SCALE
|
|
266
|
+
|
|
267
|
+
if ri == 0
|
|
268
|
+
@terminal.move_to(row, left)
|
|
269
|
+
@terminal.write KittyText.sized(col_widths.map { |w| "-" * w }.join("--+--"), s: DEFAULT_SCALE)
|
|
270
|
+
row += DEFAULT_SCALE
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
row
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def render_image(block, width, row)
|
|
277
|
+
path = resolve_image_path(block[:path])
|
|
278
|
+
return row + DEFAULT_SCALE unless File.exist?(path)
|
|
279
|
+
|
|
280
|
+
img_size = ImageUtil.image_size(path)
|
|
281
|
+
return row + DEFAULT_SCALE unless img_size
|
|
282
|
+
|
|
283
|
+
img_w, img_h = img_size
|
|
284
|
+
cell_w, cell_h = @terminal.cell_pixel_size
|
|
285
|
+
|
|
286
|
+
available_rows = @terminal.height - row - 2
|
|
287
|
+
left = content_left(width)
|
|
288
|
+
available_cols = width - left * 2
|
|
289
|
+
|
|
290
|
+
if (rh = block[:attrs]['relative_height'])
|
|
291
|
+
target_rows = (@terminal.height * rh.to_i / 100.0).to_i
|
|
292
|
+
available_rows = [target_rows, available_rows].min
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Calculate target cell size maintaining aspect ratio
|
|
296
|
+
img_cell_w = img_w.to_f / cell_w
|
|
297
|
+
img_cell_h = img_h.to_f / cell_h
|
|
298
|
+
scale = [available_cols / img_cell_w, available_rows / img_cell_h, 1.0].min
|
|
299
|
+
target_cols = (img_cell_w * scale).to_i
|
|
300
|
+
target_rows = (img_cell_h * scale).to_i
|
|
301
|
+
target_cols = [target_cols, 1].max
|
|
302
|
+
target_rows = [target_rows, 1].max
|
|
303
|
+
|
|
304
|
+
x = [(width - target_cols) / 2, 0].max
|
|
305
|
+
|
|
306
|
+
if ImageUtil.kitty_terminal?
|
|
307
|
+
data = ImageUtil.kitty_icat(path, cols: target_cols, rows: target_rows, x: x, y: row - 1)
|
|
308
|
+
@terminal.write data if data && !data.empty?
|
|
309
|
+
elsif ImageUtil.sixel_available?
|
|
310
|
+
@terminal.move_to(row, x + 1)
|
|
311
|
+
target_pixel_w = target_cols * cell_w
|
|
312
|
+
target_pixel_h = target_rows * cell_h
|
|
313
|
+
sixel = ImageUtil.sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
|
|
314
|
+
@terminal.write sixel if sixel && !sixel.empty?
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
row + target_rows
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def resolve_image_path(path)
|
|
321
|
+
return path if File.absolute_path?(path) == path
|
|
322
|
+
File.expand_path(path, @base_dir)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def content_left(width)
|
|
326
|
+
width / 16
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def max_text_width(terminal_width, left_col, scale)
|
|
330
|
+
(terminal_width - left_col) / scale
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def compute_pad(width, content_width, align)
|
|
334
|
+
case align
|
|
335
|
+
when :right then [(width - content_width - 2), 0].max
|
|
336
|
+
when :center then [(width - content_width) / 2, 0].max
|
|
337
|
+
else content_left(width)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def render_inline(text)
|
|
342
|
+
Parser.parse_inline(text).map { |segment|
|
|
343
|
+
type = segment[0]
|
|
344
|
+
content = segment[1]
|
|
345
|
+
case type
|
|
346
|
+
when :tag then render_tag(content, segment[2])
|
|
347
|
+
when :note then "#{ANSI[:dim]}#{content}#{ANSI[:reset]}"
|
|
348
|
+
when :bold then "#{ANSI[:bold]}#{content}#{ANSI[:reset]}"
|
|
349
|
+
when :italic then "#{ANSI[:italic]}#{content}#{ANSI[:reset]}"
|
|
350
|
+
when :strikethrough then "#{ANSI[:strikethrough]}#{content}#{ANSI[:reset]}"
|
|
351
|
+
when :code then "#{ANSI[:gray_bg]} #{content} #{ANSI[:reset]}"
|
|
352
|
+
when :text then content
|
|
353
|
+
end
|
|
354
|
+
}.join
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def render_tag(text, tag_name)
|
|
358
|
+
if (scale = Parser::SIZE_SCALES[tag_name])
|
|
359
|
+
KittyText.sized(text, s: scale)
|
|
360
|
+
elsif Parser::NAMED_COLORS.key?(tag_name)
|
|
361
|
+
"#{color_code(tag_name)}#{text}#{ANSI[:reset]}"
|
|
362
|
+
else
|
|
363
|
+
text
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def render_segments_scaled(segments, para_scale)
|
|
368
|
+
segments.map { |segment|
|
|
369
|
+
type = segment[0]
|
|
370
|
+
content = segment[1]
|
|
371
|
+
case type
|
|
372
|
+
when :tag
|
|
373
|
+
tag_name = segment[2]
|
|
374
|
+
if (scale = Parser::SIZE_SCALES[tag_name])
|
|
375
|
+
KittyText.sized(content, s: scale)
|
|
376
|
+
elsif Parser::NAMED_COLORS.key?(tag_name)
|
|
377
|
+
"#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
|
|
378
|
+
else
|
|
379
|
+
KittyText.sized(content, s: para_scale)
|
|
380
|
+
end
|
|
381
|
+
when :note then "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
|
|
382
|
+
when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
|
|
383
|
+
when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
|
|
384
|
+
when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
|
|
385
|
+
when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale)}#{ANSI[:reset]}"
|
|
386
|
+
when :text then KittyText.sized(content, s: para_scale)
|
|
387
|
+
end
|
|
388
|
+
}.join
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def render_inline_scaled(text, para_scale)
|
|
392
|
+
render_segments_scaled(Parser.parse_inline(text), para_scale)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Wrap parsed inline segments into lines that fit within max_width display units
|
|
396
|
+
def wrap_segments(segments, max_width)
|
|
397
|
+
return [segments] if max_width <= 0
|
|
398
|
+
|
|
399
|
+
lines = [[]]
|
|
400
|
+
width = 0
|
|
401
|
+
|
|
402
|
+
segments.each do |seg|
|
|
403
|
+
content = seg[1] || ""
|
|
404
|
+
seg_w = display_width(content)
|
|
405
|
+
|
|
406
|
+
if width + seg_w <= max_width
|
|
407
|
+
lines.last << seg
|
|
408
|
+
width += seg_w
|
|
409
|
+
next
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
remaining = content
|
|
413
|
+
loop do
|
|
414
|
+
space = max_width - width
|
|
415
|
+
if space <= 0
|
|
416
|
+
lines << []
|
|
417
|
+
width = 0
|
|
418
|
+
space = max_width
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
chunk, remaining = split_by_display_width(remaining, space)
|
|
422
|
+
lines.last << [seg[0], chunk, *Array(seg[2..])]
|
|
423
|
+
width += display_width(chunk)
|
|
424
|
+
|
|
425
|
+
break unless remaining
|
|
426
|
+
lines << []
|
|
427
|
+
width = 0
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
lines
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def split_by_display_width(text, max_width)
|
|
435
|
+
w = 0
|
|
436
|
+
text.each_char.with_index do |c, i|
|
|
437
|
+
cw = display_width(c)
|
|
438
|
+
if w + cw > max_width && w > 0
|
|
439
|
+
return [text[0...i], text[i..]]
|
|
440
|
+
end
|
|
441
|
+
w += cw
|
|
442
|
+
end
|
|
443
|
+
[text, nil]
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def truncate_to_width(text, max_width)
|
|
447
|
+
w = 0
|
|
448
|
+
text.each_char.with_index do |c, i|
|
|
449
|
+
cw = display_width(c)
|
|
450
|
+
return text[0...i] if w + cw > max_width
|
|
451
|
+
w += cw
|
|
452
|
+
end
|
|
453
|
+
text
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def pad_to_width(text, target_width)
|
|
457
|
+
current = display_width(text)
|
|
458
|
+
text + " " * [target_width - current, 0].max
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def max_inline_scale(text)
|
|
462
|
+
max = 0
|
|
463
|
+
text.scan(/\{::tag\s+name="([^"]+)"\}/) do
|
|
464
|
+
scale = Parser::SIZE_SCALES[$1]
|
|
465
|
+
max = scale if scale && scale > max
|
|
466
|
+
end
|
|
467
|
+
max > 0 ? max : nil
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def visible_width_scaled(text, default_scale)
|
|
471
|
+
Parser.parse_inline(text).sum { |segment|
|
|
472
|
+
type = segment[0]
|
|
473
|
+
content = segment[1]
|
|
474
|
+
case type
|
|
475
|
+
when :tag
|
|
476
|
+
scale = Parser::SIZE_SCALES[segment[2]] || default_scale
|
|
477
|
+
display_width(content) * scale
|
|
478
|
+
else
|
|
479
|
+
display_width(content) * default_scale
|
|
480
|
+
end
|
|
481
|
+
}
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def color_code(color)
|
|
485
|
+
if (code = Parser::NAMED_COLORS[color])
|
|
486
|
+
"\e[#{code}m"
|
|
487
|
+
elsif color.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
488
|
+
r, g, b = color.scan(/../).map { |h| h.to_i(16) }
|
|
489
|
+
"\e[38;2;#{r};#{g};#{b}m"
|
|
490
|
+
else
|
|
491
|
+
""
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def visible_length(text)
|
|
496
|
+
display_width(strip_markup(text))
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def display_width(str)
|
|
500
|
+
str.each_char.sum { |c|
|
|
501
|
+
o = c.ord
|
|
502
|
+
if o >= 0x1100 &&
|
|
503
|
+
(o <= 0x115f ||
|
|
504
|
+
o == 0x2329 || o == 0x232a ||
|
|
505
|
+
(o >= 0x2e80 && o <= 0x303e) ||
|
|
506
|
+
(o >= 0x3040 && o <= 0x33bf) ||
|
|
507
|
+
(o >= 0x3400 && o <= 0x4dbf) ||
|
|
508
|
+
(o >= 0x4e00 && o <= 0xa4cf) ||
|
|
509
|
+
(o >= 0xac00 && o <= 0xd7a3) ||
|
|
510
|
+
(o >= 0xf900 && o <= 0xfaff) ||
|
|
511
|
+
(o >= 0xfe30 && o <= 0xfe6f) ||
|
|
512
|
+
(o >= 0xff00 && o <= 0xff60) ||
|
|
513
|
+
(o >= 0xffe0 && o <= 0xffe6) ||
|
|
514
|
+
(o >= 0x20000 && o <= 0x2fffd) ||
|
|
515
|
+
(o >= 0x30000 && o <= 0x3fffd))
|
|
516
|
+
2
|
|
517
|
+
else
|
|
518
|
+
1
|
|
519
|
+
end
|
|
520
|
+
}
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def strip_markup(text)
|
|
524
|
+
text
|
|
525
|
+
.gsub(/\{::tag\s+name="[^"]+"\}(.*?)\{:\/tag\}/, '\1')
|
|
526
|
+
.gsub(/\{::note\}(.*?)\{:\/note\}/, '\1')
|
|
527
|
+
.gsub(/\{::wait\/\}/, '')
|
|
528
|
+
.gsub(/\*\*(.+?)\*\*/, '\1')
|
|
529
|
+
.gsub(/\*(.+?)\*/, '\1')
|
|
530
|
+
.gsub(/~~(.+?)~~/, '\1')
|
|
531
|
+
.gsub(/`([^`]+)`/, '\1')
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def calculate_height(blocks, width)
|
|
535
|
+
blocks.sum { |b| block_height(b, width) }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def block_height(block, width)
|
|
539
|
+
s = DEFAULT_SCALE
|
|
540
|
+
left = content_left(width)
|
|
541
|
+
max_w = max_text_width(width, left, s)
|
|
542
|
+
|
|
543
|
+
case block[:type]
|
|
544
|
+
when :heading
|
|
545
|
+
scale = KittyText::HEADING_SCALES[block[:level]] || s
|
|
546
|
+
if block[:level] == 1
|
|
547
|
+
scale + 4
|
|
548
|
+
else
|
|
549
|
+
lines_count(block[:content], [max_w - 2, 1].max) * scale
|
|
550
|
+
end
|
|
551
|
+
when :paragraph
|
|
552
|
+
para_scale = max_inline_scale(block[:content]) || s
|
|
553
|
+
lines_count(block[:content], [max_text_width(width, left, para_scale), 1].max) * para_scale
|
|
554
|
+
when :code_block
|
|
555
|
+
[block[:content].lines.size * s, s].max
|
|
556
|
+
when :unordered_list
|
|
557
|
+
block[:items].sum { |item|
|
|
558
|
+
prefix_w = (item[:depth] || 0) * 2 + 2
|
|
559
|
+
lines_count(item[:text], [max_w - prefix_w, 1].max) * s
|
|
560
|
+
}
|
|
561
|
+
when :ordered_list
|
|
562
|
+
block[:items].size * s
|
|
563
|
+
when :definition_list
|
|
564
|
+
term_lines = lines_count(block[:term], [max_w, 1].max)
|
|
565
|
+
def_lines = block[:definition].lines.sum { |l| lines_count(l.chomp, [max_w - 4, 1].max) }
|
|
566
|
+
(term_lines + def_lines) * s
|
|
567
|
+
when :blockquote
|
|
568
|
+
block[:content].lines.sum { |l| lines_count(l.chomp, [max_w - 3, 1].max) } * s
|
|
569
|
+
when :table
|
|
570
|
+
((block[:header] ? 2 : 0) + block[:rows].size) * s
|
|
571
|
+
when :image
|
|
572
|
+
image_block_height(block, width)
|
|
573
|
+
when :align
|
|
574
|
+
0
|
|
575
|
+
when :blank
|
|
576
|
+
s
|
|
577
|
+
else
|
|
578
|
+
s
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def lines_count(text, max_width)
|
|
583
|
+
vis_w = display_width(strip_markup(text))
|
|
584
|
+
return 1 if vis_w <= max_width
|
|
585
|
+
(vis_w.to_f / max_width).ceil
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def image_block_height(block, width)
|
|
589
|
+
path = resolve_image_path(block[:path])
|
|
590
|
+
img_size = ImageUtil.image_size(path)
|
|
591
|
+
return DEFAULT_SCALE unless img_size
|
|
592
|
+
|
|
593
|
+
img_w, img_h = img_size
|
|
594
|
+
cell_w, cell_h = @terminal.cell_pixel_size
|
|
595
|
+
h = @terminal.height
|
|
596
|
+
|
|
597
|
+
left = content_left(width)
|
|
598
|
+
available_cols = width - left * 2
|
|
599
|
+
available_rows = h / 2
|
|
600
|
+
|
|
601
|
+
if (rh = block[:attrs]['relative_height'])
|
|
602
|
+
available_rows = (h * rh.to_i / 100.0).to_i
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
img_cell_w = img_w.to_f / cell_w
|
|
606
|
+
img_cell_h = img_h.to_f / cell_h
|
|
607
|
+
scale = [available_cols / img_cell_w, available_rows / img_cell_h, 1.0].min
|
|
608
|
+
[(img_cell_h * scale).ceil, 1].max
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
data/lib/przn/slide.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
5
|
+
module Przn
|
|
6
|
+
class Terminal
|
|
7
|
+
def initialize(input: $stdin, output: $stdout)
|
|
8
|
+
@in = input
|
|
9
|
+
@out = output
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def width = @in.winsize[1]
|
|
13
|
+
def height = @in.winsize[0]
|
|
14
|
+
|
|
15
|
+
def cell_pixel_size
|
|
16
|
+
@cell_pixel_size ||= query_cell_pixel_size || [10, 20]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def raw(&block) = @in.raw(&block)
|
|
20
|
+
def getch = @in.getch
|
|
21
|
+
|
|
22
|
+
def write(str)
|
|
23
|
+
@out.write(str)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def flush
|
|
27
|
+
@out.flush
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clear
|
|
31
|
+
write "\e[2J\e[H"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def move_to(row, col)
|
|
35
|
+
write "\e[#{row};#{col}H"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def hide_cursor
|
|
39
|
+
write "\e[?25l"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show_cursor
|
|
43
|
+
write "\e[?25h"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enter_alt_screen
|
|
47
|
+
write "\e[?1049h"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def leave_alt_screen
|
|
51
|
+
write "\e[?1049l"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def query_cell_pixel_size
|
|
57
|
+
buf = "\0" * 8
|
|
58
|
+
[0x40087468, 0x5413].each do |ioctl_code|
|
|
59
|
+
begin
|
|
60
|
+
@in.ioctl(ioctl_code, buf)
|
|
61
|
+
rows, cols, xpixel, ypixel = buf.unpack('SSSS')
|
|
62
|
+
if xpixel > 0 && ypixel > 0 && rows > 0 && cols > 0
|
|
63
|
+
return [xpixel / cols, ypixel / rows]
|
|
64
|
+
end
|
|
65
|
+
rescue
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|