kramdown-ansi 0.4.1 → 0.6.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: 3f2bf63710e7039ea1a6672e742bf7e65922e2f69d773744b6f280fe6dccdbce
4
- data.tar.gz: 190ace99ebf4628df9b210df731090d6b8d2927471577f60e0c50d53ac76bb12
3
+ metadata.gz: 0e9bbd4a032cd0f37629bc21f4967590fcb7140875fe79687e01f5941cdea10c
4
+ data.tar.gz: 2805ae302ae89c97266f7cfb690ab7a8b87662c6654e802d42ac7b9ae125515c
5
5
  SHA512:
6
- metadata.gz: 92b07f5df456b29bba622cf09fcc74a3a401da49fc8b7e0c550e6931c3a56889579a96c2bb4fd6c1ec6811c37c5dddc2d70d4547476ac956f2d2a4be57354548
7
- data.tar.gz: bd5f0e84afcb14349db03cb434d5c8a0012cd6f47c97f3b5cf4150eb3c3571446379315344be950c41bd2d122ae163875b6e30443e6682bfeeecd6306dc7bddd
6
+ metadata.gz: 7268907b0b2467c70767f995d7cf1823d81378c2da7f5b9d324846b54ec63ad3357a91de8cfc116b49d5efd687a79537788d5c2e2cd2c0a5f66afa0715abbce6
7
+ data.tar.gz: 2a19614f212d5f2a8a55310d6ecc51d64050378a744dd0ea180b576f7da6a90d976ffb606ff62f4ab48d5e132425ca5616d17cb88479e5d555f5013c729db31a
data/.utilsrc ADDED
@@ -0,0 +1,96 @@
1
+ # vim: set ft=ruby:
2
+
3
+ search do
4
+ prune_dirs /\A(\.svn|\.git|\.terraform|CVS|tmp|coverage|corpus|pkg|\.yardoc|doc)\z/
5
+ skip_files /(\A\.|\.sw[pon]\z|\.(log|fnm|jpg|jpeg|png|pdf|svg)\z|\A(tags|cscope\.out)\z|~\z)/i
6
+ end
7
+
8
+ discover do
9
+ prune_dirs /\A(\.svn|\.git|\.terraform|\.yardoc|CVS|tmp|coverage|corpus|pkg|\.yardoc|doc)\z/
10
+ skip_files /(\A\.|\.sw[pon]\z|\.log\z|~\z)/
11
+ index_expire_after 3_600
12
+ end
13
+
14
+ strip_spaces do
15
+ prune_dirs /\A(\..*|CVS|pkg|\.yardoc)\z/
16
+ skip_files /(\A\.|\.sw[pon]\z|\.log\z|~\z)/
17
+ end
18
+
19
+ probe do
20
+ test_framework :rspec
21
+ end
22
+
23
+ ssh_tunnel do
24
+ terminal_multiplexer :tmux
25
+ login_session "/home/#{ENV['USER']}"
26
+ end
27
+
28
+ classify do
29
+ shift_path_by_default 1
30
+ end
31
+
32
+ code_indexer do
33
+ verbose false
34
+
35
+ gems = %w[
36
+ all_images
37
+ amatch
38
+ base64
39
+ bigdecimal
40
+ complex_config
41
+ date
42
+ debug
43
+ diff-lcs
44
+ erb
45
+ excon
46
+ fileutils
47
+ gem_hadar
48
+ infobar
49
+ io-console
50
+ irb
51
+ json
52
+ kramdown
53
+ kramdown-ansi
54
+ kramdown-parser-gfm
55
+ logger
56
+ mize
57
+ more_math
58
+ net-http
59
+ nokogiri
60
+ ollama-ruby
61
+ openssl
62
+ ostruct
63
+ pp
64
+ prettyprint
65
+ prism
66
+ psych
67
+ racc
68
+ rake
69
+ rdoc
70
+ readline
71
+ reline
72
+ rexml
73
+ rspec
74
+ rspec-core
75
+ rspec-expectations
76
+ rspec-mocks
77
+ rspec-support
78
+ search_ui
79
+ shellwords
80
+ simplecov
81
+ stringio
82
+ sync
83
+ term-ansicolor
84
+ terminal-table
85
+ tins
86
+ tsort
87
+ unicode-display_width
88
+ uri
89
+ yaml
90
+ yard
91
+ ]
92
+
93
+ paths {
94
+ %w[ lib ] + gems.map { `bundle show #{it}` }.map(&:chomp)
95
+ }
96
+ end
data/CHANGES.md CHANGED
@@ -1,5 +1,101 @@
1
1
  # Changes
