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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 852764b68219de1ce528f772a55c3e454b3556c35aa9f0c0e89c29f31c2293ba
4
- data.tar.gz: e2827430cb5dada9ae7d8ca1cb59d9fbca4df95790404c1bcb893e7323864b95
3
+ metadata.gz: 35642b63217134791fd7201a1b26488473164d74646bfa3b2492294611e92340
4
+ data.tar.gz: 3c9633c93770785e7b003b81fb8ff484c21114184ac2b0615fee1bd16380fbca
5
5
  SHA512:
6
- metadata.gz: 95388b58b409347a55ebd1ef3dded1d3520810c7f31292bb83e14dd73192f356ec453b5688dd9dc122ae1e626ff365d6738c2d9a4de799adf53df2b2cced8916
7
- data.tar.gz: 342ed768c7a9f04fadc1da1ec1d775fbbbdf482fc446f55ed69b4eb880159118cd36f6860c11964ddcc7371e70bcc35adcca0203183fcc558a604bd43786091e
6
+ metadata.gz: 21e958777b0107f4a92b7de61aa725cca02951b5aff8874c3ab9149b1c8cb45c1998d39f93529e04d92e2e45c4ba67ad4f053568ea1dfe503bd6d9568c7f511d
7
+ data.tar.gz: b5abb3cf83b0f53100f13286dcb388a54b9238503ff276f2a591a71d873eaaef116a4dc599175b75023388f95f30d6934da36071fddb88cecff367b1e07c2910
@@ -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/, '').length
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
- "#{AnsiCode.font(:cyan, font_bright: true, bold: true)}#{text}#{AnsiCode.reset}\n" \
107
- "#{AnsiCode.color(:cyan, true)}#{'=' * [vw, 1].max}#{AnsiCode.reset}\n\n"
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
- "#{AnsiCode.font(:blue, font_bright: true, bold: true)}#{text}#{AnsiCode.reset}\n" \
110
- "#{AnsiCode.color(:blue, true)}#{'-' * [vw, 1].max}#{AnsiCode.reset}\n\n"
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
- "#{AnsiCode.font(:yellow, font_bright: true, bold: true)}### #{text}#{AnsiCode.reset}\n\n"
170
+ "#{tfont(:heading_3, bold: true)}### #{text}#{AnsiCode.reset}\n\n"
113
171
  else
114
172
  prefix = '#' * level
115
- "#{AnsiCode.font(:black, font_bright: true, bold: true)}#{prefix} #{text}#{AnsiCode.reset}\n\n"
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.strip
123
- highlighted = Syntax.highlight(code, lang)
124
- bg = AnsiCode.background(:black, true)
125
- fg = AnsiCode.color(:white, true)
126
- "#{bg}#{fg}#{indent_lines(highlighted)}#{AnsiCode.reset}\n\n"
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
- "#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
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
- "#{AnsiCode.color(:black, true)}│ #{AnsiCode.color(:white, true)}#{strip_ansi_reset(line)}"
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
- marker = if el.options[:parent]&.type == :ol
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
- text = inline_content(el)
161
- "#{AnsiCode.color(:cyan, true)}#{marker}#{AnsiCode.reset} #{text.strip}\n"
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
- "#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{text}#{AnsiCode.reset} " \
186
- "#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
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
- "#{AnsiCode.color(:magenta, true)}[Image: #{alt}]#{AnsiCode.reset} " \
195
- "#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
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
- "#{AnsiCode.color(:black, true)}#{line_char * @width}#{AnsiCode.reset}\n\n"
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
- "#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{content} #{AnsiCode.reset}"
551
+ "#{tbg(:kbd_bg)}#{tc(:kbd_fg)} #{content} #{AnsiCode.reset}"
386
552
  when 'mark'
387
- "#{AnsiCode.background(:yellow)}#{AnsiCode.color(:black)}#{content}#{AnsiCode.reset}"
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
- "#{AnsiCode.color(:magenta, true)}[^#{name}]#{AnsiCode.reset}"
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}#{AnsiCode.color(:black, true)}(#{title})#{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
- "#{AnsiCode.color(:magenta, true)}[Math]\n#{formula}\n[/Math]#{AnsiCode.reset}\n\n"
632
+ "#{color}#{rendered}#{AnsiCode.reset}\n\n"
466
633
  else
467
- "#{AnsiCode.color(:magenta, true)}[Math: #{formula}]#{AnsiCode.reset}"
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(markdown_text, kramdown_opts)
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 = {})
@@ -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 : align_cell(rendered, column_widths[i])
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 : align_cell(rendered, column_widths[i])
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 + "\e[0m"
285
+ style_sequences.first + padded_content
286
286
  else
287
287
  padded_content
288
288
  end
@@ -1,3 +1,3 @@
1
1
  module RubyRich
2
- VERSION = "0.4.9"
2
+ VERSION = "0.5.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_rich
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.9
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei