przn 0.1.6 → 0.3.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
@@ -18,37 +18,62 @@ module Przn
18
18
  def initialize(terminal, base_dir: '.', theme: nil)
19
19
  @terminal = terminal
20
20
  @base_dir = base_dir
21
- @theme = theme
21
+ @theme = theme || Theme.default
22
+ @image_cache = {}
23
+ @kitty_uploads = {}
24
+ @mutex = Mutex.new
22
25
  end
23
26
 
24
27
  def render(slide, current:, total:)
25
- @terminal.clear
26
- w = @terminal.width
27
- h = @terminal.height
28
+ @mutex.synchronize do
29
+ @terminal.clear
30
+ apply_slide_background(slide)
31
+ w = @terminal.width
32
+ h = @terminal.height
33
+
34
+ row = if current == 0
35
+ content_height = calculate_height(slide.blocks, w)
36
+ usable_height = h - 1
37
+ [(usable_height - content_height) / 2 + 1, 1].max
38
+ else
39
+ 2
40
+ end
28
41
 
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
42
+ pending_align = nil
43
+ slide.blocks.each do |block|
44
+ if block[:type] == :align
45
+ pending_align = block[:align]
46
+ else
47
+ row = render_block(block, w, row, align: pending_align)
48
+ pending_align = nil
49
+ end
50
+ end
51
+
52
+ status = " #{current + 1} / #{total} "
53
+ @terminal.move_to(h, w - status.size)
54
+ @terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
55
+
56
+ @terminal.flush
35
57
  end
58
+ end
36
59
 
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
60
+ # Warm caches for a slide we expect to navigate to soon. Uploads any PNG
61
+ # images on the Kitty Graphics Protocol so the next render only needs a
62
+ # placement command. Safe to call from a background thread; serialized
63
+ # against `render` via the renderer's mutex so terminal writes don't
64
+ # interleave.
65
+ def preload(slide)
66
+ return unless ImageUtil.kitty_terminal?
67
+
68
+ @mutex.synchronize do
69
+ slide.blocks.each do |block|
70
+ next unless block[:type] == :image
71
+ path = resolve_image_path(block[:path])
72
+ next unless File.exist?(path) && ImageUtil.png?(path)
73
+ ensure_kitty_uploaded(path)
44
74
  end
75
+ @terminal.flush
45
76
  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
77
  end
53
78
 
54
79
  private
@@ -65,32 +90,67 @@ module Przn
65
90
  when :table then render_table(block, width, row)
66
91
  when :image then render_image(block, width, row)
67
92
  when :blank then row + DEFAULT_SCALE
93
+ when :bg then row
68
94
  else row + 1
69
95
  end
70
96
  end
71
97
 
98
+ # Emit Echoes' OSC 7772 to set a slide-specific solid color or gradient,
99
+ # or clear any previous override. A `<bg .../>` block on the slide wins;
100
+ # otherwise the theme's `bg:` section is used as the deck-wide default.
101
+ # Other terminals ignore the OSC code, so this is a no-op outside Echoes.
102
+ def apply_slide_background(slide)
103
+ block = slide.blocks.find { |b| b[:type] == :bg }
104
+ attrs = block ? block[:attrs] : (@theme.background || {})
105
+
106
+ @terminal.write "\e]7772;bg-clear\a"
107
+ return if attrs.empty?
108
+
109
+ if (color = attrs[:color])
110
+ @terminal.write "\e]7772;bg-color;#{color}\a"
111
+ return
112
+ end
113
+
114
+ colors = [attrs[:from], attrs[:to]].compact
115
+ return if colors.size < 2
116
+
117
+ type = attrs[:type] || 'linear'
118
+ angle = attrs[:angle] || 0
119
+ @terminal.write "\e]7772;bg-gradient;type=#{type}:angle=#{angle}:colors=#{colors.join(',')}\a"
120
+ end
121
+
72
122
  def render_heading(block, width, row)
73
123
  text = block[:content]
74
124
 
75
125
  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
