ruby_rich 0.4.8 → 0.5.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/lib/ruby_rich/markdown.rb +721 -37
- data/lib/ruby_rich/table.rb +9 -9
- data/lib/ruby_rich/transcript.rb +2 -0
- data/lib/ruby_rich/version.rb +1 -1
- data/lib/ruby_rich/viewport.rb +75 -15
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6db3483399e193c2a8feb97472df06341391d165f07aafde1af299a0892bb83c
|
|
4
|
+
data.tar.gz: 71ef2db35e2b369953c7201e13bc751001f02815dd2717ea3358964ff12b30b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4d3b29b2e73d8ebe31e883ba0001cb207f65b2683897d861601918e097823bce393302dd51b276fe15c71608959d28d61c6413e82c26fc0226245cf9b2c358b
|
|
7
|
+
data.tar.gz: 41274198774c77707e3d052a9b81b1d1e18d2ae293a63b0dadcdbf508b1d74c3892203433d2a4cd92759275ce44b31906f83bd4ee3e10a128fefe3e8b890bcd5
|
data/lib/ruby_rich/markdown.rb
CHANGED
|
@@ -13,6 +13,46 @@ module RubyRich
|
|
|
13
13
|
# - 数学公式 (需 math engine)
|
|
14
14
|
# - 缩写 (abbreviations)
|
|
15
15
|
# - 活跃维护
|
|
16
|
+
|
|
17
|
+
# === Markdown rendering colour theme. ===
|
|
18
|
+
# Each key maps to `[color, bright]` accepted by AnsiCode.
|
|
19
|
+
MarkdownTheme = {
|
|
20
|
+
heading_1: [:cyan, true],
|
|
21
|
+
heading_2: [:blue, true],
|
|
22
|
+
heading_3: [:yellow, true],
|
|
23
|
+
heading_4_6: [:black, true],
|
|
24
|
+
heading_underline: [:cyan, true], # H1
|
|
25
|
+
heading_underline2:[:blue, true], # H2
|
|
26
|
+
text: [:white, true],
|
|
27
|
+
strong_text: [:white, true],
|
|
28
|
+
code_border: [:black, true],
|
|
29
|
+
code_bg: [:black, true],
|
|
30
|
+
code_fg: [:white, true],
|
|
31
|
+
inline_code_fg: [:black, false],
|
|
32
|
+
inline_code_bg: [:white, false],
|
|
33
|
+
blockquote_marker: [:black, true],
|
|
34
|
+
blockquote_text: [:white, true],
|
|
35
|
+
blockquote_italic: true,
|
|
36
|
+
list_level_1: [:cyan, true],
|
|
37
|
+
list_level_2: [:magenta, true],
|
|
38
|
+
list_level_3: [:yellow, true],
|
|
39
|
+
ordered_list: [:cyan, true],
|
|
40
|
+
task_checked: [:green, true],
|
|
41
|
+
task_unchecked: [:black, true],
|
|
42
|
+
link_text: [:blue, true],
|
|
43
|
+
link_url: [:black, true],
|
|
44
|
+
image_label: [:magenta, true],
|
|
45
|
+
rule: [:black, true],
|
|
46
|
+
footnote: [:magenta, true],
|
|
47
|
+
abbreviation: [:black, true],
|
|
48
|
+
math: [:magenta, true],
|
|
49
|
+
mark_fg: [:black, false],
|
|
50
|
+
mark_bg: [:yellow, false],
|
|
51
|
+
kbd_fg: [:black, false],
|
|
52
|
+
kbd_bg: [:white, false],
|
|
53
|
+
table_border: :simple
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
16
56
|
class TerminalConverter < Kramdown::Converter::Base
|
|
17
57
|
|
|
18
58
|
def initialize(root, options)
|
|
@@ -20,8 +60,26 @@ module RubyRich
|
|
|
20
60
|
@width = options[:width] || 80
|
|
21
61
|
@indent_str = options[:indent] || ' '
|
|
22
62
|
@table_border_style = options[:table_border_style] || :simple
|
|
63
|
+
@theme = (options[:theme] || MarkdownTheme).freeze
|
|
23
64
|
# 用于有序列表编号
|
|
24
65
|
@list_counters = []
|
|
66
|
+
@list_types = []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Shortcut: AnsiCode.color with theme lookup.
|
|
70
|
+
def tc(key)
|
|
71
|
+
color, bright = @theme[key]
|
|
72
|
+
AnsiCode.color(color, bright)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def tbg(key)
|
|
76
|
+
color, bright = @theme[key]
|
|
77
|
+
AnsiCode.background(color, bright)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tfont(key, **overrides)
|
|
81
|
+
color, bright = @theme[key]
|
|
82
|
+
AnsiCode.font(color, font_bright: bright, **overrides)
|
|
25
83
|
end
|
|
26
84
|
|
|
27
85
|
# 主分发方法 — 根据 AST 元素类型路由到对应处理方法
|
|
@@ -88,7 +146,7 @@ module RubyRich
|
|
|
88
146
|
end
|
|
89
147
|
|
|
90
148
|
def visible_width(text)
|
|
91
|
-
text.to_s.gsub(/\e\[[0-9;]*m/, '').
|
|
149
|
+
text.to_s.gsub(/\e\[[0-9;]*m/, '').display_width
|
|
92
150
|
end
|
|
93
151
|
|
|
94
152
|
# ---- 块级元素 ----
|
|
@@ -103,39 +161,112 @@ module RubyRich
|
|
|
103
161
|
vw = visible_width(text)
|
|
104
162
|
case level
|
|
105
163
|
when 1
|
|
106
|
-
"#{
|
|
107
|
-
"#{
|
|
164
|
+
"#{tfont(:heading_1, bold: true)}#{text}#{AnsiCode.reset}\n" \
|
|
165
|
+
"#{tc(:heading_underline)}#{'=' * [vw, 1].max}#{AnsiCode.reset}\n\n"
|
|
108
166
|
when 2
|
|
109
|
-
"#{
|
|
110
|
-
"#{
|
|
167
|
+
"#{tfont(:heading_2, bold: true)}#{text}#{AnsiCode.reset}\n" \
|
|
168
|
+
"#{tc(:heading_underline2)}#{'-' * [vw, 1].max}#{AnsiCode.reset}\n\n"
|
|
111
169
|
when 3
|
|
112
|
-
"#{
|
|
170
|
+
"#{tfont(:heading_3, bold: true)}### #{text}#{AnsiCode.reset}\n\n"
|
|
113
171
|
else
|
|
114
172
|
prefix = '#' * level
|
|
115
|
-
"#{
|
|
173
|
+
"#{tfont(:heading_4_6, bold: true)}#{prefix} #{text}#{AnsiCode.reset}\n\n"
|
|
116
174
|
end
|
|
117
175
|
end
|
|
118
176
|
|
|
119
177
|
def convert_codeblock(el)
|
|
120
178
|
lang = el.options[:lang]&.strip
|
|
121
179
|
lang = nil if lang && lang.empty?
|
|
122
|
-
code = el.value
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
180
|
+
code = el.value
|
|
181
|
+
# Preserve trailing newlines for correct line count; strip leading/trailing
|
|
182
|
+
# blank lines that would produce empty first/last code rows.
|
|
183
|
+
code_lines = code.split("\n", -1)
|
|
184
|
+
code_lines.shift while code_lines.first&.strip&.empty?
|
|
185
|
+
code_lines.pop while code_lines.last&.strip&.empty?
|
|
186
|
+
return "#{tc(:code_border)}┌─ #{lang || "text"} ─┐#{AnsiCode.reset}\n#{tc(:code_border)}└──┘#{AnsiCode.reset}\n\n" if code_lines.empty?
|
|
187
|
+
|
|
188
|
+
# Mermaid diagram support
|
|
189
|
+
if lang == "mermaid" || lang == "mmd"
|
|
190
|
+
rendered = MermaidRenderer.render(code, @width.to_i)
|
|
191
|
+
return "#{rendered}\n\n"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
total_lines = code_lines.length
|
|
195
|
+
digit_width = [total_lines.to_s.length, 1].max
|
|
196
|
+
label = lang && !lang.empty? ? lang : "text"
|
|
197
|
+
# Left gutter: "│ " (2) + digits (digit_width) + " │ " (3) = 5 + digit_width
|
|
198
|
+
# Right: " │" (2). Total overhead = 7 + digit_width.
|
|
199
|
+
gutter_width = 5 + digit_width
|
|
200
|
+
code_width = [@width.to_i - gutter_width - 1, 20].max
|
|
201
|
+
|
|
202
|
+
border = tc(:code_border)
|
|
203
|
+
rows = []
|
|
204
|
+
code_lines.each_with_index do |raw_line, idx|
|
|
205
|
+
num = format("%#{digit_width}d", idx + 1)
|
|
206
|
+
highlighted = Syntax.highlight(raw_line, lang)
|
|
207
|
+
fit = fit_code_line(highlighted, code_width)
|
|
208
|
+
rows << "#{border}│#{AnsiCode.reset} #{num} #{border}│#{AnsiCode.reset} #{fit}#{border}│#{AnsiCode.reset}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
top_border = "#{border}┌─ #{label} #{'─' * [@width.to_i - label.display_width - 5, 2].max}┐#{AnsiCode.reset}"
|
|
212
|
+
bottom_border = "#{border}└#{'─' * (@width.to_i - 2)}┘#{AnsiCode.reset}"
|
|
213
|
+
|
|
214
|
+
[top_border, rows.join("\n"), bottom_border, "", ""].join("\n")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Fit a syntax-highlighted line to a given display width, preserving
|
|
218
|
+
# ANSI escape sequences and truncating with "…" when necessary.
|
|
219
|
+
def fit_code_line(line, max_width)
|
|
220
|
+
return "" if max_width <= 0
|
|
221
|
+
|
|
222
|
+
result = +""
|
|
223
|
+
width = 0
|
|
224
|
+
in_escape = false
|
|
225
|
+
escape = +""
|
|
226
|
+
truncated = false
|
|
227
|
+
|
|
228
|
+
line.each_char do |char|
|
|
229
|
+
if in_escape
|
|
230
|
+
escape << char
|
|
231
|
+
if char == "m"
|
|
232
|
+
result << escape
|
|
233
|
+
escape = +""
|
|
234
|
+
in_escape = false
|
|
235
|
+
end
|
|
236
|
+
next
|
|
237
|
+
elsif char.ord == 27
|
|
238
|
+
escape << char
|
|
239
|
+
in_escape = true
|
|
240
|
+
next
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
244
|
+
if width + char_width + 1 > max_width
|
|
245
|
+
truncated = true
|
|
246
|
+
break
|
|
247
|
+
end
|
|
248
|
+
result << char
|
|
249
|
+
width += char_width
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
result << "…" if truncated
|
|
253
|
+
result << " " * (max_width - width) unless truncated
|
|
254
|
+
result << AnsiCode.reset
|
|
255
|
+
result
|
|
127
256
|
end
|
|
128
257
|
|
|
129
258
|
def convert_codespan(el)
|
|
130
259
|
code = el.value
|
|
131
|
-
"#{
|
|
260
|
+
"#{tbg(:inline_code_bg)}#{tc(:inline_code_fg)} #{code} #{AnsiCode.reset}"
|
|
132
261
|
end
|
|
133
262
|
|
|
134
263
|
def convert_blockquote(el)
|
|
135
264
|
content = inline_content(el)
|
|
136
265
|
lines = content.strip.split("\n")
|
|
266
|
+
marker = tc(:blockquote_marker)
|
|
267
|
+
text_c = tc(:blockquote_text)
|
|
137
268
|
quoted = lines.map do |line|
|
|
138
|
-
"#{
|
|
269
|
+
"#{marker}│ #{text_c}#{strip_ansi_reset(line)}"
|
|
139
270
|
end
|
|
140
271
|
"#{quoted.join("\n")}#{AnsiCode.reset}\n\n"
|
|
141
272
|
end
|
|
@@ -145,20 +276,54 @@ module RubyRich
|
|
|
145
276
|
def convert_list(el)
|
|
146
277
|
# 保存/恢复计数器,支持嵌套列表
|
|
147
278
|
@list_counters.push(0)
|
|
279
|
+
@list_types.push(el.type)
|
|
148
280
|
result = convert_children(el)
|
|
281
|
+
@list_types.pop
|
|
149
282
|
@list_counters.pop
|
|
150
283
|
"#{result}\n"
|
|
151
284
|
end
|
|
152
285
|
|
|
286
|
+
UNORDERED_MARKERS = { 1 => '•', 2 => '◦', 3 => '▸' }.freeze
|
|
287
|
+
|
|
153
288
|
def convert_li(el)
|
|
154
|
-
|
|
289
|
+
depth = [@list_counters.length, 1].max
|
|
290
|
+
list_type = @list_types.last
|
|
291
|
+
marker = if list_type == :ol
|
|
155
292
|
@list_counters[-1] += 1
|
|
156
293
|
"#{@list_counters[-1]}."
|
|
157
294
|
else
|
|
158
|
-
'
|
|
295
|
+
UNORDERED_MARKERS[depth.clamp(1, 3)] || '▸'
|
|
159
296
|
end
|
|
297
|
+
task = detect_task_marker(el)
|
|
160
298
|
text = inline_content(el)
|
|
161
|
-
|
|
299
|
+
if task
|
|
300
|
+
"#{tc(task ? :task_checked : :task_unchecked)}#{task}#{AnsiCode.reset} #{text.strip}\n"
|
|
301
|
+
else
|
|
302
|
+
"#{tc(list_type == :ol ? :ordered_list : :"list_level_#{depth.clamp(1, 3)}")}#{marker}#{AnsiCode.reset} #{text.strip}\n"
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Detect GitHub Flavored Markdown task list markers.
|
|
307
|
+
# kramdown-parser-gfm represents them as a child HTML input element
|
|
308
|
+
# inside the li's paragraph (p); we extract the checked state for
|
|
309
|
+
# terminal rendering.
|
|
310
|
+
TASK_CHECKED = '☑'.freeze
|
|
311
|
+
TASK_UNCHECKED = '☐'.freeze
|
|
312
|
+
|
|
313
|
+
def detect_task_marker(el)
|
|
314
|
+
return nil unless el.children
|
|
315
|
+
|
|
316
|
+
para = el.children.find { |c| c.type == :p }
|
|
317
|
+
return nil unless para&.children
|
|
318
|
+
|
|
319
|
+
input_idx = para.children.index { |c| c.type == :html_element && c.value.to_s.downcase == 'input' }
|
|
320
|
+
return nil unless input_idx
|
|
321
|
+
|
|
322
|
+
raw = para.children[input_idx].attr['checked']
|
|
323
|
+
checked = raw&.to_s&.downcase == 'checked'
|
|
324
|
+
# Remove the hidden input element so text is clean.
|
|
325
|
+
para.children.delete_at(input_idx)
|
|
326
|
+
checked ? TASK_CHECKED : TASK_UNCHECKED
|
|
162
327
|
end
|
|
163
328
|
|
|
164
329
|
# ---- 内联样式 ----
|
|
@@ -182,8 +347,8 @@ module RubyRich
|
|
|
182
347
|
title = el.attr['title']
|
|
183
348
|
text = inline_content(el)
|
|
184
349
|
title_part = title && !title.empty? ? " - #{title}" : ""
|
|
185
|
-
"#{
|
|
186
|
-
"#{
|
|
350
|
+
"#{tc(:link_text)}#{AnsiCode.underline}#{text}#{AnsiCode.reset} " \
|
|
351
|
+
"#{tc(:link_url)}(#{url}#{title_part})#{AnsiCode.reset}"
|
|
187
352
|
end
|
|
188
353
|
|
|
189
354
|
def convert_image(el)
|
|
@@ -191,15 +356,15 @@ module RubyRich
|
|
|
191
356
|
title = el.attr['title']
|
|
192
357
|
alt = el.attr['alt'] || ''
|
|
193
358
|
title_part = title && !title.empty? ? " - #{title}" : ""
|
|
194
|
-
"#{
|
|
195
|
-
"#{
|
|
359
|
+
"#{tc(:image_label)}[Image: #{alt}]#{AnsiCode.reset} " \
|
|
360
|
+
"#{tc(:link_url)}(#{url}#{title_part})#{AnsiCode.reset}"
|
|
196
361
|
end
|
|
197
362
|
|
|
198
363
|
# ---- 水平线 ----
|
|
199
364
|
|
|
200
365
|
def convert_hr(_el)
|
|
201
366
|
line_char = '─'
|
|
202
|
-
"#{
|
|
367
|
+
"#{tc(:rule)}#{line_char * @width}#{AnsiCode.reset}\n\n"
|
|
203
368
|
end
|
|
204
369
|
|
|
205
370
|
# ---- 表格(kramdown 原生 AST) ----
|
|
@@ -221,27 +386,28 @@ module RubyRich
|
|
|
221
386
|
|
|
222
387
|
return "" if header_rows.empty? || body_rows.empty?
|
|
223
388
|
|
|
224
|
-
headers = header_rows.last
|
|
389
|
+
headers, fitted_body_rows = fit_table_rows(header_rows.last, body_rows)
|
|
225
390
|
begin
|
|
226
391
|
tbl = RubyRich::Table.new(
|
|
227
392
|
headers: headers,
|
|
228
|
-
border_style: @table_border_style
|
|
393
|
+
border_style: @table_border_style || :simple
|
|
229
394
|
)
|
|
230
|
-
|
|
395
|
+
fitted_body_rows.each do |row|
|
|
231
396
|
padded = row + Array.new([0, headers.length - row.length].max, "")
|
|
232
397
|
tbl.add_row(padded[0...headers.length])
|
|
233
398
|
end
|
|
234
399
|
"#{tbl.render}\n\n"
|
|
235
400
|
rescue
|
|
236
|
-
# fallback:
|
|
401
|
+
# fallback: plain text table
|
|
237
402
|
result = "\n"
|
|
238
403
|
result += header_rows.last.join(" | ")
|
|
239
404
|
result += "\n#{"-" * [result.strip.length, 20].min}\n"
|
|
240
405
|
body_rows.each { |row| result += row.join(" | ") + "\n" }
|
|
241
|
-
"#{result}\n"
|
|
406
|
+
return "#{result}\n"
|
|
242
407
|
end
|
|
243
408
|
end
|
|
244
409
|
|
|
410
|
+
# Extract cell text from a table row element.
|
|
245
411
|
def collect_row_cells(tr)
|
|
246
412
|
tr.children.select { |c| [:th, :td].include?(c.type) }
|
|
247
413
|
.map { |c| inline_content(c) }
|
|
@@ -255,6 +421,116 @@ module RubyRich
|
|
|
255
421
|
inline_content(el)
|
|
256
422
|
end
|
|
257
423
|
|
|
424
|
+
# ---- 表格宽度自适应 ----
|
|
425
|
+
|
|
426
|
+
# Fit table cell content to terminal width by normalising column counts,
|
|
427
|
+
# calculating natural widths, constraining to available space, and wrapping
|
|
428
|
+
# cell text.
|
|
429
|
+
def fit_table_rows(header_row, body_rows)
|
|
430
|
+
column_count = [header_row.length, *body_rows.map(&:length)].max.to_i
|
|
431
|
+
normalized_header = header_row + Array.new([0, column_count - header_row.length].max, "")
|
|
432
|
+
normalized_body = body_rows.map { |row| row + Array.new([0, column_count - row.length].max, "") }
|
|
433
|
+
natural_widths = table_natural_widths(normalized_header, normalized_body)
|
|
434
|
+
column_widths = constrain_table_widths(natural_widths)
|
|
435
|
+
|
|
436
|
+
headers = normalized_header.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
|
|
437
|
+
rows = normalized_body.map do |row|
|
|
438
|
+
row.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
[headers, rows]
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Maximum display width per column.
|
|
445
|
+
def table_natural_widths(header_row, body_rows)
|
|
446
|
+
rows = [header_row] + body_rows
|
|
447
|
+
return [] if rows.empty?
|
|
448
|
+
|
|
449
|
+
rows.transpose.map do |cells|
|
|
450
|
+
cells.map { |cell| cell_display_width(table_cell_text(cell)) }.max.to_i
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Strip ANSI escape sequences from a cell value.
|
|
455
|
+
def table_cell_text(cell)
|
|
456
|
+
cell.to_s.gsub(/\e\[[0-9;:]*m/, "")
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Shrink column widths proportionally to fit the terminal width.
|
|
460
|
+
def constrain_table_widths(natural_widths)
|
|
461
|
+
return natural_widths if natural_widths.empty?
|
|
462
|
+
|
|
463
|
+
border_overhead = (natural_widths.length * 3) + 1
|
|
464
|
+
max_table_width = [[(@width || 80).to_i - 1, 20].max, border_overhead + natural_widths.length].max
|
|
465
|
+
available_content_width = [max_table_width - border_overhead, natural_widths.length].max
|
|
466
|
+
widths = natural_widths.map { |width| [width, 1].max }
|
|
467
|
+
return widths if widths.sum <= available_content_width
|
|
468
|
+
|
|
469
|
+
min_width = available_content_width < natural_widths.length * 3 ? 1 : 3
|
|
470
|
+
while widths.sum > available_content_width
|
|
471
|
+
index = widths.each_with_index.select { |width, _| width > min_width }.max_by(&:first)&.last
|
|
472
|
+
break unless index
|
|
473
|
+
|
|
474
|
+
widths[index] -= 1
|
|
475
|
+
end
|
|
476
|
+
widths
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Wrap cell text to fit a given display width, splitting across newlines
|
|
480
|
+
# and wrapping long lines.
|
|
481
|
+
def wrap_table_cell(text, width)
|
|
482
|
+
width = [width.to_i, 1].max
|
|
483
|
+
text.to_s.split("\n", -1).flat_map do |line|
|
|
484
|
+
wrap_table_line(line, width)
|
|
485
|
+
end.join("\n")
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Wrap a single line of text to the given display width, preserving any
|
|
489
|
+
# ANSI escape sequences (re-emitted on each wrapped segment).
|
|
490
|
+
def wrap_table_line(line, width)
|
|
491
|
+
return [""] if line.empty?
|
|
492
|
+
|
|
493
|
+
lines = []
|
|
494
|
+
current = +""
|
|
495
|
+
current_width = 0
|
|
496
|
+
in_escape = false
|
|
497
|
+
escape = +""
|
|
498
|
+
|
|
499
|
+
line.each_char do |char|
|
|
500
|
+
if in_escape
|
|
501
|
+
escape << char
|
|
502
|
+
if char == "m"
|
|
503
|
+
current << escape
|
|
504
|
+
escape = +""
|
|
505
|
+
in_escape = false
|
|
506
|
+
end
|
|
507
|
+
next
|
|
508
|
+
elsif char.ord == 27
|
|
509
|
+
escape << char
|
|
510
|
+
in_escape = true
|
|
511
|
+
next
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
515
|
+
if current_width.positive? && current_width + char_width > width
|
|
516
|
+
lines << current
|
|
517
|
+
current = +""
|
|
518
|
+
current_width = 0
|
|
519
|
+
end
|
|
520
|
+
current << char
|
|
521
|
+
current_width += char_width
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
lines << current unless current.empty?
|
|
525
|
+
lines.empty? ? [""] : lines
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Display width of text after stripping ANSI escape sequences, taking the
|
|
529
|
+
# maximum across lines (for multi-line cells).
|
|
530
|
+
def cell_display_width(text)
|
|
531
|
+
text.to_s.gsub(/\e\[[0-9;:]*m/, "").split("\n").map(&:display_width).max.to_i
|
|
532
|
+
end
|
|
533
|
+
|
|
258
534
|
# ---- HTML 元素处理 ----
|
|
259
535
|
|
|
260
536
|
def convert_html_element(el)
|
|
@@ -271,9 +547,9 @@ module RubyRich
|
|
|
271
547
|
when 'sup'
|
|
272
548
|
content # 终端无上标,保留文本
|
|
273
549
|
when 'kbd'
|
|
274
|
-
"#{
|
|
550
|
+
"#{tbg(:kbd_bg)}#{tc(:kbd_fg)} #{content} #{AnsiCode.reset}"
|
|
275
551
|
when 'mark'
|
|
276
|
-
"#{
|
|
552
|
+
"#{tbg(:mark_bg)}#{tc(:mark_fg)}#{content}#{AnsiCode.reset}"
|
|
277
553
|
when 'details', 'summary'
|
|
278
554
|
content
|
|
279
555
|
when 'br'
|
|
@@ -315,7 +591,7 @@ module RubyRich
|
|
|
315
591
|
# 脚注内容在文档末尾自动收集
|
|
316
592
|
content = inline_content(el)
|
|
317
593
|
name = el.options[:name]
|
|
318
|
-
"#{
|
|
594
|
+
"#{tc(:footnote)}[^#{name}]#{AnsiCode.reset}"
|
|
319
595
|
end
|
|
320
596
|
|
|
321
597
|
# ---- 定义列表(kramdown 独有) ----
|
|
@@ -338,7 +614,7 @@ module RubyRich
|
|
|
338
614
|
title = el.attr['title']
|
|
339
615
|
text = inline_content(el)
|
|
340
616
|
if title && !title.empty?
|
|
341
|
-
"#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{
|
|
617
|
+
"#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{tc(:abbreviation)}(#{title})#{AnsiCode.reset}"
|
|
342
618
|
else
|
|
343
619
|
text
|
|
344
620
|
end
|
|
@@ -347,13 +623,308 @@ module RubyRich
|
|
|
347
623
|
# ---- 数学公式(kramdown 独有,需 math engine) ----
|
|
348
624
|
|
|
349
625
|
def convert_math(el)
|
|
350
|
-
# 在终端中显示原始 LaTeX 公式
|
|
351
626
|
mode = el.options[:category] == :block ? 'block' : 'inline'
|
|
352
627
|
formula = el.value.to_s.strip
|
|
628
|
+
rendered = LatexConverter.convert(formula)
|
|
629
|
+
color = tc(:math)
|
|
353
630
|
if mode == 'block'
|
|
354
|
-
"#{
|
|
631
|
+
"#{color}#{rendered}#{AnsiCode.reset}\n\n"
|
|
355
632
|
else
|
|
356
|
-
"#{
|
|
633
|
+
"#{color}#{rendered}#{AnsiCode.reset}"
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# ---- LaTeX to Unicode converter ----
|
|
638
|
+
# Translates common LaTeX math commands to Unicode characters
|
|
639
|
+
# for terminal display. Handles Greek letters, big operators,
|
|
640
|
+
# frac, sqrt, super/subscript, cases, and ~150 common symbols.
|
|
641
|
+
module LatexConverter
|
|
642
|
+
# --- big lookup table ------------------------------------
|
|
643
|
+
# Format: "\\command" => "unicode_char"
|
|
644
|
+
SYMBOLS = {
|
|
645
|
+
# Greek lowercase
|
|
646
|
+
'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ',
|
|
647
|
+
'delta' => 'δ', 'epsilon' => 'ε', 'varepsilon' => 'ɛ',
|
|
648
|
+
'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
|
|
649
|
+
'vartheta' => 'ϑ', 'iota' => 'ι', 'kappa' => 'κ',
|
|
650
|
+
'lambda' => 'λ', 'mu' => 'μ', 'nu' => 'ν',
|
|
651
|
+
'xi' => 'ξ', 'pi' => 'π', 'varpi' => 'ϖ',
|
|
652
|
+
'rho' => 'ρ', 'varrho' => 'ϱ', 'sigma' => 'σ',
|
|
653
|
+
'varsigma' => 'ς', 'tau' => 'τ', 'upsilon' => 'υ',
|
|
654
|
+
'phi' => 'φ', 'varphi' => 'ϕ', 'chi' => 'χ',
|
|
655
|
+
'psi' => 'ψ', 'omega' => 'ω',
|
|
656
|
+
# Greek uppercase
|
|
657
|
+
'Gamma' => 'Γ', 'Delta' => 'Δ', 'Theta' => 'Θ',
|
|
658
|
+
'Lambda' => 'Λ', 'Xi' => 'Ξ', 'Pi' => 'Π',
|
|
659
|
+
'Sigma' => 'Σ', 'Upsilon' => 'Υ', 'Phi' => 'Φ',
|
|
660
|
+
'Psi' => 'Ψ', 'Omega' => 'Ω',
|
|
661
|
+
# Relations
|
|
662
|
+
'leq' => '≤', 'geq' => '≥', 'neq' => '≠',
|
|
663
|
+
'equiv' => '≡', 'approx' => '≈', 'sim' => '∼',
|
|
664
|
+
'simeq' => '≃', 'propto' => '∝', 'll' => '≪',
|
|
665
|
+
'gg' => '≫', 'doteq' => '≐', 'prec' => '≺',
|
|
666
|
+
'succ' => '≻', 'preceq' => '≼', 'succeq' => '≽',
|
|
667
|
+
'subset' => '⊂', 'supset' => '⊃', 'subseteq' => '⊆',
|
|
668
|
+
'supseteq' => '⊇', 'in' => '∈', 'ni' => '∋',
|
|
669
|
+
'notin' => '∉', 'perp' => '⊥', 'parallel' => '∥',
|
|
670
|
+
# Binary operators
|
|
671
|
+
'times' => '×', 'div' => '÷', 'cdot' => '·',
|
|
672
|
+
'pm' => '±', 'mp' => '∓', 'oplus' => '⊕',
|
|
673
|
+
'ominus' => '⊖', 'otimes' => '⊗', 'oslash' => '⊘',
|
|
674
|
+
'odot' => '⊙', 'circ' => '∘', 'bullet' => '∙',
|
|
675
|
+
'cap' => '∩', 'cup' => '∪', 'setminus' => '∖',
|
|
676
|
+
'land' => '∧', 'lor' => '∨', 'wedge' => '∧',
|
|
677
|
+
'vee' => '∨', 'star' => '⋆',
|
|
678
|
+
# Arrows
|
|
679
|
+
'to' => '→', 'rightarrow' => '→', 'Rightarrow' => '⇒',
|
|
680
|
+
'leftarrow' => '←', 'Leftarrow' => '⇐',
|
|
681
|
+
'leftrightarrow' => '↔', 'Leftrightarrow' => '⇔',
|
|
682
|
+
'mapsto' => '↦', 'longmapsto' => '⟼',
|
|
683
|
+
'uparrow' => '↑', 'downarrow' => '↓',
|
|
684
|
+
'longrightarrow' => '⟶', 'Longrightarrow' => '⟹',
|
|
685
|
+
# Big operators
|
|
686
|
+
'sum' => '∑', 'prod' => '∏', 'coprod' => '∐',
|
|
687
|
+
'int' => '∫', 'iint' => '∬', 'iiint' => '∭',
|
|
688
|
+
'oint' => '∮', 'bigcup' => '⋃', 'bigcap' => '⋂',
|
|
689
|
+
'bigvee' => '⋁', 'bigwedge' => '⋀',
|
|
690
|
+
# Misc symbols
|
|
691
|
+
'infty' => '∞', 'partial' => '∂', 'nabla' => '∇',
|
|
692
|
+
'forall' => '∀', 'exists' => '∃', 'nexists' => '∄',
|
|
693
|
+
'emptyset' => '∅', 'varnothing' => '∅',
|
|
694
|
+
'Re' => 'ℜ', 'Im' => 'ℑ', 'aleph' => 'ℵ',
|
|
695
|
+
'ell' => 'ℓ', 'hbar' => 'ℏ', 'wp' => '℘',
|
|
696
|
+
'angle' => '∠', 'triangle' => '△', 'triangledown' => '▽',
|
|
697
|
+
'square' => '□', 'Box' => '□', 'diamond' => '◇',
|
|
698
|
+
'clubsuit' => '♣', 'diamondsuit' => '♢',
|
|
699
|
+
'heartsuit' => '♡', 'spadesuit' => '♠',
|
|
700
|
+
'ldots' => '…', 'cdots' => '⋯', 'vdots' => '⋮',
|
|
701
|
+
'ddots' => '⋱', 'dots' => '…',
|
|
702
|
+
'cong' => '≅', 'models' => '⊨', 'mid' => '∣',
|
|
703
|
+
'nmid' => '∤', 'therefore' => '∴', 'because' => '∵',
|
|
704
|
+
'neg' => '¬', 'lnot' => '¬', 'top' => '⊤', 'bot' => '⊥',
|
|
705
|
+
'degree' => '°', 'prime' => '′', 'dag' => '†',
|
|
706
|
+
'ddag' => '‡', 'S' => '§', 'P' => '¶',
|
|
707
|
+
'pound' => '£', 'euro' => '€', 'yen' => '¥',
|
|
708
|
+
'copyright' => '©', 'circledR' => '®',
|
|
709
|
+
# Delimiters – strip LaTeX wrapper
|
|
710
|
+
'left' => '', 'right' => '', 'bigl' => '', 'bigr' => '',
|
|
711
|
+
'Bigl' => '', 'Bigr' => '', 'biggl' => '', 'biggr' => '',
|
|
712
|
+
# Arrows special
|
|
713
|
+
'gets' => '←',
|
|
714
|
+
# Text sub/sup scripts
|
|
715
|
+
'text' => '',
|
|
716
|
+
}.freeze
|
|
717
|
+
|
|
718
|
+
# Commands whose argument should be preserved verbatim (e.g. \text{abc})
|
|
719
|
+
TEXT_LIKE = %w[text textrm textsf texttt textbf textit].freeze
|
|
720
|
+
|
|
721
|
+
SUPERSCRIPTS = {
|
|
722
|
+
'0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
|
|
723
|
+
'5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
|
|
724
|
+
'+' => '⁺', '-' => '⁻', '=' => '⁼', '(' => '⁽', ')' => '⁾',
|
|
725
|
+
'i' => 'ⁱ', 'n' => 'ⁿ',
|
|
726
|
+
}.freeze
|
|
727
|
+
|
|
728
|
+
SUBSCRIPTS = {
|
|
729
|
+
'0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
|
|
730
|
+
'5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
|
|
731
|
+
'+' => '₊', '-' => '₋', '=' => '₌', '(' => '₍', ')' => '₎',
|
|
732
|
+
'a' => 'ₐ', 'e' => 'ₑ', 'i' => 'ᵢ', 'j' => 'ⱼ',
|
|
733
|
+
'n' => 'ₙ', 'x' => 'ₓ',
|
|
734
|
+
}.freeze
|
|
735
|
+
|
|
736
|
+
def self.convert(formula)
|
|
737
|
+
return formula if formula.nil? || formula.strip.empty?
|
|
738
|
+
|
|
739
|
+
result = formula.dup
|
|
740
|
+
result = process_frac(result)
|
|
741
|
+
result = process_sqrt(result)
|
|
742
|
+
result = process_cases(result)
|
|
743
|
+
result = process_scripts(result)
|
|
744
|
+
result = replace_symbols(result)
|
|
745
|
+
result = strip_delim_spacing(result)
|
|
746
|
+
result
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# \frac{num}{den} → (num)/(den) or num/den when single-char
|
|
750
|
+
def self.process_frac(text)
|
|
751
|
+
text.gsub(/\\frac\s*\{([^{}]*(?:\{[^}]*\}[^{}]*)*)\}\s*\{([^{}]*(?:\{[^}]*\}[^{}]*)*)\}/) do
|
|
752
|
+
num = Regexp.last_match(1)
|
|
753
|
+
den = Regexp.last_match(2)
|
|
754
|
+
num_wrap = num.length > 1 ? "(#{num})" : num
|
|
755
|
+
den_wrap = den.length > 1 ? "(#{den})" : den
|
|
756
|
+
"#{num_wrap}/#{den_wrap}"
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# \sqrt{x} → √(x) \sqrt[n]{x} → ⁿ√(x)
|
|
761
|
+
def self.process_sqrt(text)
|
|
762
|
+
text.gsub(/\\sqrt(?:\[([^\]]*)\])?\s*\{([^{}]*(?:\{[^}]*\}[^{}]*)*)\}/) do
|
|
763
|
+
degree = Regexp.last_match(1)
|
|
764
|
+
radicand = Regexp.last_match(2)
|
|
765
|
+
prefix = degree ? script_chars(degree, SUPERSCRIPTS) : ''
|
|
766
|
+
"#{prefix}√(#{radicand})"
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# \begin{cases} ... \end{cases} → ⎧ … ⎨ … ⎩ …
|
|
771
|
+
def self.process_cases(text)
|
|
772
|
+
text.gsub(/\\begin\{cases\}(.*?)\\end\{cases\}/m) do
|
|
773
|
+
body = Regexp.last_match(1).strip
|
|
774
|
+
lines = body.split('\\\\').map(&:strip).reject(&:empty?)
|
|
775
|
+
return '{}' if lines.empty?
|
|
776
|
+
out = +""
|
|
777
|
+
lines.each_with_index do |line, i|
|
|
778
|
+
leader = case i
|
|
779
|
+
when 0 then '⎧'
|
|
780
|
+
when lines.length - 1 then '⎩'
|
|
781
|
+
else '⎨'
|
|
782
|
+
end
|
|
783
|
+
out << "#{leader} #{line.gsub('&', '')}\n"
|
|
784
|
+
end
|
|
785
|
+
out.strip
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# ^{x} / _{x} → Unicode super/subscript
|
|
790
|
+
def self.process_scripts(text)
|
|
791
|
+
# ^{...}
|
|
792
|
+
text = text.gsub(/\^\{([^}]+)\}/) {
|
|
793
|
+
inner = Regexp.last_match(1)
|
|
794
|
+
inner.include?('\\') ? "^\{#{inner}\}" : script_chars(inner, SUPERSCRIPTS)
|
|
795
|
+
}
|
|
796
|
+
# _{...}
|
|
797
|
+
text = text.gsub(/_\{([^}]+)\}/) {
|
|
798
|
+
inner = Regexp.last_match(1)
|
|
799
|
+
inner.include?('\\') ? "_\{#{inner}\}" : script_chars(inner, SUBSCRIPTS)
|
|
800
|
+
}
|
|
801
|
+
# ^x (single non-whitespace char, not \ or {)
|
|
802
|
+
text = text.gsub(/\^([^\s\\{])/) { SUPERSCRIPTS[Regexp.last_match(1)] || "^#{Regexp.last_match(1)}" }
|
|
803
|
+
# _x (single non-whitespace char, not \ or {)
|
|
804
|
+
text = text.gsub(/_([^\s\\{])/) { SUBSCRIPTS[Regexp.last_match(1)] || "_#{Regexp.last_match(1)}" }
|
|
805
|
+
text
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def self.script_chars(str, map)
|
|
809
|
+
str.each_char.map { |c| map[c] || c }.join
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Replace \command tokens with Unicode equivalents.
|
|
813
|
+
def self.replace_symbols(text)
|
|
814
|
+
# Handle \text{…} first – keep content, remove wrapper
|
|
815
|
+
text = text.gsub(/\\(text\w*)\s*\{(.*?)\}/) { Regexp.last_match(2) }
|
|
816
|
+
# Replace all other \commands
|
|
817
|
+
text.gsub(/\\([a-zA-Z]+)/) { |m|
|
|
818
|
+
SYMBOLS[Regexp.last_match(1)] || m
|
|
819
|
+
}
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Remove stray spaces inserted by \left / \right.
|
|
823
|
+
def self.strip_delim_spacing(text)
|
|
824
|
+
text.gsub(/\(\s+/, '(').gsub(/\s+\)/, ')')
|
|
825
|
+
.gsub(/\[\s+/, '[').gsub(/\s+\]/, ']')
|
|
826
|
+
.gsub(/\{\s+/, '{').gsub(/\s+\}/, '}')
|
|
827
|
+
.gsub(/\\s+/, ' ')
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# ---- Mermaid diagram renderer ----
|
|
832
|
+
# Renders pie charts inline; other diagram types show source with a
|
|
833
|
+
# hint to install `mmdc` for full rendering.
|
|
834
|
+
module MermaidRenderer
|
|
835
|
+
BAR_MAX = 32
|
|
836
|
+
|
|
837
|
+
def self.render(source, width = 80)
|
|
838
|
+
trimmed = source.strip
|
|
839
|
+
return "" if trimmed.empty?
|
|
840
|
+
|
|
841
|
+
type = detect_type(trimmed)
|
|
842
|
+
case type
|
|
843
|
+
when :pie
|
|
844
|
+
render_pie(trimmed, width)
|
|
845
|
+
else
|
|
846
|
+
render_fallback(trimmed, type, width)
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def self.detect_type(source)
|
|
851
|
+
first = source.lines.first&.strip&.downcase || ""
|
|
852
|
+
return :pie if first.start_with?("pie")
|
|
853
|
+
return :flowchart if first.start_with?("flowchart") || first.start_with?("graph")
|
|
854
|
+
return :sequence if first.start_with?("sequencediagram")
|
|
855
|
+
return :class if first.start_with?("classdiagram")
|
|
856
|
+
return :gantt if first.start_with?("gantt")
|
|
857
|
+
return :state if first.start_with?("statediagram")
|
|
858
|
+
:generic
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
# Pie chart → horizontal bar chart with percentage labels.
|
|
862
|
+
def self.render_pie(source, width)
|
|
863
|
+
title = ""
|
|
864
|
+
entries = []
|
|
865
|
+
source.each_line do |line|
|
|
866
|
+
line = line.strip
|
|
867
|
+
next if line.empty?
|
|
868
|
+
if line.downcase.start_with?("pie")
|
|
869
|
+
rest = line[3..].strip
|
|
870
|
+
if rest.downcase.start_with?("title")
|
|
871
|
+
title = rest[5..].strip
|
|
872
|
+
end
|
|
873
|
+
next
|
|
874
|
+
end
|
|
875
|
+
if line.downcase.start_with?("title")
|
|
876
|
+
title = line[5..].strip
|
|
877
|
+
next
|
|
878
|
+
end
|
|
879
|
+
# Parse "label" : value
|
|
880
|
+
label_part, value_part = line.split(":", 2).map(&:strip)
|
|
881
|
+
next unless label_part && value_part
|
|
882
|
+
label = label_part.delete_prefix('"').delete_suffix('"')
|
|
883
|
+
value = value_part.to_f
|
|
884
|
+
entries << [label, value] if value > 0
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
return "[Mermaid pie: no data]" if entries.empty?
|
|
888
|
+
|
|
889
|
+
total = entries.sum { |_l, v| v }
|
|
890
|
+
return "[Mermaid pie: total is zero]" if total <= 0
|
|
891
|
+
|
|
892
|
+
max_label = entries.map { |l, _| l.length }.max
|
|
893
|
+
out = +""
|
|
894
|
+
out << "#{tc(:heading_3)}#{title}#{AnsiCode.reset}\n" unless title.empty?
|
|
895
|
+
|
|
896
|
+
entries.each do |label, value|
|
|
897
|
+
pct = value / total * 100.0
|
|
898
|
+
filled = (pct / 100.0 * BAR_MAX).round
|
|
899
|
+
half = (pct / 100.0 * BAR_MAX * 2).round % 2 == 1
|
|
900
|
+
bar = "█" * filled + (half ? "▌" : "")
|
|
901
|
+
out << sprintf("%-#{BAR_MAX + 1}s %-#{max_label}s %5.1f%%\n", bar, label, pct)
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
out.strip
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# Fallback: show diagram source with a labelled header.
|
|
908
|
+
def self.render_fallback(source, type, width)
|
|
909
|
+
label = type.to_s.capitalize
|
|
910
|
+
pad = (width - label.length - 2).clamp(2, 60)
|
|
911
|
+
lines = source.lines.map(&:chomp)
|
|
912
|
+
[
|
|
913
|
+
"#{tc(:code_border)}┌─ #{label} #{'─' * pad}┐#{AnsiCode.reset}",
|
|
914
|
+
*lines.map { |l| "#{tc(:code_border)}│#{AnsiCode.reset} #{l}" },
|
|
915
|
+
"#{tc(:code_border)}└#{'─' * (width - 2)}┘#{AnsiCode.reset}",
|
|
916
|
+
"#{tc(:muted || :heading_4_6)}Install mmdc (npm i -g @mermaid-js/mermaid-cli) for full diagram rendering.#{AnsiCode.reset}",
|
|
917
|
+
].join("\n")
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# Proxy theme colour access (same instance as TerminalConverter).
|
|
921
|
+
def self.tc(key)
|
|
922
|
+
color, bright = MarkdownTheme[key]
|
|
923
|
+
AnsiCode.color(color, bright)
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def self.AnsiCode
|
|
927
|
+
::RubyRich::AnsiCode
|
|
357
928
|
end
|
|
358
929
|
end
|
|
359
930
|
|
|
@@ -369,6 +940,77 @@ module RubyRich
|
|
|
369
940
|
end
|
|
370
941
|
end
|
|
371
942
|
|
|
943
|
+
# ---- Frontmatter extraction ----
|
|
944
|
+
# Extracts YAML-style frontmatter (delimited by ---) and returns
|
|
945
|
+
# [content_without_fm, parsed_pairs, is_vertical].
|
|
946
|
+
module Frontmatter
|
|
947
|
+
VERTICAL_THRESHOLD = 5
|
|
948
|
+
|
|
949
|
+
def self.extract(markdown_text)
|
|
950
|
+
return [markdown_text, nil, false] unless markdown_text.start_with?("---\n")
|
|
951
|
+
|
|
952
|
+
rest = markdown_text[4..]
|
|
953
|
+
offset = 4
|
|
954
|
+
rest.each_line do |line|
|
|
955
|
+
if line == "---\n" || line == "...\n" || line == "---" || line == "..."
|
|
956
|
+
fm_block = markdown_text[4...offset]
|
|
957
|
+
content = markdown_text[(offset + line.length)..] || ""
|
|
958
|
+
pairs = parse_pairs(fm_block)
|
|
959
|
+
return [markdown_text, nil, false] if pairs.empty?
|
|
960
|
+
vertical = pairs.length >= VERTICAL_THRESHOLD
|
|
961
|
+
return [content, pairs, vertical]
|
|
962
|
+
end
|
|
963
|
+
offset += line.length
|
|
964
|
+
end
|
|
965
|
+
[markdown_text, nil, false]
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
def self.parse_pairs(block)
|
|
969
|
+
pairs = []
|
|
970
|
+
lines = block.lines.map(&:chomp)
|
|
971
|
+
i = 0
|
|
972
|
+
while i < lines.length
|
|
973
|
+
trimmed = lines[i].strip
|
|
974
|
+
i += 1 and next if trimmed.empty? || trimmed.start_with?('#')
|
|
975
|
+
|
|
976
|
+
colon_pos = trimmed.index(':')
|
|
977
|
+
i += 1 and next unless colon_pos
|
|
978
|
+
|
|
979
|
+
key = trimmed[0...colon_pos].strip
|
|
980
|
+
raw_value = trimmed[(colon_pos + 1)..].strip
|
|
981
|
+
i += 1 and next if key.empty?
|
|
982
|
+
|
|
983
|
+
if ["", ">-", ">", "|", "|-"].include?(raw_value)
|
|
984
|
+
# Multiline value
|
|
985
|
+
parts = []
|
|
986
|
+
while i < lines.length && lines[i].start_with?(' ', "\t")
|
|
987
|
+
part = lines[i].strip
|
|
988
|
+
parts << part unless part.empty?
|
|
989
|
+
i += 1
|
|
990
|
+
end
|
|
991
|
+
pairs << [key, parts.join(" ")]
|
|
992
|
+
elsif raw_value.empty?
|
|
993
|
+
# List value (indented items)
|
|
994
|
+
items = []
|
|
995
|
+
while i < lines.length && lines[i].start_with?(' ', "\t")
|
|
996
|
+
item = lines[i].strip
|
|
997
|
+
items << (item.start_with?("- ") ? item[2..].strip : item)
|
|
998
|
+
i += 1
|
|
999
|
+
end
|
|
1000
|
+
pairs << [key, items.empty? ? "" : items.join(", ")]
|
|
1001
|
+
else
|
|
1002
|
+
pairs << [key, unquote(raw_value)]
|
|
1003
|
+
i += 1
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
pairs
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
def self.unquote(s)
|
|
1010
|
+
(s.length >= 2 && ((s.start_with?('"') && s.end_with?('"')) || (s.start_with?("'") && s.end_with?("'")))) ? s[1...-1] : s
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
|
|
372
1014
|
# ---- 公开 API ----
|
|
373
1015
|
|
|
374
1016
|
# 渲染 Markdown 文本为 ANSI 终端输出
|
|
@@ -388,6 +1030,26 @@ module RubyRich
|
|
|
388
1030
|
table_border_style: options[:table_border_style] || :simple
|
|
389
1031
|
}
|
|
390
1032
|
|
|
1033
|
+
# Pre-process frontmatter
|
|
1034
|
+
content, fm_pairs, fm_vertical = Frontmatter.extract(markdown_text)
|
|
1035
|
+
|
|
1036
|
+
# Pre-process inline math $...$ (kramdown needs a math-engine gem for this)
|
|
1037
|
+
math_color = AnsiCode.color(*MarkdownTheme[:math])
|
|
1038
|
+
content = content.gsub(/(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/) do
|
|
1039
|
+
rendered = TerminalConverter::LatexConverter.convert(Regexp.last_match(1).strip)
|
|
1040
|
+
"#{math_color}#{rendered}#{AnsiCode.reset}"
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
fm_output = ""
|
|
1044
|
+
if fm_pairs && !fm_pairs.empty?
|
|
1045
|
+
if fm_vertical
|
|
1046
|
+
# Vertical frontmatter: one column per key-value pair
|
|
1047
|
+
fm_output = render_frontmatter_vertical(fm_pairs, converter_options)
|
|
1048
|
+
else
|
|
1049
|
+
fm_output = render_frontmatter_horizontal(fm_pairs, converter_options)
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
391
1053
|
kramdown_opts = {
|
|
392
1054
|
input: 'GFM', # GitHub Flavored Markdown
|
|
393
1055
|
syntax_highlighter: nil, # 自行处理语法高亮
|
|
@@ -396,9 +1058,31 @@ module RubyRich
|
|
|
396
1058
|
line_width: converter_options[:width]
|
|
397
1059
|
}.merge(options[:kramdown] || {})
|
|
398
1060
|
|
|
399
|
-
doc = Kramdown::Document.new(
|
|
1061
|
+
doc = Kramdown::Document.new(content, kramdown_opts)
|
|
400
1062
|
result, _warnings = TerminalConverter.convert(doc.root, converter_options)
|
|
401
|
-
result
|
|
1063
|
+
"#{fm_output}#{result}"
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# Render frontmatter as a vertical key-value table (many pairs).
|
|
1067
|
+
def self.render_frontmatter_vertical(pairs, opts)
|
|
1068
|
+
tbl = RubyRich::Table.new(
|
|
1069
|
+
headers: %w[Key Value],
|
|
1070
|
+
border_style: opts[:table_border_style] || :simple
|
|
1071
|
+
)
|
|
1072
|
+
pairs.each { |k, v| tbl.add_row([k, v]) }
|
|
1073
|
+
"#{tbl.render}\n\n"
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
# Render frontmatter as a horizontal 2-row table (few pairs).
|
|
1077
|
+
def self.render_frontmatter_horizontal(pairs, opts)
|
|
1078
|
+
keys = pairs.map(&:first)
|
|
1079
|
+
vals = pairs.map(&:last)
|
|
1080
|
+
tbl = RubyRich::Table.new(
|
|
1081
|
+
headers: keys,
|
|
1082
|
+
border_style: opts[:table_border_style] || :simple
|
|
1083
|
+
)
|
|
1084
|
+
tbl.add_row(vals)
|
|
1085
|
+
"#{tbl.render}\n\n"
|
|
402
1086
|
end
|
|
403
1087
|
|
|
404
1088
|
def initialize(options = {})
|
|
@@ -409,4 +1093,4 @@ module RubyRich
|
|
|
409
1093
|
self.class.render(markdown_text, @options)
|
|
410
1094
|
end
|
|
411
1095
|
end
|
|
412
|
-
end
|
|
1096
|
+
end
|
data/lib/ruby_rich/table.rb
CHANGED
|
@@ -126,13 +126,13 @@ module RubyRich
|
|
|
126
126
|
border_chars = BORDER_STYLES[@border_style]
|
|
127
127
|
|
|
128
128
|
row_content = row.map.with_index do |cell, i|
|
|
129
|
-
rendered = cell.render
|
|
129
|
+
rendered = cell.render.sub(/\e\[0m\z/, '')
|
|
130
130
|
content = bold ? rendered : align_cell(rendered, column_widths[i])
|
|
131
|
-
aligned_content = align_cell(content, column_widths[i])
|
|
131
|
+
aligned_content = align_cell(content, column_widths[i]).sub(/\e\[0m\z/, '')
|
|
132
132
|
" #{aligned_content} "
|
|
133
133
|
end.join(border_chars[:vertical])
|
|
134
134
|
|
|
135
|
-
["#{border_chars[:left]}#{row_content}#{border_chars[:right]}"]
|
|
135
|
+
["#{border_chars[:left]}#{row_content}#{border_chars[:right]}\e[0m"]
|
|
136
136
|
end
|
|
137
137
|
|
|
138
138
|
def render_styled_multiline_row(row, column_widths)
|
|
@@ -213,10 +213,10 @@ module RubyRich
|
|
|
213
213
|
|
|
214
214
|
def render_row(row, column_widths, bold: false)
|
|
215
215
|
row.map.with_index do |cell, i|
|
|
216
|
-
rendered = cell.render
|
|
216
|
+
rendered = cell.render.sub(/\e\[0m\z/, '')
|
|
217
217
|
content = bold ? rendered : align_cell(rendered, column_widths[i])
|
|
218
|
-
align_cell(content, column_widths[i])
|
|
219
|
-
end.join(" | ").prepend("| ").concat("
|
|
218
|
+
align_cell(content, column_widths[i]).sub(/\e\[0m\z/, '')
|
|
219
|
+
end.join(" | ").prepend("| ").concat(" |\e[0m")
|
|
220
220
|
end
|
|
221
221
|
|
|
222
222
|
def render_multiline_row(row, column_widths)
|
|
@@ -254,7 +254,7 @@ module RubyRich
|
|
|
254
254
|
|
|
255
255
|
# Render each line of the row
|
|
256
256
|
(0...max_height).map do |line_index|
|
|
257
|
-
row_lines.map { |lines| lines[line_index] }.join(" | ").prepend("| ").concat("
|
|
257
|
+
row_lines.map { |lines| lines[line_index].sub(/\e\[0m\z/, '') }.join(" | ").prepend("| ").concat(" |\e[0m")
|
|
258
258
|
end
|
|
259
259
|
end
|
|
260
260
|
|
|
@@ -263,7 +263,7 @@ module RubyRich
|
|
|
263
263
|
end
|
|
264
264
|
|
|
265
265
|
def align_cell(content, width)
|
|
266
|
-
style_sequences = content.scan(/\e\[[0-9;]*m/)
|
|
266
|
+
style_sequences = content.scan(/\e\[[0-9;]*m/).reject { |s| s == "\e[0m" }
|
|
267
267
|
plain_content = content.gsub(/\e\[[0-9;]*m/, '')
|
|
268
268
|
|
|
269
269
|
# 计算实际显示宽度
|
|
@@ -282,7 +282,7 @@ module RubyRich
|
|
|
282
282
|
end
|
|
283
283
|
|
|
284
284
|
if style_sequences.any?
|
|
285
|
-
style_sequences.first + padded_content
|
|
285
|
+
style_sequences.first + padded_content
|
|
286
286
|
else
|
|
287
287
|
padded_content
|
|
288
288
|
end
|
data/lib/ruby_rich/transcript.rb
CHANGED
|
@@ -412,6 +412,8 @@ module RubyRich
|
|
|
412
412
|
private
|
|
413
413
|
|
|
414
414
|
def render_entry(entry, index)
|
|
415
|
+
return entry.content.to_s.split("\n", -1) if entry.metadata[:plain]
|
|
416
|
+
|
|
415
417
|
case entry.type
|
|
416
418
|
when :user
|
|
417
419
|
render_plain_message(entry.content, first_prefix: "#{AnsiCode.color(:blue, true)}●#{AnsiCode.reset} ", rest_prefix: " ")
|
data/lib/ruby_rich/version.rb
CHANGED
data/lib/ruby_rich/viewport.rb
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
3
5
|
module RubyRich
|
|
4
6
|
class Viewport
|
|
5
7
|
attr_accessor :width, :height, :scroll_top
|
|
6
8
|
attr_reader :content, :selected_text
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
# drag_mode controls what left-click drag does in the content area:
|
|
11
|
+
# :viewport – drag scrolls the viewport (default, backward-compatible)
|
|
12
|
+
# :selection – drag selects text
|
|
13
|
+
DRAG_MODES = [:viewport, :selection].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(content = "",
|
|
16
|
+
scrollbar: true, auto_scroll: false,
|
|
17
|
+
scrollbar_style: :blue, auto_copy: true,
|
|
18
|
+
drag_mode: :viewport)
|
|
9
19
|
@content = content
|
|
10
20
|
@scrollbar = scrollbar
|
|
11
21
|
@auto_scroll = auto_scroll
|
|
@@ -22,6 +32,8 @@ module RubyRich
|
|
|
22
32
|
@selection_end = nil
|
|
23
33
|
@selected_text = ""
|
|
24
34
|
@focused = true
|
|
35
|
+
@auto_copy = auto_copy
|
|
36
|
+
@drag_mode = DRAG_MODES.include?(drag_mode) ? drag_mode : :viewport
|
|
25
37
|
@rendered_lines_cache_key = nil
|
|
26
38
|
@rendered_lines_cache = nil
|
|
27
39
|
end
|
|
@@ -88,10 +100,14 @@ module RubyRich
|
|
|
88
100
|
true
|
|
89
101
|
when :mouse_down
|
|
90
102
|
return copy_selection if event_data[:button] == :right
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
if @drag_mode == :selection
|
|
104
|
+
start_scrollbar_drag(event_data, layout) || start_selection(event_data, layout)
|
|
105
|
+
else
|
|
106
|
+
start_scrollbar_drag(event_data, layout) || start_viewport_drag(event_data, layout)
|
|
107
|
+
end
|
|
93
108
|
when :mouse_drag
|
|
94
|
-
drag_scrollbar(event_data, layout) ||
|
|
109
|
+
drag_scrollbar(event_data, layout) ||
|
|
110
|
+
(@drag_mode == :selection ? drag_selection(event_data, layout) : (drag_viewport(event_data, layout) || drag_selection(event_data, layout)))
|
|
95
111
|
when :mouse_up
|
|
96
112
|
stop_scrollbar_drag || stop_viewport_drag || stop_selection
|
|
97
113
|
else
|
|
@@ -415,7 +431,7 @@ module RubyRich
|
|
|
415
431
|
|
|
416
432
|
@selecting = false
|
|
417
433
|
@selected_text = extract_selected_text
|
|
418
|
-
copy_selection
|
|
434
|
+
copy_selection if @auto_copy
|
|
419
435
|
true
|
|
420
436
|
end
|
|
421
437
|
|
|
@@ -477,6 +493,7 @@ module RubyRich
|
|
|
477
493
|
escape << char
|
|
478
494
|
if char == "m"
|
|
479
495
|
result << escape
|
|
496
|
+
result << AnsiCode.inverse if active
|
|
480
497
|
escape = +""
|
|
481
498
|
in_escape = false
|
|
482
499
|
end
|
|
@@ -531,18 +548,61 @@ module RubyRich
|
|
|
531
548
|
text.gsub(/\e\[[0-9;:]*m/, "")
|
|
532
549
|
end
|
|
533
550
|
|
|
551
|
+
# Copy text to the system clipboard, trying every available method in
|
|
552
|
+
# sequence and falling back to the OSC 52 terminal clipboard so that
|
|
553
|
+
# remote (SSH) sessions also work.
|
|
534
554
|
def copy_to_clipboard(text)
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
555
|
+
text = text.to_s
|
|
556
|
+
return false if text.empty?
|
|
557
|
+
return true if RubyRich::Terminal.windows? && try_windows_clipboard(text)
|
|
558
|
+
|
|
559
|
+
clipboard_commands.each do |command|
|
|
560
|
+
return true if write_clipboard_command(command, text)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
copy_to_terminal_clipboard(text)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Ordered list of [command, *args] arrays to try for clipboard access.
|
|
567
|
+
def clipboard_commands
|
|
568
|
+
commands = []
|
|
569
|
+
commands << ["wl-copy"] if ENV["WAYLAND_DISPLAY"]
|
|
570
|
+
if ENV["DISPLAY"]
|
|
571
|
+
commands << ["xclip", "-selection", "clipboard"]
|
|
572
|
+
commands << ["xsel", "--clipboard", "--input"]
|
|
543
573
|
end
|
|
574
|
+
commands << ["pbcopy"] if RUBY_PLATFORM.match?(/darwin/)
|
|
575
|
+
commands
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Attempt to write text to a given clipboard command via a pipe.
|
|
579
|
+
# Returns true when the subprocess exits successfully.
|
|
580
|
+
def write_clipboard_command(command, text)
|
|
581
|
+
IO.popen(command, "w") { |io| io.write(text) }
|
|
582
|
+
$?&.success? == true
|
|
544
583
|
rescue IOError, SystemCallError
|
|
545
|
-
|
|
584
|
+
false
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Try the OS-specific Windows clipboard helper and return true when it
|
|
588
|
+
# succeeds (any IO/Syscall error is treated as failure).
|
|
589
|
+
def try_windows_clipboard(text)
|
|
590
|
+
copy_to_windows_clipboard(text)
|
|
591
|
+
true
|
|
592
|
+
rescue IOError, SystemCallError
|
|
593
|
+
false
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# OSC 52 terminal clipboard – the only fallback that works over SSH.
|
|
597
|
+
# Encoded payload is emitted on stdout for the hosting terminal to
|
|
598
|
+
# capture.
|
|
599
|
+
def copy_to_terminal_clipboard(text)
|
|
600
|
+
encoded = Base64.strict_encode64(text.encode(Encoding::UTF_8))
|
|
601
|
+
$stdout.print("\e]52;c;#{encoded}\a")
|
|
602
|
+
$stdout.flush
|
|
603
|
+
true
|
|
604
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError, IOError, SystemCallError
|
|
605
|
+
false
|
|
546
606
|
end
|
|
547
607
|
|
|
548
608
|
def copy_to_windows_clipboard(text)
|
|
@@ -559,4 +619,4 @@ module RubyRich
|
|
|
559
619
|
end
|
|
560
620
|
end
|
|
561
621
|
end
|
|
562
|
-
end
|
|
622
|
+
end
|