From 83afc8fa2692815f947bd26f1cb1a1b31f73f905 Mon Sep 17 00:00:00 2001 From: dualfroz Date: Wed, 2 Sep 2026 11:59:02 +0200 Subject: [PATCH] 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. --- src/Markdig.Tests/TestEmphasisRoundtrip.cs | 70 +++++++++++++++++++ .../Parsers/Inlines/EmphasisInlineParser.cs | 21 ++++++ 2 files changed, 91 insertions(+) create mode 100644 src/Markdig.Tests/TestEmphasisRoundtrip.cs diff --git a/src/Markdig.Tests/TestEmphasisRoundtrip.cs b/src/Markdig.Tests/TestEmphasisRoundtrip.cs new file mode 100644 index 0000000..dc6648a --- /dev/null +++ b/src/Markdig.Tests/TestEmphasisRoundtrip.cs @@ -0,0 +1,70 @@ +// 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(); + } + + // A closer with leftover delimiter characters that dropped below the minimum count + // used to stay nested inside the emphasis it closed, together with the content that + // followed it. The round-tripped text then emitted the leftover run at the end of the + // block. Reproduces 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)); + } + + // Minimal reduction of the same defect: two '=' runs whose leftover characters + // (15 = 7 * 2 + 1) fall below the "marked" (`==`) minimum count of 2. + [Test] + public void UnbalancedMarkedRunsRoundtrip() + { + string markdown = "+===============+===============+\n"; + + var pipeline = new MarkdownPipelineBuilder() + .UseEmphasisExtras() + .EnableTrackTrivia() + .Build(); + + Assert.That(RoundTrip(markdown, pipeline), Is.EqualTo(markdown)); + } + + // 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..32566af 100644 --- a/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs +++ b/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs @@ -419,6 +419,27 @@ public class EmphasisInlineParser : InlineParser, IPostInlineProcessor break; } } + + // The closer could not be fully consumed and still holds leftover delimiter + // characters that cannot match any opener. When the closer was embraced as the + // closing side of an emphasis, the inlines that follow the closer belong after + // that emphasis, not nested inside it. This is already handled when the opener is + // fully consumed, but not when the opener also keeps leftover characters. Move the + // trailing content out to the outermost enclosing emphasis so it keeps its original + // position; otherwise it is rendered before the closing delimiters, which corrupts + // round-tripped output when EnableTrackTrivia() is used (for example a grid table + // separator, #743). The closer itself keeps its position, matching the behaviour + // for a leftover closer that has no trailing content. + if (closeDelimiter.DelimiterCount > 0 && closeDelimiter.Parent is EmphasisInline closerParentEmphasis) + { + var outermostEmphasis = closerParentEmphasis; + while (outermostEmphasis.Parent is EmphasisInline ancestorEmphasis) + { + outermostEmphasis = ancestorEmphasis; + } + + closeDelimiter.MoveChildrenAfter(outermostEmphasis); + } } }