126
+ title = @theme.title || {}
127
+ scale = (title[:size] && Parser::SIZE_SCALES[title[:size].to_s]) || KittyText::HEADING_SCALES[1]
128
+ face = title[:family]
129
+ color = title[:color]
130
+ max_w = max_text_width(width, 0, scale)
131
+ segments = Parser.parse_inline(text)
132
+ wrapped = wrap_segments(segments, max_w, scale)
133
+
134
+ wrapped.each do |line_segs|
135
+ vis = segments_visible_cells(line_segs, scale)
136
+ pad = [(width - vis) / 2, 0].max
137
+ @terminal.move_to(row, pad + 1)
138
+ @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, scale, default_face: face, default_h: 2, default_color: color)}#{ANSI[:reset]}"
139
+ row += scale
140
+ end
141
+ row + 4
82
142
  else
83
143
  left = content_left(width)
84
- prefix = "・"
144
+ prefix = @theme.bullet[:text]
85
145
  prefix_w = display_width(prefix)
86
146
  max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
87
147
  segments = Parser.parse_inline(text)
88
- wrapped = wrap_segments(segments, max_w)
148
+ wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
89
149
 
90
150
  wrapped.each_with_index do |line_segs, li|
91
151
  @terminal.move_to(row, left)
92
152
  if li == 0
93
- @terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
153
+ @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
94
154
  else
95
155
  @terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
96
156
  end
@@ -112,7 +172,7 @@ module Przn
112
172
 
113
173
  max_w = max_text_width(width, left, scale)
114
174
  segments = Parser.parse_inline(text)
115
- wrapped = wrap_segments(segments, max_w)
175
+ wrapped = wrap_segments(segments, max_w, scale)
116
176
 
117
177
  wrapped.each do |line_segs|
118
178
  @terminal.move_to(row, left + 1)
@@ -147,17 +207,17 @@ module Przn
147
207
  block[:items].each do |item|
148
208
  depth = item[:depth] || 0
149
209
  indent = " " * depth
150
- prefix = "#{indent}"
210
+ prefix = "#{indent}#{@theme.bullet[:text]}"
151
211
  prefix_w = display_width(prefix)
152
212
  max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
153
213
 
154
214
  segments = Parser.parse_inline(item[:text])
155
- wrapped = wrap_segments(segments, max_w)
215
+ wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
156
216
 
157
217
  wrapped.each_with_index do |line_segs, li|
158
218
  @terminal.move_to(row, left)
159
219
  if li == 0
160
- @terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
220
+ @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
161
221
  else
162
222
  @terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
163
223
  end
@@ -178,7 +238,7 @@ module Przn
178
238
  max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
179
239
 
180
240
  segments = Parser.parse_inline(item[:text])
181
- wrapped = wrap_segments(segments, max_w)
241
+ wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
182
242
 
183
243
  wrapped.each_with_index do |line_segs, li|
184
244
  @terminal.move_to(row, left)
@@ -199,7 +259,7 @@ module Przn
199
259
  max_w = max_text_width(width, left, DEFAULT_SCALE)
200
260
 
201
261
  segments = Parser.parse_inline(block[:term])
202
- wrapped = wrap_segments(segments, max_w)
262
+ wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
203
263
  wrapped.each do |line_segs|
204
264
  @terminal.move_to(row, left)
205
265
  @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
@@ -209,7 +269,7 @@ module Przn
209
269
  def_max_w = [max_w - 4, 1].max
210
270
  block[:definition].each_line do |line|
211
271
  segments = Parser.parse_inline(line.chomp)
212
- wrapped = wrap_segments(segments, def_max_w)
272
+ wrapped = wrap_segments(segments, def_max_w, DEFAULT_SCALE)
213
273
  wrapped.each do |line_segs|
214
274
  @terminal.move_to(row, left + 4)
215
275
  @terminal.write render_segments_scaled(line_segs, DEFAULT_SCALE)
@@ -228,7 +288,7 @@ module Przn
228
288
  block[:content].each_line do |line|
