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.
@@ -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
- @code_bg = @theme.colors[:code_bg]
73
- @dim_color = @theme.colors[:dim]
74
- @inline_code_color = @theme.colors[:inline_code]
75
- base = (@theme.font[:size] || DEFAULT_FONT_SIZE).to_f
76
- ratio = base / DEFAULT_FONT_SIZE
77
- @scale_to_pt = DEFAULT_SCALE_TO_PT.transform_values { |v| v * ratio }
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
- total = @presentation.total
295
- status = "#{slide_index + 1} / #{total}"
296
- pdf.fill_color @dim_color
297
- pdf.text_box status, at: [0, 16], width: PAGE_WIDTH - 10, height: 14, size: 8, align: :right
298
- pdf.fill_color @fg_color
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
- formatted = build_formatted_text(text, pt).map { |f| f.merge(styles: (f[:styles] || []) | [:bold]) }
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: @fg_color, overflow: :shrink_to_fit
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: @inline_code_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
@@ -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 the theme opts into the rabbit/turtle indicator, the parent
33
- # renderer has already drawn it on rows h-1 and h. Lift the strip up
34
- # so it doesn't clobber the runner bar; the indicator itself replaces
35
- # the strip's own footer line (slide #, elapsed time are visible there
36
- # anyway via rabbit position and turtle position).
37
- rabbit_mode = !@theme.rabbit.nil?
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