przn 0.2.0 → 0.4.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.
@@ -24,7 +24,7 @@ module PrawnCJKLineWrap
24
24
  "[#{CJK_CHARS}]",
25
25
  "[#{ews}]+",
26
26
  "#{ehy}+[^#{ebc}]*",
27
- eshy.to_s,
27
+ eshy.to_s
28
28
  ]
29
29
 
30
30
  Regexp.new(patterns.join('|'))
@@ -32,13 +32,25 @@ module PrawnCJKLineWrap
32
32
  end
33
33
 
34
34
  module Przn
35
- class PdfExporter
35
+ # Legacy PDF export via Prawn — renders the deck directly into a vector
36
+ # PDF without touching the terminal. Diverges from what's on screen for
37
+ # any feature the live renderer adds (OSC 66 sized text, OSC 7772
38
+ # backgrounds, proportional fonts) but works headlessly.
39
+ def self.export_pdf_prawn(file, output, theme: nil)
40
+ markdown = File.read(file)
41
+ presentation = Parser.parse(markdown)
42
+ base_dir = File.dirname(File.expand_path(file))
43
+ PrawnPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
44
+ puts "Generated: #{output}"
45
+ end
46
+
47
+ class PrawnPdfExporter
36
48
  PAGE_WIDTH = 960
37
49
  PAGE_HEIGHT = 540
38
50
  DEFAULT_FONT_SIZE = 18
39
51
  DEFAULT_SCALE_TO_PT = {
40
52
  1 => 10, 2 => 18, 3 => 24, 4 => 32,
41
- 5 => 40, 6 => 48, 7 => 56,
53
+ 5 => 40, 6 => 48, 7 => 56
42
54
  }.freeze
43
55
 
44
56
  DEFAULT_SCALE = Renderer::DEFAULT_SCALE
@@ -48,19 +60,18 @@ module Przn
48
60
  'magenta' => 'FF79C6', 'cyan' => '8BE9FD', 'white' => 'F8F8F2',
49
61
  'bright_red' => 'FF6E6E', 'bright_green' => '69FF94', 'bright_yellow' => 'FFFFA5',
50
62
  'bright_blue' => 'D6ACFF', 'bright_magenta' => 'FF92DF', 'bright_cyan' => 'A4FFFF',
51
- 'bright_white' => 'FFFFFF',
63
+ 'bright_white' => 'FFFFFF'
52
64
  }.freeze
53
65
 
54
66
  def initialize(presentation, base_dir: '.', theme: nil)
55
67
  @presentation = presentation
56
68
  @base_dir = base_dir
57
69
  @theme = theme || Theme.default
58
- @bg_color = @theme.colors[:background]
59
- @fg_color = @theme.colors[:foreground]
70
+ @bg_color = @theme.background && @theme.background[:color]
71
+ @fg_color = @theme.font[:color] || '000000'
60
72
  @code_bg = @theme.colors[:code_bg]
61
73
  @dim_color = @theme.colors[:dim]
62
74
  @inline_code_color = @theme.colors[:inline_code]
63
- @heading_color = @theme.colors[:heading] || @fg_color
64
75
  base = (@theme.font[:size] || DEFAULT_FONT_SIZE).to_f
65
76
  ratio = base / DEFAULT_FONT_SIZE
66
77
  @scale_to_pt = DEFAULT_SCALE_TO_PT.transform_values { |v| v * ratio }
@@ -74,7 +85,7 @@ module Przn
74
85
  -> { Dir.glob('/usr/share/fonts/**/NotoSansJP-Regular.ttf').first },
75
86
  -> { File.join(Dir.home, 'Library/Fonts/HackGen-Regular.ttf') },
76
87
  -> { '/Library/Fonts/Arial Unicode.ttf' },
77
- -> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' },
88
+ -> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' }
78
89
  ].freeze
79
90
 
80
91
  FALLBACK_FONT_FAMILIES = %w[NotoSansCJK NotoSansJP HackGen].freeze
@@ -85,7 +96,7 @@ module Przn
85
96
 
86
97
  pdf = Prawn::Document.new(
87
98
  page_size: [PAGE_WIDTH, PAGE_HEIGHT],
88
- margin: 0,
99
+ margin: 0
89
100
  )
90
101
 
91
102
  register_fonts(pdf)
@@ -109,7 +120,7 @@ module Przn
109
120
  'CJK' => {
110
121
  normal: {file: font_path, font: 0},
111
122
  bold: {file: font_path.sub('W3', 'W6').then { |p| File.exist?(p) ? p : font_path }, font: 0},
112
- italic: {file: font_path, font: 0},
123
+ italic: {file: font_path, font: 0}
113
124
  }
114
125
  )
115
126
  else
@@ -118,7 +129,7 @@ module Przn
118
129
  'CJK' => {
119
130
  normal: font_path,
120
131
  bold: bold_path,
121
- italic: font_path,
132
+ italic: font_path
122
133
  }
123
134
  )
124
135
  end
@@ -145,10 +156,10 @@ module Przn
145
156
  pdf.font_families.update(family => {
146
157
  normal: path,
147
158
  bold: bold_path,
148
- italic: path,
159
+ italic: path
149
160
  })
150
161
  @registered_inline_fonts[family] = true
151
- rescue
162
+ rescue StandardError
152
163
  next
153
164
  end
154
165
  end
@@ -219,7 +230,7 @@ module Przn
219
230
  'Emoji' => { normal: emoji_path, bold: emoji_path, italic: emoji_path }
220
231
  )
221
232
  pdf.fallback_fonts = ['Emoji']
222
- rescue
233
+ rescue StandardError
223
234
  nil
224
235
  end
225
236
 
@@ -232,7 +243,7 @@ module Przn
232
243
  # Only use fonts with glyf outlines (not SBIX/COLR bitmap-only fonts)
233
244
  ttf = TTFunk::File.open(path)
234
245
  return path if ttf.directory.tables.key?('glyf')
235
- rescue
246
+ rescue StandardError
236
247
  next
237
248
  end
238
249
  nil
@@ -288,6 +299,8 @@ module Przn
288
299
  end
289
300
 
290
301
  def draw_background(pdf)
302
+ return unless @bg_color
303
+
291
304
  pdf.canvas do
292
305
  pdf.fill_color @bg_color
293
306
  pdf.fill_rectangle [0, PAGE_HEIGHT], PAGE_WIDTH, PAGE_HEIGHT
@@ -321,7 +334,7 @@ module Przn
321
334
  y - h - heading_margin(pt)
322
335
  else
323
336
  pt = @scale_to_pt[DEFAULT_SCALE]
324
- prefix = [{text: bullet, size: pt, color: @heading_color, styles: [:bold]}]
337
+ prefix = [{text: bullet, size: pt, color: @fg_color, styles: [:bold]}]
325
338
  formatted = prefix + build_formatted_text(text, pt)
326
339
  h = render_formatted(pdf, formatted, at: [margin_x, y], width: content_width)
327
340
  y - h - 4
@@ -608,7 +621,7 @@ module Przn
608
621
  end
609
622
 
610
623
  def bullet
611
- @font_registered ? @theme.bullet : "-"
624
+ @font_registered ? @theme.bullet[:text] : '-'
612
625
  end
613
626
 
614
627
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Przn
4
+ # Drives the laptop-side view in extended-display mode. Reuses the existing
5
+ # Renderer to draw the current slide (notes still rendered dim-inline so the
6
+ # presenter sees them in context), then overlays a three-line strip at the
7
+ # bottom of the terminal: speaker notes summary, next-slide preview, and a
8
+ # footer with the slide counter + elapsed time.
9
+ class PresenterRenderer < Renderer
10
+ def initialize(terminal, presentation:, base_dir: '.', theme: nil)
11
+ super(terminal, base_dir: base_dir, theme: theme, mode: :presenter)
12
+ @presentation = presentation
13
+ @started_at = Time.now
14
+ end
15
+
16
+ def render(slide, current:, total:, started_at: nil)
17
+ super(slide, current: current, total: total, started_at: started_at)
18
+ @mutex.synchronize { draw_presenter_strip(current, total) }
19
+ end
20
+
21
+ private
22
+
23
+ def draw_presenter_strip(current, total)
24
+ w = @terminal.width
25
+ h = @terminal.height
26
+ slide = @presentation.slides[current]
27
+ notes_text = slide.notes.join(' / ')
28
+ next_title = current + 1 < total ? preview_title(@presentation.slides[current + 1]) : nil
29
+ elapsed = format_elapsed(Time.now - @started_at)
30
+ footer = "Slide #{current + 1} / #{total} #{elapsed}"
31
+
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?
38
+ notes_row = rabbit_mode ? h - 3 : h - 2
39
+ next_row = rabbit_mode ? h - 2 : h - 1
40
+
41
+ @terminal.move_to(notes_row, 1)
42
+ @terminal.write "#{ANSI[:dim]}Notes: #{truncate_to_width(notes_text, [w - 8, 1].max)}#{ANSI[:reset]}"
43
+ @terminal.move_to(next_row, 1)
44
+ @terminal.write "#{ANSI[:dim]}Next: #{truncate_to_width(next_title || '—', [w - 8, 1].max)}#{ANSI[:reset]}"
45
+
46
+ unless rabbit_mode
47
+ @terminal.move_to(h, 1)
48
+ @terminal.write "#{ANSI[:dim]}#{truncate_to_width(footer, w)}#{ANSI[:reset]}"
49
+ end
50
+
51
+ @terminal.flush
52
+ end
53
+
54
+ def preview_title(slide)
55
+ return nil unless slide
56
+ slide.blocks.each do |b|
57
+ case b[:type]
58
+ when :heading, :paragraph then return strip_markup(b[:content].to_s)
59
+ end
60
+ end
61
+ nil
62
+ end
63
+
64
+ def format_elapsed(seconds)
65
+ h = (seconds / 3600).to_i
66
+ m = ((seconds % 3600) / 60).to_i
67
+ s = (seconds % 60).to_i
68
+ format('%02d:%02d:%02d', h, m, s)
69
+ end
70
+ end
71
+ end
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
@@ -101,7 +119,7 @@ module Przn
101
119
  # Other terminals ignore the OSC code, so this is a no-op outside Echoes.
102
120
  def apply_slide_background(slide)
103
121
  block = slide.blocks.find { |b| b[:type] == :bg }
104
- attrs = block ? block[:attrs] : (@theme.bg || {})
122
+ attrs = block ? block[:attrs] : (@theme.background || {})
105
123
 
106
124
  @terminal.write "\e]7772;bg-clear\a"
107
125
  return if attrs.empty?
@@ -119,12 +137,68 @@ module Przn
119
137
  @terminal.write "\e]7772;bg-gradient;type=#{type}:angle=#{angle}:colors=#{colors.join(',')}\a"
120
138
  end
121
139
 
140
+ # Bottom-row progress indicator (Rabbit-style):
141
+ #
142
+ # 1 🐢 🐇 9
143
+ # └ current slide # └ elapsed time └ slide progress └ goal (total slides)
144
+ #
145
+ # The anchor numbers (current at the left, total at the right) sit on the
146
+ # very bottom row; the emojis render at OSC 66 scale 2 and are anchored at
147
+ # row `h-1` so their bottom half lands on row `h` next to the numbers,
148
+ # making them visibly twice as large as the labels without needing more
149
+ # vertical screen real-estate. The turtle is hidden when
150
+ # `theme.rabbit.duration` is unset / unparseable. `flip=h` mirrors each
151
+ # glyph horizontally on terminals that honor it (Echoes); others ignore
152
+ # the parameter and the emojis face left.
153
+ EMOJI_RUNNER_CELLS = 4 # 🐇/🐢 are 2 source cells wide, rendered at s=2 → 4 cells
154
+
155
+ def draw_runner_bar(h, w, current, total, started_at)
156
+ left = (current + 1).to_s
157
+ right = total.to_s
158
+ track_left = left.size + 2 # 1 cell gap after the left number
159
+ track_right = w - right.size - 1 # 1 cell gap before the right number
160
+ return if track_right - track_left < EMOJI_RUNNER_CELLS
161
+
162
+ @terminal.move_to(h, 1)
163
+ @terminal.write "#{ANSI[:dim]}#{left}#{ANSI[:reset]}"
164
+ @terminal.move_to(h, w - right.size + 1)
165
+ @terminal.write "#{ANSI[:dim]}#{right}#{ANSI[:reset]}"
166
+
167
+ rabbit_row = [h - 1, 1].max
168
+ rabbit_col = runner_col(current, [total - 1, 1].max, track_left, track_right)
169
+ @terminal.move_to(rabbit_row, rabbit_col)
170
+ @terminal.write KittyText.sized('🐇', s: 2, flip: 'h')
171
+
172
+ duration_s = Theme.parse_duration(@theme.rabbit[:duration])
173
+ return unless started_at && duration_s && duration_s.positive?
174
+
175
+ elapsed = Time.now - started_at
176
+ frac = (elapsed / duration_s).clamp(0.0, 1.0)
177
+ span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
178
+ turtle_col = track_left + (frac * [span, 0].max).round
179
+ @terminal.move_to(rabbit_row, turtle_col)
180
+ @terminal.write KittyText.sized('🐢', s: 2, flip: 'h')
181
+ end
182
+
183
+ # Linear-interpolate a runner's column inside the track. `step` is 0..max
184
+ # (e.g. current slide index 0..total-1), and the returned column leaves
185
+ # enough room for an emoji `EMOJI_RUNNER_CELLS` cells wide before the
186
+ # right-anchor number.
187
+ def runner_col(step, max, track_left, track_right)
188
+ return track_left if max <= 0
189
+ span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
190
+ span = 0 if span < 0
191
+ track_left + (step.to_f / max * span).round
192
+ end
193
+
122
194
  def render_heading(block, width, row)
