ruby_rich 0.5.0 → 0.5.2

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: 6db3483399e193c2a8feb97472df06341391d165f07aafde1af299a0892bb83c
4
- data.tar.gz: 71ef2db35e2b369953c7201e13bc751001f02815dd2717ea3358964ff12b30b3
3
+ metadata.gz: 0245a4d95c0aa26f8f5ec3075ee2d5eaf8aecb151cd3f62635369da837d0a7e9
4
+ data.tar.gz: dad422979d8bb74c9be0cc6ecf2fb32166b54958ea117cbae1cc1daa2a86dcf3
5
5
  SHA512:
6
- metadata.gz: d4d3b29b2e73d8ebe31e883ba0001cb207f65b2683897d861601918e097823bce393302dd51b276fe15c71608959d28d61c6413e82c26fc0226245cf9b2c358b
7
- data.tar.gz: 41274198774c77707e3d052a9b81b1d1e18d2ae293a63b0dadcdbf508b1d74c3892203433d2a4cd92759275ce44b31906f83bd4ee3e10a128fefe3e8b890bcd5
6
+ metadata.gz: e2aec4aecc0f07687a61cb4f1c1d34dd5f2fcd8618541355f1fa521aaf040688454bae6ac4dfe3dc13571c53f7816333d8a9d155e77b29e5fb4760b051e93cb4
7
+ data.tar.gz: e0c2aae4eb11397504b4d5ee9324102df7156504d5ac60a85132528cb949deba16b57d47a29809626aa65cb3b57e6a514f0d0206332c51aa333f2f3c78efedb4
@@ -185,11 +185,18 @@ module RubyRich
185
185
  code_lines.pop while code_lines.last&.strip&.empty?
186
186
  return "#{tc(:code_border)}┌─ #{lang || "text"} ─┐#{AnsiCode.reset}\n#{tc(:code_border)}└──┘#{AnsiCode.reset}\n\n" if code_lines.empty?
187
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
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
+ # LaTeX math block support (```latex ... ``` / ```tex ... ```)
195
+ if lang == "latex" || lang == "tex"
196
+ rendered = LatexConverter.convert(code)
197
+ color = tc(:math)
198
+ return "#{color}#{rendered}#{AnsiCode.reset}\n\n"
199
+ end
193
200
 
194
201
  total_lines = code_lines.length
195
202
  digit_width = [total_lines.to_s.length, 1].max
@@ -286,8 +293,9 @@ module RubyRich
286
293
  UNORDERED_MARKERS = { 1 => '•', 2 => '◦', 3 => '▸' }.freeze
287
294
 
288
295
  def convert_li(el)
289
- depth = [@list_counters.length, 1].max
296
+ depth = [@list_types.length, 1].max
290
297
  list_type = @list_types.last
298
+ indent = " " * (depth - 1)
291
299
  marker = if list_type == :ol
292
300
  @list_counters[-1] += 1
293
301
  "#{@list_counters[-1]}."
@@ -295,11 +303,11 @@ module RubyRich
295
303
  UNORDERED_MARKERS[depth.clamp(1, 3)] || '▸'
296
304
  end
297
305
  task = detect_task_marker(el)
298
- text = inline_content(el)
306
+ text = inline_content(el).gsub(/\n{2,}/, "\n")
299
307
  if task
300
- "#{tc(task ? :task_checked : :task_unchecked)}#{task}#{AnsiCode.reset} #{text.strip}\n"
308
+ "#{indent}#{tc(task ? :task_checked : :task_unchecked)}#{task}#{AnsiCode.reset} #{text.strip}\n"
301
309
  else
302
- "#{tc(list_type == :ol ? :ordered_list : :"list_level_#{depth.clamp(1, 3)}")}#{marker}#{AnsiCode.reset} #{text.strip}\n"
310
+ "#{indent}#{tc(list_type == :ol ? :ordered_list : :"list_level_#{depth.clamp(1, 3)}")}#{marker}#{AnsiCode.reset} #{text.strip}\n"
303
311
  end
304
312
  end
305
313
 
@@ -737,34 +745,119 @@ module RubyRich
737
745
  return formula if formula.nil? || formula.strip.empty?
738
746
 
739
747
  result = formula.dup
740
- result = process_frac(result)
741
- result = process_sqrt(result)
742
748
  result = process_cases(result)
743
- result = process_scripts(result)
744
749
  result = replace_symbols(result)
750
+ result = process_scripts(result)
751
+ result = process_frac(result)
752
+ result = process_sqrt(result)
745
753
  result = strip_delim_spacing(result)
746
754
  result
747
755
  end
