ruby_rich 0.5.0 → 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: 6db3483399e193c2a8feb97472df06341391d165f07aafde1af299a0892bb83c
4
- data.tar.gz: 71ef2db35e2b369953c7201e13bc751001f02815dd2717ea3358964ff12b30b3
3
+ metadata.gz: 35642b63217134791fd7201a1b26488473164d74646bfa3b2492294611e92340
4
+ data.tar.gz: 3c9633c93770785e7b003b81fb8ff484c21114184ac2b0615fee1bd16380fbca
5
5
  SHA512:
6
- metadata.gz: d4d3b29b2e73d8ebe31e883ba0001cb207f65b2683897d861601918e097823bce393302dd51b276fe15c71608959d28d61c6413e82c26fc0226245cf9b2c358b
7
- data.tar.gz: 41274198774c77707e3d052a9b81b1d1e18d2ae293a63b0dadcdbf508b1d74c3892203433d2a4cd92759275ce44b31906f83bd4ee3e10a128fefe3e8b890bcd5
6
+ metadata.gz: 21e958777b0107f4a92b7de61aa725cca02951b5aff8874c3ab9149b1c8cb45c1998d39f93529e04d92e2e45c4ba67ad4f053568ea1dfe503bd6d9568c7f511d
7
+ data.tar.gz: b5abb3cf83b0f53100f13286dcb388a54b9238503ff276f2a591a71d873eaaef116a4dc599175b75023388f95f30d6934da36071fddb88cecff367b1e07c2910
@@ -286,8 +286,9 @@ module RubyRich
286
286
  UNORDERED_MARKERS = { 1 => '•', 2 => '◦', 3 => '▸' }.freeze
287
287
 
288
288
  def convert_li(el)
289
- depth = [@list_counters.length, 1].max
289
+ depth = [@list_types.length, 1].max
290
290
  list_type = @list_types.last
291
+ indent = " " * (depth - 1)
291
292
  marker = if list_type == :ol
292
293
  @list_counters[-1] += 1
293
294
  "#{@list_counters[-1]}."
@@ -295,11 +296,11 @@ module RubyRich
295
296
  UNORDERED_MARKERS[depth.clamp(1, 3)] || '▸'
296
297
  end
297
298
  task = detect_task_marker(el)
298
- text = inline_content(el)
299
+ text = inline_content(el).gsub(/\n{2,}/, "\n")
299
300
  if task
300
- "#{tc(task ? :task_checked : :task_unchecked)}#{task}#{AnsiCode.reset} #{text.strip}\n"
301
+ "#{indent}#{tc(task ? :task_checked : :task_unchecked)}#{task}#{AnsiCode.reset} #{text.strip}\n"
301
302
  else
302
- "#{tc(list_type == :ol ? :ordered_list : :"list_level_#{depth.clamp(1, 3)}")}#{marker}#{AnsiCode.reset} #{text.strip}\n"
303
+ "#{indent}#{tc(list_type == :ol ? :ordered_list : :"list_level_#{depth.clamp(1, 3)}")}#{marker}#{AnsiCode.reset} #{text.strip}\n"
303
304
  end
304
305
  end
305
306
 
@@ -737,34 +738,119 @@ module RubyRich
737
738
  return formula if formula.nil? || formula.strip.empty?
738
739
 
739
740
  result = formula.dup
740
- result = process_frac(result)
741
- result = process_sqrt(result)
742
741
  result = process_cases(result)
743
- result = process_scripts(result)
744
742
  result = replace_symbols(result)
743
+ result = process_scripts(result)
744
+ result = process_frac(result)
745
+ result = process_sqrt(result)
745
746
  result = strip_delim_spacing(result)
746
747
  result
747
748
  end
748
749
 
749
- # \frac{num}{den} → (num)/(den) or num/den when single-char
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
750
769
  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}"
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
757
813
  end
814
+ result
758
815
  end
759
816
 
760
817
  # \sqrt{x} → √(x) \sqrt[n]{x} → ⁿ√(x)