123
195
  text = block[:content]
124
196
 
125
197
  if block[:level] == 1
126
- scale = KittyText::HEADING_SCALES[1]
127
- face = @theme.heading_face
198
+ title = @theme.title || {}
199
+ scale = (title[:size] && Parser::SIZE_SCALES[title[:size].to_s]) || KittyText::HEADING_SCALES[1]
200
+ face = title[:family]
201
+ color = title[:color]
128
202
  max_w = max_text_width(width, 0, scale)
129
203
  segments = Parser.parse_inline(text)
130
204
  wrapped = wrap_segments(segments, max_w, scale)
@@ -133,13 +207,13 @@ module Przn
133
207
  vis = segments_visible_cells(line_segs, scale)
134
208
  pad = [(width - vis) / 2, 0].max
135
209
  @terminal.move_to(row, pad + 1)
136
- @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, scale, default_face: face, default_h: 2)}#{ANSI[:reset]}"
210
+ @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, scale, default_face: face, default_h: 2, default_color: color)}#{ANSI[:reset]}"
137
211
  row += scale
138
212
  end
139
213
  row + 4
140
214
  else
141
215
  left = content_left(width)
142
- prefix = @theme.bullet
216
+ prefix = @theme.bullet[:text]
143
217
  prefix_w = display_width(prefix)
144
218
  max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
145
219
  segments = Parser.parse_inline(text)
@@ -150,7 +224,7 @@ module Przn
150
224
  if li == 0
151
225
  @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
152
226
  else
153
- @terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
227
+ @terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
154
228
  end
155
229
  row += DEFAULT_SCALE
156
230
  end
@@ -204,8 +278,8 @@ module Przn
204
278
  left = content_left(width)
205
279
  block[:items].each do |item|
206
280
  depth = item[:depth] || 0
207
- indent = " " * depth
208
- prefix = "#{indent}#{@theme.bullet}"
281
+ indent = ' ' * depth
282
+ prefix = "#{indent}#{@theme.bullet[:text]}"
209
283
  prefix_w = display_width(prefix)
210
284
  max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
211
285
 
@@ -217,7 +291,7 @@ module Przn
217
291
  if li == 0
218
292
  @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
219
293
  else
220
- @terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
294
+ @terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
221
295
  end
222
296
  row += DEFAULT_SCALE
223
297
  end
@@ -230,7 +304,7 @@ module Przn
230
304
  left = content_left(width)
231
305
  block[:items].each_with_index do |item, i|
232
306
  depth = item[:depth] || 0
233
- indent = " " * depth
307
+ indent = ' ' * depth
234
308
  prefix = "#{indent}#{i + 1}. "
235
309
  prefix_w = display_width(prefix)
236
310
  max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
@@ -243,7 +317,7 @@ module Przn
243
317
  if li == 0
244
318
  @terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
245
319
  else
246
- @terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
320
+ @terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
247
321
  end
248
322
  row += DEFAULT_SCALE
249
323
  end
@@ -279,7 +353,7 @@ module Przn
279
353
 
280
354
  def render_blockquote(block, width, row)
281
355
  left = content_left(width)
282
- prefix = "| "
356
+ prefix = '| '
283
357
  prefix_w = display_width(prefix)
284
358
  max_w = max_text_width(width, left + 1, DEFAULT_SCALE) - prefix_w
285
359
 
@@ -290,7 +364,7 @@ module Przn
290
364
 
291
365
  wrapped.each_with_index do |line_segs, li|
292
366
  @terminal.move_to(row, left + 1)
293
- p = li == 0 ? prefix : " " * prefix_w
367
+ p = li == 0 ? prefix : ' ' * prefix_w
294
368
  @terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
295
369
  row += DEFAULT_SCALE
296
370
  end
@@ -314,7 +388,7 @@ module Przn
314
388
  @terminal.move_to(row, left)
315
389
  line = cells.each_with_index.map { |cell, ci|
316
390
  pad_to_width(cell, col_widths[ci] || 0)
317
- }.join(" | ")
391
+ }.join(' | ')
318
392
  if ri == 0
319
393
  @terminal.write "#{ANSI[:bold]}#{KittyText.sized(line, s: DEFAULT_SCALE)}#{ANSI[:reset]}"
320
394
  else
@@ -324,7 +398,7 @@ module Przn
324
398
 
325
399
  if ri == 0
326
400
  @terminal.move_to(row, left)
327
- @terminal.write KittyText.sized(col_widths.map { |w| "-" * w }.join("--+--"), s: DEFAULT_SCALE)
401
+ @terminal.write KittyText.sized(col_widths.map { |w| '-' * w }.join('--+--'), s: DEFAULT_SCALE)
328
402
  row += DEFAULT_SCALE
329
403
  end
330
404
  end
@@ -345,10 +419,16 @@ module Przn
345
419
  left = content_left(width)
346
420
  available_cols = width - left * 2
347
421
 
348
- if (rh = block[:attrs]['relative_height'])
349
- target_rows = (@terminal.height * rh.to_i / 100.0).to_i
350
- available_rows = [target_rows, available_rows].min
351
- end
422
+ # Cap the default vertical area to 70 % of the screen, matching what
423
+ # `{:relative_height="70"}` would do explicitly. Large images that
424
+ # extend to within a couple of rows of the screen edge render
425
+ # unreliably in some terminals — they're known-good at 70 %, and
426
+ # smaller images sit well within this cap so they're unaffected.
427
+ # An explicit `relative_height` still overrides.
428
+ default_rh = DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT
429
+ rh = block[:attrs]['relative_height'] || default_rh
430
+ target_rows = (@terminal.height * rh.to_i / 100.0).to_i
431
+ available_rows = [target_rows, available_rows].min
352
432
 
353
433
  # Calculate target cell size maintaining aspect ratio
354
434
  img_cell_w = img_w.to_f / cell_w
@@ -465,7 +545,7 @@ module Przn
465
545
  # centered. Plain `s=N` for a smaller bullet would top-align it inside
466
546
  # the row, which looks wrong against the larger body text.
467
547
  def render_bullet(prefix)
468
- size = @theme.bullet_size
548
+ size = @theme.bullet[:size]
469
549
  if size && size < DEFAULT_SCALE
470
550
  KittyText.sized(prefix, s: DEFAULT_SCALE, n: size, d: DEFAULT_SCALE, v: 2)
471
551
  else
@@ -485,22 +565,25 @@ module Przn
485
565
  "#{color_code(color)}#{base}#{ANSI[:reset]}"
486
566
  end
487
567
 
488
- # `default_face:` lets a caller (currently h1 rendering) override every
489
- # OSC 66 emit's `f=` attribute. When unset, body text falls back to
490
- # `theme.font.family`. To opt out of that body fallback (so a heading can
491
- # render in the terminal's default font when its own `heading_face` is
492
- # unset), pass `default_face:` explicitly even `nil` is honored.
493
- # Inline `<font face="...">` runs still win for their own segments.
568
+ # `default_face:` / `default_color:` let a caller (currently h1 rendering)
569
+ # override the OSC 66 `f=` and the ANSI fg for every emit on the line.
570
+ # When unset, body text falls back to `theme.font.family` / `theme.font.color`.
571
+ # To opt out of that body fallback (so a heading can render in the
572
+ # terminal's defaults even when body text is themed), pass the keyword
573
+ # explicitly even `nil` is honored. Inline `<font face/color>` and
574
+ # `<color=...>` runs still win for their own segments.
494
575
  #
495
576
  # `default_h:` threads an OSC 66 `h=` (horizontal alignment) into every
496
- # emit on the line. h1 uses h=2 so a proportional `heading_face` is
577
+ # emit on the line. h1 uses h=2 so a proportional `title.family` is
497
578
  # centered within the reserved cell block — without it the glyphs left-