748
756
 
749
- # \frac{num}{den} → (num)/(den) or num/den when single-char
757
+ # Find the index of the } that matches the { at `open_pos`.
758
+ # Returns nil when braces are unbalanced.
759
+ def self.find_matching_brace(text, open_pos)
760
+ return nil unless text[open_pos] == '{'
761
+ depth = 1
762
+ i = open_pos + 1
763
+ while i < text.length && depth > 0
764
+ case text[i]
765
+ when '{' then depth += 1
766
+ when '}' then depth -= 1
767
+ when '\\' then i += 1
768
+ end
769
+ i += 1
770
+ end
771
+ depth == 0 ? i - 1 : nil
772
+ end
773
+
774
+ # \frac{num}{den} / \dfrac{num}{den} / \tfrac{num}{den}
775
+ # → (num)/(den) when num/den include operators, otherwise num/den
750
776
  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}"
777
+ result = +""
778
+ i = 0
779
+ while i < text.length
780
+ cmd_len = nil
781
+ if text[i..].start_with?('\\dfrac') || text[i..].start_with?('\\tfrac')
782
+ cmd_len = 6
783
+ elsif text[i..].start_with?('\\frac')
784
+ cmd_len = 5
785
+ end
786
+ if cmd_len
787
+ j = i + cmd_len
788
+ while j < text.length && text[j] =~ /\s/
789
+ j += 1
790
+ end
791
+ if j < text.length && text[j] == '{'
792
+ num_start = j
793
+ num_end = find_matching_brace(text, num_start)
794
+ if num_end
795
+ k = num_end + 1
796
+ while k < text.length && text[k] =~ /\s/
797
+ k += 1
798
+ end
799
+ if k < text.length && text[k] == '{'
800
+ den_start = k
801
+ den_end = find_matching_brace(text, den_start)
802
+ if den_end
803
+ num = text[num_start + 1...num_end]
804
+ den = text[den_start + 1...den_end]
805
+ # Only wrap in parens when the expression includes
806
+ # operators that would change precedence without them.
807
+ op_rx = /[+\-±∓×÷=<>]/
808
+ num_wrap = num =~ op_rx ? "(#{num})" : num
809
+ den_wrap = den =~ op_rx ? "(#{den})" : den
810
+ result << "#{num_wrap}/#{den_wrap}"
811
+ i = den_end + 1
812
+ next
813
+ end
814
+ end
815
+ end
816
+ end
817
+ end
818
+ result << text[i]
819
+ i += 1
757
820
  end
821
+ result
758
822
  end
759
823
 
760
824
  # \sqrt{x} → √(x) \sqrt[n]{x} → ⁿ√(x)
761
825
  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})"
826
+ result = +""
827
+ i = 0
828
+ while i < text.length
829
+ if text[i..].start_with?('\\sqrt')
830
+ j = i + 5
831
+ deg_text = nil
832
+ while j < text.length && text[j] =~ /\s/
833
+ j += 1
834
+ end
835
+ if j < text.length && text[j] == '['
836
+ close_br = text.index(']', j)
837
+ if close_br
838
+ deg_text = text[j + 1...close_br]
839
+ j = close_br + 1
840
+ end
841
+ end
842
+ while j < text.length && text[j] =~ /\s/
843
+ j += 1
844
+ end
845
+ if j < text.length && text[j] == '{'
846
+ rad_start = j
847
+ rad_end = find_matching_brace(text, rad_start)
848
+ if rad_end
849
+ rad = text[rad_start + 1...rad_end]
850
+ prefix = deg_text ? script_chars(deg_text, SUPERSCRIPTS) : ''
851
+ result << "#{prefix}√(#{rad})"
852
+ i = rad_end + 1
853
+ next
854
+ end
855
+ end
856
+ end
857
+ result << text[i]
858
+ i += 1
767
859
  end
860
+ result
768
861
  end
769
862
 
770
863
  # \begin{cases} ... \end{cases} → ⎧ … ⎨ … ⎩ …
@@ -811,8 +904,15 @@ module RubyRich
811
904
 
812
905
  # Replace \command tokens with Unicode equivalents.
813
906
  def self.replace_symbols(text)
