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.
- checksums.yaml +4 -4
- data/README.md +150 -2
- data/default_theme.yml +28 -9
- data/exe/przn +21 -5
- data/lib/przn/controller.rb +27 -0
- data/lib/przn/image_util.rb +24 -0
- data/lib/przn/kitty_text.rb +4 -1
- data/lib/przn/parser.rb +46 -2
- data/lib/przn/pdf_exporter.rb +76 -5
- data/lib/przn/renderer.rb +253 -68
- data/lib/przn/screenshot_pdf_exporter.rb +101 -0
- data/lib/przn/terminal.rb +4 -0
- data/lib/przn/theme.rb +19 -1
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +21 -1
- metadata +71 -51
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
|
-
@
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 "#{
|
|
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 "#{
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
368
|
-
|
|
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 :
|
|
382
|
-
when :
|
|
383
|
-
when :
|
|
384
|
-
when :
|
|
385
|
-
when :
|
|
386
|
-
when :
|
|
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
|
|
396
|
-
|
|
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
|
-
|
|
545
|
+
used = 0
|
|
401
546
|
|
|
402
547
|
segments.each do |seg|
|
|
403
548
|
content = seg[1] || ""
|
|
404
|
-
|
|
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
|
|
554
|
+
if used + seg_cells <= max_cells
|
|
407
555
|
lines.last << seg
|
|
408
|
-
|
|
556
|
+
used += seg_cells
|
|
409
557
|
next
|
|
410
558
|
end
|
|
411
559
|
|
|
412
560
|
remaining = content
|
|
413
561
|
loop do
|
|
414
|
-
|
|
415
|
-
if
|
|
562
|
+
space_cells = max_cells - used
|
|
563
|
+
if space_cells < seg_scale && used > 0
|
|
416
564
|
lines << []
|
|
417
|
-
|
|
418
|
-
|
|
565
|
+
used = 0
|
|
566
|
+
space_cells = max_cells
|
|
419
567
|
end
|
|
420
568
|
|
|
421
|
-
|
|
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
|
-
|
|
572
|
+
used += display_width(chunk) * seg_scale
|
|
424
573
|
|
|
425
574
|
break unless remaining
|
|
426
575
|
lines << []
|
|
427
|
-
|
|
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
|
-
|
|
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