ruby_rich 0.4.9 → 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 +604 -31
- data/lib/ruby_rich/table.rb +9 -9
- data/lib/ruby_rich/version.rb +1 -1
- 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) ----
|
|
@@ -382,9 +547,9 @@ module RubyRich
|
|
|
382
547
|
when 'sup'
|
|
383
548
|
content # 终端无上标,保留文本
|
|
384
549
|
when 'kbd'
|
|
385
|
-
"#{
|
|
550
|
+
"#{tbg(:kbd_bg)}#{tc(:kbd_fg)} #{content} #{AnsiCode.reset}"
|
|
386
551
|
when 'mark'
|
|
387
|
-
"#{
|
|
552
|
+
"#{tbg(:mark_bg)}#{tc(:mark_fg)}#{content}#{AnsiCode.reset}"
|
|
388
553
|
when 'details', 'summary'
|
|
389
554
|
content
|
|
390
555
|
when 'br'
|
|
@@ -426,7 +591,7 @@ module RubyRich
|
|
|
426
591
|
# 脚注内容在文档末尾自动收集
|
|
427
592
|
content = inline_content(el)
|
|
428
593
|
name = el.options[:name]
|
|
429
|
-
"#{
|
|
594
|
+
"#{tc(:footnote)}[^#{name}]#{AnsiCode.reset}"
|
|
430
595
|
end
|
|
431
596
|
|
|
432
597
|
# ---- 定义列表(kramdown 独有) ----
|
|
@@ -449,7 +614,7 @@ module RubyRich
|
|
|
449
614
|
title = el.attr['title']
|
|
450
615
|
text = inline_content(el)
|
|
451
616
|
if title && !title.empty?
|
|
452
|
-
"#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{
|
|
617
|
+
"#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{tc(:abbreviation)}(#{title})#{AnsiCode.reset}"
|
|
453
618
|
else
|
|
454
619
|
text
|
|
455
620
|
end
|
|
@@ -458,13 +623,308 @@ module RubyRich
|
|
|
458
623
|
# ---- 数学公式(kramdown 独有,需 math engine) ----
|
|
459
624
|
|
|
460
625
|
def convert_math(el)
|
|
461
|
-
# 在终端中显示原始 LaTeX 公式
|
|
462
626
|
mode = el.options[:category] == :block ? 'block' : 'inline'
|
|
463
627
|
formula = el.value.to_s.strip
|
|
628
|
+
rendered = LatexConverter.convert(formula)
|
|
629
|
+
color = tc(:math)
|
|
464
630
|
if mode == 'block'
|
|
465
|
-
"#{
|
|
631
|
+
"#{color}#{rendered}#{AnsiCode.reset}\n\n"
|
|
466
632
|
else
|
|
467
|
-
"#{
|
|
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
|
|
468
928
|
end
|
|
469
929
|
end
|
|
470
930
|
|
|
@@ -480,6 +940,77 @@ module RubyRich
|
|
|
480
940
|
end
|
|
481
941
|
end
|
|
482
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
|
+
|
|
483
1014
|
# ---- 公开 API ----
|
|
484
1015
|
|
|
485
1016
|
# 渲染 Markdown 文本为 ANSI 终端输出
|
|
@@ -499,6 +1030,26 @@ module RubyRich
|
|
|
499
1030
|
table_border_style: options[:table_border_style] || :simple
|
|
500
1031
|
}
|
|
501
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
|
+
|
|
502
1053
|
kramdown_opts = {
|
|
503
1054
|
input: 'GFM', # GitHub Flavored Markdown
|
|
504
1055
|
syntax_highlighter: nil, # 自行处理语法高亮
|
|
@@ -507,9 +1058,31 @@ module RubyRich
|
|
|
507
1058
|
line_width: converter_options[:width]
|
|
508
1059
|
}.merge(options[:kramdown] || {})
|
|
509
1060
|
|
|
510
|
-
doc = Kramdown::Document.new(
|
|
1061
|
+
doc = Kramdown::Document.new(content, kramdown_opts)
|
|
511
1062
|
result, _warnings = TerminalConverter.convert(doc.root, converter_options)
|
|
512
|
-
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"
|
|
513
1086
|
end
|
|
514
1087
|
|
|
515
1088
|
def initialize(options = {})
|
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/version.rb
CHANGED