2
2
 
3
+ ## 2026-10-21 v0.6.0
4
+
5
+ ### Features
6
+
7
+ - Add support for nested blockquotes with cycling typographic quotation marks
8
+ based on nesting depth.
9
+ - Add ANSI styling support for HTML elements: `ins`/`u` (underline), `mark`
10
+ (negative/inverse), `tt` (code), `small` (faint), and passthrough for
11
+ `span`/`sub`/`sup`.
12
+ - Add strikethrough support for `del` and `s` HTML elements.
13
+ - Introduce `ProtectEnvVars` module in test helper to isolate environment
14
+ variable changes during tests tagged with `:protect_env`.
15
+
16
+ ### Fixes
17
+
18
+ - Fix GFM table parsing by inserting required blank lines before and after pipe
19
+ tables to ensure correct recognition by `kramdown`.
20
+ - Fix `Integer#size` regression in table column width calculation by using
21
+ `display_width` directly instead of `.size`, ensuring correct column ratios
22
+ on Ruby 4.
23
+ - Fix empty table cell rendering by replacing empty strings with a single space
24
+ and falling back to equal-width splits when total width is zero.
25
+ - Fix typo in `convert_html_element` where `e.value` was used instead of
26
+ `el.value` for the `del`/`s` branch.
27
+ - Make `Token` definition in `Kramdown::ANSI::Width` compatible with Ruby
28
+ versions lacking `Data` by providing a `Struct`-based fallback.
29
+
30
+ ### Improvements
31
+
32
+ - Rewrite `Kramdown::ANSI::Width#truncate` to be ANSI-aware, correctly handling
33
+ escape sequences, wide characters (emojis), and zero-width characters using
34
+ `display_width`.
35
+ - Optimize `wrap` and `truncate` methods by using `scan` instead of `gsub` to
36
+ reduce allocation.
37
+ - Generalize the `capture:tables` Rake task to `capture:ansi` to regenerate
38
+ golden files for all `spec/assets/*.md` fixtures.
39
+ - Update `README.md` style examples to include `blockquote` and add a nested
40
+ dialog example.
41
+
42
+ ### Build & CI
43
+
44
+ - Add `tins` gem (`~> 1.61`) as a runtime dependency.
45
+ - Upgrade `gem_hadar` development dependency to `>= 2.20.1`.
46
+ - Update `rubygems_version` in gemspec to **4.0.22**.
47
+ - Remove unused gems (`docile`, `simplecov-html`, `simplecov_json_formatter`)
48
+ from `.utilsrc`.
49
+ - Add `git config --global --add safe.directory /work` to Docker test script.
50
+ - Use `nproc` instead of `getconf _NPROCESSORS_ONLN` for CPU count in Docker
51
+ test script.
52
+ - Add `tmp/.gitkeep` to track the empty `tmp` directory.
53
+
54
+ ### Testing
55
+
56
+ - Add `spec/kramdown/render_spec.rb` to test `Kramdown::ANSI.parse` for
57
+ headers, inline emphasis, code blocks, blockquotes, horizontal rules, lists,
58
+ links, images, and paragraphs.
59
+ - Add `spec/assets/tables.md` and `spec/assets/tables.ansi` golden files
60
+ covering 26 edge cases for table rendering.
61
+ - Add `spec/kramdown/ansi_golden_spec.rb` for output regression testing against
62
+ golden files.
63
+ - Add `spec/assets/html.md` and `spec/assets/html.ansi` golden files covering
64
+ HTML element styling.
65
+ - Add 9 new specs for `Kramdown::ANSI::Width#truncate` covering wide emojis,
66
+ empty strings, pure ANSI, zero-width characters, newline preservation,
67
+ multi-cell ellipsis, nested-style reset, and post-cut escape omission.
68
+ - Stub `Tins::Terminal.columns` to 80 in `ansi_spec.rb` and `render_spec.rb`
69
+ for deterministic output.
70
+
71
+ ## 2026-03-21 v0.5.0
72
+
73
+ - Added `spec/kramdown/ansi_table_spec.rb` to the test suite and gemspec file
74
+ list.
75
+ - Updated `Kramdown::ANSI#convert_tr` to use column‑width ratios instead of a
76
+ fixed `full_width`.
77
+ - Refactored `Kramdown::ANSI::Width.wrap` to preserve ANSI escapes, clamp
78
+ lengths, and use a token‑based wrapping algorithm.
79
+ - Introduced `display_width` method in `Kramdown::ANSI::Width` to compute
80
+ visual width after stripping ANSI codes.
81
+ - Extracted ANSI escape removal logic into reusable
82
+ `Kramdown::ANSI::Width.remove_ansi_escapes`.
83
+ - Added lightweight `LoremIpsum` helper class in `spec/spec_helper.rb` for
84
+ deterministic dummy text.
85
+ - Updated `spec/kramdown/ansi_spec.rb` expectations to reflect new line‑break
86
+ behavior and added a direct `output.puts` call.
87
+ - Updated CI image configuration: install `build-base`, `yaml-dev`,
88
+ `openssl-dev`, `git`; removed `bundle update`; added before/after blocks;
89
+ removed `Gemfile.lock`.
90
+ - Added `.rspec` configuration with `--force-color` and exclusion of
91
+ `spec_helper`.
92
+ - Added `.utilsrc` configuration for util indexing, listing numerous gems and
93
+ test framework settings.
94
+ - Removed `require 'spec_helper'` from spec files, enabling colorized test
95
+ output.
96
+ - Added runtime dependency on `unicode-display_width` (~> **3.0**) for accurate
97
+ string width calculations.
98
+
3
99
  ## 2026-02-23 v0.4.1