229
289
  text = line.chomp
230
290
  segments = [[:text, text]]
231
- wrapped = wrap_segments(segments, max_w)
291
+ wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
232
292
 
233
293
  wrapped.each_with_index do |line_segs, li|
234
294
  @terminal.move_to(row, left + 1)
@@ -303,14 +363,18 @@ module Przn
303
363
 
304
364
  x = [(width - target_cols) / 2, 0].max
305
365
 
306
- if ImageUtil.kitty_terminal?
307
- data = ImageUtil.kitty_icat(path, cols: target_cols, rows: target_rows, x: x, y: row - 1)
366
+ if ImageUtil.kitty_terminal? && ImageUtil.png?(path)
367
+ image_id = ensure_kitty_uploaded(path)
368
+ @terminal.move_to(row, x + 1)
369
+ @terminal.write ImageUtil.kitty_place(image_id: image_id, cols: target_cols, rows: target_rows)
370
+ elsif ImageUtil.kitty_terminal?
371
+ data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x: x, y: row - 1)
308
372
  @terminal.write data if data && !data.empty?
309
373
  elsif ImageUtil.sixel_available?
310
374
  @terminal.move_to(row, x + 1)
311
375
  target_pixel_w = target_cols * cell_w
312
376
  target_pixel_h = target_rows * cell_h
313
- sixel = ImageUtil.sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
377
+ sixel = cached_sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
314
378
  @terminal.write sixel if sixel && !sixel.empty?
315
379
  end
316
380
 
@@ -322,6 +386,39 @@ module Przn
322
386
  File.expand_path(path, @base_dir)
323
387
  end
324
388
 
389
+ # Memoize the encoded escape-sequence bytes so revisiting a slide
390
+ # skips both the subprocess fork and the image decode/encode work.
391
+ # Keyed by file mtime so edits to the source image invalidate.
392
+ def cached_kitty_icat(path, cols:, rows:, x:, y:)
393
+ key = [:kitty, path, image_mtime(path), cols, rows, x, y]
394
+ return @image_cache[key] if @image_cache.key?(key)
395
+ @image_cache[key] = ImageUtil.kitty_icat(path, cols: cols, rows: rows, x: x, y: y)
396
+ end
397
+
398
+ def cached_sixel_encode(path, width:, height:)
399
+ key = [:sixel, path, image_mtime(path), width, height]
400
+ return @image_cache[key] if @image_cache.key?(key)
401
+ @image_cache[key] = ImageUtil.sixel_encode(path, width: width, height: height)
402
+ end
403
+
404
+ def image_mtime(path)
405
+ File.mtime(path).to_f
406
+ rescue Errno::ENOENT
407
+ nil
408
+ end
409
+
410
+ # Upload a PNG to the Kitty terminal once and return the assigned image
411
+ # id. Subsequent renders of the same file (same mtime) reuse the id and
412
+ # only emit a small placement command, skipping the file-transfer cost.
413
+ def ensure_kitty_uploaded(path)
414
+ key = [path, image_mtime(path)]
415
+ return @kitty_uploads[key] if @kitty_uploads.key?(key)
416
+
417
+ image_id = @kitty_uploads.size + 1
418
+ @terminal.write ImageUtil.kitty_upload_png(path, image_id: image_id)
419
+ @kitty_uploads[key] = image_id
420
+ end
421
+
325
422
  def content_left(width)
326
423
  width / 16
327
424
  end
@@ -364,81 +461,165 @@ module Przn
364
461
  end
365
462
  end
366
463
 
367
- def render_segments_scaled(segments, para_scale)
368
- segments.map { |segment|
464
+ # Render the list/heading bullet. When `bullet_size` is smaller than the
465
+ # body scale, use OSC 66 fractional scaling (n/d) with v=2 to keep the
466
+ # glyph's cell footprint at body scale but draw a smaller dot vertically
467
+ # centered. Plain `s=N` for a smaller bullet would top-align it inside
468
+ # the row, which looks wrong against the larger body text.
469
+ def render_bullet(prefix)
470
+ size = @theme.bullet[:size]
471
+ if size && size < DEFAULT_SCALE
472
+ KittyText.sized(prefix, s: DEFAULT_SCALE, n: size, d: DEFAULT_SCALE, v: 2)
473
+ else
474
+ KittyText.sized(prefix, s: size || DEFAULT_SCALE)
475
+ end
476
+ end
477
+
478
+ # Render a <font face="..." size="..." color="..."> run. The face goes out
479
+ # via OSC 66 f= (Echoes extension); the size resolves through the same
480
+ # SIZE_SCALES table that <size=N> uses; the color wraps in the same ANSI
481
+ # escape that <color=NAME> uses.
482
+ def render_font_segment(content, attrs, para_scale, default_face: nil, default_h: nil)
483
+ scale = (attrs[:size] && Parser::SIZE_SCALES[attrs[:size]]) || para_scale
484
+ base = KittyText.sized(content, s: scale, f: attrs[:face] || default_face, h: default_h)
485
+ color = attrs[:color]
486
+ return base unless color
487
+ "#{color_code(color)}#{base}#{ANSI[:reset]}"
488
+ end
489
+
490
+ # `default_face:` / `default_color:` let a caller (currently h1 rendering)
491
+ # override the OSC 66 `f=` and the ANSI fg for every emit on the line.
492
+ # When unset, body text falls back to `theme.font.family` / `theme.font.color`.
493
+ # To opt out of that body fallback (so a heading can render in the
494
+ # terminal's defaults even when body text is themed), pass the keyword
495
+ # explicitly — even `nil` is honored. Inline `<font face/color>` and
496
+ # `<color=...>` runs still win for their own segments.
497
+ #
498
+ # `default_h:` threads an OSC 66 `h=` (horizontal alignment) into every
499
+ # emit on the line. h1 uses h=2 so a proportional `title.family` is
500
+ # centered within the reserved cell block — without it the glyphs left-
501
+ # align inside the block and the visible text drifts left of the center
502
+ # column we computed.
503
+ def render_segments_scaled(segments, para_scale, default_face: :body, default_h: nil, default_color: :body)
504
+ f = default_face == :body ? @theme.font[:family] : default_face
505
+ h = default_h
506
+ c = default_color == :body ? @theme.font[:color] : default_color
507
+ body_open = c ? color_code(c) : ""
508
+ inner = segments.map { |segment|
369
509
  type = segment[0]
370
510
  content = segment[1]
371
511
  case type
372
512
  when :tag
373
513
  tag_name = segment[2]
374
514
  if (scale = Parser::SIZE_SCALES[tag_name])
375
- KittyText.sized(content, s: scale)
515
+ KittyText.sized(content, s: scale, f: f, h: h)
376
516
  elsif Parser::NAMED_COLORS.key?(tag_name)
377
- "#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
517
+ "#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
378
518
  else
379
- KittyText.sized(content, s: para_scale)
519
+ KittyText.sized(content, s: para_scale, f: f, h: h)
380
520
  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)
521
+ 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}"
523
+ when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
524
+ when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
525
+ when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
526
+ when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
527
+ when :text then KittyText.sized(content, s: para_scale, f: f, h: h)
387
528
  end
388
529
  }.join
530
+ body_open.empty? ? inner : "#{body_open}#{inner}#{ANSI[:reset]}"
389
531
  end
390
532
 
391
533
  def render_inline_scaled(text, para_scale)
392
534
  render_segments_scaled(Parser.parse_inline(text), para_scale)
393
535
  end
394
536
 
395
- # Wrap parsed inline segments into lines that fit within max_width display units
396
- def wrap_segments(segments, max_width)
537
+ # Wrap parsed inline segments into lines that fit within max_width units,
538
+ # where 1 unit = `para_scale` terminal cells. Per-segment scaling (e.g.
539
+ # size tags) is honored so a span with a larger scale consumes more budget.
540
+ def wrap_segments(segments, max_width, para_scale = DEFAULT_SCALE)
397
541
  return [segments] if max_width <= 0
