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.
- checksums.yaml +4 -4
- data/README.md +55 -18
- data/Rakefile +5 -5
- data/default_theme.yml +26 -14
- data/exe/przn +32 -7
- data/lib/przn/audience_link.rb +51 -0
- data/lib/przn/controller.rb +15 -2
- data/lib/przn/echoes_client.rb +83 -0
- data/lib/przn/image_util.rb +5 -3
- data/lib/przn/kitty_text.rb +23 -4
- data/lib/przn/parser.rb +12 -12
- data/lib/przn/{pdf_exporter.rb → prawn_pdf_exporter.rb} +30 -17
- data/lib/przn/presenter_renderer.rb +71 -0
- data/lib/przn/renderer.rb +132 -48
- data/lib/przn/screenshot_pdf_exporter.rb +115 -0
- data/lib/przn/slide.rb +25 -0
- data/lib/przn/theme.rb +49 -12
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +81 -14
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
- data/sample/sample.md +8 -0
- metadata +24 -2
|
@@ -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
|
-
|
|
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.
|
|
59
|
-
@fg_color = @theme.
|
|
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: @
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
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
|
-
|
|
127
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 :
|
|
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|
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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.
|
|
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:`
|
|
489
|
-
# OSC 66
|
|
490
|
-
#
|
|
491
|
-
#
|
|
492
|
-
#
|
|
493
|
-
#
|
|
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 `
|
|
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
|
-
|
|
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 +
|
|
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(
|
|
787
|
+
.gsub('{::wait/}', '')
|
|
704
788
|
.gsub(/\*\*(.+?)\*\*/, '\1')
|
|
705
789
|
.gsub(/\*(.+?)\*/, '\1')
|
|
706
790
|
.gsub(/~~(.+?)~~/, '\1')
|
|
707
791
|
.gsub(/`([^`]+)`/, '\1')
|
|
708
|
-
.gsub(/&(lt|gt|amp);/) { |_| {
|
|
792
|
+
.gsub(/&(lt|gt|amp);/) { |_| {'lt' => '<', 'gt' => '>', 'amp' => '&'}[$1] }
|
|
709
793
|
end
|
|
710
794
|
|
|
711
795
|
def calculate_height(blocks, width)
|