814
- # Handle \text{} first – keep content, remove wrapper
815
- text = text.gsub(/\\(text\w*)\s*\{(.*?)\}/) { Regexp.last_match(2) }
907
+ # Handle brace-wrapped font/formatting commands: \text{ab}, \mathbf{ab}, \mathbb{R}, etc.
908
+ # Strip the wrapper, keep the content.
909
+ text = text.gsub(/\\(?:text\w*|math[bif]|mathbf|mathrm|mathit|mathsf|mathtt|mathcal|mathfrak|mathbb|mathscr|boldsymbol|bm|emph)\s*\{(.*?)\}/) {
910
+ Regexp.last_match(1)
911
+ }
912
+ # Handle font commands with single-char arg (space-separated): \mathbf E
913
+ text = text.gsub(/\\(?:mathbf|mathrm|mathit|mathsf|mathtt|mathcal|mathfrak|mathbb|mathscr|boldsymbol|bm)\s+([a-zA-Z0-9])/) {
914
+ Regexp.last_match(1)
915
+ }
816
916
  # Replace all other \commands
817
917
  text.gsub(/\\([a-zA-Z]+)/) { |m|
818
918
  SYMBOLS[Regexp.last_match(1)] || m
@@ -825,6 +925,8 @@ module RubyRich
825
925
  .gsub(/\[\s+/, '[').gsub(/\s+\]/, ']')
826
926
  .gsub(/\{\s+/, '{').gsub(/\s+\}/, '}')
827
927
  .gsub(/\\s+/, ' ')
928
+ .gsub(/([·×÷]) +/, '\1')
929
+ .gsub(/ +([·×÷])/, '\1')
828
930
  end
829
931
  end
830
932
 
@@ -834,6 +936,23 @@ module RubyRich
834
936
  module MermaidRenderer
835
937
  BAR_MAX = 32
836
938
 
939
+ LEAF_BIN = "leaf"
940
+
941
+ def self.leaf_available?
942
+ @leaf_available ||= system("which #{LEAF_BIN} > /dev/null 2>&1")
943
+ end
944
+
945
+ def self.render_via_leaf(source, width)
946
+ return nil unless leaf_available?
947
+ IO.popen([LEAF_BIN, "--inline", "plain:#{width}"], "r+", err: "/dev/null") do |io|
948
+ io.write(source)
949
+ io.close_write
950
+ io.read.strip
951
+ end
952
+ rescue
953
+ nil
954
+ end
955
+
837
956
  def self.render(source, width = 80)
838
957
  trimmed = source.strip
839
958
  return "" if trimmed.empty?
@@ -842,6 +961,10 @@ module RubyRich
842
961
  case type
843
962
  when :pie
844
963
  render_pie(trimmed, width)
964
+ when :flowchart, :sequence, :class, :gantt, :state, :generic
965
+ # Prefer leaf for high-quality ASCII-art rendering
966
+ result = render_via_leaf("```mermaid\n#{trimmed}\n```\n", width)
967
+ result && !result.empty? ? result : render_fallback(trimmed, type, width)
845
968
  else
846
969
  render_fallback(trimmed, type, width)
847
970
  end
@@ -917,6 +1040,148 @@ module RubyRich
917
1040
  ].join("\n")
918
1041
  end
919
1042
 
