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.
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
- def initialize(terminal, base_dir: '.', theme: nil)
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
- status = " #{current + 1} / #{total} "
53
- @terminal.move_to(h, w - status.size)
54
- @terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
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(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
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 = " " * depth
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(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
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 = " " * depth
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(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
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 : " " * prefix_w
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| "-" * w }.join("--+--"), s: DEFAULT_SCALE)
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
- available_rows = @terminal.height - row - 2
347
- left = content_left(width)
348
- available_cols = width - left * 2
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
- if (rh = block[:attrs]['relative_height'])
351
- target_rows = (@terminal.height * rh.to_i / 100.0).to_i
352
- available_rows = [target_rows, available_rows].min
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
- x = [(width - target_cols) / 2, 0].max
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(row, x + 1)
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: x, y: row - 1)
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(row, x + 1)
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 + " " * [target_width - current, 0].max
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(/\{::wait\/\}/, '')
845
+ .gsub('{::wait/}', '')
710
846
  .gsub(/\*\*(.+?)\*\*/, '\1')
711
847
  .gsub(/\*(.+?)\*/, '\1')
712
848
  .gsub(/~~(.+?)~~/, '\1')
713
849
  .gsub(/`([^`]+)`/, '\1')
714
- .gsub(/&(lt|gt|amp);/) { |_| {"lt" => "<", "gt" => ">", "amp" => "&"}[$1] }
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
- image_block_height(block, width)
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("slide-%04d.pdf", i))
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
- "Is Echoes running and recent enough to honor OSC 7772 `capture` to a .pdf path?"
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 "No slides captured" if pdf_paths.empty?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Przn
4
- VERSION = "0.3.0"
4
+ VERSION = '0.5.0'
5
5
  end