398
542
 
543
+ max_cells = max_width * para_scale
399
544
  lines = [[]]
400
- width = 0
545
+ used = 0
401
546
 
402
547
  segments.each do |seg|
403
548
  content = seg[1] || ""
404
- seg_w = display_width(content)
549
+ next if content.empty?
550
+
551
+ seg_scale = effective_seg_scale(seg, para_scale)
552
+ seg_cells = display_width(content) * seg_scale
405
553
 
406
- if width + seg_w <= max_width
554
+ if used + seg_cells <= max_cells
407
555
  lines.last << seg
408
- width += seg_w
556
+ used += seg_cells
409
557
  next
410
558
  end
411
559
 
412
560
  remaining = content
413
561
  loop do
414
- space = max_width - width
415
- if space <= 0
562
+ space_cells = max_cells - used
563
+ if space_cells < seg_scale && used > 0
416
564
  lines << []
417
- width = 0
418
- space = max_width
565
+ used = 0
566
+ space_cells = max_cells
419
567
  end
420
568
 
421
- chunk, remaining = split_by_display_width(remaining, space)
569
+ chunk_max_dw = [space_cells / seg_scale, 1].max
570
+ chunk, remaining = split_by_display_width(remaining, chunk_max_dw)
422
571
  lines.last << [seg[0], chunk, *Array(seg[2..])]
423
- width += display_width(chunk)
572
+ used += display_width(chunk) * seg_scale
424
573
 
425
574
  break unless remaining
426
575
  lines << []
427
- width = 0
576
+ used = 0
428
577
  end
429
578
  end
430
579
 
431
580
  lines
432
581
  end
433
582
 
583
+ def effective_seg_scale(seg, para_scale)
584
+ case seg[0]
585
+ when :tag
586
+ Parser::SIZE_SCALES[seg[2]] || para_scale
587
+ when :font
588
+ size = seg[2].is_a?(Hash) ? seg[2][:size] : nil
589
+ (size && Parser::SIZE_SCALES[size]) || para_scale
590
+ else
591
+ para_scale
592
+ end
593
+ end
594
+
595
+ def segments_visible_cells(segments, para_scale)
596
+ segments.sum { |seg|
597
+ content = seg[1] || ""
598
+ display_width(content) * effective_seg_scale(seg, para_scale)
599
+ }
600
+ end
601
+
602
+ # Split `text` so the first piece fits within `max_width` cells, preferring
603
+ # to break at the last whitespace before the overflow rather than mid-word.
604
+ # Falls back to a char-level split when no whitespace is available — single
605
+ # long words, CJK runs (no inter-character whitespace) — so a word that's
606
+ # itself longer than the line still wraps instead of overflowing.
434
607
  def split_by_display_width(text, max_width)
435
608
  w = 0
609
+ last_space = nil
436
610
  text.each_char.with_index do |c, i|
437
611
  cw = display_width(c)
438
612
  if w + cw > max_width && w > 0
439
- return [text[0...i], text[i..]]
613
+ if c == ' '
614
+ return [text[0...i], text[(i + 1)..]]
615
+ elsif last_space && last_space > 0
616
+ return [text[0...last_space], text[(last_space + 1)..]]
617
+ else
618
+ return [text[0...i], text[i..]]
619
+ end
440
620
  end
441
621
  w += cw
622
+ last_space = i if c == ' '
442
623
  end
443
624
  [text, nil]
444
625
  end
@@ -511,6 +692,7 @@ module Przn
511
692
  (o >= 0xfe30 && o <= 0xfe6f) ||
512
693
  (o >= 0xff00 && o <= 0xff60) ||
513
694
  (o >= 0xffe0 && o <= 0xffe6) ||
695
+ (o >= 0x1f300 && o <= 0x1faff) || # emoji blocks; terminals render these as 2 cells
514
696
  (o >= 0x20000 && o <= 0x2fffd) ||
515
697
  (o >= 0x30000 && o <= 0x3fffd))
516
698
  2
@@ -529,6 +711,7 @@ module Przn
529
711
  .gsub(/\*(.+?)\*/, '\1')
530
712
  .gsub(/~~(.+?)~~/, '\1')
531
713
  .gsub(/`([^`]+)`/, '\1')
714
+ .gsub(/&(lt|gt|amp);/) { |_| {"lt" => "<", "gt" => ">", "amp" => "&"}[$1] }
532
715
  end
533
716
 
534
717
  def calculate_height(blocks, width)
@@ -572,6 +755,8 @@ module Przn
572
755
  image_block_height(block, width)
573
756
  when :align
574
757
  0
758
+ when :bg
759
+ 0
575
760
  when :blank
576
761
  s
577
762
  else
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+
6
+ module Przn
7
+ # Renders each slide live to the user's terminal, asks the terminal to save
8
+ # the current pane as a vector PDF via Echoes' OSC 7772 `capture` command,
9
+ # then concatenates the per-slide PDFs into a single multi-page PDF.
10
+ #
11
+ # Trade-off vs the Prawn-based PdfExporter:
12
+ # - Pixel-perfect match with what's on screen (gradients, fonts, OSC 66
13
+ # sized text, bullet glyphs, the lot) — but vector, so the result is
14
+ # small, sharp at any zoom, and text stays selectable.
15
+ # - Requires running inside a terminal that implements OSC 7772 capture
16
+ # to a `.pdf` path (i.e. Echoes). Won't work in CI or any terminal that
17
+ # doesn't honor the command.
18
+ #
19
+ # Echoes-side wire format (independent of przn):
20
+ # ESC ] 7772 ; capture ; <absolute_path> BEL
21
+ # On receipt, Echoes saves the current pane to the path. The file
22
+ # extension picks the format — `.pdf` produces a single-page vector PDF
23
+ # by replaying the same drawing pipeline into a CGPDFContext instead of
24
+ # the screen's NSGraphicsContext.
25
+ class ScreenshotPdfExporter
26
+ OSC = "\e]7772".freeze
27
+ BEL = "\a".freeze
28
+
29
+ POLL_INTERVAL = 0.05 # seconds between file-existence checks
30
+ CAPTURE_TIMEOUT = 10 # seconds per slide before giving up
31
+
32
+ def initialize(presentation, base_dir: '.', theme: nil, terminal: nil)
33
+ @presentation = presentation
34
+ @base_dir = base_dir
35
+ @theme = theme || Theme.default
36
+ @terminal = terminal || Terminal.new
37
+ @renderer = Renderer.new(@terminal, base_dir: base_dir, theme: theme)
38
+ end
39
+
40
+ def export(output_path)
41
+ require 'hexapdf'
42
+
43
+ Dir.mktmpdir('przn-capture') do |dir|
44
+ pdf_paths = capture_all_slides(dir)
45
+ merge_pdfs(pdf_paths, output_path)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def capture_all_slides(dir)
52
+ paths = []
53
+ @terminal.enter_alt_screen
54
+ @terminal.hide_cursor
55
+ @presentation.slides.each_with_index do |slide, i|
56
+ pdf_path = File.join(dir, format("slide-%04d.pdf", i))
57
+ @renderer.render(slide, current: i, total: @presentation.total)
58
+ request_capture(pdf_path)
59
+ wait_for_capture(pdf_path)
60
+ paths << pdf_path
61
+ end
62
+ paths
63
+ ensure
64
+ @terminal.write "#{OSC};bg-clear#{BEL}"
65
+ @terminal.show_cursor
66
+ @terminal.leave_alt_screen
67
+ @terminal.flush
68
+ end
69
+
70
+ def request_capture(path)
71
+ @terminal.write "#{OSC};capture;#{path}#{BEL}"
72
+ @terminal.flush
73
+ end
74
+
75
+ def wait_for_capture(path)
76
+ deadline = Time.now + CAPTURE_TIMEOUT
77
+ until File.exist?(path) && File.size?(path).to_i.positive?
78
+ if Time.now > deadline
79
+ raise "Capture timed out for #{path}. " \
80
+ "Is Echoes running and recent enough to honor OSC 7772 `capture` to a .pdf path?"
81
+ end
82
+ sleep POLL_INTERVAL
83
+ end
84
+ # Small grace period to ensure the PDF write is fully flushed.
85
+ sleep POLL_INTERVAL
86
+ end
87
+
88
+ def merge_pdfs(pdf_paths, output_path)
89
+ raise "No slides captured" if pdf_paths.empty?
90
+
91
+ output = HexaPDF::Document.new
92
+ pdf_paths.each do |path|
93
+ src = HexaPDF::Document.open(path)
94
+ src.pages.each do |page|
95
+ output.pages << output.import(page)
96
+ end
97
+ end
98
+ output.write(output_path)
99
+ end
100
+ end
101
+ end
data/lib/przn/terminal.rb CHANGED
@@ -4,6 +4,8 @@ require 'io/console'
4
4
 
5
5
  module Przn
6
6
  class Terminal
7
+ MOUSE_OFF = "\e[?1006l\e[?1003l\e[?1002l\e[?1000l"
8
+
7
9
  def initialize(input: $stdin, output: $stdout)
8
10
  @in = input
9
11
  @out = output
@@ -45,9 +47,11 @@ module Przn
45
47
 
46
48
  def enter_alt_screen
47
49
  write "\e[?1049h"
50
+ write MOUSE_OFF
48
51
  end
49
52
 
50
53
  def leave_alt_screen
54
+ write MOUSE_OFF
51
55
  write "\e[?1049l"
52
56
  end
53
57
 
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
9
+ attr_reader :colors, :font, :bullet, :background, :title
10
10
 
11
11
  def self.load(path)
12
12
  raise ArgumentError, "Theme file not found: #{path}" unless File.exist?(path)
@@ -16,6 +16,9 @@ module Przn
16
16
  merged = {
17
17
  colors: defaults[:colors].merge(overrides[:colors] || {}),
18
18
  font: defaults[:font].merge(overrides[:font] || {}),
19
+ bullet: defaults[:bullet].merge(overrides[:bullet] || {}),
20
+ background: defaults[:background].merge(overrides[:background] || {}),
21
+ title: defaults[:title].merge(overrides[:title] || {}),
19
22
  }
20
23
  new(merged)
21
24
  end
@@ -24,11 +27,23 @@ module Przn
24
27
  new(load_yaml(DEFAULT_PATH))
25
28
  end
26
29
 
30
+ # Look for a sibling `theme.yml` next to the given file and load it if
31
+ # present, so a deck can ship its theme alongside the markdown without
32
+ # the user having to pass `--theme` explicitly. Returns nil if no file
33
+ # is found.
34
+ def self.auto_discover(near:)
35
+ candidate = File.join(File.dirname(File.expand_path(near)), 'theme.yml')
36
+ File.exist?(candidate) ? load(candidate) : nil
37
+ end
38
+
27
39
  def self.load_yaml(path)
28
40
  data = YAML.safe_load_file(path, symbolize_names: true) || {}
29
41
  {
30
42
  colors: data[:colors] || {},
31
43
  font: data[:font] || {},
44
+ bullet: (data[:bullet] || {}).compact,
45
+ background: (data[:background] || {}).compact,
46
+ title: (data[:title] || {}).compact,
32
47
  }
33
48
  end
34
49
  private_class_method :load_yaml
@@ -36,6 +51,9 @@ module Przn
36
51
  def initialize(config)
37
52
  @colors = config[:colors]
38
53
  @font = config[:font]
54
+ @bullet = config[:bullet]
55
+ @background = config[:background]
56
+ @title = config[:title]
39
57
  end
40
58
  end
41
59
  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.1.6"
4
+ VERSION = "0.3.0"
5
5
  end