Skip to content

Commit 3571212

Browse files
committed
FEATURE: Optional strip_trailing_invisibles config flag
Outlook/Word HTML exports frequently leave zero-width soft-break hints and nbsp spacers at the end of paragraph content. Without trimming, these survive into the rendered Markdown as invisible artifacts — visually identical in the final rendered HTML, but confusing for anyone editing the stored Markdown later or trying to diff/grep it. Add a `strip_trailing_invisibles` config flag (default false) that, when enabled, has `cleanup_markdown` rstrip the invisible set at every line end. Members: NBSP (U+00A0), ZWSP (U+200B), ZWNJ (U+200C), ZWJ (U+200D), WJ (U+2060), ZWNBSP/BOM (U+FEFF). Excludes ASCII space and tab so Markdown's two-trailing-spaces hard-break syntax keeps working. ```ruby Markbridge.configure { |c| c.strip_trailing_invisibles = true } ``` Default-off matches the existing `escape_hard_line_breaks` precedent ("preserve more semantics by default") and means no behavior change for current consumers. High-throughput migration paths pay nothing extra; consumers who care about clean stored output opt in for the ~4-5% slowdown. The Outlook spacer paragraph case (`<p>&nbsp;</p>` between two paragraphs) drops out indirectly when enabled: rstripping the nbsp leaves an empty line, which the existing `gsub(/\n{3,}/, "\n\n")` collapse plus the trailing `.strip` swallow. Benchmark, mixed block + inline HTML (ruby 3.4.8 +YJIT, 5 runs avg): - main / flag off: ~3.27k i/s - flag on: ~3.13k i/s (-4.5%) Within typical run-to-run variance on this benchmark.
1 parent 7f1ce67 commit 3571212

5 files changed

Lines changed: 109 additions & 1 deletion

File tree

lib/markbridge.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,19 @@ def build_renderer(tag_library:)
156156
Renderers::Discourse::Renderer.new(tag_library:, escaper:)
157157
end
158158

159+
# Trailing-invisibles set, applied only when opted into via
160+
# `Markbridge.configuration.strip_trailing_invisibles = true`.
161+
# NBSP (U+00A0) plus the zero-width format chars that render as
162+
# nothing — ZWSP U+200B, ZWNJ U+200C, ZWJ U+200D, WJ U+2060,
163+
# ZWNBSP/BOM U+FEFF. Deliberately excludes ASCII space and tab so
164+
# Markdown's "two trailing spaces = hard line break" rule still
165+
# works. The `$` anchors to end-of-line (default Ruby regex mode),
166+
# so this strips per line without consuming the line break.
167+
TRAILING_INVISIBLE_RE = /[\u00A0\u200B\u200C\u200D\u2060\uFEFF]+$/
168+
private_constant :TRAILING_INVISIBLE_RE
169+
159170
def cleanup_markdown(text)
171+
text = text.gsub(TRAILING_INVISIBLE_RE, "") if configuration.strip_trailing_invisibles
160172
text
161173
.gsub(/\n{3,}/, "\n\n") # Max 2 consecutive newlines
162174
.gsub(/^[ \t]+$/, "") # Remove whitespace-only lines

lib/markbridge/configuration.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22

33
module Markbridge
44
class Configuration
5-
attr_accessor :escape_hard_line_breaks
5+
attr_accessor :escape_hard_line_breaks, :strip_trailing_invisibles
66

77
def initialize
88
@escape_hard_line_breaks = false
9+
# When true, `cleanup_markdown` rstrips a small set of invisible
10+
# characters (NBSP, ZWSP, ZWNJ, ZWJ, WJ, ZWNBSP/BOM) at each line
11+
# end. Useful for cleaning Outlook/Word HTML exports where these
12+
# show up as soft-break hints and spacer-paragraph fillers. Adds
13+
# one regex pass over the rendered output (~4-5% slowdown on a
14+
# mixed-content benchmark), so default off; opt in if the polish
15+
# matters more than throughput.
16+
@strip_trailing_invisibles = false
917
end
1018
end
1119
end

spec/markbridge_spec.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,44 @@ def render(_element, _interface, **_kwargs)
231231
it "strips leading and trailing whitespace from the final output" do
232232
expect(described_class.bbcode_to_markdown(" hi ")).to eq("hi")
233233
end
234+
235+
it "preserves trailing invisible characters by default (config flag off)" do
236+
# Default config keeps the output as-is — no extra regex pass on
237+
# cleanup. The U+200B byte sequence (e2 80 8b in UTF-8) survives
238+
# at the end of the rendered output.
239+
result = described_class.html_to_markdown("<p>Hello&#8203;</p>")
240+
241+
expect(result.bytes.last(4)).to eq([0x6f, 0xe2, 0x80, 0x8b]) # "o" + ZWSP
242+
end
243+
244+
context "with strip_trailing_invisibles enabled" do
245+
before { described_class.configure { |c| c.strip_trailing_invisibles = true } }
246+
247+
it "strips a trailing zero-width space at the end of a paragraph" do
248+
# Outlook-style soft-break ZWSP after content.
249+
expect(described_class.html_to_markdown("<p>Hello&#8203;</p>")).to eq("Hello")
250+
end
251+
252+
it "drops nbsp-only spacer paragraphs by stripping their trailing nbsp" do
253+
# `<p>&nbsp;</p>` collapses to "<nbsp>\n\n"; rstripping the nbsp
254+
# leaves an empty line which the existing \n{3,} collapse drops.
255+
expect(described_class.html_to_markdown("<p>a</p><p>&nbsp;</p><p>b</p>")).to eq("a\n\nb")
256+
end
257+
258+
it "preserves trailing ASCII spaces — they are the Markdown hard-break syntax" do
259+
# `hello \nworld` (two trailing spaces) is the hard-line-break form;
260+
# the trailing-invisibles strip must not touch ASCII spaces.
261+
expect(described_class.bbcode_to_markdown("hello \nworld")).to eq("hello \nworld")
262+
end
263+
264+
it "strips trailing invisibles on every affected line, not just the first" do
265+
# gsub vs sub: with sub, only the first paragraph would get cleaned
266+
# and the second's ZWSP would leak through.
267+
result = described_class.html_to_markdown("<p>first&#8203;</p><p>second&#8203;</p>")
268+
269+
expect(result).to eq("first\n\nsecond")
270+
end
271+
end
234272
end
235273

236274
describe ".parse_html" do

spec/system/html_to_markdown_spec.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,45 @@
286286
end
287287
end
288288

289+
describe "trailing invisible characters with strip_trailing_invisibles" do
290+
around do |example|
291+
Markbridge.configure { |c| c.strip_trailing_invisibles = true }
292+
example.run
293+
ensure
294+
Markbridge.reset_defaults!
295+
end
296+
297+
it "strips trailing zero-width space at the end of a paragraph" do
298+
html = "<p>Hello Specialist&#8203;</p><p>Our customer are unhappy</p>"
299+
300+
result = Markbridge.html_to_markdown(html)
301+
expect(result).to eq("Hello Specialist\n\nOur customer are unhappy")
302+
end
303+
304+
it "drops an Outlook-style nbsp-only spacer paragraph between content" do
305+
html = '<p>before</p><p class="MsoNormal">&nbsp;</p><p>after</p>'
306+
307+
result = Markbridge.html_to_markdown(html)
308+
expect(result).to eq("before\n\nafter")
309+
end
310+
311+
it "preserves leading nbsp (author intent — used as indentation)" do
312+
html = "<p>&nbsp;Hello</p>"
313+
314+
result = Markbridge.html_to_markdown(html)
315+
expect(result).to eq(" Hello")
316+
end
317+
318+
it "preserves invisibles in the middle of content" do
319+
# Mid-content ZWSP is a meaningful soft-break hint (long URLs, CJK),
320+
# only line-end invisibles get stripped.
321+
html = "<p>before​inline​text</p>"
322+
323+
result = Markbridge.html_to_markdown(html)
324+
expect(result).to eq("before​inline​text")
325+
end
326+
end
327+
289328
describe "complex combinations" do
290329
it "converts mixed content" do
291330
html = <<~HTML

spec/unit/markbridge/configuration_spec.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,15 @@
1313
expect(configuration.escape_hard_line_breaks).to be true
1414
end
1515
end
16+
17+
describe "#strip_trailing_invisibles" do
18+
it "defaults to false" do
19+
expect(configuration.strip_trailing_invisibles).to be false
20+
end
21+
22+
it "can be set to true" do
23+
configuration.strip_trailing_invisibles = true
24+
expect(configuration.strip_trailing_invisibles).to be true
25+
end
26+
end
1627
end

0 commit comments

Comments
 (0)