przn 0.4.0 → 0.6.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 +496 -66
- data/default_theme.yml +98 -18
- data/exe/przn +8 -2
- data/lib/przn/audience_link.rb +3 -3
- data/lib/przn/code_highlighter.rb +165 -0
- data/lib/przn/controller.rb +506 -14
- data/lib/przn/echoes_client.rb +20 -0
- data/lib/przn/file_watcher.rb +69 -0
- data/lib/przn/image_util.rb +65 -2
- data/lib/przn/kitty_text.rb +2 -1
- data/lib/przn/mermaid_renderer.rb +88 -0
- data/lib/przn/parser.rb +417 -35
- data/lib/przn/prawn_pdf_exporter.rb +133 -15
- data/lib/przn/presentation.rb +43 -0
- data/lib/przn/presenter_renderer.rb +22 -8
- data/lib/przn/renderer.rb +2411 -305
- data/lib/przn/screenshot_pdf_exporter.rb +173 -5
- data/lib/przn/slide.rb +4 -2
- data/lib/przn/terminal.rb +10 -0
- data/lib/przn/theme.rb +85 -10
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +11 -5
- data/sample/ruby-logo-LICENSE +15 -0
- data/sample/ruby.jpg +0 -0
- data/sample/ruby.png +0 -0
- data/sample/sample.md +285 -28
- data/sample/shapes.md +55 -0
- metadata +91 -77
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
|
@@ -63,18 +63,51 @@ module Przn
|
|
|
63
63
|
'bright_white' => 'FFFFFF'
|
|
64
64
|
}.freeze
|
|
65
65
|
|
|
66
|
+
# Fallback palette used when the theme leaves a colour unset and
|
|
67
|
+
# no legacy `colors.*` key fills in either. These are hex (no `#`)
|
|
68
|
+
# so they can flow straight into `pdf.fill_color`.
|
|
69
|
+
DEFAULT_CODE_BG = '313244'
|
|
70
|
+
DEFAULT_DIM = '6c7086'
|
|
71
|
+
|
|
66
72
|
def initialize(presentation, base_dir: '.', theme: nil)
|
|
67
73
|
@presentation = presentation
|
|
68
74
|
@base_dir = base_dir
|
|
69
75
|
@theme = theme || Theme.default
|
|
70
76
|
@bg_color = @theme.background && @theme.background[:color]
|
|
71
77
|
@fg_color = @theme.font[:color] || '000000'
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
# Resolve in priority order:
|
|
79
|
+
# 1. new `theme.code` keys (terminal & PDF share them)
|
|
80
|
+
# 2. legacy `theme.colors.*` (back-compat for decks that haven't
|
|
81
|
+
# migrated yet — accepted on read but no longer documented)
|
|
82
|
+
# 3. hardcoded default
|
|
83
|
+
#
|
|
84
|
+
# The page-number footer reads `counter.color` first so the same
|
|
85
|
+
# theme key styles the counter in both the terminal and PDF paths.
|
|
86
|
+
colors = @theme.colors || {}
|
|
87
|
+
@code_bg = sanitize_hex(@theme.code[:bg]) || colors[:code_bg] || DEFAULT_CODE_BG
|
|
88
|
+
# `code.color` styles ALL code text — fenced blocks and inline
|
|
89
|
+
# `<code>` segments alike. Used to be split into two knobs
|
|
90
|
+
# (`colors.inline_code` for inline only, body fg for fenced)
|
|
91
|
+
# for no real reason beyond historical defaults.
|
|
92
|
+
@code_color = sanitize_hex(@theme.code[:color]) || colors[:inline_code] || @fg_color
|
|
93
|
+
@counter_color = sanitize_hex(@theme.counter[:color]) || colors[:dim] || DEFAULT_DIM
|
|
94
|
+
@dim_color = colors[:dim] || DEFAULT_DIM
|
|
95
|
+
# `theme.font.size` is now an OSC 66 scale (1–7), matching
|
|
96
|
+
# title.size — meaningful to the terminal renderer, not to
|
|
97
|
+
# this PDF path. Prawn keeps its own fixed point-size table.
|
|
98
|
+
@scale_to_pt = DEFAULT_SCALE_TO_PT
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Reduce a theme colour value to a 6-digit hex string (no `#`) so
|
|
102
|
+
# Prawn's `fill_color` / `stroke_color` can consume it directly.
|
|
103
|
+
# The terminal-side resolver accepts CSS / ANSI named colours via
|
|
104
|
+
# CSS_NAMED_COLORS, but Prawn doesn't have that table — named
|
|
105
|
+
# values fall through to the legacy `colors.*` back-compat key
|
|
106
|
+
# and then to the hardcoded default in the PDF path.
|
|
107
|
+
def sanitize_hex(value)
|
|
108
|
+
return nil if value.nil?
|
|
109
|
+
s = value.to_s.strip.sub(/\A#/, '')
|
|
110
|
+
s.match?(/\A[0-9a-fA-F]{6}\z/) ? s : nil
|
|
78
111
|
end
|
|
79
112
|
|
|
80
113
|
# Fallback font paths when fc-match is not available.
|
|
@@ -290,12 +323,16 @@ module Przn
|
|
|
290
323
|
end
|
|
291
324
|
end
|
|
292
325
|
|
|
293
|
-
# Page number
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
326
|
+
# Page number — reads `theme.counter.color` like the terminal's
|
|
327
|
+
# bottom-right counter does, so a single theme key styles both.
|
|
328
|
+
# Skipped on a `{counter=hide}` slide, as in the terminal.
|
|
329
|
+
unless slide.attrs[:counter].to_s == 'hide'
|
|
330
|
+
total = @presentation.total
|
|
331
|
+
status = "#{slide_index + 1} / #{total}"
|
|
332
|
+
pdf.fill_color @counter_color
|
|
333
|
+
pdf.text_box status, at: [0, 16], width: PAGE_WIDTH - 10, height: 14, size: 8, align: :right
|
|
334
|
+
pdf.fill_color @fg_color
|
|
335
|
+
end
|
|
299
336
|
end
|
|
300
337
|
|
|
301
338
|
def draw_background(pdf)
|
|
@@ -319,17 +356,98 @@ module Przn
|
|
|
319
356
|
when :blockquote then render_blockquote(pdf, block, margin_x, content_width, y)
|
|
320
357
|
when :table then render_table(pdf, block, margin_x, content_width, y)
|
|
321
358
|
when :image then render_image(pdf, block, margin_x, content_width, y)
|
|
359
|
+
when :ref then render_ref(pdf, block, margin_x, content_width, y, align: align)
|
|
360
|
+
when :group then render_group(pdf, block, margin_x, content_width, y, align: align)
|
|
322
361
|
when :blank then y - @scale_to_pt[DEFAULT_SCALE]
|
|
323
362
|
else y - @scale_to_pt[DEFAULT_SCALE]
|
|
324
363
|
end
|
|
325
364
|
end
|
|
326
365
|
|
|
366
|
+
# Walk a `<group>`'s children, threading y through each child's
|
|
367
|
+
# render. Same atomic-container semantics as the terminal renderer's
|
|
368
|
+
# render_group: align flows through; waits / actions pass without
|
|
369
|
+
# advancing y; everything else dispatches to render_block. v1
|
|
370
|
+
# caveat: positioned children (`<at>`, shapes) inherit the existing
|
|
371
|
+
# Prawn fallback limitation — neither type is drawn in PDF today.
|
|
372
|
+
def render_group(pdf, block, margin_x, content_width, y, align: nil)
|
|
373
|
+
pending_align = align
|
|
374
|
+
(block[:children] || []).each do |child|
|
|
375
|
+
case child[:type]
|
|
376
|
+
when :align then pending_align = child[:align]
|
|
377
|
+
when :wait, :action then next
|
|
378
|
+
else
|
|
379
|
+
y = render_block(pdf, child, margin_x, content_width, y, align: pending_align)
|
|
380
|
+
pending_align = nil
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
y
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Look up the source block by id and re-dispatch through render_block.
|
|
387
|
+
# Mirrors the terminal renderer's render_ref. PDF parity is partial in
|
|
388
|
+
# v1: refs to flow blocks (paragraph / list / code / blockquote /
|
|
389
|
+
# heading / table / image) render; refs to `<at>` or shapes match
|
|
390
|
+
# the source's existing Prawn limitation (those types aren't drawn
|
|
391
|
+
# in PDF today regardless).
|
|
392
|
+
def render_ref(pdf, block, margin_x, content_width, y, align: nil)
|
|
393
|
+
id = block[:attrs] && (block[:attrs][:id] || block[:attrs]['id'])
|
|
394
|
+
return y unless id
|
|
395
|
+
source = @presentation.find_by_id(id)
|
|
396
|
+
return y unless source
|
|
397
|
+
|
|
398
|
+
# Deep-clone the source (so a group source's children don't share
|
|
399
|
+
# storage with the deck) and strip ids from every descendant —
|
|
400
|
+
# mirrors the terminal renderer's render_ref semantics.
|
|
401
|
+
synthetic = deep_clone_strip_ids(source)
|
|
402
|
+
src_attrs = source[:attrs] || {}
|
|
403
|
+
merged = synthetic[:attrs]
|
|
404
|
+
(block[:attrs] || {}).each do |k, v|
|
|
405
|
+
next if k.to_s == 'id'
|
|
406
|
+
if src_attrs.key?(k.to_sym)
|
|
407
|
+
merged[k.to_sym] = v
|
|
408
|
+
elsif src_attrs.key?(k.to_s)
|
|
409
|
+
merged[k.to_s] = v
|
|
410
|
+
elsif k.is_a?(Symbol)
|
|
411
|
+
merged[k] = v
|
|
412
|
+
else
|
|
413
|
+
merged[k.to_s] = v
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
render_block(pdf, synthetic, margin_x, content_width, y, align: align)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Recursive clone with every node's `id` stripped — same shape as
|
|
421
|
+
# the terminal renderer's helper. Kept local to the exporter (~10
|
|
422
|
+
# lines, no shared base class) rather than reaching across module
|
|
423
|
+
# boundaries.
|
|
424
|
+
def deep_clone_strip_ids(block)
|
|
425
|
+
cloned = block.dup
|
|
426
|
+
attrs = (block[:attrs] || {}).dup
|
|
427
|
+
attrs.delete(:id)
|
|
428
|
+
attrs.delete('id')
|
|
429
|
+
cloned[:attrs] = attrs
|
|
430
|
+
if block[:children].is_a?(Array)
|
|
431
|
+
cloned[:children] = block[:children].map { |c| deep_clone_strip_ids(c) }
|
|
432
|
+
end
|
|
433
|
+
cloned
|
|
434
|
+
end
|
|
435
|
+
|
|
327
436
|
def render_heading(pdf, block, margin_x, content_width, y)
|
|
328
437
|
text = block[:content]
|
|
329
438
|
if block[:level] == 1
|
|
330
439
|
scale = KittyText::HEADING_SCALES[1]
|
|
331
440
|
pt = @scale_to_pt[scale]
|
|
332
|
-
|
|
441
|
+
# h1 spans get the bold heading weight, except spans that are
|
|
442
|
+
# already italic — Prawn would look up a `bold_italic` variant
|
|
443
|
+
# which the CJK fallback font doesn't ship, and the heading
|
|
444
|
+
# would crash. The italic span keeps its emphasis; the rest
|
|
445
|
+
# of the title stays bold.
|
|
446
|
+
formatted = build_formatted_text(text, pt).map do |f|
|
|
447
|
+
styles = f[:styles] || []
|
|
448
|
+
styles = styles | [:bold] unless styles.include?(:italic)
|
|
449
|
+
f.merge(styles: styles)
|
|
450
|
+
end
|
|
333
451
|
h = render_formatted(pdf, formatted, at: [margin_x, y], width: content_width, align: :center)
|
|
334
452
|
y - h - heading_margin(pt)
|
|
335
453
|
else
|
|
@@ -370,7 +488,7 @@ module Przn
|
|
|
370
488
|
code_lines.each do |line|
|
|
371
489
|
# Replace leading spaces with non-breaking spaces to preserve indentation
|
|
372
490
|
preserved = line.sub(/\A +/) { |m| "\u00A0" * m.length }
|
|
373
|
-
pdf.text_box preserved, at: [margin_x + padding, code_y], width: content_width - padding * 2, height: line_height, size: pt, color: @
|
|
491
|
+
pdf.text_box preserved, at: [margin_x + padding, code_y], width: content_width - padding * 2, height: line_height, size: pt, color: @code_color, overflow: :shrink_to_fit
|
|
374
492
|
code_y -= line_height
|
|
375
493
|
end
|
|
376
494
|
|
|
@@ -545,7 +663,7 @@ module Przn
|
|
|
545
663
|
when :strikethrough
|
|
546
664
|
{text: content, size: default_pt, color: @fg_color, styles: [:strikethrough]}
|
|
547
665
|
when :code
|
|
548
|
-
{text: " #{content} ", size: default_pt * 0.85, color: @
|
|
666
|
+
{text: " #{content} ", size: default_pt * 0.85, color: @code_color}
|
|
549
667
|
when :note
|
|
550
668
|
{text: content, size: default_pt * 0.7, color: @dim_color}
|
|
551
669
|
when :text
|
data/lib/przn/presentation.rb
CHANGED
|
@@ -33,5 +33,48 @@ module Przn
|
|
|
33
33
|
def last_slide!
|
|
34
34
|
@current = total - 1
|
|
35
35
|
end
|
|
36
|
+
|
|
37
|
+
# Deck-wide lookup for a block declared with `id="..."`. Used by
|
|
38
|
+
# `<ref id="..."/>` to find the source block on another (or the
|
|
39
|
+
# same) slide. Returns the source block hash, or nil when no
|
|
40
|
+
# block with that id is found. First match by slide order wins;
|
|
41
|
+
# `:ref` blocks themselves are excluded from the index so a chain
|
|
42
|
+
# of refs doesn't resolve transitively in v1.
|
|
43
|
+
def find_by_id(id)
|
|
44
|
+
@by_id ||= build_id_index
|
|
45
|
+
@by_id[id.to_s]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_id_index
|
|
51
|
+
index = {}
|
|
52
|
+
@slides.each { |slide| walk_blocks(slide.blocks) { |b| record_id(index, b) } }
|
|
53
|
+
index
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Yield every block reachable from `blocks`, descending into
|
|
57
|
+
# `:group` (or any other) children. Lets the deck-wide index see
|
|
58
|
+
# ids declared inside composites so `<ref id="inner-x"/>` resolves
|
|
59
|
+
# to a nested block regardless of which group wraps it.
|
|
60
|
+
def walk_blocks(blocks, &block)
|
|
61
|
+
blocks.each do |b|
|
|
62
|
+
yield b
|
|
63
|
+
walk_blocks(b[:children], &block) if b[:children].is_a?(Array)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def record_id(index, b)
|
|
68
|
+
return if b[:type] == :ref
|
|
69
|
+
a = b[:attrs]
|
|
70
|
+
return unless a
|
|
71
|
+
# Parser key style is split: `:at` and shapes keep symbols
|
|
72
|
+
# from parse_xml_attrs, while `:image` (and any other block
|
|
73
|
+
# that runs `transform_keys(&:to_s)`) stores string keys.
|
|
74
|
+
# Check both — same as compute_effective_state's by_id build.
|
|
75
|
+
bid = a['id'] || a[:id]
|
|
76
|
+
return unless bid
|
|
77
|
+
index[bid.to_s] ||= b
|
|
78
|
+
end
|
|
36
79
|
end
|
|
37
80
|
end
|
|
@@ -8,8 +8,7 @@ module Przn
|
|
|
8
8
|
# footer with the slide counter + elapsed time.
|
|
9
9
|
class PresenterRenderer < Renderer
|
|
10
10
|
def initialize(terminal, presentation:, base_dir: '.', theme: nil)
|
|
11
|
-
super(terminal, base_dir: base_dir, theme: theme, mode: :presenter)
|
|
12
|
-
@presentation = presentation
|
|
11
|
+
super(terminal, base_dir: base_dir, theme: theme, mode: :presenter, presentation: presentation)
|
|
13
12
|
@started_at = Time.now
|
|
14
13
|
end
|
|
15
14
|
|
|
@@ -29,12 +28,13 @@ module Przn
|
|
|
29
28
|
elapsed = format_elapsed(Time.now - @started_at)
|
|
30
29
|
footer = "Slide #{current + 1} / #{total} #{elapsed}"
|
|
31
30
|
|
|
32
|
-
# When
|
|
33
|
-
# renderer has already drawn it on rows h-1 and h. Lift the
|
|
34
|
-
# so it doesn't clobber the runner bar; the indicator
|
|
35
|
-
# the strip's own footer line (slide #, elapsed
|
|
36
|
-
# anyway via rabbit
|
|
37
|
-
|
|
31
|
+
# When `theme.counter.duration` opts into the 🐇 / 🐢 indicator, the
|
|
32
|
+
# parent renderer has already drawn it on rows h-1 and h. Lift the
|
|
33
|
+
# strip up so it doesn't clobber the runner bar; the indicator
|
|
34
|
+
# itself replaces the strip's own footer line (slide #, elapsed
|
|
35
|
+
# time are visible there anyway via rabbit / turtle position).
|
|
36
|
+
counter = @theme.counter || {}
|
|
37
|
+
rabbit_mode = !counter[:duration].nil? && !counter[:duration].to_s.empty?
|
|
38
38
|
notes_row = rabbit_mode ? h - 3 : h - 2
|
|
39
39
|
next_row = rabbit_mode ? h - 2 : h - 1
|
|
40
40
|
|
|
@@ -61,6 +61,20 @@ module Przn
|
|
|
61
61
|
nil
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Flatten inline markup to plain text via the parser's own inline
|
|
65
|
+
# scanner, so the preview tracks every tag the slide itself renders.
|
|
66
|
+
# Notes are presenter-strip material, not title text; `<br>` reads
|
|
67
|
+
# as a space on the one-line strip.
|
|
68
|
+
def strip_markup(text)
|
|
69
|
+
Parser.parse_inline(text).filter_map { |type, content|
|
|
70
|
+
case type
|
|
71
|
+
when :note then nil
|
|
72
|
+
when :break then ' '
|
|
73
|
+
else content
|
|
74
|
+
end
|
|
75
|
+
}.join
|
|
76
|
+
end
|
|
77
|
+
|
|
64
78
|
def format_elapsed(seconds)
|
|
65
79
|
h = (seconds / 3600).to_i
|
|
66
80
|
m = ((seconds % 3600) / 60).to_i
|