From 425c9f8a4673a6e7f9e56bdcc764d82779e2905b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=22DualFroz=22=20Fox?= Date: Sat, 19 Sep 2026 11:52:40 +0200 Subject: [PATCH] Fix: keep trailing content out of an unmatched emphasis closer (#743) (#950) * fix: keep trailing content out of unmatched emphasis closer (#743) When EnableTrackTrivia() is enabled, an emphasis closer whose delimiter run is only partially consumed (its leftover dropping below the minimum count) kept the inlines that follow it nested inside the emphasis. The roundtrip renderer then emitted the closing delimiters after that trailing content, corrupting the output, for example a grid table separator row using the "marked" (==) delimiter from EmphasisExtras. Move the trailing content out to the outermost enclosing emphasis when the closer can no longer be matched, mirroring the existing handling for a fully consumed opener. The leftover closer keeps its position, so cases without trailing content are unaffected. Adds a regression test. * Fix unmatched emphasis closer cleanup order (#743) --------- Co-authored-by: Alexandre Mutel --- site/docs/extensions/emphasis-extras.md | 5 ++ src/Markdig.Tests/TestEmphasisRoundtrip.cs | 83 +++++++++++++++++++ .../Parsers/Inlines/EmphasisInlineParser.cs | 29 +++++-- 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 src/Markdig.Tests/TestEmphasisRoundtrip.cs diff --git a/site/docs/extensions/emphasis-extras.md b/site/docs/extensions/emphasis-extras.md index 8c0ee64..6f4a5ce 100644 --- a/site/docs/extensions/emphasis-extras.md +++ b/site/docs/extensions/emphasis-extras.md @@ -60,6 +60,11 @@ This is ==highlighted== text. This is ==highlighted== text. +An unmatched remainder of a closing delimiter run must not pull subsequent text +into the emphasis. For example, `===a===== tail` renders as +`=a=== tail`. This example also round-trips unchanged with +`EnableTrackTrivia()` and `RoundtripRenderer`. + ## HTML output {.table} diff --git a/src/Markdig.Tests/TestEmphasisRoundtrip.cs b/src/Markdig.Tests/TestEmphasisRoundtrip.cs new file mode 100644 index 0000000..295df0c --- /dev/null +++ b/src/Markdig.Tests/TestEmphasisRoundtrip.cs @@ -0,0 +1,83 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using Markdig.Renderers.Roundtrip; +using Markdig.Syntax; + +namespace Markdig.Tests; + +[TestFixture] +public class TestEmphasisRoundtrip +{ + private static string RoundTrip(string markdown, MarkdownPipeline pipeline) + { + MarkdownDocument document = Markdown.Parse(markdown, pipeline); + var writer = new StringWriter(); + var renderer = new RoundtripRenderer(writer); + pipeline.Setup(renderer); + renderer.Write(document); + return writer.ToString(); + } + + // Leftover delimiter characters below the minimum count used to stay nested + // inside the emphasis they closed, moving the following text to the end of + // the block. https://github.com/xoofx/markdig/issues/743 + [Test] + public void GridTableSeparatorRoundtripsWithTrackTrivia() + { + string markdown = + "+---------------+---------------+--------------------+\n" + + "| Fruit | Price | Advantages |\n" + + "+===============+===============+====================+\n" + + "| Bananas | first line | first line |\n" + + "| | next line | next line |\n" + + "+---------------+---------------+--------------------+\n"; + + var pipeline = new MarkdownPipelineBuilder() + .UseAutoLinks() + .UseEmphasisExtras() + .UseListExtras() + .EnableTrackTrivia() + .Build(); + + Assert.That(RoundTrip(markdown, pipeline), Is.EqualTo(markdown)); + } + + // Same defect, minimal: leftovers (15 = 7 * 2 + 1) below the "==" minimum. + [Test] + public void UnbalancedMarkedRunsRoundtrip() + { + string markdown = "+===============+===============+\n"; + + var pipeline = new MarkdownPipelineBuilder() + .UseEmphasisExtras() + .EnableTrackTrivia() + .Build(); + + Assert.That(RoundTrip(markdown, pipeline), Is.EqualTo(markdown)); + } + + [TestCase("===a===== tail\n", "

=a=== tail

")] + [TestCase("===a==== tail\n", "

=a== tail

")] + [TestCase("=====a======= tail\n", "

=a=== tail

")] + [TestCase("+++a+++++ tail\n", "

+a+++ tail

")] + [TestCase("*===a===== tail*\n", "

=a=== tail

")] + [TestCase("===a===== tail ==next==\n", "

=a=== tail next

")] + public void UnmatchedCloserPreservesTrailingContent(string markdown, string expectedHtml) + { + var pipeline = new MarkdownPipelineBuilder().UseEmphasisExtras().EnableTrackTrivia().Build(); + + Assert.That(RoundTrip(markdown, pipeline), Is.EqualTo(markdown)); + Assert.That(Markdown.ToHtml(markdown, pipeline).Trim(), Is.EqualTo(expectedHtml)); + Assert.That(Markdown.ToHtml(markdown, new MarkdownPipelineBuilder().UseEmphasisExtras().Build()).Trim(), Is.EqualTo(expectedHtml)); + } + + // The fix must not prevent balanced "marked" emphasis from being detected. + [Test] + public void BalancedMarkedEmphasisStillParses() + { + var pipeline = new MarkdownPipelineBuilder().UseEmphasisExtras().Build(); + Assert.That(Markdown.ToHtml("==bold==", pipeline).Trim(), Is.EqualTo("

bold

")); + } +} diff --git a/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs b/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs index 10fb4e3..bc0b08e 100644 --- a/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs +++ b/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs @@ -407,18 +407,33 @@ public class EmphasisInlineParser : InlineParser, IPostInlineProcessor i--; } } - else if ((closeDelimiter.Type & DelimiterType.Open) == 0) - { - closeDelimiter.ReplaceBy(closeDelimiter.AsLiteralInline()); - delimiters.RemoveAt(i); - i--; - break; - } else { + // Keep unmatched closers attached until their trailing content + // has been moved out below. break; } } + + // Leftover delimiter characters keep the following inlines nested inside + // the emphasis; move them out so they keep their original position. + if (closeDelimiter.DelimiterCount > 0 && closeDelimiter.Parent is EmphasisInline closerParentEmphasis) + { + var outermostEmphasis = closerParentEmphasis; + while (outermostEmphasis.Parent is EmphasisInline ancestorEmphasis) + { + outermostEmphasis = ancestorEmphasis; + } + + closeDelimiter.MoveChildrenAfter(outermostEmphasis); + } + + if (closeDelimiter.DelimiterCount > 0 && (closeDelimiter.Type & DelimiterType.Open) == 0) + { + closeDelimiter.ReplaceBy(closeDelimiter.AsLiteralInline()); + delimiters.RemoveAt(i); + i--; + } } }