1043
+ # Flowchart / graph → edge-list rendering with node labels.
1044
+ def self.render_flowchart(source, width)
1045
+ lines = source.lines.map(&:chomp)
1046
+ # Build node registry: id => label
1047
+ nodes = {}
1048
+ edges = []
1049
+
1050
+ lines.each do |line|
1051
+ stripped = line.strip
1052
+ next if stripped.empty?
1053
+ next if stripped.downcase.start_with?("flowchart", "graph")
1054
+
1055
+ # Parse edge: src ---|label|---> tgt
1056
+ m = stripped.match(
1057
+ /\A(.+?)\s*(-+>|==+>|-\.+>|=+>)\s*(\|(.*?)\|)?\s*(.+)\z/
1058
+ )
1059
+ if m
1060
+ src_raw = m[1].strip
1061
+ tgt_raw = m[5].strip
1062
+ arrow = m[2]
1063
+ label = m[4]&.strip
1064
+
1065
+ src_id, src_lbl = parse_node(src_raw)
1066
+ tgt_id, tgt_lbl = parse_node(tgt_raw)
1067
+
1068
+ # Only store shaped labels — don't let bare IDs overwrite them
1069
+ nodes[src_id] = src_lbl if src_lbl && src_raw =~ /[\[\(\{]/
1070
+ nodes[tgt_id] = tgt_lbl if tgt_lbl && tgt_raw =~ /[\[\(\{]/
1071
+
1072
+ edges << {
1073
+ src: src_id, src_label: src_lbl || src_id,
1074
+ tgt: tgt_id, tgt_label: tgt_lbl || tgt_id,
1075
+ edge_label: label
1076
+ }
1077
+ next
1078
+ end
1079
+
1080
+ # Standalone node definition: id[text] / id{text} / id(text)
1081
+ nm = stripped.match(/\A([A-Za-z0-9_]+)\s*[\[\(\{].+[\]\)\}]\z/)
1082
+ if nm
1083
+ nid, nlbl = parse_node(stripped)
1084
+ nodes[nid] = nlbl if nlbl
1085
+ end
1086
+ end
1087
+
1088
+ return "[Mermaid flowchart: no edges found]" if edges.empty?
1089
+
1090
+ out = +""
1091
+ edges.each do |e|
1092
+ src = nodes[e[:src]] || e[:src_label]
1093
+ tgt = nodes[e[:tgt]] || e[:tgt_label]
1094
+ lbl = e[:edge_label] ? " ─#{e[:edge_label]}─▶ " : " ──▶ "
1095
+ out << "#{src}#{lbl}#{tgt}\n"
1096
+ end
1097
+ out.strip
1098
+ end
1099
+
1100
+ # Extract [id, label] from a node token like "A[开始]" or "B{是否通过?}"
1101
+ def self.parse_node(raw)
1102
+ raw = raw.strip
1103
+ # Square brackets
1104
+ if raw =~ /\A([A-Za-z0-9_]+)\s*\[(.+)\]\z/
1105
+ [$1, $2]
1106
+ # Curly braces (diamond)
1107
+ elsif raw =~ /\A([A-Za-z0-9_]+)\s*\{(.+)\}\z/
1108
+ [$1, $2]
1109
+ # Round parens
1110
+ elsif raw =~ /\A([A-Za-z0-9_]+)\s*\((.+)\)\z/
1111
+ [$1, $2]
1112
+ # Just an id
1113
+ elsif raw =~ /\A([A-Za-z0-9_]+)\z/
1114
+ [$1, $1]
1115
+ else
1116
+ [raw, raw]
1117
+ end
1118
+ end
1119
+
1120
+ # Sequence diagram → participant-message listing.
1121
+ def self.render_sequence(source, width)
1122
+ lines = source.lines.map(&:chomp)
1123
+ participants = []
1124
+ messages = []
1125
+
1126
+ lines.each do |line|
1127
+ stripped = line.strip
1128
+ next if stripped.empty? || stripped.downcase.start_with?("sequencediagram")
1129
+
1130
+ # participant / actor definition
1131
+ if stripped =~ /\A(?:participant|actor)\s+(.+)\z/i
1132
+ participants << $1.strip
1133
+ next
1134
+ end
1135
+
1136
+ # Note
1137
+ if stripped =~ /\ANote\s+(?:over\s+)?(.+?):\s*(.+)\z/i
1138
+ messages << { type: :note, target: $1.strip, text: $2.strip }
1139
+ next
1140
+ end
1141
+
1142
+ # Message: A->>B: text / A-->>B: text / A-)B: text
1143
+ if stripped =~ /\A(.+?)\s*(-+>>?|-->>|-\)|-[xX])\s*(.+?)\s*:\s*(.+)\z/
1144
+ src = $1.strip
1145
+ arrow_type = $2.strip
1146
+ tgt = $3.strip
1147
+ text = $4.strip
1148
+ participants |= [src, tgt] unless participants.include?(src) && participants.include?(tgt)
1149
+ dashed = arrow_type.start_with?("--")
1150
+ messages << { type: :msg, src: src, tgt: tgt, text: text, dashed: dashed }
1151
+ end
1152
+ end
1153
+
1154
+ return "[Mermaid sequence: no messages found]" if messages.empty?
1155
+
1156
+ max_participant = participants.map(&:length).max
1157
+ max_participant = 8 if max_participant < 8
1158
+
1159
+ out = +""
1160
+ participants.each do |p|
1161
+ out << sprintf("%-#{max_participant + 4}s", "[#{p}]")
1162
+ end
1163
+ out << "\n#{'─' * ((max_participant + 4) * participants.size)}\n"
1164
+
1165
+ messages.each do |m|
1166
+ case m[:type]
1167
+ when :note
1168
+ out << " 📝 #{m[:target]}: #{m[:text]}\n"
1169
+ when :msg
1170
+ src_idx = participants.index(m[:src]) || 0
1171
+ tgt_idx = participants.index(m[:tgt]) || participants.size - 1
1172
+ rightward = src_idx <= tgt_idx
1173
+ if m[:dashed]
1174
+ arrow = rightward ? "╌╌▶" : "◀╌╌"
1175
+ else
1176
+ arrow = rightward ? "──▶" : "◀──"
1177
+ end
1178
+ out << sprintf(" %-#{max_participant}s #{arrow} %s: %s\n",
1179
+ m[:src], m[:tgt], m[:text])
1180
+ end
1181
+ end
1182
+ out.strip
1183
+ end
1184
+
920
1185
  # Proxy theme colour access (same instance as TerminalConverter).
921
1186
  def self.tc(key)
922
1187
  color, bright = MarkdownTheme[key]
@@ -947,14 +1212,18 @@ module RubyRich
947
1212
  VERTICAL_THRESHOLD = 5
948
1213
 
949
1214
  def self.extract(markdown_text)
950
- return [markdown_text, nil, false] unless markdown_text.start_with?("---\n")
1215
+ # Strip leading blank lines so that a heredoc like <<~'MD'\n\n---\n
1216
+ # is still recognised as having frontmatter.
1217
+ stripped = markdown_text.lstrip
1218
+ return [markdown_text, nil, false] unless stripped.start_with?("---")
951
1219
 
952
- rest = markdown_text[4..]
953
- offset = 4
1220
+ rest = stripped[3..]
1221
+ offset = 3
954
1222
  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)..] || ""
1223
+ trimmed = line.strip
1224
+ if trimmed == "---" || trimmed == "..."
1225
+ fm_block = stripped[3...offset]
1226
+ content = stripped[(offset + line.length)..] || ""
958
1227
  pairs = parse_pairs(fm_block)
959
1228
  return [markdown_text, nil, false] if pairs.empty?
960
1229
  vertical = pairs.length >= VERTICAL_THRESHOLD
@@ -980,8 +1249,9 @@ module RubyRich
980
1249
  raw_value = trimmed[(colon_pos + 1)..].strip
981
1250
  i += 1 and next if key.empty?
982
1251
 
983
- if ["", ">-", ">", "|", "|-"].include?(raw_value)
984
- # Multiline value
1252
+ if [">-", ">", "|", "|-"].include?(raw_value)
1253
+ # Multiline value with explicit indicator
1254
+ i += 1
985
1255
  parts = []
986
1256
  while i < lines.length && lines[i].start_with?(' ', "\t")
987
1257
  part = lines[i].strip
@@ -990,7 +1260,8 @@ module RubyRich
990
1260
  end
991
1261
  pairs << [key, parts.join(" ")]
992
1262
  elsif raw_value.empty?
993
- # List value (indented items)
1263
+ # Empty value: could be a list or an implicit multiline string.
1264
+ i += 1
994
1265
  items = []
995
1266
  while i < lines.length && lines[i].start_with?(' ', "\t")
996
1267
  item = lines[i].strip
@@ -127,7 +127,7 @@ module RubyRich
127
127
 
128
128
  row_content = row.map.with_index do |cell, i|
129
129
  rendered = cell.render.sub(/\e\[0m\z/, '')
130
- content = bold ? rendered : align_cell(rendered, column_widths[i])
130
+ content = bold ? "\e[1m#{rendered}" : rendered
131
131
  aligned_content = align_cell(content, column_widths[i]).sub(/\e\[0m\z/, '')
132
132
  " #{aligned_content} "
133
133
  end.join(border_chars[:vertical])
@@ -214,7 +214,7 @@ module RubyRich
214
214
  def render_row(row, column_widths, bold: false)
215
215
  row.map.with_index do |cell, i|
216
216
  rendered = cell.render.sub(/\e\[0m\z/, '')
217
- content = bold ? rendered : align_cell(rendered, column_widths[i])
217
+ content = bold ? "\e[1m#{rendered}" : rendered
218
218
  align_cell(content, column_widths[i]).sub(/\e\[0m\z/, '')
219
219
  end.join(" | ").prepend("| ").concat(" |\e[0m")
220
220
  end
@@ -1,3 +1,3 @@
1
1
  module RubyRich
2
- VERSION = "0.5.0"
2
+ VERSION = "0.5.2"
3
3
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_rich
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-06-13 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rake
@@ -176,6 +177,7 @@ homepage: https://github.com/zhuangbiaowei/ruby_rich
176
177
  licenses:
177
178
  - MIT
178
179
  metadata: {}
180
+ post_install_message:
179
181
  rdoc_options: []
180
182
  require_paths:
181
183
  - lib
@@ -190,7 +192,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
190
192
  - !ruby/object:Gem::Version
191
193
  version: '0'
192
194
  requirements: []
193
- rubygems_version: 4.0.13
195
+ rubygems_version: 3.5.23
196
+ signing_key:
194
197
  specification_version: 4
195
198
  summary: Rich text formatting and console output for Ruby
196
199
  test_files: []