ruby_rich 0.4.9 → 0.5.1
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 +869 -32
- data/lib/ruby_rich/table.rb +11 -11
- 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: 35642b63217134791fd7201a1b26488473164d74646bfa3b2492294611e92340
|
|
4
|
+
data.tar.gz: 3c9633c93770785e7b003b81fb8ff484c21114184ac2b0615fee1bd16380fbca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 21e958777b0107f4a92b7de61aa725cca02951b5aff8874c3ab9149b1c8cb45c1998d39f93529e04d92e2e45c4ba67ad4f053568ea1dfe503bd6d9568c7f511d
|
|
7
|
+
data.tar.gz: b5abb3cf83b0f53100f13286dcb388a54b9238503ff276f2a591a71d873eaaef116a4dc599175b75023388f95f30d6934da36071fddb88cecff367b1e07c2910
|
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,55 @@ 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_types.length, 1].max
|
|
290
|
+
list_type = @list_types.last
|
|
291
|
+
indent = " " * (depth - 1)
|
|
292
|
+
marker = if list_type == :ol
|
|
155
293
|
@list_counters[-1] += 1
|
|
156
294
|
"#{@list_counters[-1]}."
|
|
157
295
|
else
|
|
158
|
-
'
|
|
296
|
+
UNORDERED_MARKERS[depth.clamp(1, 3)] || '▸'
|
|
297
|
+
end
|
|
298
|
+
task = detect_task_marker(el)
|
|
299
|
+
text = inline_content(el).gsub(/\n{2,}/, "\n")
|
|
300
|
+
if task
|
|
301
|
+
"#{indent}#{tc(task ? :task_checked : :task_unchecked)}#{task}#{AnsiCode.reset} #{text.strip}\n"
|
|
302
|
+
else
|
|
303
|
+
"#{indent}#{tc(list_type == :ol ? :ordered_list : :"list_level_#{depth.clamp(1, 3)}")}#{marker}#{AnsiCode.reset} #{text.strip}\n"
|
|
159
304
|
end
|
|
160
|
-
|
|
161
|
-
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Detect GitHub Flavored Markdown task list markers.
|
|
308
|
+
# kramdown-parser-gfm represents them as a child HTML input element
|
|
309
|
+
# inside the li's paragraph (p); we extract the checked state for
|
|
310
|
+
# terminal rendering.
|
|
311
|
+
TASK_CHECKED = '☑'.freeze
|
|
312
|
+
TASK_UNCHECKED = '☐'.freeze
|
|
313
|
+
|
|
314
|
+
def detect_task_marker(el)
|
|
315
|
+
return nil unless el.children
|
|
316
|
+
|
|
317
|
+
para = el.children.find { |c| c.type == :p }
|
|
318
|
+
return nil unless para&.children
|
|
319
|
+
|
|
320
|
+
input_idx = para.children.index { |c| c.type == :html_element && c.value.to_s.downcase == 'input' }
|
|
321
|
+
return nil unless input_idx
|
|
322
|
+
|
|
323
|
+
raw = para.children[input_idx].attr['checked']
|
|
324
|
+
checked = raw&.to_s&.downcase == 'checked'
|
|
325
|
+
# Remove the hidden input element so text is clean.
|
|
326
|
+
para.children.delete_at(input_idx)
|
|
327
|
+
checked ? TASK_CHECKED : TASK_UNCHECKED
|
|
162
328
|
end
|
|
163
329
|
|
|
164
330
|
# ---- 内联样式 ----
|
|
@@ -182,8 +348,8 @@ module RubyRich
|
|
|
182
348
|
title = el.attr['title']
|
|
183
349
|
text = inline_content(el)
|
|
184
350
|
title_part = title && !title.empty? ? " - #{title}" : ""
|
|
185
|
-
"#{
|
|
186
|
-
"#{
|
|
351
|
+
"#{tc(:link_text)}#{AnsiCode.underline}#{text}#{AnsiCode.reset} " \
|
|
352
|
+
"#{tc(:link_url)}(#{url}#{title_part})#{AnsiCode.reset}"
|
|
187
353
|
end
|
|
188
354
|
|
|
189
355
|
def convert_image(el)
|
|
@@ -191,15 +357,15 @@ module RubyRich
|
|
|
191
357
|
title = el.attr['title']
|
|
192
358
|
alt = el.attr['alt'] || ''
|
|
193
359
|
title_part = title && !title.empty? ? " - #{title}" : ""
|
|
194
|
-
"#{
|
|
195
|
-
"#{
|
|
360
|
+
"#{tc(:image_label)}[Image: #{alt}]#{AnsiCode.reset} " \
|
|
361
|
+
"#{tc(:link_url)}(#{url}#{title_part})#{AnsiCode.reset}"
|
|
196
362
|
end
|
|
197
363
|
|
|
198
364
|
# ---- 水平线 ----
|
|
199
365
|
|
|
200
366
|
def convert_hr(_el)
|
|
201
367
|
line_char = '─'
|
|
202
|
-
"#{
|
|
368
|
+
"#{tc(:rule)}#{line_char * @width}#{AnsiCode.reset}\n\n"
|
|
203
369
|
end
|
|
204
370
|
|
|
205
371
|
# ---- 表格(kramdown 原生 AST) ----
|
|
@@ -382,9 +548,9 @@ module RubyRich
|
|
|
382
548
|
when 'sup'
|
|
383
549
|
content # 终端无上标,保留文本
|
|
384
550
|
when 'kbd'
|
|
385
|
-
"#{
|
|
551
|
+
"#{tbg(:kbd_bg)}#{tc(:kbd_fg)} #{content} #{AnsiCode.reset}"
|
|
386
552
|
when 'mark'
|
|
387
|
-
"#{
|
|
553
|
+
"#{tbg(:mark_bg)}#{tc(:mark_fg)}#{content}#{AnsiCode.reset}"
|
|
388
554
|
when 'details', 'summary'
|
|
389
555
|
content
|
|
390
556
|
when 'br'
|
|
@@ -426,7 +592,7 @@ module RubyRich
|
|
|
426
592
|
# 脚注内容在文档末尾自动收集
|
|
427
593
|
content = inline_content(el)
|
|
428
594
|
name = el.options[:name]
|
|
429
|
-
"#{
|
|
595
|
+
"#{tc(:footnote)}[^#{name}]#{AnsiCode.reset}"
|
|
430
596
|
end
|
|
431
597
|
|
|
432
598
|
# ---- 定义列表(kramdown 独有) ----
|
|
@@ -449,7 +615,7 @@ module RubyRich
|
|
|
449
615
|
title = el.attr['title']
|
|
450
616
|
text = inline_content(el)
|
|
451
617
|
if title && !title.empty?
|
|
452
|
-
"#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{
|
|
618
|
+
"#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{tc(:abbreviation)}(#{title})#{AnsiCode.reset}"
|
|
453
619
|
else
|
|
454
620
|
text
|
|
455
621
|
end
|
|
@@ -458,13 +624,565 @@ module RubyRich
|
|
|
458
624
|
# ---- 数学公式(kramdown 独有,需 math engine) ----
|
|
459
625
|
|
|
460
626
|
def convert_math(el)
|
|
461
|
-
# 在终端中显示原始 LaTeX 公式
|
|
462
627
|
mode = el.options[:category] == :block ? 'block' : 'inline'
|
|
463
628
|
formula = el.value.to_s.strip
|
|
629
|
+
rendered = LatexConverter.convert(formula)
|
|
630
|
+
color = tc(:math)
|
|
464
631
|
if mode == 'block'
|
|
465
|
-
"#{
|
|
632
|
+
"#{color}#{rendered}#{AnsiCode.reset}\n\n"
|
|
466
633
|
else
|
|
467
|
-
"#{
|
|
634
|
+
"#{color}#{rendered}#{AnsiCode.reset}"
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# ---- LaTeX to Unicode converter ----
|
|
639
|
+
# Translates common LaTeX math commands to Unicode characters
|
|
640
|
+
# for terminal display. Handles Greek letters, big operators,
|
|
641
|
+
# frac, sqrt, super/subscript, cases, and ~150 common symbols.
|
|
642
|
+
module LatexConverter
|
|
643
|
+
# --- big lookup table ------------------------------------
|
|
644
|
+
# Format: "\\command" => "unicode_char"
|
|
645
|
+
SYMBOLS = {
|
|
646
|
+
# Greek lowercase
|
|
647
|
+
'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ',
|
|
648
|
+
'delta' => 'δ', 'epsilon' => 'ε', 'varepsilon' => 'ɛ',
|
|
649
|
+
'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
|
|
650
|
+
'vartheta' => 'ϑ', 'iota' => 'ι', 'kappa' => 'κ',
|
|
651
|
+
'lambda' => 'λ', 'mu' => 'μ', 'nu' => 'ν',
|
|
652
|
+
'xi' => 'ξ', 'pi' => 'π', 'varpi' => 'ϖ',
|
|
653
|
+
'rho' => 'ρ', 'varrho' => 'ϱ', 'sigma' => 'σ',
|
|
654
|
+
'varsigma' => 'ς', 'tau' => 'τ', 'upsilon' => 'υ',
|
|
655
|
+
'phi' => 'φ', 'varphi' => 'ϕ', 'chi' => 'χ',
|
|
656
|
+
'psi' => 'ψ', 'omega' => 'ω',
|
|
657
|
+
# Greek uppercase
|
|
658
|
+
'Gamma' => 'Γ', 'Delta' => 'Δ', 'Theta' => 'Θ',
|
|
659
|
+
'Lambda' => 'Λ', 'Xi' => 'Ξ', 'Pi' => 'Π',
|
|
660
|
+
'Sigma' => 'Σ', 'Upsilon' => 'Υ', 'Phi' => 'Φ',
|
|
661
|
+
'Psi' => 'Ψ', 'Omega' => 'Ω',
|
|
662
|
+
# Relations
|
|
663
|
+
'leq' => '≤', 'geq' => '≥', 'neq' => '≠',
|
|
664
|
+
'equiv' => '≡', 'approx' => '≈', 'sim' => '∼',
|
|
665
|
+
'simeq' => '≃', 'propto' => '∝', 'll' => '≪',
|
|
666
|
+
'gg' => '≫', 'doteq' => '≐', 'prec' => '≺',
|
|
667
|
+
'succ' => '≻', 'preceq' => '≼', 'succeq' => '≽',
|
|
668
|
+
'subset' => '⊂', 'supset' => '⊃', 'subseteq' => '⊆',
|
|
669
|
+
'supseteq' => '⊇', 'in' => '∈', 'ni' => '∋',
|
|
670
|
+
'notin' => '∉', 'perp' => '⊥', 'parallel' => '∥',
|
|
671
|
+
# Binary operators
|
|
672
|
+
'times' => '×', 'div' => '÷', 'cdot' => '·',
|
|
673
|
+
'pm' => '±', 'mp' => '∓', 'oplus' => '⊕',
|
|
674
|
+
'ominus' => '⊖', 'otimes' => '⊗', 'oslash' => '⊘',
|
|
675
|
+
'odot' => '⊙', 'circ' => '∘', 'bullet' => '∙',
|
|
676
|
+
'cap' => '∩', 'cup' => '∪', 'setminus' => '∖',
|
|
677
|
+
'land' => '∧', 'lor' => '∨', 'wedge' => '∧',
|
|
678
|
+
'vee' => '∨', 'star' => '⋆',
|
|
679
|
+
# Arrows
|
|
680
|
+
'to' => '→', 'rightarrow' => '→', 'Rightarrow' => '⇒',
|
|
681
|
+
'leftarrow' => '←', 'Leftarrow' => '⇐',
|
|
682
|
+
'leftrightarrow' => '↔', 'Leftrightarrow' => '⇔',
|
|
683
|
+
'mapsto' => '↦', 'longmapsto' => '⟼',
|
|
684
|
+
'uparrow' => '↑', 'downarrow' => '↓',
|
|
685
|
+
'longrightarrow' => '⟶', 'Longrightarrow' => '⟹',
|
|
686
|
+
# Big operators
|
|
687
|
+
'sum' => '∑', 'prod' => '∏', 'coprod' => '∐',
|
|
688
|
+
'int' => '∫', 'iint' => '∬', 'iiint' => '∭',
|
|
689
|
+
'oint' => '∮', 'bigcup' => '⋃', 'bigcap' => '⋂',
|
|
690
|
+
'bigvee' => '⋁', 'bigwedge' => '⋀',
|
|
691
|
+
# Misc symbols
|
|
692
|
+
'infty' => '∞', 'partial' => '∂', 'nabla' => '∇',
|
|
693
|
+
'forall' => '∀', 'exists' => '∃', 'nexists' => '∄',
|
|
694
|
+
'emptyset' => '∅', 'varnothing' => '∅',
|
|
695
|
+
'Re' => 'ℜ', 'Im' => 'ℑ', 'aleph' => 'ℵ',
|
|
696
|
+
'ell' => 'ℓ', 'hbar' => 'ℏ', 'wp' => '℘',
|
|
697
|
+
'angle' => '∠', 'triangle' => '△', 'triangledown' => '▽',
|
|
698
|
+
'square' => '□', 'Box' => '□', 'diamond' => '◇',
|
|
699
|
+
'clubsuit' => '♣', 'diamondsuit' => '♢',
|
|
700
|
+
'heartsuit' => '♡', 'spadesuit' => '♠',
|
|
701
|
+
'ldots' => '…', 'cdots' => '⋯', 'vdots' => '⋮',
|
|
702
|
+
'ddots' => '⋱', 'dots' => '…',
|
|
703
|
+
'cong' => '≅', 'models' => '⊨', 'mid' => '∣',
|
|
704
|
+
'nmid' => '∤', 'therefore' => '∴', 'because' => '∵',
|
|
705
|
+
'neg' => '¬', 'lnot' => '¬', 'top' => '⊤', 'bot' => '⊥',
|
|
706
|
+
'degree' => '°', 'prime' => '′', 'dag' => '†',
|
|
707
|
+
'ddag' => '‡', 'S' => '§', 'P' => '¶',
|
|
708
|
+
'pound' => '£', 'euro' => '€', 'yen' => '¥',
|
|
709
|
+
'copyright' => '©', 'circledR' => '®',
|
|
710
|
+
# Delimiters – strip LaTeX wrapper
|
|
711
|
+
'left' => '', 'right' => '', 'bigl' => '', 'bigr' => '',
|
|
712
|
+
'Bigl' => '', 'Bigr' => '', 'biggl' => '', 'biggr' => '',
|
|
713
|
+
# Arrows special
|
|
714
|
+
'gets' => '←',
|
|
715
|
+
# Text sub/sup scripts
|
|
716
|
+
'text' => '',
|
|
717
|
+
}.freeze
|
|
718
|
+
|
|
719
|
+
# Commands whose argument should be preserved verbatim (e.g. \text{abc})
|
|
720
|
+
TEXT_LIKE = %w[text textrm textsf texttt textbf textit].freeze
|
|
721
|
+
|
|
722
|
+
SUPERSCRIPTS = {
|
|
723
|
+
'0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
|
|
724
|
+
'5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
|
|
725
|
+
'+' => '⁺', '-' => '⁻', '=' => '⁼', '(' => '⁽', ')' => '⁾',
|
|
726
|
+
'i' => 'ⁱ', 'n' => 'ⁿ',
|
|
727
|
+
}.freeze
|
|
728
|
+
|
|
729
|
+
SUBSCRIPTS = {
|
|
730
|
+
'0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
|
|
731
|
+
'5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
|
|
732
|
+
'+' => '₊', '-' => '₋', '=' => '₌', '(' => '₍', ')' => '₎',
|
|
733
|
+
'a' => 'ₐ', 'e' => 'ₑ', 'i' => 'ᵢ', 'j' => 'ⱼ',
|
|
734
|
+
'n' => 'ₙ', 'x' => 'ₓ',
|
|
735
|
+
}.freeze
|
|
736
|
+
|
|
737
|
+
def self.convert(formula)
|
|
738
|
+
return formula if formula.nil? || formula.strip.empty?
|
|
739
|
+
|
|
740
|
+
result = formula.dup
|
|
741
|
+
result = process_cases(result)
|
|
742
|
+
result = replace_symbols(result)
|
|
743
|
+
result = process_scripts(result)
|
|
744
|
+
result = process_frac(result)
|
|
745
|
+
result = process_sqrt(result)
|
|
746
|
+
result = strip_delim_spacing(result)
|
|
747
|
+
result
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Find the index of the } that matches the { at `open_pos`.
|
|
751
|
+
# Returns nil when braces are unbalanced.
|
|
752
|
+
def self.find_matching_brace(text, open_pos)
|
|
753
|
+
return nil unless text[open_pos] == '{'
|
|
754
|
+
depth = 1
|
|
755
|
+
i = open_pos + 1
|
|
756
|
+
while i < text.length && depth > 0
|
|
757
|
+
case text[i]
|
|
758
|
+
when '{' then depth += 1
|
|
759
|
+
when '}' then depth -= 1
|
|
760
|
+
when '\\' then i += 1
|
|
761
|
+
end
|
|
762
|
+
i += 1
|
|
763
|
+
end
|
|
764
|
+
depth == 0 ? i - 1 : nil
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# \frac{num}{den} / \dfrac{num}{den} / \tfrac{num}{den}
|
|
768
|
+
# → (num)/(den) when num/den include operators, otherwise num/den
|
|
769
|
+
def self.process_frac(text)
|
|
770
|
+
result = +""
|
|
771
|
+
i = 0
|
|
772
|
+
while i < text.length
|
|
773
|
+
cmd_len = nil
|
|
774
|
+
if text[i..].start_with?('\\dfrac') || text[i..].start_with?('\\tfrac')
|
|
775
|
+
cmd_len = 6
|
|
776
|
+
elsif text[i..].start_with?('\\frac')
|
|
777
|
+
cmd_len = 5
|
|
778
|
+
end
|
|
779
|
+
if cmd_len
|
|
780
|
+
j = i + cmd_len
|
|
781
|
+
while j < text.length && text[j] =~ /\s/
|
|
782
|
+
j += 1
|
|
783
|
+
end
|
|
784
|
+
if j < text.length && text[j] == '{'
|
|
785
|
+
num_start = j
|
|
786
|
+
num_end = find_matching_brace(text, num_start)
|
|
787
|
+
if num_end
|
|
788
|
+
k = num_end + 1
|
|
789
|
+
while k < text.length && text[k] =~ /\s/
|
|
790
|
+
k += 1
|
|
791
|
+
end
|
|
792
|
+
if k < text.length && text[k] == '{'
|
|
793
|
+
den_start = k
|
|
794
|
+
den_end = find_matching_brace(text, den_start)
|
|
795
|
+
if den_end
|
|
796
|
+
num = text[num_start + 1...num_end]
|
|
797
|
+
den = text[den_start + 1...den_end]
|
|
798
|
+
# Only wrap in parens when the expression includes
|
|
799
|
+
# operators that would change precedence without them.
|
|
800
|
+
op_rx = /[+\-±∓×÷=<>]/
|
|
801
|
+
num_wrap = num =~ op_rx ? "(#{num})" : num
|
|
802
|
+
den_wrap = den =~ op_rx ? "(#{den})" : den
|
|
803
|
+
result << "#{num_wrap}/#{den_wrap}"
|
|
804
|
+
i = den_end + 1
|
|
805
|
+
next
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
result << text[i]
|
|
812
|
+
i += 1
|
|
813
|
+
end
|
|
814
|
+
result
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# \sqrt{x} → √(x) \sqrt[n]{x} → ⁿ√(x)
|
|
818
|
+
def self.process_sqrt(text)
|
|
819
|
+
result = +""
|
|
820
|
+
i = 0
|
|
821
|
+
while i < text.length
|
|
822
|
+
if text[i..].start_with?('\\sqrt')
|
|
823
|
+
j = i + 5
|
|
824
|
+
deg_text = nil
|
|
825
|
+
while j < text.length && text[j] =~ /\s/
|
|
826
|
+
j += 1
|
|
827
|
+
end
|
|
828
|
+
if j < text.length && text[j] == '['
|
|
829
|
+
close_br = text.index(']', j)
|
|
830
|
+
if close_br
|
|
831
|
+
deg_text = text[j + 1...close_br]
|
|
832
|
+
j = close_br + 1
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
while j < text.length && text[j] =~ /\s/
|
|
836
|
+
j += 1
|
|
837
|
+
end
|
|
838
|
+
if j < text.length && text[j] == '{'
|
|
839
|
+
rad_start = j
|
|
840
|
+
rad_end = find_matching_brace(text, rad_start)
|
|
841
|
+
if rad_end
|
|
842
|
+
rad = text[rad_start + 1...rad_end]
|
|
843
|
+
prefix = deg_text ? script_chars(deg_text, SUPERSCRIPTS) : ''
|
|
844
|
+
result << "#{prefix}√(#{rad})"
|
|
845
|
+
i = rad_end + 1
|
|
846
|
+
next
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
result << text[i]
|
|
851
|
+
i += 1
|
|
852
|
+
end
|
|
853
|
+
result
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# \begin{cases} ... \end{cases} → ⎧ … ⎨ … ⎩ …
|
|
857
|
+
def self.process_cases(text)
|
|
858
|
+
text.gsub(/\\begin\{cases\}(.*?)\\end\{cases\}/m) do
|
|
859
|
+
body = Regexp.last_match(1).strip
|
|
860
|
+
lines = body.split('\\\\').map(&:strip).reject(&:empty?)
|
|
861
|
+
return '{}' if lines.empty?
|
|
862
|
+
out = +""
|
|
863
|
+
lines.each_with_index do |line, i|
|
|
864
|
+
leader = case i
|
|
865
|
+
when 0 then '⎧'
|
|
866
|
+
when lines.length - 1 then '⎩'
|
|
867
|
+
else '⎨'
|
|
868
|
+
end
|
|
869
|
+
out << "#{leader} #{line.gsub('&', '')}\n"
|
|
870
|
+
end
|
|
871
|
+
out.strip
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
# ^{x} / _{x} → Unicode super/subscript
|
|
876
|
+
def self.process_scripts(text)
|
|
877
|
+
# ^{...}
|
|
878
|
+
text = text.gsub(/\^\{([^}]+)\}/) {
|
|
879
|
+
inner = Regexp.last_match(1)
|
|
880
|
+
inner.include?('\\') ? "^\{#{inner}\}" : script_chars(inner, SUPERSCRIPTS)
|
|
881
|
+
}
|
|
882
|
+
# _{...}
|
|
883
|
+
text = text.gsub(/_\{([^}]+)\}/) {
|
|
884
|
+
inner = Regexp.last_match(1)
|
|
885
|
+
inner.include?('\\') ? "_\{#{inner}\}" : script_chars(inner, SUBSCRIPTS)
|
|
886
|
+
}
|
|
887
|
+
# ^x (single non-whitespace char, not \ or {)
|
|
888
|
+
text = text.gsub(/\^([^\s\\{])/) { SUPERSCRIPTS[Regexp.last_match(1)] || "^#{Regexp.last_match(1)}" }
|
|
889
|
+
# _x (single non-whitespace char, not \ or {)
|
|
890
|
+
text = text.gsub(/_([^\s\\{])/) { SUBSCRIPTS[Regexp.last_match(1)] || "_#{Regexp.last_match(1)}" }
|
|
891
|
+
text
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def self.script_chars(str, map)
|
|
895
|
+
str.each_char.map { |c| map[c] || c }.join
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
# Replace \command tokens with Unicode equivalents.
|
|
899
|
+
def self.replace_symbols(text)
|
|
900
|
+
# Handle brace-wrapped font/formatting commands: \text{ab}, \mathbf{ab}, \mathbb{R}, etc.
|
|
901
|
+
# Strip the wrapper, keep the content.
|
|
902
|
+
text = text.gsub(/\\(?:text\w*|math[bif]|mathbf|mathrm|mathit|mathsf|mathtt|mathcal|mathfrak|mathbb|mathscr|boldsymbol|bm|emph)\s*\{(.*?)\}/) {
|
|
903
|
+
Regexp.last_match(1)
|
|
904
|
+
}
|
|
905
|
+
# Handle font commands with single-char arg (space-separated): \mathbf E
|
|
906
|
+
text = text.gsub(/\\(?:mathbf|mathrm|mathit|mathsf|mathtt|mathcal|mathfrak|mathbb|mathscr|boldsymbol|bm)\s+([a-zA-Z0-9])/) {
|
|
907
|
+
Regexp.last_match(1)
|
|
908
|
+
}
|
|
909
|
+
# Replace all other \commands
|
|
910
|
+
text.gsub(/\\([a-zA-Z]+)/) { |m|
|
|
911
|
+
SYMBOLS[Regexp.last_match(1)] || m
|
|
912
|
+
}
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Remove stray spaces inserted by \left / \right.
|
|
916
|
+
def self.strip_delim_spacing(text)
|
|
917
|
+
text.gsub(/\(\s+/, '(').gsub(/\s+\)/, ')')
|
|
918
|
+
.gsub(/\[\s+/, '[').gsub(/\s+\]/, ']')
|
|
919
|
+
.gsub(/\{\s+/, '{').gsub(/\s+\}/, '}')
|
|
920
|
+
.gsub(/\\s+/, ' ')
|
|
921
|
+
.gsub(/([·×÷]) +/, '\1')
|
|
922
|
+
.gsub(/ +([·×÷])/, '\1')
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# ---- Mermaid diagram renderer ----
|
|
927
|
+
# Renders pie charts inline; other diagram types show source with a
|
|
928
|
+
# hint to install `mmdc` for full rendering.
|
|
929
|
+
module MermaidRenderer
|
|
930
|
+
BAR_MAX = 32
|
|
931
|
+
|
|
932
|
+
LEAF_BIN = "leaf"
|
|
933
|
+
|
|
934
|
+
def self.leaf_available?
|
|
935
|
+
@leaf_available ||= system("which #{LEAF_BIN} > /dev/null 2>&1")
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
def self.render_via_leaf(source, width)
|
|
939
|
+
return nil unless leaf_available?
|
|
940
|
+
IO.popen([LEAF_BIN, "--inline", "plain:#{width}"], "r+", err: "/dev/null") do |io|
|
|
941
|
+
io.write(source)
|
|
942
|
+
io.close_write
|
|
943
|
+
io.read.strip
|
|
944
|
+
end
|
|
945
|
+
rescue
|
|
946
|
+
nil
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def self.render(source, width = 80)
|
|
950
|
+
trimmed = source.strip
|
|
951
|
+
return "" if trimmed.empty?
|
|
952
|
+
|
|
953
|
+
type = detect_type(trimmed)
|
|
954
|
+
case type
|
|
955
|
+
when :pie
|
|
956
|
+
render_pie(trimmed, width)
|
|
957
|
+
when :flowchart, :sequence, :class, :gantt, :state, :generic
|
|
958
|
+
# Prefer leaf for high-quality ASCII-art rendering
|
|
959
|
+
result = render_via_leaf("```mermaid\n#{trimmed}\n```\n", width)
|
|
960
|
+
result && !result.empty? ? result : render_fallback(trimmed, type, width)
|
|
961
|
+
else
|
|
962
|
+
render_fallback(trimmed, type, width)
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def self.detect_type(source)
|
|
967
|
+
first = source.lines.first&.strip&.downcase || ""
|
|
968
|
+
return :pie if first.start_with?("pie")
|
|
969
|
+
return :flowchart if first.start_with?("flowchart") || first.start_with?("graph")
|
|
970
|
+
return :sequence if first.start_with?("sequencediagram")
|
|
971
|
+
return :class if first.start_with?("classdiagram")
|
|
972
|
+
return :gantt if first.start_with?("gantt")
|
|
973
|
+
return :state if first.start_with?("statediagram")
|
|
974
|
+
:generic
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# Pie chart → horizontal bar chart with percentage labels.
|
|
978
|
+
def self.render_pie(source, width)
|
|
979
|
+
title = ""
|
|
980
|
+
entries = []
|
|
981
|
+
source.each_line do |line|
|
|
982
|
+
line = line.strip
|
|
983
|
+
next if line.empty?
|
|
984
|
+
if line.downcase.start_with?("pie")
|
|
985
|
+
rest = line[3..].strip
|
|
986
|
+
if rest.downcase.start_with?("title")
|
|
987
|
+
title = rest[5..].strip
|
|
988
|
+
end
|
|
989
|
+
next
|
|
990
|
+
end
|
|
991
|
+
if line.downcase.start_with?("title")
|
|
992
|
+
title = line[5..].strip
|
|
993
|
+
next
|
|
994
|
+
end
|
|
995
|
+
# Parse "label" : value
|
|
996
|
+
label_part, value_part = line.split(":", 2).map(&:strip)
|
|
997
|
+
next unless label_part && value_part
|
|
998
|
+
label = label_part.delete_prefix('"').delete_suffix('"')
|
|
999
|
+
value = value_part.to_f
|
|
1000
|
+
entries << [label, value] if value > 0
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
return "[Mermaid pie: no data]" if entries.empty?
|
|
1004
|
+
|
|
1005
|
+
total = entries.sum { |_l, v| v }
|
|
1006
|
+
return "[Mermaid pie: total is zero]" if total <= 0
|
|
1007
|
+
|
|
1008
|
+
max_label = entries.map { |l, _| l.length }.max
|
|
1009
|
+
out = +""
|
|
1010
|
+
out << "#{tc(:heading_3)}#{title}#{AnsiCode.reset}\n" unless title.empty?
|
|
1011
|
+
|
|
1012
|
+
entries.each do |label, value|
|
|
1013
|
+
pct = value / total * 100.0
|
|
1014
|
+
filled = (pct / 100.0 * BAR_MAX).round
|
|
1015
|
+
half = (pct / 100.0 * BAR_MAX * 2).round % 2 == 1
|
|
1016
|
+
bar = "█" * filled + (half ? "▌" : "")
|
|
1017
|
+
out << sprintf("%-#{BAR_MAX + 1}s %-#{max_label}s %5.1f%%\n", bar, label, pct)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
out.strip
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
# Fallback: show diagram source with a labelled header.
|
|
1024
|
+
def self.render_fallback(source, type, width)
|
|
1025
|
+
label = type.to_s.capitalize
|
|
1026
|
+
pad = (width - label.length - 2).clamp(2, 60)
|
|
1027
|
+
lines = source.lines.map(&:chomp)
|
|
1028
|
+
[
|
|
1029
|
+
"#{tc(:code_border)}┌─ #{label} #{'─' * pad}┐#{AnsiCode.reset}",
|
|
1030
|
+
*lines.map { |l| "#{tc(:code_border)}│#{AnsiCode.reset} #{l}" },
|
|
1031
|
+
"#{tc(:code_border)}└#{'─' * (width - 2)}┘#{AnsiCode.reset}",
|
|
1032
|
+
"#{tc(:muted || :heading_4_6)}Install mmdc (npm i -g @mermaid-js/mermaid-cli) for full diagram rendering.#{AnsiCode.reset}",
|
|
1033
|
+
].join("\n")
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# Flowchart / graph → edge-list rendering with node labels.
|
|
1037
|
+
def self.render_flowchart(source, width)
|
|
1038
|
+
lines = source.lines.map(&:chomp)
|
|
1039
|
+
# Build node registry: id => label
|
|
1040
|
+
nodes = {}
|
|
1041
|
+
edges = []
|
|
1042
|
+
|
|
1043
|
+
lines.each do |line|
|
|
1044
|
+
stripped = line.strip
|
|
1045
|
+
next if stripped.empty?
|
|
1046
|
+
next if stripped.downcase.start_with?("flowchart", "graph")
|
|
1047
|
+
|
|
1048
|
+
# Parse edge: src ---|label|---> tgt
|
|
1049
|
+
m = stripped.match(
|
|
1050
|
+
/\A(.+?)\s*(-+>|==+>|-\.+>|=+>)\s*(\|(.*?)\|)?\s*(.+)\z/
|
|
1051
|
+
)
|
|
1052
|
+
if m
|
|
1053
|
+
src_raw = m[1].strip
|
|
1054
|
+
tgt_raw = m[5].strip
|
|
1055
|
+
arrow = m[2]
|
|
1056
|
+
label = m[4]&.strip
|
|
1057
|
+
|
|
1058
|
+
src_id, src_lbl = parse_node(src_raw)
|
|
1059
|
+
tgt_id, tgt_lbl = parse_node(tgt_raw)
|
|
1060
|
+
|
|
1061
|
+
# Only store shaped labels — don't let bare IDs overwrite them
|
|
1062
|
+
nodes[src_id] = src_lbl if src_lbl && src_raw =~ /[\[\(\{]/
|
|
1063
|
+
nodes[tgt_id] = tgt_lbl if tgt_lbl && tgt_raw =~ /[\[\(\{]/
|
|
1064
|
+
|
|
1065
|
+
edges << {
|
|
1066
|
+
src: src_id, src_label: src_lbl || src_id,
|
|
1067
|
+
tgt: tgt_id, tgt_label: tgt_lbl || tgt_id,
|
|
1068
|
+
edge_label: label
|
|
1069
|
+
}
|
|
1070
|
+
next
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
# Standalone node definition: id[text] / id{text} / id(text)
|
|
1074
|
+
nm = stripped.match(/\A([A-Za-z0-9_]+)\s*[\[\(\{].+[\]\)\}]\z/)
|
|
1075
|
+
if nm
|
|
1076
|
+
nid, nlbl = parse_node(stripped)
|
|
1077
|
+
nodes[nid] = nlbl if nlbl
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
return "[Mermaid flowchart: no edges found]" if edges.empty?
|
|
1082
|
+
|
|
1083
|
+
out = +""
|
|
1084
|
+
edges.each do |e|
|
|
1085
|
+
src = nodes[e[:src]] || e[:src_label]
|
|
1086
|
+
tgt = nodes[e[:tgt]] || e[:tgt_label]
|
|
1087
|
+
lbl = e[:edge_label] ? " ─#{e[:edge_label]}─▶ " : " ──▶ "
|
|
1088
|
+
out << "#{src}#{lbl}#{tgt}\n"
|
|
1089
|
+
end
|
|
1090
|
+
out.strip
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# Extract [id, label] from a node token like "A[开始]" or "B{是否通过?}"
|
|
1094
|
+
def self.parse_node(raw)
|
|
1095
|
+
raw = raw.strip
|
|
1096
|
+
# Square brackets
|
|
1097
|
+
if raw =~ /\A([A-Za-z0-9_]+)\s*\[(.+)\]\z/
|
|
1098
|
+
[$1, $2]
|
|
1099
|
+
# Curly braces (diamond)
|
|
1100
|
+
elsif raw =~ /\A([A-Za-z0-9_]+)\s*\{(.+)\}\z/
|
|
1101
|
+
[$1, $2]
|
|
1102
|
+
# Round parens
|
|
1103
|
+
elsif raw =~ /\A([A-Za-z0-9_]+)\s*\((.+)\)\z/
|
|
1104
|
+
[$1, $2]
|
|
1105
|
+
# Just an id
|
|
1106
|
+
elsif raw =~ /\A([A-Za-z0-9_]+)\z/
|
|
1107
|
+
[$1, $1]
|
|
1108
|
+
else
|
|
1109
|
+
[raw, raw]
|
|
1110
|
+
end
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
# Sequence diagram → participant-message listing.
|
|
1114
|
+
def self.render_sequence(source, width)
|
|
1115
|
+
lines = source.lines.map(&:chomp)
|
|
1116
|
+
participants = []
|
|
1117
|
+
messages = []
|
|
1118
|
+
|
|
1119
|
+
lines.each do |line|
|
|
1120
|
+
stripped = line.strip
|
|
1121
|
+
next if stripped.empty? || stripped.downcase.start_with?("sequencediagram")
|
|
1122
|
+
|
|
1123
|
+
# participant / actor definition
|
|
1124
|
+
if stripped =~ /\A(?:participant|actor)\s+(.+)\z/i
|
|
1125
|
+
participants << $1.strip
|
|
1126
|
+
next
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
# Note
|
|
1130
|
+
if stripped =~ /\ANote\s+(?:over\s+)?(.+?):\s*(.+)\z/i
|
|
1131
|
+
messages << { type: :note, target: $1.strip, text: $2.strip }
|
|
1132
|
+
next
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# Message: A->>B: text / A-->>B: text / A-)B: text
|
|
1136
|
+
if stripped =~ /\A(.+?)\s*(-+>>?|-->>|-\)|-[xX])\s*(.+?)\s*:\s*(.+)\z/
|
|
1137
|
+
src = $1.strip
|
|
1138
|
+
arrow_type = $2.strip
|
|
1139
|
+
tgt = $3.strip
|
|
1140
|
+
text = $4.strip
|
|
1141
|
+
participants |= [src, tgt] unless participants.include?(src) && participants.include?(tgt)
|
|
1142
|
+
dashed = arrow_type.start_with?("--")
|
|
1143
|
+
messages << { type: :msg, src: src, tgt: tgt, text: text, dashed: dashed }
|
|
1144
|
+
end
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
return "[Mermaid sequence: no messages found]" if messages.empty?
|
|
1148
|
+
|
|
1149
|
+
max_participant = participants.map(&:length).max
|
|
1150
|
+
max_participant = 8 if max_participant < 8
|
|
1151
|
+
|
|
1152
|
+
out = +""
|
|
1153
|
+
participants.each do |p|
|
|
1154
|
+
out << sprintf("%-#{max_participant + 4}s", "[#{p}]")
|
|
1155
|
+
end
|
|
1156
|
+
out << "\n#{'─' * ((max_participant + 4) * participants.size)}\n"
|
|
1157
|
+
|
|
1158
|
+
messages.each do |m|
|
|
1159
|
+
case m[:type]
|
|
1160
|
+
when :note
|
|
1161
|
+
out << " 📝 #{m[:target]}: #{m[:text]}\n"
|
|
1162
|
+
when :msg
|
|
1163
|
+
src_idx = participants.index(m[:src]) || 0
|
|
1164
|
+
tgt_idx = participants.index(m[:tgt]) || participants.size - 1
|
|
1165
|
+
rightward = src_idx <= tgt_idx
|
|
1166
|
+
if m[:dashed]
|
|
1167
|
+
arrow = rightward ? "╌╌▶" : "◀╌╌"
|
|
1168
|
+
else
|
|
1169
|
+
arrow = rightward ? "──▶" : "◀──"
|
|
1170
|
+
end
|
|
1171
|
+
out << sprintf(" %-#{max_participant}s #{arrow} %s: %s\n",
|
|
1172
|
+
m[:src], m[:tgt], m[:text])
|
|
1173
|
+
end
|
|
1174
|
+
end
|
|
1175
|
+
out.strip
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
# Proxy theme colour access (same instance as TerminalConverter).
|
|
1179
|
+
def self.tc(key)
|
|
1180
|
+
color, bright = MarkdownTheme[key]
|
|
1181
|
+
AnsiCode.color(color, bright)
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
def self.AnsiCode
|
|
1185
|
+
::RubyRich::AnsiCode
|
|
468
1186
|
end
|
|
469
1187
|
end
|
|
470
1188
|
|
|
@@ -480,6 +1198,83 @@ module RubyRich
|
|
|
480
1198
|
end
|
|
481
1199
|
end
|
|
482
1200
|
|
|
1201
|
+
# ---- Frontmatter extraction ----
|
|
1202
|
+
# Extracts YAML-style frontmatter (delimited by ---) and returns
|
|
1203
|
+
# [content_without_fm, parsed_pairs, is_vertical].
|
|
1204
|
+
module Frontmatter
|
|
1205
|
+
VERTICAL_THRESHOLD = 5
|
|
1206
|
+
|
|
1207
|
+
def self.extract(markdown_text)
|
|
1208
|
+
# Strip leading blank lines so that a heredoc like <<~'MD'\n\n---\n
|
|
1209
|
+
# is still recognised as having frontmatter.
|
|
1210
|
+
stripped = markdown_text.lstrip
|
|
1211
|
+
return [markdown_text, nil, false] unless stripped.start_with?("---")
|
|
1212
|
+
|
|
1213
|
+
rest = stripped[3..]
|
|
1214
|
+
offset = 3
|
|
1215
|
+
rest.each_line do |line|
|
|
1216
|
+
trimmed = line.strip
|
|
1217
|
+
if trimmed == "---" || trimmed == "..."
|
|
1218
|
+
fm_block = stripped[3...offset]
|
|
1219
|
+
content = stripped[(offset + line.length)..] || ""
|
|
1220
|
+
pairs = parse_pairs(fm_block)
|
|
1221
|
+
return [markdown_text, nil, false] if pairs.empty?
|
|
1222
|
+
vertical = pairs.length >= VERTICAL_THRESHOLD
|
|
1223
|
+
return [content, pairs, vertical]
|
|
1224
|
+
end
|
|
1225
|
+
offset += line.length
|
|
1226
|
+
end
|
|
1227
|
+
[markdown_text, nil, false]
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def self.parse_pairs(block)
|
|
1231
|
+
pairs = []
|
|
1232
|
+
lines = block.lines.map(&:chomp)
|
|
1233
|
+
i = 0
|
|
1234
|
+
while i < lines.length
|
|
1235
|
+
trimmed = lines[i].strip
|
|
1236
|
+
i += 1 and next if trimmed.empty? || trimmed.start_with?('#')
|
|
1237
|
+
|
|
1238
|
+
colon_pos = trimmed.index(':')
|
|
1239
|
+
i += 1 and next unless colon_pos
|
|
1240
|
+
|
|
1241
|
+
key = trimmed[0...colon_pos].strip
|
|
1242
|
+
raw_value = trimmed[(colon_pos + 1)..].strip
|
|
1243
|
+
i += 1 and next if key.empty?
|
|
1244
|
+
|
|
1245
|
+
if [">-", ">", "|", "|-"].include?(raw_value)
|
|
1246
|
+
# Multiline value with explicit indicator
|
|
1247
|
+
i += 1
|
|
1248
|
+
parts = []
|
|
1249
|
+
while i < lines.length && lines[i].start_with?(' ', "\t")
|
|
1250
|
+
part = lines[i].strip
|
|
1251
|
+
parts << part unless part.empty?
|
|
1252
|
+
i += 1
|
|
1253
|
+
end
|
|
1254
|
+
pairs << [key, parts.join(" ")]
|
|
1255
|
+
elsif raw_value.empty?
|
|
1256
|
+
# Empty value: could be a list or an implicit multiline string.
|
|
1257
|
+
i += 1
|
|
1258
|
+
items = []
|
|
1259
|
+
while i < lines.length && lines[i].start_with?(' ', "\t")
|
|
1260
|
+
item = lines[i].strip
|
|
1261
|
+
items << (item.start_with?("- ") ? item[2..].strip : item)
|
|
1262
|
+
i += 1
|
|
1263
|
+
end
|
|
1264
|
+
pairs << [key, items.empty? ? "" : items.join(", ")]
|
|
1265
|
+
else
|
|
1266
|
+
pairs << [key, unquote(raw_value)]
|
|
1267
|
+
i += 1
|
|
1268
|
+
end
|
|
1269
|
+
end
|
|
1270
|
+
pairs
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
def self.unquote(s)
|
|
1274
|
+
(s.length >= 2 && ((s.start_with?('"') && s.end_with?('"')) || (s.start_with?("'") && s.end_with?("'")))) ? s[1...-1] : s
|
|
1275
|
+
end
|
|
1276
|
+
end
|
|
1277
|
+
|
|
483
1278
|
# ---- 公开 API ----
|
|
484
1279
|
|
|
485
1280
|
# 渲染 Markdown 文本为 ANSI 终端输出
|
|
@@ -499,6 +1294,26 @@ module RubyRich
|
|
|
499
1294
|
table_border_style: options[:table_border_style] || :simple
|
|
500
1295
|
}
|
|
501
1296
|
|
|
1297
|
+
# Pre-process frontmatter
|
|
1298
|
+
content, fm_pairs, fm_vertical = Frontmatter.extract(markdown_text)
|
|
1299
|
+
|
|
1300
|
+
# Pre-process inline math $...$ (kramdown needs a math-engine gem for this)
|
|
1301
|
+
math_color = AnsiCode.color(*MarkdownTheme[:math])
|
|
1302
|
+
content = content.gsub(/(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/) do
|
|
1303
|
+
rendered = TerminalConverter::LatexConverter.convert(Regexp.last_match(1).strip)
|
|
1304
|
+
"#{math_color}#{rendered}#{AnsiCode.reset}"
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
fm_output = ""
|
|
1308
|
+
if fm_pairs && !fm_pairs.empty?
|
|
1309
|
+
if fm_vertical
|
|
1310
|
+
# Vertical frontmatter: one column per key-value pair
|
|
1311
|
+
fm_output = render_frontmatter_vertical(fm_pairs, converter_options)
|
|
1312
|
+
else
|
|
1313
|
+
fm_output = render_frontmatter_horizontal(fm_pairs, converter_options)
|
|
1314
|
+
end
|
|
1315
|
+
end
|
|
1316
|
+
|
|
502
1317
|
kramdown_opts = {
|
|
503
1318
|
input: 'GFM', # GitHub Flavored Markdown
|
|
504
1319
|
syntax_highlighter: nil, # 自行处理语法高亮
|
|
@@ -507,9 +1322,31 @@ module RubyRich
|
|
|
507
1322
|
line_width: converter_options[:width]
|
|
508
1323
|
}.merge(options[:kramdown] || {})
|
|
509
1324
|
|
|
510
|
-
doc = Kramdown::Document.new(
|
|
1325
|
+
doc = Kramdown::Document.new(content, kramdown_opts)
|
|
511
1326
|
result, _warnings = TerminalConverter.convert(doc.root, converter_options)
|
|
512
|
-
result
|
|
1327
|
+
"#{fm_output}#{result}"
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
# Render frontmatter as a vertical key-value table (many pairs).
|
|
1331
|
+
def self.render_frontmatter_vertical(pairs, opts)
|
|
1332
|
+
tbl = RubyRich::Table.new(
|
|
1333
|
+
headers: %w[Key Value],
|
|
1334
|
+
border_style: opts[:table_border_style] || :simple
|
|
1335
|
+
)
|
|
1336
|
+
pairs.each { |k, v| tbl.add_row([k, v]) }
|
|
1337
|
+
"#{tbl.render}\n\n"
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
# Render frontmatter as a horizontal 2-row table (few pairs).
|
|
1341
|
+
def self.render_frontmatter_horizontal(pairs, opts)
|
|
1342
|
+
keys = pairs.map(&:first)
|
|
1343
|
+
vals = pairs.map(&:last)
|
|
1344
|
+
tbl = RubyRich::Table.new(
|
|
1345
|
+
headers: keys,
|
|
1346
|
+
border_style: opts[:table_border_style] || :simple
|
|
1347
|
+
)
|
|
1348
|
+
tbl.add_row(vals)
|
|
1349
|
+
"#{tbl.render}\n\n"
|
|
513
1350
|
end
|
|
514
1351
|
|
|
515
1352
|
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
|
|
130
|
-
content = bold ? rendered :
|
|
131
|
-
aligned_content = align_cell(content, column_widths[i])
|
|
129
|
+
rendered = cell.render.sub(/\e\[0m\z/, '')
|
|
130
|
+
content = bold ? "\e[1m#{rendered}" : rendered
|
|
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
|
|
217
|
-
content = bold ? rendered :
|
|
218
|
-
align_cell(content, column_widths[i])
|
|
219
|
-
end.join(" | ").prepend("| ").concat("
|
|
216
|
+
rendered = cell.render.sub(/\e\[0m\z/, '')
|
|
217
|
+
content = bold ? "\e[1m#{rendered}" : rendered
|
|
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