761
818
  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})"
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
767
852
  end
853
+ result
768
854
  end
769
855
 
770
856
  # \begin{cases} ... \end{cases} → ⎧ … ⎨ … ⎩ …
@@ -811,8 +897,15 @@ module RubyRich
811
897
 
812
898
  # Replace \command tokens with Unicode equivalents.
813
899
  def self.replace_symbols(text)
814
- # Handle \text{} first – keep content, remove wrapper
815
- text = text.gsub(/\\(text\w*)\s*\{(.*?)\}/) { Regexp.last_match(2) }
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
+ }
816
909
  # Replace all other \commands
817
910
  text.gsub(/\\([a-zA-Z]+)/) { |m|
818
911
  SYMBOLS[Regexp.last_match(1)] || m
@@ -825,6 +918,8 @@ module RubyRich
825
918
  .gsub(/\[\s+/, '[').gsub(/\s+\]/, ']')
826
919
  .gsub(/\{\s+/, '{').gsub(/\s+\}/, '}')
827
920
  .gsub(/\\s+/, ' ')
921
+ .gsub(/([·×÷]) +/, '\1')
922
+ .gsub(/ +([·×÷])/, '\1')
828
923
  end
829
924
  end
830
925
 
@@ -834,6 +929,23 @@ module RubyRich
834
929
  module MermaidRenderer
835
930
  BAR_MAX = 32
836
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
+
837
949
  def self.render(source, width = 80)
838
950
  trimmed = source.strip
839
951
  return "" if trimmed.empty?
@@ -842,6 +954,10 @@ module RubyRich
842
954
  case type
843
955
  when :pie
844
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)
845
961
  else
846
962
  render_fallback(trimmed, type, width)
847
963
  end
@@ -917,6 +1033,148 @@ module RubyRich
917
1033
  ].join("\n")
918
1034
  end
919
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
+
920
1178
  # Proxy theme colour access (same instance as TerminalConverter).
921
1179
  def self.tc(key)
922
1180
  color, bright = MarkdownTheme[key]
@@ -947,14 +1205,18 @@ module RubyRich
947
1205
  VERTICAL_THRESHOLD = 5
948
1206
 
949
1207
  def self.extract(markdown_text)
950
- return [markdown_text, nil, false] unless markdown_text.start_with?("---\n")
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?("---")
951
1212
 
952
- rest = markdown_text[4..]
953
- offset = 4
1213
+ rest = stripped[3..]
1214
+ offset = 3
954
1215
  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)..] || ""
1216
+ trimmed = line.strip
1217
+ if trimmed == "---" || trimmed == "..."
1218
+ fm_block = stripped[3...offset]
1219
+ content = stripped[(offset + line.length)..] || ""
958
1220
  pairs = parse_pairs(fm_block)
959
1221
  return [markdown_text, nil, false] if pairs.empty?
960
1222
  vertical = pairs.length >= VERTICAL_THRESHOLD
@@ -980,8 +1242,9 @@ module RubyRich
980
1242
  raw_value = trimmed[(colon_pos + 1)..].strip
981
1243
  i += 1 and next if key.empty?
982
1244
 
983
- if ["", ">-", ">", "|", "|-"].include?(raw_value)
984
- # Multiline value
1245
+ if [">-", ">", "|", "|-"].include?(raw_value)
1246
+ # Multiline value with explicit indicator
1247
+ i += 1
985
1248
  parts = []
986
1249
  while i < lines.length && lines[i].start_with?(' ', "\t")
987
1250
  part = lines[i].strip
@@ -990,7 +1253,8 @@ module RubyRich
990
1253
  end
991
1254
  pairs << [key, parts.join(" ")]
992
1255
  elsif raw_value.empty?
993
- # List value (indented items)
1256
+ # Empty value: could be a list or an implicit multiline string.
1257
+ i += 1
994
1258
  items = []
995
1259
  while i < lines.length && lines[i].start_with?(' ', "\t")
996
1260
  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.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.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei