ruby_rich 0.4.7 → 0.4.9

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: f66e41bbaf0499f11d62693a7d39addf65dd1bdf5e86f2a90a1f4048293bc639
4
- data.tar.gz: cccaf6008a1c66532bb8efea80433b76532ee281e8c45f7ea9d29b39f315e0fa
3
+ metadata.gz: 852764b68219de1ce528f772a55c3e454b3556c35aa9f0c0e89c29f31c2293ba
4
+ data.tar.gz: e2827430cb5dada9ae7d8ca1cb59d9fbca4df95790404c1bcb893e7323864b95
5
5
  SHA512:
6
- metadata.gz: 9a7da5985548800496d5f896fbd2d1335673140d3337375d4e44e25df4ec577c74026e8af363e601d3bf9a5813f670d7c2251839a5b9cab0bafef77a1f588239
7
- data.tar.gz: 6c5cc67a1c8a8a85622021e17b5fdb6b65504c273713668ede8b1f641eb33ce2344fd14b852310f17761c026d044a9049a51f74cb8c8d053f294578c0a938c13
6
+ metadata.gz: 95388b58b409347a55ebd1ef3dded1d3520810c7f31292bb83e14dd73192f356ec453b5688dd9dc122ae1e626ff365d6738c2d9a4de799adf53df2b2cced8916
7
+ data.tar.gz: 342ed768c7a9f04fadc1da1ec1d775fbbbdf482fc446f55ed69b4eb880159118cd36f6860c11964ddcc7371e70bcc35adcca0203183fcc558a604bd43786091e
@@ -1,213 +1,515 @@
1
- require 'redcarpet'
1
+ require 'kramdown'
2
2
 
3
3
  module RubyRich
4
4
  class Markdown
5
- # Converts markdown to ANSI-styled terminal output.
6
- # Uses Redcarpet for block parsing with custom inline processing.
7
- class TerminalRenderer < Redcarpet::Render::Base
8
- INLINE_MARKERS = {
9
- # triple-backtick must come before double-backtick
10
- %r{```(.+?)```}m => ->(m) { codespan_compat(Regexp.last_match(1)) },
11
- %r{``(.+?)``}m => ->(m) { codespan_compat(Regexp.last_match(1)) },
12
- %r{`(.+?)`} => ->(m) { codespan_compat(Regexp.last_match(1)) },
13
- %r{\*\*\*(.+?)\*\*\*} => ->(m) { "#{AnsiCode.bold}#{AnsiCode.italic}#{Regexp.last_match(1)}#{AnsiCode.reset}" },
14
- %r{\*\*(.+?)\*\*} => ->(m) { "#{AnsiCode.bold}#{Regexp.last_match(1)}#{AnsiCode.reset}" },
15
- %r{(?<!\*)\*([^*]+)\*(?!\*)} => ->(m) { "#{AnsiCode.italic}#{Regexp.last_match(1)}#{AnsiCode.reset}" },
16
- %r{~~(.+?)~~} => ->(m) { "#{AnsiCode.strikethrough}#{Regexp.last_match(1)}#{AnsiCode.reset}" },
17
- %r{\[([^\]]+)\]\(([^)]+)\)} => ->(m) {
18
- link_text = Regexp.last_match(1)
19
- url = Regexp.last_match(2)
20
- "#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{link_text}#{AnsiCode.reset} #{AnsiCode.color(:black, true)}(#{url})#{AnsiCode.reset}"
21
- }
22
- }.freeze
23
-
24
- def initialize(options = {})
25
- @options = {
26
- width: 80,
27
- indent: ' '
28
- }.merge(options)
29
- super()
30
- reset_table_state
31
- end
32
-
33
- def reset_table_state
34
- @table_state = { current_row: [], all_rows: [] }
35
- end
36
-
37
- # ---- block-level callbacks ----
38
-
39
- def paragraph(text)
40
- "#{process_inline(text)}\n\n"
41
- end
42
-
43
- def header(text, level)
44
- processed = process_inline(text)
5
+ # Markdown 转换为 ANSI 终端输出。
6
+ # 使用 kramdown AST 遍历 + 自定义 Converter 实现。
7
+ #
8
+ # kramdown 比 redcarpet 的优势:
9
+ # - Ruby 实现,无需 C 扩展编译
10
+ # - 原生 GFM 支持(表格、任务列表、删除线)
11
+ # - 定义列表 (definition lists)
12
+ # - 脚注 (footnotes)
13
+ # - 数学公式 ( math engine)
14
+ # - 缩写 (abbreviations)
15
+ # - 活跃维护
16
+ class TerminalConverter < Kramdown::Converter::Base
17
+
18
+ def initialize(root, options)
19
+ super
20
+ @width = options[:width] || 80
21
+ @indent_str = options[:indent] || ' '
22
+ @table_border_style = options[:table_border_style] || :simple
23
+ # 用于有序列表编号
24
+ @list_counters = []
25
+ end
26
+
27
+ # 主分发方法 — 根据 AST 元素类型路由到对应处理方法
28
+ def convert(el, _indent = 0)
29
+ case el.type
30
+ when :root then convert_children(el)
31
+ when :blank then "\n"
32
+ when :text then el.value
33
+ when :p then convert_p(el)
34
+ when :header then convert_header(el)
35
+ when :codeblock then convert_codeblock(el)
36
+ when :codespan then convert_codespan(el)
37
+ when :blockquote then convert_blockquote(el)
38
+ when :ul then convert_list(el)
39
+ when :ol then convert_list(el)
40
+ when :li then convert_li(el)
41
+ when :em then convert_em(el)
42
+ when :strong then convert_strong(el)
43
+ when :em_strong then convert_em_strong(el)
44
+ when :a then convert_link(el)
45
+ when :img then convert_image(el)
46
+ when :hr then convert_hr(el)
47
+ when :br then "\n"
48
+ when :table then convert_table(el)
49
+ when :thead then convert_children(el)
50
+ when :tbody then convert_children(el)
51
+ when :tr then convert_table_row(el)
52
+ when :th then convert_table_cell(el)
53
+ when :td then convert_table_cell(el)
54
+ when :html_element then convert_html_element(el)
55
+ when :html_entity then convert_html_entity(el)
56
+ when :smart_quote then convert_smart_quote(el)
57
+ when :entity then el.value.to_s
58
+ when :raw then convert_raw(el)
59
+ when :comment then ''
60
+ when :footnote then convert_footnote(el)
61
+ when :dl then convert_definition_list(el)
62
+ when :dt then convert_definition_term(el)
63
+ when :dd then convert_definition_desc(el)
64
+ when :abbreviation then convert_abbreviation(el)
65
+ when :math then convert_math(el)
66
+ else
67
+ # 未知类型,递归处理子元素
68
+ if el.children && !el.children.empty?
69
+ convert_children(el)
70
+ else
71
+ el.value || el.text || ''
72
+ end
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ # ---- 辅助方法 ----
79
+
80
+ def convert_children(el)
81
+ return '' unless el.children
82
+ el.children.map { |child| convert(child) }.join
83
+ end
84
+
85
+ def inline_content(el)
86
+ return '' unless el.children
87
+ el.children.map { |child| convert(child) }.join
88
+ end
89
+
90
+ def visible_width(text)
91
+ text.to_s.gsub(/\e\[[0-9;]*m/, '').length
92
+ end
93
+
94
+ # ---- 块级元素 ----
95
+
96
+ def convert_p(el)
97
+ "#{inline_content(el)}\n\n"
98
+ end
99
+
100
+ def convert_header(el)
101
+ level = el.options[:level] || 1
102
+ text = inline_content(el)
103
+ vw = visible_width(text)
45
104
  case level
46
- when 1 then "#{AnsiCode.font(:cyan, font_bright: true, bold: true)}#{processed}#{AnsiCode.reset}\n#{AnsiCode.color(:cyan, true)}#{'=' * visible_width(text)}#{AnsiCode.reset}\n\n"
47
- when 2 then "#{AnsiCode.font(:blue, font_bright: true, bold: true)}#{processed}#{AnsiCode.reset}\n#{AnsiCode.color(:blue, true)}#{'-' * visible_width(text)}#{AnsiCode.reset}\n\n"
48
- when 3 then "#{AnsiCode.font(:yellow, font_bright: true, bold: true)}### #{processed}#{AnsiCode.reset}\n\n"
49
- else "#{AnsiCode.font(:black, font_bright: true, bold: true)}#{'#' * level} #{processed}#{AnsiCode.reset}\n\n"
105
+ 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"
108
+ 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"
111
+ when 3
112
+ "#{AnsiCode.font(:yellow, font_bright: true, bold: true)}### #{text}#{AnsiCode.reset}\n\n"
113
+ else
114
+ prefix = '#' * level
115
+ "#{AnsiCode.font(:black, font_bright: true, bold: true)}#{prefix} #{text}#{AnsiCode.reset}\n\n"
50
116
  end
51
117
  end
52
118
 
53
- def block_code(code, language)
54
- lang = language&.strip
119
+ def convert_codeblock(el)
120
+ lang = el.options[:lang]&.strip
55
121
  lang = nil if lang && lang.empty?
56
- highlighted = Syntax.highlight(code.strip, lang)
57
- bg = AnsiCode.background(:black, true)
58
- fg = AnsiCode.color(:white, true)
59
- pad = @options[:indent]
122
+ code = el.value.strip
123
+ highlighted = Syntax.highlight(code, lang)
124
+ bg = AnsiCode.background(:black, true)
125
+ fg = AnsiCode.color(:white, true)
60
126
  "#{bg}#{fg}#{indent_lines(highlighted)}#{AnsiCode.reset}\n\n"
61
127
  end
62
128
 
63
- def codespan(code)
129
+ def convert_codespan(el)
130
+ code = el.value
64
131
  "#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
65
132
  end
66
133
 
67
- def block_quote(quote)
68
- lines = quote.strip.split("\n")
69
- quoted_lines = lines.map { |line| "#{AnsiCode.color(:black, true)}│ #{AnsiCode.color(:white, true)}#{process_inline(line.strip)}" }
70
- "#{quoted_lines.join("\n")}#{AnsiCode.reset}\n\n"
134
+ def convert_blockquote(el)
135
+ content = inline_content(el)
136
+ lines = content.strip.split("\n")
137
+ quoted = lines.map do |line|
138
+ "#{AnsiCode.color(:black, true)}│ #{AnsiCode.color(:white, true)}#{strip_ansi_reset(line)}"
139
+ end
140
+ "#{quoted.join("\n")}#{AnsiCode.reset}\n\n"
71
141
  end
72
142
 
73
- def list_item(text, list_type)
74
- marker = list_type == :ordered ? '1.' : '•'
75
- "#{AnsiCode.color(:cyan, true)}#{marker}#{AnsiCode.reset} #{process_inline(text.strip)}\n"
143
+ # ---- 列表 ----
144
+
145
+ def convert_list(el)
146
+ # 保存/恢复计数器,支持嵌套列表
147
+ @list_counters.push(0)
148
+ result = convert_children(el)
149
+ @list_counters.pop
150
+ "#{result}\n"
76
151
  end
77
152
 
78
- def list(contents, list_type)
79
- "#{contents}\n"
153
+ def convert_li(el)
154
+ marker = if el.options[:parent]&.type == :ol
155
+ @list_counters[-1] += 1
156
+ "#{@list_counters[-1]}."
157
+ else
158
+ '•'
159
+ end
160
+ text = inline_content(el)
161
+ "#{AnsiCode.color(:cyan, true)}#{marker}#{AnsiCode.reset} #{text.strip}\n"
80
162
  end
81
163
 
82
- def emphasis(text)
83
- "#{AnsiCode.italic}#{text}#{AnsiCode.reset}"
164
+ # ---- 内联样式 ----
165
+
166
+ def convert_em(el)
167
+ "#{AnsiCode.italic}#{inline_content(el)}#{AnsiCode.reset}"
84
168
  end
85
- def double_emphasis(text)
86
- "#{AnsiCode.bold}#{text}#{AnsiCode.reset}"
169
+
170
+ def convert_strong(el)
171
+ "#{AnsiCode.bold}#{inline_content(el)}#{AnsiCode.reset}"
87
172
  end
88
- def strikethrough(text)
89
- "#{AnsiCode.strikethrough}#{text}#{AnsiCode.reset}"
173
+
174
+ def convert_em_strong(el)
175
+ "#{AnsiCode.bold}#{AnsiCode.italic}#{inline_content(el)}#{AnsiCode.reset}"
90
176
  end
91
177
 
92
- def link(link, title, content)
178
+ # ---- 链接与图片 ----
179
+
180
+ def convert_link(el)
181
+ url = el.attr['href'] || ''
182
+ title = el.attr['title']
183
+ text = inline_content(el)
93
184
  title_part = title && !title.empty? ? " - #{title}" : ""
94
- "#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{content}#{AnsiCode.reset} #{AnsiCode.color(:black, true)}(#{link}#{title_part})#{AnsiCode.reset}"
185
+ "#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{text}#{AnsiCode.reset} " \
186
+ "#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
95
187
  end
96
188
 
97
- def image(link, title, alt_text)
189
+ def convert_image(el)
190
+ url = el.attr['src'] || ''
191
+ title = el.attr['title']
192
+ alt = el.attr['alt'] || ''
98
193
  title_part = title && !title.empty? ? " - #{title}" : ""
99
- "#{AnsiCode.color(:magenta, true)}[Image: #{alt_text}]#{AnsiCode.reset} #{AnsiCode.color(:black, true)}(#{link}#{title_part})#{AnsiCode.reset}"
194
+ "#{AnsiCode.color(:magenta, true)}[Image: #{alt}]#{AnsiCode.reset} " \
195
+ "#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
100
196
  end
101
197
 
102
- def hrule
103
- "#{AnsiCode.color(:black, true)}#{"─" * @options[:width]}#{AnsiCode.reset}\n\n"
104
- end
198
+ # ---- 水平线 ----
105
199
 
106
- def linebreak
107
- "\n"
200
+ def convert_hr(_el)
201
+ line_char = '─'
202
+ "#{AnsiCode.color(:black, true)}#{line_char * @width}#{AnsiCode.reset}\n\n"
108
203
  end
109
204
 
110
- # ---- table callbacks ----
111
-
112
- def table(header, body)
113
- all_rows = @table_state[:all_rows]
114
- reset_table_state
115
- return "" if all_rows.empty?
116
-
117
- header_line_count = [header.to_s.strip.split("\n").size, 1].max
118
- header_rows = all_rows[0...header_line_count]
119
- body_rows = all_rows[header_line_count..] || []
205
+ # ---- 表格(kramdown 原生 AST) ----
206
+
207
+ def convert_table(el)
208
+ # 收集表头行和表体行
209
+ header_rows = []
210
+ body_rows = []
211
+ el.children.each do |section|
212
+ case section.type
213
+ when :thead
214
+ section.children.each { |tr| header_rows << collect_row_cells(tr) }
215
+ when :tbody
216
+ section.children.each { |tr| body_rows << collect_row_cells(tr) }
217
+ when :tr
218
+ body_rows << collect_row_cells(section)
219
+ end
220
+ end
120
221
 
121
222
  return "" if header_rows.empty? || body_rows.empty?
122
223
 
123
- headers = header_rows.last.map { |c| process_inline(c) }
224
+ headers, fitted_body_rows = fit_table_rows(header_rows.last, body_rows)
124
225
  begin
125
- tbl = RubyRich::Table.new(headers: headers, border_style: @options[:table_border_style] || :simple)
126
- body_rows.each do |row|
127
- processed = row.map { |c| process_inline(c) }
128
- padded = processed + Array.new([0, headers.length - processed.length].max, "")
226
+ tbl = RubyRich::Table.new(
227
+ headers: headers,
228
+ border_style: @table_border_style || :simple
229
+ )
230
+ fitted_body_rows.each do |row|
231
+ padded = row + Array.new([0, headers.length - row.length].max, "")
129
232
  tbl.add_row(padded[0...headers.length])
130
233
  end
131
- return "#{tbl.render}\n\n"
234
+ "#{tbl.render}\n\n"
132
235
  rescue
133
- # fallback
236
+ # fallback: plain text table
237
+ result = "\n"
238
+ result += header_rows.last.join(" | ")
239
+ result += "\n#{"-" * [result.strip.length, 20].min}\n"
240
+ body_rows.each { |row| result += row.join(" | ") + "\n" }
241
+ return "#{result}\n"
134
242
  end
243
+ end
135
244
 
136
- result = "\n"
137
- result += "#{header.strip}\n"
138
- result += "#{"-" * [header.strip.length, 20].min}\n"
139
- result += "#{body.strip}\n" if body && !body.strip.empty?
140
- "#{result}\n"
245
+ # Extract cell text from a table row element.
246
+ def collect_row_cells(tr)
247
+ tr.children.select { |c| [:th, :td].include?(c.type) }
248
+ .map { |c| inline_content(c) }
141
249
  end
142
250
 
143
- def table_row(content)
144
- @table_state[:all_rows] << @table_state[:current_row].dup
145
- @table_state[:current_row] = []
146
- "#{content}\n"
251
+ def convert_table_row(el)
252
+ convert_children(el)
147
253
  end
148
254
 
149
- def table_cell(content, alignment)
150
- @table_state[:current_row] << content.strip
151
- content
255
+ def convert_table_cell(el)
256
+ inline_content(el)
152
257
  end
153
258
 
154
- private
259
+ # ---- 表格宽度自适应 ----
260
+
261
+ # Fit table cell content to terminal width by normalising column counts,
262
+ # calculating natural widths, constraining to available space, and wrapping
263
+ # cell text.
264
+ def fit_table_rows(header_row, body_rows)
265
+ column_count = [header_row.length, *body_rows.map(&:length)].max.to_i
266
+ normalized_header = header_row + Array.new([0, column_count - header_row.length].max, "")
267
+ normalized_body = body_rows.map { |row| row + Array.new([0, column_count - row.length].max, "") }
268
+ natural_widths = table_natural_widths(normalized_header, normalized_body)
269
+ column_widths = constrain_table_widths(natural_widths)
270
+
271
+ headers = normalized_header.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
272
+ rows = normalized_body.map do |row|
273
+ row.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
274
+ end
155
275
 
156
- def process_inline(text)
157
- return text if text.nil? || text.empty?
276
+ [headers, rows]
277
+ end
278
+
279
+ # Maximum display width per column.
280
+ def table_natural_widths(header_row, body_rows)
281
+ rows = [header_row] + body_rows
282
+ return [] if rows.empty?
158
283
 
159
- result = text.dup
160
- INLINE_MARKERS.each do |regex, handler|
161
- result.gsub!(regex, &handler)
284
+ rows.transpose.map do |cells|
285
+ cells.map { |cell| cell_display_width(table_cell_text(cell)) }.max.to_i
162
286
  end
163
- result
164
287
  end
165
288
 
166
- def self.codespan_compat(code)
167
- "#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
289
+ # Strip ANSI escape sequences from a cell value.
290
+ def table_cell_text(cell)
291
+ cell.to_s.gsub(/\e\[[0-9;:]*m/, "")
292
+ end
293
+
294
+ # Shrink column widths proportionally to fit the terminal width.
295
+ def constrain_table_widths(natural_widths)
296
+ return natural_widths if natural_widths.empty?
297
+
298
+ border_overhead = (natural_widths.length * 3) + 1
299
+ max_table_width = [[(@width || 80).to_i - 1, 20].max, border_overhead + natural_widths.length].max
300
+ available_content_width = [max_table_width - border_overhead, natural_widths.length].max
301
+ widths = natural_widths.map { |width| [width, 1].max }
302
+ return widths if widths.sum <= available_content_width
303
+
304
+ min_width = available_content_width < natural_widths.length * 3 ? 1 : 3
305
+ while widths.sum > available_content_width
306
+ index = widths.each_with_index.select { |width, _| width > min_width }.max_by(&:first)&.last
307
+ break unless index
308
+
309
+ widths[index] -= 1
310
+ end
311
+ widths
312
+ end
313
+
314
+ # Wrap cell text to fit a given display width, splitting across newlines
315
+ # and wrapping long lines.
316
+ def wrap_table_cell(text, width)
317
+ width = [width.to_i, 1].max
318
+ text.to_s.split("\n", -1).flat_map do |line|
319
+ wrap_table_line(line, width)
320
+ end.join("\n")
168
321
  end
169
322
 
170
- def wrap_text(text, width = nil)
171
- width ||= @options[:width]
172
- return text if text.length <= width
323
+ # Wrap a single line of text to the given display width, preserving any
324
+ # ANSI escape sequences (re-emitted on each wrapped segment).
325
+ def wrap_table_line(line, width)
326
+ return [""] if line.empty?
173
327
 
174
- words = text.split(' ')
175
328
  lines = []
176
- current_line = ''
329
+ current = +""
330
+ current_width = 0
331
+ in_escape = false
332
+ escape = +""
333
+
334
+ line.each_char do |char|
335
+ if in_escape
336
+ escape << char
337
+ if char == "m"
338
+ current << escape
339
+ escape = +""
340
+ in_escape = false
341
+ end
342
+ next
343
+ elsif char.ord == 27
344
+ escape << char
345
+ in_escape = true
346
+ next
347
+ end
177
348
 
178
- words.each do |word|
179
- if (current_line + ' ' + word).length <= width
180
- current_line += current_line.empty? ? word : ' ' + word
181
- else
182
- lines << current_line unless current_line.empty?
183
- current_line = word
349
+ char_width = Unicode::DisplayWidth.of(char)
350
+ if current_width.positive? && current_width + char_width > width
351
+ lines << current
352
+ current = +""
353
+ current_width = 0
184
354
  end
355
+ current << char
356
+ current_width += char_width
357
+ end
358
+
359
+ lines << current unless current.empty?
360
+ lines.empty? ? [""] : lines
361
+ end
362
+
363
+ # Display width of text after stripping ANSI escape sequences, taking the
364
+ # maximum across lines (for multi-line cells).
365
+ def cell_display_width(text)
366
+ text.to_s.gsub(/\e\[[0-9;:]*m/, "").split("\n").map(&:display_width).max.to_i
367
+ end
368
+
369
+ # ---- HTML 元素处理 ----
370
+
371
+ def convert_html_element(el)
372
+ tag = el.value.to_s.downcase
373
+ content = inline_content(el)
374
+
375
+ case tag
376
+ when 'del', 's', 'strike'
377
+ "#{AnsiCode.strikethrough}#{content}#{AnsiCode.reset}"
378
+ when 'ins', 'u'
379
+ "#{AnsiCode.underline}#{content}#{AnsiCode.reset}"
380
+ when 'sub'
381
+ content # 终端无下标,保留文本
382
+ when 'sup'
383
+ content # 终端无上标,保留文本
384
+ when 'kbd'
385
+ "#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{content} #{AnsiCode.reset}"
386
+ when 'mark'
387
+ "#{AnsiCode.background(:yellow)}#{AnsiCode.color(:black)}#{content}#{AnsiCode.reset}"
388
+ when 'details', 'summary'
389
+ content
390
+ when 'br'
391
+ "\n"
392
+ when 'hr'
393
+ convert_hr(nil)
394
+ else
395
+ content
396
+ end
397
+ end
398
+
399
+ def convert_html_entity(el)
400
+ # kramdown 已解析 HTML 实体为 UTF-8 字符
401
+ el.value.to_s
402
+ end
403
+
404
+ # ---- 智能引号 ----
405
+
406
+ def convert_smart_quote(el)
407
+ case el.value
408
+ when :lsquo then "'"
409
+ when :rsquo then "'"
410
+ when :ldquo then '"'
411
+ when :rdquo then '"'
412
+ else el.value.to_s
413
+ end
414
+ end
415
+
416
+ # ---- 原始内容 ----
417
+
418
+ def convert_raw(el)
419
+ # 原始 HTML,在终端中通常跳过
420
+ el.value.to_s.start_with?('<') ? '' : el.value.to_s
421
+ end
422
+
423
+ # ---- 脚注 ----
424
+
425
+ def convert_footnote(el)
426
+ # 脚注内容在文档末尾自动收集
427
+ content = inline_content(el)
428
+ name = el.options[:name]
429
+ "#{AnsiCode.color(:magenta, true)}[^#{name}]#{AnsiCode.reset}"
430
+ end
431
+
432
+ # ---- 定义列表(kramdown 独有) ----
433
+
434
+ def convert_definition_list(el)
435
+ "#{convert_children(el)}\n"
436
+ end
437
+
438
+ def convert_definition_term(el)
439
+ "#{AnsiCode.bold}#{inline_content(el)}#{AnsiCode.reset}\n"
440
+ end
441
+
442
+ def convert_definition_desc(el)
443
+ "#{@indent_str}#{inline_content(el)}\n\n"
444
+ end
445
+
446
+ # ---- 缩写(kramdown 独有) ----
447
+
448
+ def convert_abbreviation(el)
449
+ title = el.attr['title']
450
+ text = inline_content(el)
451
+ if title && !title.empty?
452
+ "#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{AnsiCode.color(:black, true)}(#{title})#{AnsiCode.reset}"
453
+ else
454
+ text
185
455
  end
456
+ end
457
+
458
+ # ---- 数学公式(kramdown 独有,需 math engine) ----
186
459
 
187
- lines << current_line unless current_line.empty?
188
- lines.join("\n")
460
+ def convert_math(el)
461
+ # 在终端中显示原始 LaTeX 公式
462
+ mode = el.options[:category] == :block ? 'block' : 'inline'
463
+ formula = el.value.to_s.strip
464
+ if mode == 'block'
465
+ "#{AnsiCode.color(:magenta, true)}[Math]\n#{formula}\n[/Math]#{AnsiCode.reset}\n\n"
466
+ else
467
+ "#{AnsiCode.color(:magenta, true)}[Math: #{formula}]#{AnsiCode.reset}"
468
+ end
189
469
  end
190
470
 
471
+ # ---- 辅助 ----
472
+
191
473
  def indent_lines(text)
192
- text.split("\n").map { |line| "#{@options[:indent]}#{line}" }.join("\n")
474
+ text.split("\n").map { |line| "#{@indent_str}#{line}" }.join("\n")
193
475
  end
194
476
 
195
- def visible_width(text)
196
- text.gsub(/\e\[[0-9;]*m/, '').length
477
+ def strip_ansi_reset(text)
478
+ # 去掉末尾 AnsiCode.reset,让 blockquote 统一添加
479
+ text.gsub(/\e\[0m$/, '')
197
480
  end
198
481
  end
199
482
 
483
+ # ---- 公开 API ----
484
+
485
+ # 渲染 Markdown 文本为 ANSI 终端输出
486
+ #
487
+ # @param markdown_text [String] 输入的 Markdown 文本
488
+ # @param options [Hash] 渲染选项
489
+ # @option options [Integer] :width 终端宽度(默认 80)
490
+ # @option options [String] :indent 缩进字符串(默认 ' ')
491
+ # @option options [Symbol] :table_border_style 表格边框样式 (:none, :simple, :full)
492
+ # @option options [Hash] :kramdown 传递给 Kramdown::Document 的额外选项
493
+ #
494
+ # @return [String] ANSI 格式的终端输出
200
495
  def self.render(markdown_text, options = {})
201
- renderer = TerminalRenderer.new(options)
202
- markdown_processor = Redcarpet::Markdown.new(renderer, {
203
- fenced_code_blocks: true,
204
- tables: true,
205
- autolink: true,
206
- strikethrough: true,
207
- space_after_headers: true
208
- })
209
-
210
- markdown_processor.render(markdown_text)
496
+ converter_options = {
497
+ width: options[:width] || 80,
498
+ indent: options[:indent] || ' ',
499
+ table_border_style: options[:table_border_style] || :simple
500
+ }
501
+
502
+ kramdown_opts = {
503
+ input: 'GFM', # GitHub Flavored Markdown
504
+ syntax_highlighter: nil, # 自行处理语法高亮
505
+ hard_wrap: false,
506
+ html_to_native: true,
507
+ line_width: converter_options[:width]
508
+ }.merge(options[:kramdown] || {})
509
+
510
+ doc = Kramdown::Document.new(markdown_text, kramdown_opts)
511
+ result, _warnings = TerminalConverter.convert(doc.root, converter_options)
512
+ result
211
513
  end
212
514
 
213
515
  def initialize(options = {})
@@ -218,4 +520,4 @@ module RubyRich
218
520
  self.class.render(markdown_text, @options)
219
521
  end
220
522
  end
221
- end
523
+ end
@@ -412,6 +412,8 @@ module RubyRich
412
412
  private
413
413
 
414
414
  def render_entry(entry, index)
415
+ return entry.content.to_s.split("\n", -1) if entry.metadata[:plain]
416
+
415
417
  case entry.type
416
418
  when :user
417
419
  render_plain_message(entry.content, first_prefix: "#{AnsiCode.color(:blue, true)}●#{AnsiCode.reset} ", rest_prefix: " ")
@@ -1,3 +1,3 @@
1
1
  module RubyRich
2
- VERSION = "0.4.7"
2
+ VERSION = "0.4.9"
3
3
  end
@@ -1,11 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "base64"
4
+
3
5
  module RubyRich
4
6
  class Viewport
5
7
  attr_accessor :width, :height, :scroll_top
6
8
  attr_reader :content, :selected_text
7
9
 
8
- def initialize(content = "", scrollbar: true, auto_scroll: false, scrollbar_style: :blue)
10
+ # drag_mode controls what left-click drag does in the content area:
11
+ # :viewport – drag scrolls the viewport (default, backward-compatible)
12
+ # :selection – drag selects text
13
+ DRAG_MODES = [:viewport, :selection].freeze
14
+
15
+ def initialize(content = "",
16
+ scrollbar: true, auto_scroll: false,
17
+ scrollbar_style: :blue, auto_copy: true,
18
+ drag_mode: :viewport)
9
19
  @content = content
10
20
  @scrollbar = scrollbar
11
21
  @auto_scroll = auto_scroll
@@ -22,6 +32,8 @@ module RubyRich
22
32
  @selection_end = nil
23
33
  @selected_text = ""
24
34
  @focused = true
35
+ @auto_copy = auto_copy
36
+ @drag_mode = DRAG_MODES.include?(drag_mode) ? drag_mode : :viewport
25
37
  @rendered_lines_cache_key = nil
26
38
  @rendered_lines_cache = nil
27
39
  end
@@ -88,10 +100,14 @@ module RubyRich
88
100
  true
89
101
  when :mouse_down
90
102
  return copy_selection if event_data[:button] == :right
91
-
92
- start_scrollbar_drag(event_data, layout) || start_viewport_drag(event_data, layout)
103
+ if @drag_mode == :selection
104
+ start_scrollbar_drag(event_data, layout) || start_selection(event_data, layout)
105
+ else
106
+ start_scrollbar_drag(event_data, layout) || start_viewport_drag(event_data, layout)
107
+ end
93
108
  when :mouse_drag
94
- drag_scrollbar(event_data, layout) || drag_viewport(event_data, layout) || drag_selection(event_data, layout)
109
+ drag_scrollbar(event_data, layout) ||
110
+ (@drag_mode == :selection ? drag_selection(event_data, layout) : (drag_viewport(event_data, layout) || drag_selection(event_data, layout)))
95
111
  when :mouse_up
96
112
  stop_scrollbar_drag || stop_viewport_drag || stop_selection
97
113
  else
@@ -415,7 +431,7 @@ module RubyRich
415
431
 
416
432
  @selecting = false
417
433
  @selected_text = extract_selected_text
418
- copy_selection
434
+ copy_selection if @auto_copy
419
435
  true
420
436
  end
421
437
 
@@ -477,6 +493,7 @@ module RubyRich
477
493
  escape << char
478
494
  if char == "m"
479
495
  result << escape
496
+ result << AnsiCode.inverse if active
480
497
  escape = +""
481
498
  in_escape = false
482
499
  end
@@ -531,18 +548,61 @@ module RubyRich
531
548
  text.gsub(/\e\[[0-9;:]*m/, "")
532
549
  end
533
550
 
551
+ # Copy text to the system clipboard, trying every available method in
552
+ # sequence and falling back to the OSC 52 terminal clipboard so that
553
+ # remote (SSH) sessions also work.
534
554
  def copy_to_clipboard(text)
535
- if RubyRich::Terminal.windows?
536
- copy_to_windows_clipboard(text)
537
- elsif ENV["WAYLAND_DISPLAY"]
538
- IO.popen("wl-copy", "w") { |io| io.write(text) }
539
- elsif ENV["DISPLAY"]
540
- IO.popen("xclip -selection clipboard", "w") { |io| io.write(text) }
541
- elsif RUBY_PLATFORM.match?(/darwin/)
542
- IO.popen("pbcopy", "w") { |io| io.write(text) }
555
+ text = text.to_s
556
+ return false if text.empty?
557
+ return true if RubyRich::Terminal.windows? && try_windows_clipboard(text)
558
+
559
+ clipboard_commands.each do |command|
560
+ return true if write_clipboard_command(command, text)
561
+ end
562
+
563
+ copy_to_terminal_clipboard(text)
564
+ end
565
+
566
+ # Ordered list of [command, *args] arrays to try for clipboard access.
567
+ def clipboard_commands
568
+ commands = []
569
+ commands << ["wl-copy"] if ENV["WAYLAND_DISPLAY"]
570
+ if ENV["DISPLAY"]
571
+ commands << ["xclip", "-selection", "clipboard"]
572
+ commands << ["xsel", "--clipboard", "--input"]
543
573
  end
574
+ commands << ["pbcopy"] if RUBY_PLATFORM.match?(/darwin/)
575
+ commands
576
+ end
577
+
578
+ # Attempt to write text to a given clipboard command via a pipe.
579
+ # Returns true when the subprocess exits successfully.
580
+ def write_clipboard_command(command, text)
581
+ IO.popen(command, "w") { |io| io.write(text) }
582
+ $?&.success? == true
544
583
  rescue IOError, SystemCallError
545
- nil
584
+ false
585
+ end
586
+
587
+ # Try the OS-specific Windows clipboard helper and return true when it
588
+ # succeeds (any IO/Syscall error is treated as failure).
589
+ def try_windows_clipboard(text)
590
+ copy_to_windows_clipboard(text)
591
+ true
592
+ rescue IOError, SystemCallError
593
+ false
594
+ end
595
+
596
+ # OSC 52 terminal clipboard – the only fallback that works over SSH.
597
+ # Encoded payload is emitted on stdout for the hosting terminal to
598
+ # capture.
599
+ def copy_to_terminal_clipboard(text)
600
+ encoded = Base64.strict_encode64(text.encode(Encoding::UTF_8))
601
+ $stdout.print("\e]52;c;#{encoded}\a")
602
+ $stdout.flush
603
+ true
604
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError, IOError, SystemCallError
605
+ false
546
606
  end
547
607
 
548
608
  def copy_to_windows_clipboard(text)
@@ -559,4 +619,4 @@ module RubyRich
559
619
  end
560
620
  end
561
621
  end
562
- end
622
+ end
data/lib/ruby_rich.rb CHANGED
@@ -5,7 +5,7 @@ require 'logger'
5
5
  require 'rouge'
6
6
  require 'tty-cursor'
7
7
  require 'tty-screen'
8
- require 'redcarpet'
8
+ require 'kramdown'
9
9
 
10
10
  # Load all internal modules
11
11
  require_relative 'ruby_rich/console'
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.7
4
+ version: 0.4.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei
@@ -86,19 +86,33 @@ dependencies:
86
86
  - !ruby/object:Gem::Version
87
87
  version: 0.8.2
88
88
  - !ruby/object:Gem::Dependency
89
- name: redcarpet
89
+ name: kramdown
90
90
  requirement: !ruby/object:Gem::Requirement
91
91
  requirements:
92
92
  - - "~>"
93
93
  - !ruby/object:Gem::Version
94
- version: 3.6.1
94
+ version: '2.4'
95
95
  type: :runtime
96
96
  prerelease: false
97
97
  version_requirements: !ruby/object:Gem::Requirement
98
98
  requirements:
99
99
  - - "~>"
100
100
  - !ruby/object:Gem::Version
101
- version: 3.6.1
101
+ version: '2.4'
102
+ - !ruby/object:Gem::Dependency
103
+ name: kramdown-parser-gfm
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '1.1'
109
+ type: :runtime
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '1.1'
102
116
  - !ruby/object:Gem::Dependency
103
117
  name: unicode-display_width
104
118
  requirement: !ruby/object:Gem::Requirement
@@ -176,7 +190,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
176
190
  - !ruby/object:Gem::Version
177
191
  version: '0'
178
192
  requirements: []
179
- rubygems_version: 4.0.10
193
+ rubygems_version: 4.0.13
180
194
  specification_version: 4
181
195
  summary: Rich text formatting and console output for Ruby
182
196
  test_files: []