przn 0.3.0 → 0.5.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 +66 -0
- data/Rakefile +5 -5
- data/default_theme.yml +8 -0
- data/exe/przn +21 -4
- data/lib/przn/audience_link.rb +51 -0
- data/lib/przn/controller.rb +16 -2
- data/lib/przn/echoes_client.rb +83 -0
- data/lib/przn/image_util.rb +15 -3
- data/lib/przn/kitty_text.rb +23 -4
- data/lib/przn/parser.rb +90 -20
- data/lib/przn/{pdf_exporter.rb → prawn_pdf_exporter.rb} +26 -14
- data/lib/przn/presenter_renderer.rb +71 -0
- data/lib/przn/renderer.rb +180 -35
- data/lib/przn/screenshot_pdf_exporter.rb +18 -3
- data/lib/przn/slide.rb +25 -0
- data/lib/przn/theme.rb +32 -1
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +77 -28
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
- data/sample/sample.md +24 -0
- metadata +7 -2
data/lib/przn/renderer.rb
CHANGED
|
@@ -10,21 +10,35 @@ module Przn
|
|
|
10
10
|
dim: "\e[2m",
|
|
11
11
|
cyan: "\e[36m",
|
|
12
12
|
gray_bg: "\e[48;5;236m",
|
|
13
|
-
reset: "\e[0m"
|
|
13
|
+
reset: "\e[0m"
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
16
|
DEFAULT_SCALE = 2
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# Default `relative_height` (as a percent of terminal height) applied to
|
|
19
|
+
# image blocks that don't carry an explicit one. Caps how much of the
|
|
20
|
+
# screen a single image can occupy; the rest leaves predictable margin
|
|
21
|
+
# for the slide footer and avoids placement-clearing edge cases in some
|
|
22
|
+
# terminals when an image lands right against the bottom row.
|
|
23
|
+
DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT = 70
|
|
24
|
+
|
|
25
|
+
# `mode:` controls whether `{::note}` / `<note>` segments are rendered:
|
|
26
|
+
# :solo — dim-inline (today's behavior), default for stand-alone runs.
|
|
27
|
+
# :audience — stripped from output; the projector view never shows notes.
|
|
28
|
+
# :presenter — dim-inline (so the presenter sees them in context) and
|
|
29
|
+
# ALSO aggregated separately for the side panel via
|
|
30
|
+
# Slide#notes; this renderer just keeps the inline copy.
|
|
31
|
+
def initialize(terminal, base_dir: '.', theme: nil, mode: :solo)
|
|
19
32
|
@terminal = terminal
|
|
20
33
|
@base_dir = base_dir
|
|
21
34
|
@theme = theme || Theme.default
|
|
35
|
+
@mode = mode
|
|
22
36
|
@image_cache = {}
|
|
23
37
|
@kitty_uploads = {}
|
|
24
38
|
@mutex = Mutex.new
|
|
25
39
|
end
|
|
26
40
|
|
|
27
|
-
def render(slide, current:, total:)
|
|
41
|
+
def render(slide, current:, total:, started_at: nil)
|
|
28
42
|
@mutex.synchronize do
|
|
29
43
|
@terminal.clear
|
|
30
44
|
apply_slide_background(slide)
|
|
@@ -49,9 +63,13 @@ module Przn
|
|
|
49
63
|
end
|
|
50
64
|
end
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
if @theme.rabbit
|
|
67
|
+
draw_runner_bar(h, w, current, total, started_at)
|
|
68
|
+
else
|
|
69
|
+
status = " #{current + 1} / #{total} "
|
|
70
|
+
@terminal.move_to(h, w - status.size)
|
|
71
|
+
@terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
|
|
72
|
+
end
|
|
55
73
|
|
|
56
74
|
@terminal.flush
|
|
57
75
|
end
|
|
@@ -91,6 +109,7 @@ module Przn
|
|
|
91
109
|
when :image then render_image(block, width, row)
|
|
92
110
|
when :blank then row + DEFAULT_SCALE
|
|
93
111
|
when :bg then row
|
|
112
|
+
when :at then render_at(block); row
|
|
94
113
|
else row + 1
|
|
95
114
|
end
|
|
96
115
|
end
|
|
@@ -119,6 +138,102 @@ module Przn
|
|
|
119
138
|
@terminal.write "\e]7772;bg-gradient;type=#{type}:angle=#{angle}:colors=#{colors.join(',')}\a"
|
|
120
139
|
end
|
|
121
140
|
|
|
141
|
+
# Place text at an absolute (column, row) on the slide, escaping the
|
|
142
|
+
# normal top-down paragraph flow. Coordinates are 1-based terminal cells
|
|
143
|
+
# to match the CSI cursor-position escape. A trailing `%` interprets the
|
|
144
|
+
# value as a percentage of the terminal's width (for `x=`) or height
|
|
145
|
+
# (for `y=`) — `x="50%" y="50%"` lands at the middle of the pane,
|
|
146
|
+
# auto-resizing with the terminal. Content is parsed inline so
|
|
147
|
+
# `<size>`, `<color>`, `<font>`, **bold**, etc. all work inside `<at>`.
|
|
148
|
+
# The block contributes 0 to the slide's layout height so it doesn't
|
|
149
|
+
# push subsequent content down.
|
|
150
|
+
def render_at(block)
|
|
151
|
+
attrs = block[:attrs] || {}
|
|
152
|
+
x = resolve_at_coord(attrs[:x], @terminal.width)
|
|
153
|
+
y = resolve_at_coord(attrs[:y], @terminal.height)
|
|
154
|
+
return if x.nil? || y.nil?
|
|
155
|
+
|
|
156
|
+
segments = Parser.parse_inline(block[:content].to_s)
|
|
157
|
+
@terminal.move_to(y, x)
|
|
158
|
+
@terminal.write render_segments_scaled(segments, DEFAULT_SCALE)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Resolve an `<at>` coordinate string against the dimension it indexes.
|
|
162
|
+
# `"50%"` → halfway along `max`; plain integer string → that number of
|
|
163
|
+
# cells. Out-of-range values clamp into [1, max]. Returns nil when the
|
|
164
|
+
# input is missing or unparseable so the renderer skips silently.
|
|
165
|
+
def resolve_at_coord(raw, max)
|
|
166
|
+
return nil if raw.nil?
|
|
167
|
+
|
|
168
|
+
s = raw.to_s.strip
|
|
169
|
+
return nil if s.empty?
|
|
170
|
+
|
|
171
|
+
cells =
|
|
172
|
+
if s.end_with?('%')
|
|
173
|
+
pct = s.chomp('%').to_f
|
|
174
|
+
(pct / 100.0 * max).round
|
|
175
|
+
elsif s =~ /\A-?\d+\z/
|
|
176
|
+
s.to_i
|
|
177
|
+
end
|
|
178
|
+
return nil if cells.nil?
|
|
179
|
+
|
|
180
|
+
cells.clamp(1, max)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Bottom-row progress indicator (Rabbit-style):
|
|
184
|
+
#
|
|
185
|
+
# 1 🐢 🐇 9
|
|
186
|
+
# └ current slide # └ elapsed time └ slide progress └ goal (total slides)
|
|
187
|
+
#
|
|
188
|
+
# The anchor numbers (current at the left, total at the right) sit on the
|
|
189
|
+
# very bottom row; the emojis render at OSC 66 scale 2 and are anchored at
|
|
190
|
+
# row `h-1` so their bottom half lands on row `h` next to the numbers,
|
|
191
|
+
# making them visibly twice as large as the labels without needing more
|
|
192
|
+
# vertical screen real-estate. The turtle is hidden when
|
|
193
|
+
# `theme.rabbit.duration` is unset / unparseable. `flip=h` mirrors each
|
|
194
|
+
# glyph horizontally on terminals that honor it (Echoes); others ignore
|
|
195
|
+
# the parameter and the emojis face left.
|
|
196
|
+
EMOJI_RUNNER_CELLS = 4 # 🐇/🐢 are 2 source cells wide, rendered at s=2 → 4 cells
|
|
197
|
+
|
|
198
|
+
def draw_runner_bar(h, w, current, total, started_at)
|
|
199
|
+
left = (current + 1).to_s
|
|
200
|
+
right = total.to_s
|
|
201
|
+
track_left = left.size + 2 # 1 cell gap after the left number
|
|
202
|
+
track_right = w - right.size - 1 # 1 cell gap before the right number
|
|
203
|
+
return if track_right - track_left < EMOJI_RUNNER_CELLS
|
|
204
|
+
|
|
205
|
+
@terminal.move_to(h, 1)
|
|
206
|
+
@terminal.write "#{ANSI[:dim]}#{left}#{ANSI[:reset]}"
|
|
207
|
+
@terminal.move_to(h, w - right.size + 1)
|
|
208
|
+
@terminal.write "#{ANSI[:dim]}#{right}#{ANSI[:reset]}"
|
|
209
|
+
|
|
210
|
+
rabbit_row = [h - 1, 1].max
|
|
211
|
+
rabbit_col = runner_col(current, [total - 1, 1].max, track_left, track_right)
|
|
212
|
+
@terminal.move_to(rabbit_row, rabbit_col)
|
|
213
|
+
@terminal.write KittyText.sized('🐇', s: 2, flip: 'h')
|
|
214
|
+
|
|
215
|
+
duration_s = Theme.parse_duration(@theme.rabbit[:duration])
|
|
216
|
+
return unless started_at && duration_s && duration_s.positive?
|
|
217
|
+
|
|
218
|
+
elapsed = Time.now - started_at
|
|
219
|
+
frac = (elapsed / duration_s).clamp(0.0, 1.0)
|
|
220
|
+
span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
|
|
221
|
+
turtle_col = track_left + (frac * [span, 0].max).round
|
|
222
|
+
@terminal.move_to(rabbit_row, turtle_col)
|
|
223
|
+
@terminal.write KittyText.sized('🐢', s: 2, flip: 'h')
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Linear-interpolate a runner's column inside the track. `step` is 0..max
|
|
227
|
+
# (e.g. current slide index 0..total-1), and the returned column leaves
|
|
228
|
+
# enough room for an emoji `EMOJI_RUNNER_CELLS` cells wide before the
|
|
229
|
+
# right-anchor number.
|
|
230
|
+
def runner_col(step, max, track_left, track_right)
|
|
231
|
+
return track_left if max <= 0
|
|
232
|
+
span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
|
|
233
|
+
span = 0 if span < 0
|
|
234
|
+
track_left + (step.to_f / max * span).round
|
|
235
|
+
end
|
|
236
|
+
|
|
122
237
|
def render_heading(block, width, row)
|
|
123
238
|
text = block[:content]
|
|
124
239
|
|
|
@@ -152,7 +267,7 @@ module Przn
|
|
|
152
267
|
if li == 0
|
|
153
268
|
@terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
154
269
|
else
|
|
155
|
-
@terminal.write "#{KittyText.sized(
|
|
270
|
+
@terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
156
271
|
end
|
|
157
272
|
row += DEFAULT_SCALE
|
|
158
273
|
end
|
|
@@ -206,7 +321,7 @@ module Przn
|
|
|
206
321
|
left = content_left(width)
|
|
207
322
|
block[:items].each do |item|
|
|
208
323
|
depth = item[:depth] || 0
|
|
209
|
-
indent =
|
|
324
|
+
indent = ' ' * depth
|
|
210
325
|
prefix = "#{indent}#{@theme.bullet[:text]}"
|
|
211
326
|
prefix_w = display_width(prefix)
|
|
212
327
|
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
@@ -219,7 +334,7 @@ module Przn
|
|
|
219
334
|
if li == 0
|
|
220
335
|
@terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
221
336
|
else
|
|
222
|
-
@terminal.write "#{KittyText.sized(
|
|
337
|
+
@terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
223
338
|
end
|
|
224
339
|
row += DEFAULT_SCALE
|
|
225
340
|
end
|
|
@@ -232,7 +347,7 @@ module Przn
|
|
|
232
347
|
left = content_left(width)
|
|
233
348
|
block[:items].each_with_index do |item, i|
|
|
234
349
|
depth = item[:depth] || 0
|
|
235
|
-
indent =
|
|
350
|
+
indent = ' ' * depth
|
|
236
351
|
prefix = "#{indent}#{i + 1}. "
|
|
237
352
|
prefix_w = display_width(prefix)
|
|
238
353
|
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
@@ -245,7 +360,7 @@ module Przn
|
|
|
245
360
|
if li == 0
|
|
246
361
|
@terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
247
362
|
else
|
|
248
|
-
@terminal.write "#{KittyText.sized(
|
|
363
|
+
@terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
249
364
|
end
|
|
250
365
|
row += DEFAULT_SCALE
|
|
251
366
|
end
|
|
@@ -281,7 +396,7 @@ module Przn
|
|
|
281
396
|
|
|
282
397
|
def render_blockquote(block, width, row)
|
|
283
398
|
left = content_left(width)
|
|
284
|
-
prefix =
|
|
399
|
+
prefix = '| '
|
|
285
400
|
prefix_w = display_width(prefix)
|
|
286
401
|
max_w = max_text_width(width, left + 1, DEFAULT_SCALE) - prefix_w
|
|
287
402
|
|
|
@@ -292,7 +407,7 @@ module Przn
|
|
|
292
407
|
|
|
293
408
|
wrapped.each_with_index do |line_segs, li|
|
|
294
409
|
@terminal.move_to(row, left + 1)
|
|
295
|
-
p = li == 0 ? prefix :
|
|
410
|
+
p = li == 0 ? prefix : ' ' * prefix_w
|
|
296
411
|
@terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
297
412
|
row += DEFAULT_SCALE
|
|
298
413
|
end
|
|
@@ -316,7 +431,7 @@ module Przn
|
|
|
316
431
|
@terminal.move_to(row, left)
|
|
317
432
|
line = cells.each_with_index.map { |cell, ci|
|
|
318
433
|
pad_to_width(cell, col_widths[ci] || 0)
|
|
319
|
-
}.join(
|
|
434
|
+
}.join(' | ')
|
|
320
435
|
if ri == 0
|
|
321
436
|
@terminal.write "#{ANSI[:bold]}#{KittyText.sized(line, s: DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
322
437
|
else
|
|
@@ -326,7 +441,7 @@ module Przn
|
|
|
326
441
|
|
|
327
442
|
if ri == 0
|
|
328
443
|
@terminal.move_to(row, left)
|
|
329
|
-
@terminal.write KittyText.sized(col_widths.map { |w|
|
|
444
|
+
@terminal.write KittyText.sized(col_widths.map { |w| '-' * w }.join('--+--'), s: DEFAULT_SCALE)
|
|
330
445
|
row += DEFAULT_SCALE
|
|
331
446
|
end
|
|
332
447
|
end
|
|
@@ -343,15 +458,31 @@ module Przn
|
|
|
343
458
|
img_w, img_h = img_size
|
|
344
459
|
cell_w, cell_h = @terminal.cell_pixel_size
|
|
345
460
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
461
|
+
attrs = block[:attrs] || {}
|
|
462
|
+
abs_x = resolve_at_coord(attrs['x'], @terminal.width)
|
|
463
|
+
abs_y = resolve_at_coord(attrs['y'], @terminal.height)
|
|
464
|
+
absolute = abs_x && abs_y
|
|
349
465
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
466
|
+
origin_row = absolute ? abs_y : row
|
|
467
|
+
available_rows = [@terminal.height - origin_row - 2, 1].max
|
|
468
|
+
if absolute
|
|
469
|
+
available_cols = [@terminal.width - abs_x + 1, 1].max
|
|
470
|
+
else
|
|
471
|
+
left = content_left(width)
|
|
472
|
+
available_cols = width - left * 2
|
|
353
473
|
end
|
|
354
474
|
|
|
475
|
+
# Cap the default vertical area to 70 % of the screen, matching what
|
|
476
|
+
# `{:relative_height="70"}` would do explicitly. Large images that
|
|
477
|
+
# extend to within a couple of rows of the screen edge render
|
|
478
|
+
# unreliably in some terminals — they're known-good at 70 %, and
|
|
479
|
+
# smaller images sit well within this cap so they're unaffected.
|
|
480
|
+
# An explicit `relative_height` still overrides.
|
|
481
|
+
default_rh = DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT
|
|
482
|
+
rh = attrs['relative_height'] || default_rh
|
|
483
|
+
target_rows = (@terminal.height * rh.to_i / 100.0).to_i
|
|
484
|
+
available_rows = [target_rows, available_rows].min
|
|
485
|
+
|
|
355
486
|
# Calculate target cell size maintaining aspect ratio
|
|
356
487
|
img_cell_w = img_w.to_f / cell_w
|
|
357
488
|
img_cell_h = img_h.to_f / cell_h
|
|
@@ -361,24 +492,29 @@ module Przn
|
|
|
361
492
|
target_cols = [target_cols, 1].max
|
|
362
493
|
target_rows = [target_rows, 1].max
|
|
363
494
|
|
|
364
|
-
|
|
495
|
+
if absolute
|
|
496
|
+
y_cell, x_cell = abs_y, abs_x
|
|
497
|
+
else
|
|
498
|
+
y_cell = row
|
|
499
|
+
x_cell = [(width - target_cols) / 2, 0].max + 1
|
|
500
|
+
end
|
|
365
501
|
|
|
366
502
|
if ImageUtil.kitty_terminal? && ImageUtil.png?(path)
|
|
367
503
|
image_id = ensure_kitty_uploaded(path)
|
|
368
|
-
@terminal.move_to(
|
|
504
|
+
@terminal.move_to(y_cell, x_cell)
|
|
369
505
|
@terminal.write ImageUtil.kitty_place(image_id: image_id, cols: target_cols, rows: target_rows)
|
|
370
506
|
elsif ImageUtil.kitty_terminal?
|
|
371
|
-
data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x:
|
|
507
|
+
data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x: x_cell - 1, y: y_cell - 1)
|
|
372
508
|
@terminal.write data if data && !data.empty?
|
|
373
509
|
elsif ImageUtil.sixel_available?
|
|
374
|
-
@terminal.move_to(
|
|
510
|
+
@terminal.move_to(y_cell, x_cell)
|
|
375
511
|
target_pixel_w = target_cols * cell_w
|
|
376
512
|
target_pixel_h = target_rows * cell_h
|
|
377
513
|
sixel = cached_sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
|
|
378
514
|
@terminal.write sixel if sixel && !sixel.empty?
|
|
379
515
|
end
|
|
380
516
|
|
|
381
|
-
row + target_rows
|
|
517
|
+
absolute ? row : row + target_rows
|
|
382
518
|
end
|
|
383
519
|
|
|
384
520
|
def resolve_image_path(path)
|
|
@@ -504,7 +640,7 @@ module Przn
|
|
|
504
640
|
f = default_face == :body ? @theme.font[:family] : default_face
|
|
505
641
|
h = default_h
|
|
506
642
|
c = default_color == :body ? @theme.font[:color] : default_color
|
|
507
|
-
body_open = c ? color_code(c) :
|
|
643
|
+
body_open = c ? color_code(c) : ''
|
|
508
644
|
inner = segments.map { |segment|
|
|
509
645
|
type = segment[0]
|
|
510
646
|
content = segment[1]
|
|
@@ -519,7 +655,7 @@ module Przn
|
|
|
519
655
|
KittyText.sized(content, s: para_scale, f: f, h: h)
|
|
520
656
|
end
|
|
521
657
|
when :font then "#{render_font_segment(content, segment[2] || {}, para_scale, default_face: f, default_h: h)}#{(segment[2] || {})[:color] ? body_open : ''}"
|
|
522
|
-
when :note then "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
658
|
+
when :note then @mode == :audience ? "" : "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
523
659
|
when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
524
660
|
when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
525
661
|
when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
@@ -545,7 +681,7 @@ module Przn
|
|
|
545
681
|
used = 0
|
|
546
682
|
|
|
547
683
|
segments.each do |seg|
|
|
548
|
-
content = seg[1] ||
|
|
684
|
+
content = seg[1] || ''
|
|
549
685
|
next if content.empty?
|
|
550
686
|
|
|
551
687
|
seg_scale = effective_seg_scale(seg, para_scale)
|
|
@@ -594,7 +730,7 @@ module Przn
|
|
|
594
730
|
|
|
595
731
|
def segments_visible_cells(segments, para_scale)
|
|
596
732
|
segments.sum { |seg|
|
|
597
|
-
content = seg[1] ||
|
|
733
|
+
content = seg[1] || ''
|
|
598
734
|
display_width(content) * effective_seg_scale(seg, para_scale)
|
|
599
735
|
}
|
|
600
736
|
end
|
|
@@ -636,7 +772,7 @@ module Przn
|
|
|
636
772
|
|
|
637
773
|
def pad_to_width(text, target_width)
|
|
638
774
|
current = display_width(text)
|
|
639
|
-
text +
|
|
775
|
+
text + ' ' * [target_width - current, 0].max
|
|
640
776
|
end
|
|
641
777
|
|
|
642
778
|
def max_inline_scale(text)
|
|
@@ -669,7 +805,7 @@ module Przn
|
|
|
669
805
|
r, g, b = color.scan(/../).map { |h| h.to_i(16) }
|
|
670
806
|
"\e[38;2;#{r};#{g};#{b}m"
|
|
671
807
|
else
|
|
672
|
-
|
|
808
|
+
''
|
|
673
809
|
end
|
|
674
810
|
end
|
|
675
811
|
|
|
@@ -706,12 +842,12 @@ module Przn
|
|
|
706
842
|
text
|
|
707
843
|
.gsub(/\{::tag\s+name="[^"]+"\}(.*?)\{:\/tag\}/, '\1')
|
|
708
844
|
.gsub(/\{::note\}(.*?)\{:\/note\}/, '\1')
|
|
709
|
-
.gsub(
|
|
845
|
+
.gsub('{::wait/}', '')
|
|
710
846
|
.gsub(/\*\*(.+?)\*\*/, '\1')
|
|
711
847
|
.gsub(/\*(.+?)\*/, '\1')
|
|
712
848
|
.gsub(/~~(.+?)~~/, '\1')
|
|
713
849
|
.gsub(/`([^`]+)`/, '\1')
|
|
714
|
-
.gsub(/&(lt|gt|amp);/) { |_| {
|
|
850
|
+
.gsub(/&(lt|gt|amp);/) { |_| {'lt' => '<', 'gt' => '>', 'amp' => '&'}[$1] }
|
|
715
851
|
end
|
|
716
852
|
|
|
717
853
|
def calculate_height(blocks, width)
|
|
@@ -752,11 +888,20 @@ module Przn
|
|
|
752
888
|
when :table
|
|
753
889
|
((block[:header] ? 2 : 0) + block[:rows].size) * s
|
|
754
890
|
when :image
|
|
755
|
-
|
|
891
|
+
# Absolute-positioned images (`<img x y src/>`) layer on top of
|
|
892
|
+
# the slide and contribute 0 to the layout — same treatment as :at.
|
|
893
|
+
attrs = block[:attrs] || {}
|
|
894
|
+
if resolve_at_coord(attrs['x'], @terminal.width) && resolve_at_coord(attrs['y'], @terminal.height)
|
|
895
|
+
0
|
|
896
|
+
else
|
|
897
|
+
image_block_height(block, width)
|
|
898
|
+
end
|
|
756
899
|
when :align
|
|
757
900
|
0
|
|
758
901
|
when :bg
|
|
759
902
|
0
|
|
903
|
+
when :at
|
|
904
|
+
0
|
|
760
905
|
when :blank
|
|
761
906
|
s
|
|
762
907
|
else
|
|
@@ -4,6 +4,20 @@ require 'tmpdir'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
|
|
6
6
|
module Przn
|
|
7
|
+
# Default PDF export: drives the live renderer, asks the terminal to save
|
|
8
|
+
# each rendered slide as a one-page vector PDF via OSC 7772 `capture`,
|
|
9
|
+
# then concatenates the per-slide PDFs into a single multi-page PDF.
|
|
10
|
+
# Requires Echoes (or any terminal that implements the same capture
|
|
11
|
+
# command); use `export_pdf_prawn` instead for environments where that's
|
|
12
|
+
# not possible (CI, headless).
|
|
13
|
+
def self.export_pdf(file, output, theme: nil)
|
|
14
|
+
markdown = File.read(file)
|
|
15
|
+
presentation = Parser.parse(markdown)
|
|
16
|
+
base_dir = File.dirname(File.expand_path(file))
|
|
17
|
+
ScreenshotPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
|
|
18
|
+
puts "Generated: #{output}"
|
|
19
|
+
end
|
|
20
|
+
|
|
7
21
|
# Renders each slide live to the user's terminal, asks the terminal to save
|
|
8
22
|
# the current pane as a vector PDF via Echoes' OSC 7772 `capture` command,
|
|
9
23
|
# then concatenates the per-slide PDFs into a single multi-page PDF.
|
|
@@ -53,7 +67,7 @@ module Przn
|
|
|
53
67
|
@terminal.enter_alt_screen
|
|
54
68
|
@terminal.hide_cursor
|
|
55
69
|
@presentation.slides.each_with_index do |slide, i|
|
|
56
|
-
pdf_path = File.join(dir, format(
|
|
70
|
+
pdf_path = File.join(dir, format('slide-%04d.pdf', i))
|
|
57
71
|
@renderer.render(slide, current: i, total: @presentation.total)
|
|
58
72
|
request_capture(pdf_path)
|
|
59
73
|
wait_for_capture(pdf_path)
|
|
@@ -62,6 +76,7 @@ module Przn
|
|
|
62
76
|
paths
|
|
63
77
|
ensure
|
|
64
78
|
@terminal.write "#{OSC};bg-clear#{BEL}"
|
|
79
|
+
@terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
|
|
65
80
|
@terminal.show_cursor
|
|
66
81
|
@terminal.leave_alt_screen
|
|
67
82
|
@terminal.flush
|
|
@@ -77,7 +92,7 @@ module Przn
|
|
|
77
92
|
until File.exist?(path) && File.size?(path).to_i.positive?
|
|
78
93
|
if Time.now > deadline
|
|
79
94
|
raise "Capture timed out for #{path}. " \
|
|
80
|
-
|
|
95
|
+
'Is Echoes running and recent enough to honor OSC 7772 `capture` to a .pdf path?'
|
|
81
96
|
end
|
|
82
97
|
sleep POLL_INTERVAL
|
|
83
98
|
end
|
|
@@ -86,7 +101,7 @@ module Przn
|
|
|
86
101
|
end
|
|
87
102
|
|
|
88
103
|
def merge_pdfs(pdf_paths, output_path)
|
|
89
|
-
raise
|
|
104
|
+
raise 'No slides captured' if pdf_paths.empty?
|
|
90
105
|
|
|
91
106
|
output = HexaPDF::Document.new
|
|
92
107
|
pdf_paths.each do |path|
|
data/lib/przn/slide.rb
CHANGED
|
@@ -7,5 +7,30 @@ module Przn
|
|
|
7
7
|
def initialize(blocks)
|
|
8
8
|
@blocks = blocks.freeze
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
# Aggregate every `{::note}` / `<note>` segment in the slide's text-bearing
|
|
12
|
+
# fields. The presenter view renders these in its side panel; the audience
|
|
13
|
+
# renderer strips them from the rendered output.
|
|
14
|
+
def notes
|
|
15
|
+
out = []
|
|
16
|
+
blocks.each do |b|
|
|
17
|
+
texts = []
|
|
18
|
+
texts << b[:content] if b[:content].is_a?(String)
|
|
19
|
+
texts << b[:term] if b[:term].is_a?(String)
|
|
20
|
+
texts << b[:definition] if b[:definition].is_a?(String)
|
|
21
|
+
if b[:items].is_a?(Array)
|
|
22
|
+
b[:items].each { |it| texts << it[:text] if it.is_a?(Hash) && it[:text].is_a?(String) }
|
|
23
|
+
end
|
|
24
|
+
if b[:rows].is_a?(Array)
|
|
25
|
+
(Array(b[:header]) + b[:rows].flatten).each { |c| texts << c if c.is_a?(String) }
|
|
26
|
+
end
|
|
27
|
+
texts.each do |text|
|
|
28
|
+
Parser.parse_inline(text).each do |seg|
|
|
29
|
+
out << seg[1] if seg[0] == :note && seg[1] && !seg[1].empty?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
out
|
|
34
|
+
end
|
|
10
35
|
end
|
|
11
36
|
end
|
data/lib/przn/theme.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Przn
|
|
|
6
6
|
class Theme
|
|
7
7
|
DEFAULT_PATH = File.expand_path('../../../default_theme.yml', __FILE__)
|
|
8
8
|
|
|
9
|
-
attr_reader :colors, :font, :bullet, :background, :title
|
|
9
|
+
attr_reader :colors, :font, :bullet, :background, :title, :rabbit
|
|
10
10
|
|
|
11
11
|
def self.load(path)
|
|
12
12
|
raise ArgumentError, "Theme file not found: #{path}" unless File.exist?(path)
|
|
@@ -19,10 +19,37 @@ module Przn
|
|
|
19
19
|
bullet: defaults[:bullet].merge(overrides[:bullet] || {}),
|
|
20
20
|
background: defaults[:background].merge(overrides[:background] || {}),
|
|
21
21
|
title: defaults[:title].merge(overrides[:title] || {}),
|
|
22
|
+
# `rabbit` is opt-in: absent → nil → renderer uses the plain N/M footer.
|
|
23
|
+
# Present (even as an empty block) → hash → renderer uses the runner bar.
|
|
24
|
+
rabbit: defaults[:rabbit] || overrides[:rabbit] ?
|
|
25
|
+
(defaults[:rabbit] || {}).merge(overrides[:rabbit] || {}) :
|
|
26
|
+
nil
|
|
22
27
|
}
|
|
23
28
|
new(merged)
|
|
24
29
|
end
|
|
25
30
|
|
|
31
|
+
# Convert a human-friendly duration string to seconds.
|
|
32
|
+
# "30m" -> 1800
|
|
33
|
+
# "1h30m" -> 5400
|
|
34
|
+
# "1h2m3s" -> 3723
|
|
35
|
+
# "45" -> 45 (bare integers are seconds)
|
|
36
|
+
# 45 -> 45 (already a number)
|
|
37
|
+
# nil / "" -> nil
|
|
38
|
+
# "garbage" -> nil
|
|
39
|
+
def self.parse_duration(input)
|
|
40
|
+
return nil if input.nil?
|
|
41
|
+
return input.to_i if input.is_a?(Numeric)
|
|
42
|
+
|
|
43
|
+
s = input.to_s.strip
|
|
44
|
+
return nil if s.empty?
|
|
45
|
+
return s.to_i if s =~ /\A\d+\z/
|
|
46
|
+
|
|
47
|
+
m = s.match(/\A(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?\z/)
|
|
48
|
+
return nil unless m && m[0] != ''
|
|
49
|
+
h, mi, se = m[1].to_i, m[2].to_i, m[3].to_i
|
|
50
|
+
h * 3600 + mi * 60 + se
|
|
51
|
+
end
|
|
52
|
+
|
|
26
53
|
def self.default
|
|
27
54
|
new(load_yaml(DEFAULT_PATH))
|
|
28
55
|
end
|
|
@@ -44,6 +71,9 @@ module Przn
|
|
|
44
71
|
bullet: (data[:bullet] || {}).compact,
|
|
45
72
|
background: (data[:background] || {}).compact,
|
|
46
73
|
title: (data[:title] || {}).compact,
|
|
74
|
+
# nil when the `rabbit:` key isn't in the YAML at all (opt-in feature);
|
|
75
|
+
# empty hash when it's present but childless.
|
|
76
|
+
rabbit: data.key?(:rabbit) ? (data[:rabbit] || {}).compact : nil
|
|
47
77
|
}
|
|
48
78
|
end
|
|
49
79
|
private_class_method :load_yaml
|
|
@@ -54,6 +84,7 @@ module Przn
|
|
|
54
84
|
@bullet = config[:bullet]
|
|
55
85
|
@background = config[:background]
|
|
56
86
|
@title = config[:title]
|
|
87
|
+
@rabbit = config[:rabbit]
|
|
57
88
|
end
|
|
58
89
|
end
|
|
59
90
|
end
|
data/lib/przn/version.rb
CHANGED