4
100
 
5
101
  - Updated the Ruby CI script to use `bundle exec` when running `rake spec`,
data/README.md CHANGED
@@ -47,10 +47,11 @@ You can customize the ANSI styles by passing an `ansi_styles` option:
47
47
  require 'kramdown/ansi'
48
48
 
49
49
  ansi_styles = {
50
- header: [:bold, :magenta],
51
- strong: :green,
52
- em: :yellow,
53
- code: [:cyan, :underline]
50
+ header: [:bold, :magenta],
51
+ strong: :green,
52
+ em: :yellow,
53
+ code: [:cyan, :underline],
54
+ blockquote: :blue,
54
55
  }
55
56
 
56
57
  puts Kramdown::ANSI.parse(markdown, ansi_styles:)
@@ -65,7 +66,7 @@ methods:
65
66
  require 'kramdown/ansi'
66
67
 
67
68
  # Using from_json method
68
- json_config = '{"header": ["bold", "magenta"], "code": ["cyan", "underline"]}'
69
+ json_config = '{"header": ["bold", "magenta"], "code": ["cyan", "underline"], "blockquote": ["magenta", "underline"]}'
69
70
  ansi_styles = Kramdown::ANSI::Styles.from_json(json_config)
70
71
 
71
72
  # Apply to parsing
@@ -177,6 +178,10 @@ The homepage of this library is located at
177
178
 
178
179
  ![cat](./spec/assets/kitten.jpg)
179
180
 
181
+ Dialog:
182
+ > What's that, Fluffy?
183
+ >> That's my new friend.
184
+
180
185
  ---
181
186
 
182
187
  This is the end.
data/Rakefile CHANGED
@@ -36,8 +36,10 @@ GemHadar do
36
36
 
37
37
  dependency 'term-ansicolor', '~> 1.11'
38
38
  dependency 'kramdown-parser-gfm', '~> 1.1'
39
- dependency 'terminal-table', '~> 3.0'
39
+ dependency 'terminal-table', '~> 4.0'
40
+ dependency 'tins', '~> 1.61'
40
41
  dependency 'json', '~> 2.0'
42
+ dependency 'unicode-display_width', '~> 3.0'
41
43
  development_dependency 'all_images', '~> 0.4'
42
44
  development_dependency 'rspec', '~> 3.2'
43
45
  development_dependency 'debug'
@@ -47,3 +49,21 @@ GemHadar do
47
49
 
48
50
  clobber 'coverage'
49
51
  end
