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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 852764b68219de1ce528f772a55c3e454b3556c35aa9f0c0e89c29f31c2293ba
4
- data.tar.gz: e2827430cb5dada9ae7d8ca1cb59d9fbca4df95790404c1bcb893e7323864b95
3
+ metadata.gz: 6db3483399e193c2a8feb97472df06341391d165f07aafde1af299a0892bb83c
4
+ data.tar.gz: 71ef2db35e2b369953c7201e13bc751001f02815dd2717ea3358964ff12b30b3
5
5
  SHA512:
6
- metadata.gz: 95388b58b409347a55ebd1ef3dded1d3520810c7f31292bb83e14dd73192f356ec453b5688dd9dc122ae1e626ff365d6738c2d9a4de799adf53df2b2cced8916
7
- data.tar.gz: 342ed768c7a9f04fadc1da1ec1d775fbbbdf482fc446f55ed69b4eb880159118cd36f6860c11964ddcc7371e70bcc35adcca0203183fcc558a604bd43786091e
6
+ metadata.gz: d4d3b29b2e73d8ebe31e883ba0001cb207f65b2683897d861601918e097823bce393302dd51b276fe15c71608959d28d61c6413e82c26fc0226245cf9b2c358b
7
+ data.tar.gz: 41274198774c77707e3d052a9b81b1d1e18d2ae293a63b0dadcdbf508b1d74c3892203433d2a4cd92759275ce44b31906f83bd4ee3e10a128fefe3e8b890bcd5
@@ -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,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
- marker = if el.options[:parent]&.type == :ol
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
- "#{AnsiCode.color(:cyan, true)}#{marker}#{AnsiCode.reset} #{text.strip}\n"
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
- "#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{text}#{AnsiCode.reset} " \
186
- "#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
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
- "#{AnsiCode.color(:magenta, true)}[Image: #{alt}]#{AnsiCode.reset} " \
195
- "#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
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
- "#{AnsiCode.color(:black, true)}#{line_char * @width}#{AnsiCode.reset}\n\n"
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
- "#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{content} #{AnsiCode.reset}"
550
+ "#{tbg(:kbd_bg)}#{tc(:kbd_fg)} #{content} #{AnsiCode.reset}"
386
551
  when 'mark'
387
- "#{AnsiCode.background(:yellow)}#{AnsiCode.color(:black)}#{content}#{AnsiCode.reset}"
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
- "#{AnsiCode.color(:magenta, true)}[^#{name}]#{AnsiCode.reset}"
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}#{AnsiCode.color(:black, true)}(#{title})#{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
- "#{AnsiCode.color(:magenta, true)}[Math]\n#{formula}\n[/Math]#{AnsiCode.reset}\n\n"
631
+ "#{color}#{rendered}#{AnsiCode.reset}\n\n"
466
632
  else
467
- "#{AnsiCode.color(:magenta, true)}[Math: #{formula}]#{AnsiCode.reset}"
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(markdown_text, kramdown_opts)
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 = {})
@@ -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 + "\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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei