html2text 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/README.md +5 -5
  4. data/lib/html2text/version.rb +3 -1
  5. data/lib/html2text.rb +108 -116
  6. metadata +62 -110
  7. data/spec/examples/anchors.html +0 -12
  8. data/spec/examples/anchors.txt +0 -5
  9. data/spec/examples/basic.html +0 -21
  10. data/spec/examples/basic.txt +0 -15
  11. data/spec/examples/dom-processing.html +0 -8
  12. data/spec/examples/dom-processing.txt +0 -1
  13. data/spec/examples/empty.html +0 -0
  14. data/spec/examples/empty.txt +0 -0
  15. data/spec/examples/full_email.html +0 -220
  16. data/spec/examples/full_email.txt +0 -54
  17. data/spec/examples/huge-msoffice.html +0 -1
  18. data/spec/examples/huge-msoffice.txt +0 -25872
  19. data/spec/examples/images.html +0 -54
  20. data/spec/examples/images.txt +0 -27
  21. data/spec/examples/invalid.html +0 -4
  22. data/spec/examples/invalid.txt +0 -1
  23. data/spec/examples/lists.html +0 -24
  24. data/spec/examples/lists.txt +0 -17
  25. data/spec/examples/more-anchors.html +0 -14
  26. data/spec/examples/more-anchors.txt +0 -7
  27. data/spec/examples/msoffice.html +0 -1
  28. data/spec/examples/msoffice.txt +0 -12
  29. data/spec/examples/nbsp.html +0 -1
  30. data/spec/examples/nbsp.txt +0 -1
  31. data/spec/examples/nested-divs.html +0 -17
  32. data/spec/examples/nested-divs.txt +0 -12
  33. data/spec/examples/newlines.html +0 -50
  34. data/spec/examples/newlines.txt +0 -35
  35. data/spec/examples/non-breaking-spaces.html +0 -1
  36. data/spec/examples/non-breaking-spaces.txt +0 -1
  37. data/spec/examples/pre.html +0 -10
  38. data/spec/examples/pre.txt +0 -8
  39. data/spec/examples/table.html +0 -53
  40. data/spec/examples/table.txt +0 -7
  41. data/spec/examples/test3.html +0 -1
  42. data/spec/examples/test3.txt +0 -2
  43. data/spec/examples/test4.html +0 -1
  44. data/spec/examples/test4.txt +0 -5
  45. data/spec/examples/utf8-example.html +0 -4
  46. data/spec/examples/utf8-example.txt +0 -2
  47. data/spec/examples/windows-1252-example.html +0 -4
  48. data/spec/examples/windows-1252-example.txt +0 -2
  49. data/spec/examples/zero-width-non-joiners.html +0 -1
  50. data/spec/examples/zero-width-non-joiners.txt +0 -1
  51. data/spec/examples_spec.rb +0 -41
  52. data/spec/html2text_spec.rb +0 -58
  53. data/spec/spec_helper.rb +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d1902161f7964cd95630662cfe326001842de6ae9cfc791216b2a5c2d6fc763
4
- data.tar.gz: 4940f60ec3ea46df4a3117aa7c053d1b30b935c3114bddb81e8d6e81e29fccbb
3
+ metadata.gz: 1a1e6fcee8973815b391ca28bd156a878e36594e0bfbece01f7ff8c67f6cad6f
4
+ data.tar.gz: f515c6edb112deeae87f59b717aa4a2aed5d37b4b42db151fc49359f2b98d0b9
5
5
  SHA512:
6
- metadata.gz: cd7354466697fc737c336a6abf38e6c70a9480e7d609de135348d4f8b6ab765832929ccd5687fc88209a75d2f82932421a8a59fe8c0754121680d60a0a5f3496
7
- data.tar.gz: 39337ef32bc46adf101c06fc33cc98d8960bf31ce1816fde93dfb1a8a6aa75381b28114a8ff0ad363c5335f2bd61df9766ece0ef8c2b325c28d261e9a3552f7b
6
+ metadata.gz: 3972abc5f287146a5f43969fe3a37b83333559618b198ccc6ca3b105178c7cb37a03c9b44317428c8323c9b86e7e7a22a78276d9ddb7cccaab1819105e469a18
7
+ data.tar.gz: dcc2f39ebc4d772b47762cacb5de22ab16f386fbc4952c7fcf72ae66938fffcacad1fad7062714fdebc21a6b74c802224d02b796c099d2a00d1b3d13729f5d6d
data/CHANGELOG.md CHANGED
@@ -4,7 +4,30 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [Unreleased](https://github.com/soundasleep/html2text_ruby/compare/v0.5.0...master)
8
+
9
+ ## [0.5.0](https://github.com/soundasleep/html2text_ruby/compare/v0.4.0...v0.5.0) - 2026-07-08
10
+ ### Added
11
+ - Add a `wrap_links:` option to `Html2Text.convert` and `Html2Text.new` (default `true`); when `false`, hyperlinks render as `text: href` instead of markdown-style `[text](href)`. Only affects `<a href>` links — images and named anchors keep their `[bracketed]` format ([#40](https://github.com/soundasleep/html2text_ruby/pull/40))
12
+ - Specs covering subclass overrides of `prefix_whitespace`, `suffix_whitespace`, `iterate_over`, `wrap_link` and `image_text`, plus unit specs for `fix_newlines` and `replace_entities` ([#41](https://github.com/soundasleep/html2text_ruby/pull/41))
13
+
14
+ ### Changed
15
+ - Require Ruby 3.3+, since all earlier Rubies are EOL; CI now tests Ruby 3.3, 3.4 and 4.0 ([#42](https://github.com/soundasleep/html2text_ruby/pull/42))
16
+ - Reduce object allocations in `iterate_over` for ~1.6x faster conversion of large documents ([#41](https://github.com/soundasleep/html2text_ruby/pull/41))
17
+ - Update development dependencies (rubocop 1.88, nokogiri 1.19 in the lockfile), removing the transitive `rexml` dependency ([#42](https://github.com/soundasleep/html2text_ruby/pull/42))
18
+
19
+ ## [0.4.0](https://github.com/soundasleep/html2text_ruby/compare/0.3.1...v0.4.0) - 2024-06-08
20
+ ### Added
21
+ - Switch from Travis to Github Actions for Build and Test
22
+ - Add rubocop for linting and cleanup existing violations ([#36](https://github.com/soundasleep/html2text_ruby/pull/36))
23
+
24
+ ### Changed
25
+ - Add support for Ruby 3.x, removed support for Ruby < 3.0 since it is EOL
26
+ - Allow subclassing of `Html2Text` to override the default behaviour ([#30](https://github.com/soundasleep/html2text_ruby/pull/30))
27
+
28
+ ### Fixed
29
+ - Loosen nokogiri dependency to allow for nokogiri < 2.0 ([#17](https://github.com/soundasleep/html2text_ruby/pull/17))
30
+ - Fix `NoMethodError` when parsing nodes with no name ([#15](https://github.com/soundasleep/html2text_ruby/pull/15))
8
31
 
9
32
  ## [0.3.1] - 2019-06-12
10
33
  ### Security
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
- html2text [![Build Status](https://travis-ci.org/soundasleep/html2text_ruby.svg?branch=master)](https://travis-ci.org/soundasleep/html2text_ruby) [![Total Downloads](https://ruby-gem-downloads-badge.herokuapp.com/html2text?type=total&metric=true)](https://rubygems.org/gems/html2text/)
2
- ==============
1
+ html2text ![Build](https://github.com/soundasleep/html2text_ruby/actions/workflows/build.yml/badge.svg) [![Gem Version](https://badge.fury.io/rb/html2text.svg)](https://badge.fury.io/rb/html2text)
2
+ ---
3
3
 
4
4
  `html2text` is a very simple gem that uses DOM methods to convert HTML into a format similar to what would be
5
5
  rendered by a browser - perfect for places where you need a quick text representation. For example:
@@ -20,7 +20,7 @@ rendered by a browser - perfect for places where you need a quick text represent
20
20
  <div>Another div</div>
21
21
  <div>A div<div>within a div</div></div>
22
22
 
23
- <a href="http://foo.com">A link</a>
23
+ <a href="https://foo.com">A link</a>
24
24
 
25
25
  </body>
26
26
  </html>
@@ -40,7 +40,7 @@ Another div
40
40
  A div
41
41
  within a div
42
42
 
43
- [A link](http://foo.com)
43
+ [A link](https://foo.com)
44
44
  ```
45
45
 
46
46
  See the [original blog post](http://journals.jevon.org/users/jevon-phd/entry/19818) or the related [StackOverflow answer](http://stackoverflow.com/a/2564472/39531).
@@ -63,7 +63,7 @@ text = Html2Text.convert(html)
63
63
 
64
64
  ## Tests
65
65
 
66
- See all of the test cases defined in [spec/examples/](spec/examples/). These can be run with `bundle && rspec`.
66
+ See all of the test cases defined in [spec/examples/](spec/examples/). These can be run with `bundle exec rake`.
67
67
 
68
68
  ## License
69
69
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Html2Text
2
- VERSION = "0.3.1"
4
+ VERSION = '0.5.0'
3
5
  end
data/lib/html2text.rb CHANGED
@@ -1,23 +1,26 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'nokogiri'
2
4
 
3
5
  class Html2Text
4
6
  attr_reader :doc
5
7
 
6
- def initialize(doc)
8
+ def initialize(doc, wrap_links: true)
7
9
  @doc = doc
10
+ @wrap_links = wrap_links
8
11
  end
9
12
 
10
- def self.convert(html)
13
+ def self.convert(html, wrap_links: true)
11
14
  html = html.to_s
12
15
 
13
- if is_office_document?(html)
16
+ if office_document?(html)
14
17
  # Emulate the CSS rendering of Office documents
15
- html = html.gsub("<p class=MsoNormal>", "<br>")
16
- .gsub("<o:p>&nbsp;</o:p>", "<br>")
17
- .gsub("<o:p></o:p>", "")
18
+ html = html.gsub('<p class=MsoNormal>', '<br>')
19
+ .gsub('<o:p>&nbsp;</o:p>', '<br>')
20
+ .gsub('<o:p></o:p>', '')
18
21
  end
19
22
 
20
- if !html.include?("<html")
23
+ unless html.include?('<html')
21
24
  # Stop Nokogiri from inserting in <p> tags
22
25
  html = "<div>#{html}</div>"
23
26
  end
@@ -25,25 +28,30 @@ class Html2Text
25
28
  html = fix_newlines(replace_entities(html))
26
29
  doc = Nokogiri::HTML(html)
27
30
 
28
- Html2Text.new(doc).convert
31
+ new(doc, wrap_links: wrap_links).convert
29
32
  end
30
33
 
31
34
  def self.fix_newlines(text)
35
+ # rubocop:disable Performance/StringReplacement
32
36
  text.gsub("\r\n", "\n").gsub("\r", "\n")
37
+ # rubocop:enable Performance/StringReplacement
33
38
  end
34
39
 
35
40
  def self.replace_entities(text)
36
- text.gsub("&nbsp;", " ").gsub("\u00a0", " ").gsub("&zwnj;", "")
41
+ # rubocop:disable Performance/StringReplacement
42
+ text.gsub('&nbsp;', ' ').gsub("\u00a0", ' ').gsub('&zwnj;', '')
43
+ # rubocop:enable Performance/StringReplacement
37
44
  end
38
45
 
39
46
  def convert
40
47
  output = iterate_over(doc)
41
48
  output = remove_leading_and_trailing_whitespace(output)
42
49
  output = remove_unnecessary_empty_lines(output)
43
- return output.strip
50
+ output.strip
44
51
  end
45
52
 
46
- DO_NOT_TOUCH_WHITESPACE = "<do-not-touch-whitespace>"
53
+ DO_NOT_TOUCH_WHITESPACE = '<do-not-touch-whitespace>'
54
+ IGNORED_TAGS = Set.new(%w[style head title meta script]).freeze
47
55
 
48
56
  def remove_leading_and_trailing_whitespace(text)
49
57
  # ignore any <pre> blocks, which we don't want to interact with
@@ -51,211 +59,195 @@ class Html2Text
51
59
 
52
60
  output = []
53
61
  pre_blocks.each.with_index do |block, index|
54
- if index % 2 == 0
55
- output << block.gsub(/[ \t]*\n[ \t]*/im, "\n").gsub(/ *\t */im, "\t")
56
- else
57
- output << block
58
- end
62
+ output << if index.even?
63
+ block.gsub(/[ \t]*\n[ \t]*/im, "\n").gsub(/ *\t */im, "\t")
64
+ else
65
+ block
66
+ end
59
67
  end
60
68
 
61
- output.join("")
69
+ output.join
62
70
  end
63
71
 
64
- private
65
-
66
- def self.is_office_document?(text)
67
- text.include?("urn:schemas-microsoft-com:office")
72
+ private_class_method def self.office_document?(text)
73
+ text.include?('urn:schemas-microsoft-com:office')
68
74
  end
69
75
 
76
+ private
77
+
70
78
  def remove_unnecessary_empty_lines(text)
71
- text.gsub(/\n\n\n*/im, "\n\n")
79
+ text.gsub(/\n{3,}/, "\n\n")
72
80
  end
73
81
 
74
82
  def trimmed_whitespace(text)
75
83
  # Replace whitespace characters with a space (equivalent to \s)
76
84
  # and force any text encoding into UTF-8
77
85
  if text.valid_encoding?
78
- text.gsub(/[\t\n\f\r ]+/im, " ")
86
+ text.gsub(/[\t\n\f\r ]+/, ' ')
79
87
  else
80
- text.force_encoding("WINDOWS-1252")
81
- return trimmed_whitespace(text.encode("UTF-16be", invalid: :replace, replace: "?").encode('UTF-8'))
88
+ text.force_encoding('WINDOWS-1252')
89
+ trimmed_whitespace(text.encode('UTF-16be', invalid: :replace, replace: '?').encode('UTF-8'))
82
90
  end
83
91
  end
84
92
 
85
93
  def iterate_over(node)
86
- return "\n" if node.name.downcase == "br" && next_node_is_text?(node)
87
-
88
94
  return trimmed_whitespace(node.text) if node.text?
89
95
 
90
- if ["style", "head", "title", "meta", "script"].include?(node.name.downcase)
91
- return ""
92
- end
96
+ name = node.name&.downcase
93
97
 
94
- if node.name.downcase == "pre"
95
- return "\n#{DO_NOT_TOUCH_WHITESPACE}#{node.text}#{DO_NOT_TOUCH_WHITESPACE}"
96
- end
98
+ return '' if name.nil? || IGNORED_TAGS.include?(name)
97
99
 
98
- output = []
100
+ return "\n" if name == 'br' && next_node_is_text?(node)
99
101
 
100
- output << prefix_whitespace(node)
101
- output += node.children.map do |child|
102
- iterate_over(child)
103
- end
104
- output << suffix_whitespace(node)
102
+ return "\n#{DO_NOT_TOUCH_WHITESPACE}#{node.text}#{DO_NOT_TOUCH_WHITESPACE}" if name == 'pre'
105
103
 
106
- output = output.compact.join("") || ""
104
+ return image_text(node) if name == 'img'
107
105
 
108
- if node.name.downcase == "a"
109
- output = wrap_link(node, output)
110
- elsif node.name.downcase == "img"
111
- output = image_text(node)
106
+ output = +''
107
+ output << (prefix_whitespace(node) || '')
108
+ node.children.each do |child|
109
+ output << iterate_over(child)
112
110
  end
111
+ output << (suffix_whitespace(node) || '')
113
112
 
114
- return output
113
+ output = wrap_link(node, output) if name == 'a'
114
+
115
+ output
115
116
  end
116
117
 
117
118
  def prefix_whitespace(node)
118
119
  case node.name.downcase
119
- when "hr"
120
- "\n---------------------------------------------------------------\n"
120
+ when 'hr'
121
+ "\n---------------------------------------------------------------\n"
121
122
 
122
- when "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul"
123
- "\n\n"
123
+ when 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ol', 'ul', 'p'
124
+ "\n\n"
124
125
 
125
- when "p"
126
- "\n\n"
126
+ when 'tr'
127
+ "\n"
127
128
 
128
- when "tr"
129
+ when 'div'
130
+ if node.parent.name == 'div' && (node.parent.text.strip == node.text.strip)
131
+ ''
132
+ else
129
133
  "\n"
134
+ end
130
135
 
131
- when "div"
132
- if node.parent.name == "div" && (node.parent.text.strip == node.text.strip)
133
- ""
134
- else
135
- "\n"
136
- end
137
-
138
- when "td", "th"
139
- "\t"
136
+ when 'td', 'th'
137
+ "\t"
140
138
 
141
- when "li"
142
- "- "
139
+ when 'li'
140
+ '- '
143
141
  end
144
142
  end
145
143
 
146
144
  def suffix_whitespace(node)
147
145
  case node.name.downcase
148
- when "h1", "h2", "h3", "h4", "h5", "h6"
149
- # add another line
150
- "\n\n"
146
+ when 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'
147
+ "\n\n"
151
148
 
152
- when "p"
153
- "\n\n"
149
+ when 'br'
150
+ next_name = next_node_name(node)
151
+ "\n" if !next_name.nil? && next_name != 'div'
154
152
 
155
- when "br"
156
- if next_node_name(node) != "div" && next_node_name(node) != nil
157
- "\n"
158
- end
153
+ when 'li'
154
+ "\n"
159
155
 
160
- when "li"
156
+ when 'div'
157
+ if next_node_is_text?(node)
161
158
  "\n"
162
-
163
- when "div"
164
- if next_node_is_text?(node)
165
- "\n"
166
- elsif next_node_name(node) != "div" && next_node_name(node) != nil
167
- "\n"
168
- end
159
+ else
160
+ next_name = next_node_name(node)
161
+ "\n" if !next_name.nil? && next_name != 'div'
162
+ end
169
163
  end
170
164
  end
171
165
 
172
- # links are returned in [text](link) format
166
+ # links are returned in [text](link) format, or "text: link" with wrap_links: false
173
167
  def wrap_link(node, output)
174
- href = node.attribute("href")
175
- name = node.attribute("name")
168
+ href = node.attribute('href')
169
+ name = node.attribute('name')
176
170
 
177
171
  output = output.strip
178
172
 
179
173
  # remove double [[ ]]s from linking images
180
- if output[0] == "[" && output[-1] == "]"
174
+ if output[0] == '[' && output[-1] == ']'
181
175
  output = output[1, output.length - 2]
182
176
 
183
177
  # for linking images, the title of the <a> overrides the title of the <img>
184
- if node.attribute("title")
185
- output = node.attribute("title").to_s
186
- end
178
+ output = node.attribute('title').to_s if node.attribute('title')
187
179
  end
188
180
 
189
181
  # if there is no link text, but a title attr
190
- if output.empty? && node.attribute("title")
191
- output = node.attribute("title").to_s
192
- end
182
+ output = node.attribute('title').to_s if output.empty? && node.attribute('title')
193
183
 
194
184
  if href.nil?
195
- if !name.nil?
196
- output = "[#{output}]"
197
- end
185
+ output = "[#{output}]" unless name.nil?
198
186
  else
199
187
  href = href.to_s
200
188
 
201
189
  if href != output && href != "mailto:#{output}" &&
202
- href != "http://#{output}" && href != "https://#{output}"
203
- if output.empty?
204
- output = href
205
- else
206
- output = "[#{output}](#{href})"
207
- end
190
+ href != "http://#{output}" && href != "https://#{output}"
191
+ output = if output.empty?
192
+ href
193
+ elsif !@wrap_links
194
+ "#{output}: #{href}"
195
+ else
196
+ "[#{output}](#{href})"
197
+ end
208
198
  end
209
199
  end
210
200
 
211
201
  case next_node_name(node)
212
- when "h1", "h2", "h3", "h4", "h5", "h6"
213
- output += "\n"
202
+ when 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
203
+ output += "\n"
214
204
  end
215
205
 
216
206
  output
217
207
  end
218
208
 
219
209
  def image_text(node)
220
- if node.attribute("title")
221
- "[" + node.attribute("title").to_s + "]"
222
- elsif node.attribute("alt")
223
- "[" + node.attribute("alt").to_s + "]"
210
+ if node.attribute('title')
211
+ "[#{node.attribute('title')}]"
212
+ elsif node.attribute('alt')
213
+ "[#{node.attribute('alt')}]"
224
214
  else
225
- ""
215
+ ''
226
216
  end
227
217
  end
228
218
 
229
219
  def next_node_name(node)
230
220
  next_node = node.next_sibling
231
- while next_node != nil
221
+ until next_node.nil?
232
222
  break if next_node.element?
223
+
233
224
  next_node = next_node.next_sibling
234
225
  end
235
226
 
236
- if next_node && next_node.element?
237
- next_node.name.downcase
238
- end
227
+ return unless next_node&.element?
228
+
229
+ next_node.name.downcase
239
230
  end
240
231
 
241
232
  def next_node_is_text?(node)
242
- return !node.next_sibling.nil? && node.next_sibling.text? && !node.next_sibling.text.strip.empty?
233
+ !node.next_sibling.nil? && node.next_sibling.text? && !node.next_sibling.text.strip.empty?
243
234
  end
244
235
 
245
236
  def previous_node_name(node)
246
237
  previous_node = node.previous_sibling
247
- while previous_node != nil
238
+ until previous_node.nil?
248
239
  break if previous_node.element?
240
+
249
241
  previous_node = previous_node.previous_sibling
250
242
  end
251
243
 
252
- if previous_node && previous_node.element?
253
- previous_node.name.downcase
254
- end
244
+ return unless previous_node&.element?
245
+
246
+ previous_node.name.downcase
255
247
  end
256
248
 
257
249
  def previous_node_is_text?(node)
258
- return !node.previous_sibling.nil? && node.previous_sibling.text? && !node.previous_sibling.text.strip.empty?
250
+ !node.previous_sibling.nil? && node.previous_sibling.text? && !node.previous_sibling.text.strip.empty?
259
251
  end
260
252
 
261
253
  # def previous_node_is_not_text?(node)