52
+
53
+ namespace :capture do
54
+ desc 'Capture golden file ansi output for all spec/assets/*.md files'
55
+ task :ansi do
56
+ require 'kramdown/ansi'
57
+ module Tins::Terminal
58
+ def self.columns
59
+ 80
60
+ end
61
+ end
62
+ Dir[File.join('spec', 'assets', '*.md')].sort.each do |md|
63
+ out_path = md.sub('.md', '.ansi')
64
+ out = Kramdown::ANSI.parse(File.read(md))
65
+ File.write(out_path, out)
66
+ puts "Captured #{out_path} (#{out.bytesize} bytes)"
67
+ end
68
+ end
69
+ end
@@ -1,9 +1,9 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # stub: kramdown-ansi 0.4.1 ruby lib
2
+ # stub: kramdown-ansi 0.6.0 ruby lib
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "kramdown-ansi".freeze
6
- s.version = "0.4.1".freeze
6
+ s.version = "0.6.0".freeze
7
7
 
8
8
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
9
9
  s.require_paths = ["lib".freeze]
@@ -13,24 +13,26 @@ Gem::Specification.new do |s|
13
13
  s.email = "flori@ping.de".freeze
14
14
  s.executables = ["md".freeze, "git-md".freeze]
15
15
  s.extra_rdoc_files = ["README.md".freeze, "lib/kramdown/ansi.rb".freeze, "lib/kramdown/ansi/pager.rb".freeze, "lib/kramdown/ansi/styles.rb".freeze, "lib/kramdown/ansi/width.rb".freeze, "lib/kramdown/version.rb".freeze]