498
579
  # align inside the block and the visible text drifts left of the center
499
580
  # column we computed.
500
- def render_segments_scaled(segments, para_scale, default_face: :body, default_h: nil)
581
+ def render_segments_scaled(segments, para_scale, default_face: :body, default_h: nil, default_color: :body)
501
582
  f = default_face == :body ? @theme.font[:family] : default_face
502
583
  h = default_h
503
- segments.map { |segment|
584
+ c = default_color == :body ? @theme.font[:color] : default_color
585
+ body_open = c ? color_code(c) : ''
586
+ inner = segments.map { |segment|
504
587
  type = segment[0]
505
588
  content = segment[1]
506
589
  case type
@@ -509,19 +592,20 @@ module Przn
509
592
  if (scale = Parser::SIZE_SCALES[tag_name])
510
593
  KittyText.sized(content, s: scale, f: f, h: h)
511
594
  elsif Parser::NAMED_COLORS.key?(tag_name)
512
- "#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
595
+ "#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
513
596
  else
514
597
  KittyText.sized(content, s: para_scale, f: f, h: h)
515
598
  end
516
- when :font then render_font_segment(content, segment[2] || {}, para_scale, default_face: f, default_h: h)
517
- when :note then "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
518
- when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
519
- when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
520
- when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
521
- when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
599
+ when :font then "#{render_font_segment(content, segment[2] || {}, para_scale, default_face: f, default_h: h)}#{(segment[2] || {})[:color] ? body_open : ''}"
600
+ when :note then @mode == :audience ? "" : "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
601
+ when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
602
+ when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
603
+ when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
604
+ when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
522
605
  when :text then KittyText.sized(content, s: para_scale, f: f, h: h)
523
606
  end
524
607
  }.join
608
+ body_open.empty? ? inner : "#{body_open}#{inner}#{ANSI[:reset]}"
525
609
  end
526
610
 
527
611
  def render_inline_scaled(text, para_scale)
@@ -539,7 +623,7 @@ module Przn
539
623
  used = 0
540
624
 
541
625
  segments.each do |seg|
542
- content = seg[1] || ""
626
+ content = seg[1] || ''
543
627
  next if content.empty?
544
628
 
545
629
  seg_scale = effective_seg_scale(seg, para_scale)
@@ -588,7 +672,7 @@ module Przn
588
672
 
589
673
  def segments_visible_cells(segments, para_scale)
590
674
  segments.sum { |seg|
591
- content = seg[1] || ""
675
+ content = seg[1] || ''
592
676
  display_width(content) * effective_seg_scale(seg, para_scale)
593
677
  }
594
678
  end
@@ -630,7 +714,7 @@ module Przn
630
714
 
631
715
  def pad_to_width(text, target_width)
632
716
  current = display_width(text)
633
- text + " " * [target_width - current, 0].max
717
+ text + ' ' * [target_width - current, 0].max
634
718
  end
635
719
 
636
720
  def max_inline_scale(text)
@@ -663,7 +747,7 @@ module Przn
663
747
  r, g, b = color.scan(/../).map { |h| h.to_i(16) }
664
748
  "\e[38;2;#{r};#{g};#{b}m"
665
749
  else
666
- ""
750
+ ''
667
751
  end
668
752
  end
669
753
 
@@ -700,12 +784,12 @@ module Przn
700
784
  text
701
785
  .gsub(/\{::tag\s+name="[^"]+"\}(.*?)\{:\/tag\}/, '\1')
702
786
  .gsub(/\{::note\}(.*?)\{:\/note\}/, '\1')
703
- .gsub(/\{::wait\/\}/, '')
787
+ .gsub('{::wait/}', '')
704
788
  .gsub(/\*\*(.+?)\*\*/, '\1')
705
789
  .gsub(/\*(.+?)\*/, '\1')
706
790
  .gsub(/~~(.+?)~~/, '\1')
707
791
  .gsub(/`([^`]+)`/, '\1')
708
- .gsub(/&(lt|gt|amp);/) { |_| {"lt" => "<", "gt" => ">", "amp" => "&"}[$1] }
792
+ .gsub(/&(lt|gt|amp);/) { |_| {'lt' => '<', 'gt' => '>', 'amp' => '&'}[$1] }
709
793
  end
710
794
 
711
795
  def calculate_height(blocks, width)