16
- s.files = ["CHANGES.md".freeze, "Gemfile".freeze, "LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "bin/git-md".freeze, "bin/md".freeze, "img/git-md.png".freeze, "img/md.png".freeze, "kramdown-ansi.gemspec".freeze, "lib/kramdown/ansi.rb".freeze, "lib/kramdown/ansi/pager.rb".freeze, "lib/kramdown/ansi/styles.rb".freeze, "lib/kramdown/ansi/width.rb".freeze, "lib/kramdown/version.rb".freeze, "spec/assets/README.ansi".freeze, "spec/assets/kitten.jpg".freeze, "spec/kramdown/ansi/pager_spec.rb".freeze, "spec/kramdown/ansi/styles_spec.rb".freeze, "spec/kramdown/ansi/width_spec.rb".freeze, "spec/kramdown/ansi_spec.rb".freeze, "spec/spec_helper.rb".freeze]
16
+ s.files = [".utilsrc".freeze, "CHANGES.md".freeze, "Gemfile".freeze, "LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "bin/git-md".freeze, "bin/md".freeze, "img/git-md.png".freeze, "img/md.png".freeze, "kramdown-ansi.gemspec".freeze, "lib/kramdown/ansi.rb".freeze, "lib/kramdown/ansi/pager.rb".freeze, "lib/kramdown/ansi/styles.rb".freeze, "lib/kramdown/ansi/width.rb".freeze, "lib/kramdown/version.rb".freeze, "spec/assets/README.ansi".freeze, "spec/assets/html.ansi".freeze, "spec/assets/html.md".freeze, "spec/assets/kitten.jpg".freeze, "spec/assets/tables.ansi".freeze, "spec/assets/tables.md".freeze, "spec/kramdown/ansi/pager_spec.rb".freeze, "spec/kramdown/ansi/styles_spec.rb".freeze, "spec/kramdown/ansi/width_spec.rb".freeze, "spec/kramdown/ansi_golden_spec.rb".freeze, "spec/kramdown/ansi_spec.rb".freeze, "spec/kramdown/ansi_table_spec.rb".freeze, "spec/kramdown/render_spec.rb".freeze, "spec/spec_helper.rb".freeze, "tmp/.gitkeep".freeze]
17
17
  s.homepage = "https://github.com/flori/kramdown-ansi".freeze
18
18
  s.licenses = ["MIT".freeze]
19
19
  s.rdoc_options = ["--title".freeze, "Kramdown-ansi - Output markdown in the terminal with ANSI escape sequences".freeze, "--main".freeze, "README.md".freeze]
20
20
  s.required_ruby_version = Gem::Requirement.new(">= 3.1".freeze)
21
- s.rubygems_version = "4.0.3".freeze
21
+ s.rubygems_version = "4.0.22".freeze
22
22
  s.summary = "Output markdown in the terminal with ANSI escape sequences".freeze
23
- s.test_files = ["spec/kramdown/ansi/pager_spec.rb".freeze, "spec/kramdown/ansi/styles_spec.rb".freeze, "spec/kramdown/ansi/width_spec.rb".freeze, "spec/kramdown/ansi_spec.rb".freeze, "spec/spec_helper.rb".freeze]
23
+ s.test_files = ["spec/kramdown/ansi/pager_spec.rb".freeze, "spec/kramdown/ansi/styles_spec.rb".freeze, "spec/kramdown/ansi/width_spec.rb".freeze, "spec/kramdown/ansi_golden_spec.rb".freeze, "spec/kramdown/ansi_spec.rb".freeze, "spec/kramdown/ansi_table_spec.rb".freeze, "spec/kramdown/render_spec.rb".freeze, "spec/spec_helper.rb".freeze]
24
24
 
25
25
  s.specification_version = 4
26
26
 
27
- s.add_development_dependency(%q<gem_hadar>.freeze, [">= 2.17.0".freeze])
27
+ s.add_development_dependency(%q<gem_hadar>.freeze, [">= 2.20.1".freeze])
28
28
  s.add_development_dependency(%q<all_images>.freeze, ["~> 0.4".freeze])
29
29
  s.add_development_dependency(%q<rspec>.freeze, ["~> 3.2".freeze])
30
30
  s.add_development_dependency(%q<debug>.freeze, [">= 0".freeze])
31
31
  s.add_development_dependency(%q<simplecov>.freeze, [">= 0".freeze])
32
32
  s.add_runtime_dependency(%q<term-ansicolor>.freeze, ["~> 1.11".freeze])
33
33
  s.add_runtime_dependency(%q<kramdown-parser-gfm>.freeze, ["~> 1.1".freeze])
34
- s.add_runtime_dependency(%q<terminal-table>.freeze, ["~> 3.0".freeze])
34
+ s.add_runtime_dependency(%q<terminal-table>.freeze, ["~> 4.0".freeze])
35
+ s.add_runtime_dependency(%q<tins>.freeze, ["~> 1.61".freeze])
35
36
  s.add_runtime_dependency(%q<json>.freeze, ["~> 2.0".freeze])
37
+ s.add_runtime_dependency(%q<unicode-display_width>.freeze, ["~> 3.0".freeze])
36
38
  end
@@ -24,10 +24,15 @@ class Kramdown::ANSI::Styles
24
24
  # configuration.
25
25
  def initialize
26
26
  @default_ansi_styles = {
27
- header: %i[ bold underline ],
28
- strong: :bold,
29
- em: :italic,
30
- code: :blue,
27
+ header: %i[ bold underline ],
28
+ strong: :bold,
29
+ em: :italic,
30
+ code: :blue,
31
+ blockquote: :faint,
32
+ del: :strikethrough,
33
+ ins: :underline,
34
+ mark: :negative,
35
+ small: :faint,
31
36
  }
32
37
  @ansi_styles = @default_ansi_styles.dup
33
38
  end
@@ -19,6 +19,18 @@ module Kramdown::ANSI::Width
19
19
 
20
20
  module_function
21
21
 
22
+ ANSI_ESCAPES = /\e\[.*?m|\e\].*?(\e|\a)\\?/
23
+
24
+ private_constant :ANSI_ESCAPES
25
+
26
+ # Removes ANSI escape sequences from the given string.
27
+ #
28
+ # @param line [String] the string potentially containing ANSI escape codes.
29
+ # @return [String] the string with all ANSI escape sequences removed.
30
+ def remove_ansi_escapes(line)
31
+ line.to_s.gsub(ANSI_ESCAPES, '')
32
+ end
33
+
22
34
  # Returns the width of the terminal in characters, given a percentage.
23
35
  #
24
36
  # @param percentage [Numeric] the percentage value (defaults to 100.0)
@@ -27,26 +39,84 @@ module Kramdown::ANSI::Width
27
39
  ((Float(percentage) * Tins::Terminal.columns) / 100).floor
28
40
  end
29
41
 
30
- # Wraps text to a specified width.
42
+ # Calculates the display width of a string after removing ANSI escape
43
+ # sequences.
44
+ #
45
+ # @param line [String] the string to measure
46
+ # @return [Integer] the number of columns the string occupies in a terminal
47
+ def display_width(line)
48
+ Unicode::DisplayWidth.of(remove_ansi_escapes(line))
49
+ end
50
+
51
+ # Wraps a string to a given width, either by percentage of terminal
52
+ # width or by an explicit character length.
31
53
  #
32
54
  # @param text [String] the text to wrap
33
- # @option percentage [Numeric] the percentage value for the width (defaults to nil)
34
- # @option length [Integer] the character length for the width (defaults to nil)
35
- # @return [String] the wrapped text
55
+ # @option percentage [Numeric] the percentage of terminal width to use
56
+ # @option length [Integer] the explicit character width to wrap to
57
+ # @return [String] the wrapped string, preserving ANSI escape sequences
36
58
  # @raise [ArgumentError] if neither `percentage` nor `length` is provided
59
+ # @example
60
+ # Kramdown::ANSI::Width.wrap("Hello world!", percentage: 50)
61
+ # # => "Hello\nworld!"
37
62
  def wrap(text, percentage: nil, length: nil)
38
63
  percentage.nil? ^ length.nil? or
39
64
  raise ArgumentError, "either pass percentage or length argument"
40
65
  percentage and length ||= width(percentage:)
41
- text.gsub(/(?<!\n)\n(?!\n)/, ' ').lines.map do |line|
42
- if length >= 1 && uncolor { line }.length > length
43
- line.gsub(/(.{1,#{length}})(\s+|$)/, "\\1\n").strip
66
+ return text if length <= 0
67
+ begin
68
+ length = length.to_i
69
+ rescue FloatDomainError
70
+ return text
71
+ end
72
+ length = length.clamp(1..)
73
+ length_without_ansi = 0
74
+ result = +''
75
+ text.scan(/(?<nws>\S+)|(?<ws>\s)/) do
76
+ token_value = $&
77
+ token_length = display_width(token_value)
78
+ if $~[:nws]
79
+ if length_without_ansi + token_length > length
80
+ length_without_ansi = token_length
81
+ if result[-1] == ' '
82
+ result[-1, 1] = "\n#{token_value}"
83
+ else
84
+ result << "\n#{token_value}"
85
+ end
86
+ else
87
+ length_without_ansi += token_length
88
+ result << token_value
89
+ end
44
90
  else
45
- line.strip
91
+ length_without_ansi += token_length
92
+ result << token_value
93
+ end
94
+ end
95
+ result
96
+ end
97
+
98
+ if defined?(Data)
99
+ # A frozen data record for a single token in the `truncate` scan.
100
+ #
101
+ # +type+ is `:esc` (ANSI escape, zero display width) or
102
+ # `:txt` (visible character, display width from
103
+ # `Unicode::DisplayWidth`).
104
+ # +value+ is the raw matched substring.
105
+ # +length+ is the display width in terminal columns (0 for escapes).
106
+ Token = Data.define(:type, :value, :length)
107
+ else
108
+ class Token < Struct.new(:type, :value, :length)
109
+ def initialize(**opts)
110
+ opts.each do |key, value|
111
+ self.send("#{key}=", value)
112
+ end
113
+ freeze
46
114
  end
47
- end * ?\n
115
+ end
48
116
  end
49
117
 
118
+ private_constant :Token
119
+
50
120
  # Truncates a given string to a specified length or percentage. If the text
51
121
  # is longer an ellipsis sequence is added at the end of the generated string,
52
122
  # to indicate that a truncation has been performed.
@@ -61,13 +131,59 @@ module Kramdown::ANSI::Width
61
131
  percentage.nil? ^ length.nil? or
62
132
  raise ArgumentError, "either pass percentage or length argument"
63
133
  percentage and length ||= width(percentage:)
64
- ellipsis_length = ellipsis.size
65
- if length < ellipsis_length
66
- +''
67
- elsif text.size >= length + ellipsis_length
68
- text[0, length - ellipsis_length] + ellipsis
69
- else
70
- text
134
+ ellipsis_length = display_width(remove_ansi_escapes(ellipsis))
135
+ found_esc = false
136
+ result = +''
137
+
138
+ return result if text.empty?
139
+ return result if length < ellipsis_length
140
+
141
+ current_length = 0
142
+ tokens = []
143
+
144
+ text.scan(/(?<esc>#{ANSI_ESCAPES})|(?<txt>.)/m) do
145
+ token = Token.new(
146
+ type: $~[:esc] ? :esc : :txt,
147
+ value: $&,
148
+ length: $~[:esc] ? 0 : display_width($&)
149
+ )
150
+ tokens << token
151
+ end
152
+
153
+ while token = tokens.shift
154
+ if token.type == :esc
155
+ found_esc = true
156
+ result << token.value
157
+ next
158
+ end
159
+
160
+ if current_length + token.length == length && tokens.empty?
161
+ result << token.value
162
+ current_length += token.length
163
+ break
164
+ elsif current_length + token.length + ellipsis_length > length
165
+ result << ellipsis
166
+ break
167
+ elsif current_length + token.length + ellipsis_length == length
168
+ result << token.value
169
+ current_length += token.length
170
+ remaining_text_length = tokens.select { _1.type == :txt }.map(&:length).sum
171
+ if current_length + remaining_text_length == length
172
+ next
173
+ elsif current_length + remaining_text_length > length
174
+ result << ellipsis
175
+ break
176
+ else
177
+ next
178
+ end
179
+ else
180
+ current_length += token.length
181
+ result << token.value
182
+ end
183
+
71
184
  end
185
+
186
+ result << reset if found_esc && !result.end_with?(reset)
187
+ result
72
188
  end
73
189
  end
data/lib/kramdown/ansi.rb CHANGED
@@ -56,7 +56,7 @@ class Kramdown::ANSI < Kramdown::Converter::Base
56
56
  # @return [String] a copy of the input string with all ANSI escape
57
57
  # sequences removed
58
58
  def self.ansi_escape(line)
59
- line.to_s.gsub(/\e\[.*?m|\e\].*?(\e|\a)\\?/, '')
59
+ Kramdown::ANSI::Width.remove_ansi_escapes(line)
60
60
  end
61
61
  end
62
62
 
@@ -91,6 +91,35 @@ class Kramdown::ANSI < Kramdown::Converter::Base
91
91
  options = { input: :mygfm, auto_ids: false, entity_output: :as_char }.merge(
92
92
  options.transform_keys(&:to_sym)
93
93
  )
94
+ # Insert blank line before GFM pipe tables glued to preceding
95
+ # content; kramdown requires it to parse the table correctly.
96
+ #
97
+ # Matches: a non-table line immediately followed by a table header,
98
+ # a valid GFM separator row, and at least one body row.
99
+ # A two-line "table" (header + separator, no body) is not a table
100
+ # per GFM, so no blank line is inserted.
101
+ source = source.gsub(
102
+ /
103
+ ( [^|\n][^\n]*\n ) # $1: preceding line (not a pipe line)
104
+ ( \|[^\n]*\n ) # $2: table header (starts with |)
105
+ ( \|(?:\s*:?-+:?\s*\|)+\n ) # $3: GFM separator + newline
106
+ ( \|[^\n]* ) # $4: first body row (starts with |)
107
+ \n
108
+ /x,
109
+ "\\1\n\\2\\3\\4\n"
110
+ )
111
+ # Insert blank line after a table's last row when glued to
112
+ # following content; kramdown requires it to close the table.
113
+ source = source.gsub(
114
+ /(\|[^\n]*\n)([^|\n])/,
115
+ ) do |match|
116
+ next_char = Regexp.last_match(2)
117
+ if next_char.match?(/\A\s*:?-+/)
118
+ match
119
+ else
120
+ "#{Regexp.last_match(1)}\n#{next_char}"
121
+ end
122
+ end
94
123
  @doc = Kramdown::Document.new(source, options).to_ansi
95
124
  end
96
125
 
@@ -253,6 +282,28 @@ class Kramdown::ANSI < Kramdown::Converter::Base
253
282
  apply_style(:code) { el.value }
254
283
  end
255
284
 
285
+ BLOCKQUOTE_QUOTES = [
286
+ %w[“ ”],
287
+ %w[‘ ’],
288
+ %w[ » « ],
289
+ %w[ › ‹ ],
290
+ ].map(&:freeze).freeze
291
+ private_constant :BLOCKQUOTE_QUOTES
292
+
293
+ # Wraps the content of a blockquote level with the corresponding
294
+ # typographic quotation marks, cycling through a predefined set of
295
+ # quote pairs based on the nesting depth.
296
+ #
297
+ # @param level [ Integer ] the nesting level of the blockquote
298
+ # @yield [ String ] the inner content to be wrapped in quotation marks
299
+ # @return [ String ] the content enclosed by the appropriate opening and
300
+ # closing quote characters
301
+ def blockquote_quote(level, &block)
302
+ quote = BLOCKQUOTE_QUOTES[level % BLOCKQUOTE_QUOTES.size]
303
+ quote[0] + block.() + quote[1]
304
+ end
305
+ private :blockquote_quote
306
+
256
307
  # The convert_blockquote method processes a blockquote element by applying
257
308
  # proper formatting.
258
309
  #
@@ -266,7 +317,19 @@ class Kramdown::ANSI < Kramdown::Converter::Base
266
317
  # @return [String] the formatted blockquote content with quotation marks and
267
318
  # proper newlines
268
319
  def convert_blockquote(el, opts)
269
- newline ?“ + inner(el, opts).sub(/\n+\z/, '') + ?”
320
+ old_level = opts[:blockquote_level] ||= -1
321
+ level = opts[:blockquote_level] = old_level + 1
322
+ if level.zero?
323
+ newline(
324
+ blockquote_quote(level) {
325
+ apply_style(:blockquote) { inner(el, opts).strip }
326
+ }.gsub(/^/, ' ')
327
+ )
328
+ else
329
+ blockquote_quote(level) { inner(el, opts).strip }.gsub(/^/, ' ')
330
+ end
331
+ ensure
332
+ opts[:blockquote_level] = old_level
270
333
  end
271
334
 
272
335
  # The convert_hr method processes a horizontal rule element by generating a
@@ -373,6 +436,18 @@ class Kramdown::ANSI < Kramdown::Converter::Base
373
436
  apply_style(:em) { inner(el, opts) }
374
437
  elsif el.value == 'b' || el.value == 'strong'
375
438
  apply_style(:strong) { inner(el, opts) }
439
+ elsif el.value == 'ins' || el.value == 'u'
440
+ apply_style(:ins) { inner(el, opts) }
441
+ elsif el.value == 'mark'
442
+ apply_style(:mark) { inner(el, opts) }
443
+ elsif el.value == 'del' || el.value == 's'
444
+ apply_style(:del) { inner(el, opts) }
445
+ elsif el.value == 'tt'
446
+ apply_style(:code) { inner(el, opts) }
447
+ elsif el.value == 'small'
448
+ apply_style(:small) { inner(el, opts) }
449
+ elsif %w[ span sub sup ].include?(el.value)
450
+ inner(el, opts)
376
451
  else
377
452
  ''
378
453
  end
@@ -457,14 +532,23 @@ class Kramdown::ANSI < Kramdown::Converter::Base
457
532
  # addition of the row to the table instance
458
533
  def convert_tr(el, opts)
459
534
  return '' if el.children.empty?
460
- full_width = width(percentage: 90)
461
- cols = el.children.map { |c| convert(c, opts).strip }
462
- row_size = cols.sum(&:size)
463
- return '' if row_size.zero?
464
- opts[:table] << cols.map { |c|
465
- length = (full_width * (c.size / row_size.to_f)).floor
466
- wrap(c, length:)
467
- }
535
+ cols = el.children.map do |c|
536
+ stripped = convert(c, opts).strip
537
+ stripped.empty? ? ' ' : stripped
538
+ end
539
+ return '' if cols.empty?
540
+ col_widths = cols.map { |c| display_width(c) }
541
+ col_widths_sum = col_widths.sum
542
+ full_width = width(percentage: 90)
543
+ ratios = cols.each_with_index.map do |c, i|
544
+ if col_widths_sum.zero?
545
+ 1.0 / cols.size
546
+ else
547
+ col_widths[i] / col_widths_sum.to_f
548
+ end
549
+ end
550
+ wrapped_cols = cols.zip(ratios).map { |c, r| wrap(c, length: r * full_width) }
551
+ opts[:table] << wrapped_cols
468
552
  ''
469
553
  end
470
554