Files
xoofx-markdig/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs
T
dualfroz 83afc8fa26 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.
2026-09-02 11:59:02 +02:00

477 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
using Markdig.Helpers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Markdig.Parsers.Inlines;
/// <summary>
/// An inline parser for <see cref="EmphasisInline"/>.
/// </summary>
/// <seealso cref="InlineParser" />
/// <seealso cref="IPostInlineProcessor" />
public class EmphasisInlineParser : InlineParser, IPostInlineProcessor
{
private CharacterMap<EmphasisDescriptor>? emphasisMap;
private readonly DelimitersObjectCache inlinesCache = new();
/// <summary>
/// Represents the EmphasisInline type.
/// </summary>
public delegate EmphasisInline? TryCreateEmphasisInlineDelegate(char emphasisChar, int delimiterCount);
/// <summary>
/// Initializes a new instance of the <see cref="EmphasisInlineParser"/> class.
/// </summary>
public EmphasisInlineParser()
{
EmphasisDescriptors =
[
new EmphasisDescriptor('*', 1, 2, true),
new EmphasisDescriptor('_', 1, 2, false)
];
}
/// <summary>
/// Gets the emphasis descriptors.
/// </summary>
public List<EmphasisDescriptor> EmphasisDescriptors { get; }
/// <summary>
/// Gets or toggles whether the emphasis parser should be CJK-friendly.
/// </summary>
/// <seealso href="https://github.com/tats-u/markdown-cjk-friendly"/>
public bool CjkFriendlyEmphasis { get; set; } = false;
/// <summary>
/// Determines whether this parser is using the specified character as an emphasis delimiter.
/// </summary>
/// <param name="c">The character to look for.</param>
/// <returns><c>true</c> if this parser is using the specified character as an emphasis delimiter; otherwise <c>false</c></returns>
public bool HasEmphasisChar(char c)
{
foreach (var emphasis in EmphasisDescriptors)
{
if (emphasis.Character == c)
{
return true;
}
}
return false;
}
/// <summary>
/// Gets or sets the try create emphasis inline list.
/// </summary>
public readonly List<TryCreateEmphasisInlineDelegate> TryCreateEmphasisInlineList = [];
/// <summary>
/// Performs the initialize operation.
/// </summary>
public override void Initialize()
{
OpeningCharacters = new char[EmphasisDescriptors.Count];
var tempMap = new List<KeyValuePair<char, EmphasisDescriptor>>();
for (int i = 0; i < EmphasisDescriptors.Count; i++)
{
var emphasis = EmphasisDescriptors[i];
if (Array.IndexOf(OpeningCharacters, emphasis.Character) >= 0)
{
ThrowHelper.InvalidOperationException(
$"The character `{emphasis.Character}` is already used by another emphasis descriptor");
}
OpeningCharacters[i] = emphasis.Character;
tempMap.Add(new KeyValuePair<char, EmphasisDescriptor>(emphasis.Character, emphasis));
}
emphasisMap = new CharacterMap<EmphasisDescriptor>(tempMap);
}
/// <summary>
/// Performs the post process operation.
/// </summary>
public bool PostProcess(InlineProcessor state, Inline? root, Inline? lastChild, int postInlineProcessorIndex, bool isFinalProcessing)
{
if (root is null || !root.IsContainerInline)
{
return true;
}
ContainerInline container = Unsafe.As<ContainerInline>(root);
List<EmphasisDelimiterInline>? delimiters = null;
if (container is EmphasisDelimiterInline emphasisDelimiter)
{
delimiters = inlinesCache.Get();
delimiters.Add(emphasisDelimiter);
}
// Collect all EmphasisDelimiterInline by searching from the root container
var child = container.FirstChild;
while (child != null)
{
// Stop the search on the delimitation child
if (child == lastChild)
{
break;
}
if (child.IsContainer && child is DelimiterInline delimiterInline)
{
// If we have a delimiter, we search into it as we should have a tree of EmphasisDelimiterInline
if (delimiterInline is EmphasisDelimiterInline delimiter)
{
delimiters ??= inlinesCache.Get();
delimiters.Add(delimiter);
}
// Follow DelimiterInline (EmphasisDelimiter, TableDelimiter...)
// If the delimiter has IsClosed=true (e.g., pipe table delimiter), it has no children
// In that case, continue to next sibling instead of stopping
var firstChild = delimiterInline.FirstChild;
child = firstChild ?? delimiterInline.NextSibling;
}
else
{
child = child.NextSibling;
}
}
if (delimiters != null)
{
ProcessEmphasis(state, delimiters);
inlinesCache.Release(delimiters);
}
return true;
}
/// <summary>
/// Attempts to match the parser at the current position.
/// </summary>
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// First, some definitions.
// A delimiter run is a sequence of one or more delimiter characters that is not preceded or followed by the same delimiter character
// The amount of delimiter characters in the delimiter run may exceed emphasisDesc.MaximumCount, as that is handeled in `ProcessEmphasis`
var delimiterChar = slice.CurrentChar;
var emphasisDesc = emphasisMap![delimiterChar]!;
Rune pc = (Rune)0;
Rune twoPreviousChar = default;
if (processor.Inline is HtmlEntityInline htmlEntityInline)
{
if (htmlEntityInline.Transcoded.Length > 0)
{
var transcoded = htmlEntityInline.Transcoded.Text.AsSpan(htmlEntityInline.Transcoded.Start, htmlEntityInline.Transcoded.Length);
Rune.DecodeLastFromUtf16(transcoded, out pc, out int charsConsumed);
if (CjkFriendlyEmphasis && charsConsumed < transcoded.Length)
{
Rune.DecodeLastFromUtf16(transcoded.Slice(0, transcoded.Length - charsConsumed), out twoPreviousChar, out _);
}
}
}
if (pc.Value == 0)
{
pc = slice.PeekRuneExtra(-1);
if (CjkFriendlyEmphasis)
{
// This cannot be a delegate (Func<Rune>?) because slice is a reference
twoPreviousChar = slice.PeekRuneExtra(-1 - pc.Utf16SequenceLength);
}
// delimiterChar is BMP, so slice.PeekCharExtra(-2) is (a part of) the character two positions back.
if (pc == (Rune)delimiterChar && slice.PeekCharExtra(-2) != '\\')
{
// If we get here, we determined that either:
// a) there weren't enough delimiters in the delimiter run to satisfy the MinimumCount condition
// b) the previous character couldn't open/close
return false;
}
}
var startPosition = slice.Start;
int delimiterCount = slice.CountAndSkipChar(delimiterChar);
// If the emphasis doesn't have the minimum required character
if (delimiterCount < emphasisDesc.MinimumCount)
{
return false;
}
Rune c = slice.CurrentRune;
// The following character is actually an entity, we need to decode it
if (HtmlEntityParser.TryParse(ref slice, out string? htmlString, out int htmlLength))
{
// Note: c is U+FFFD when decode error
Rune.DecodeFromUtf16(htmlString, out c, out _);
}
bool canOpen;
bool canClose;
// Calculate Open-Close for current character
if (CjkFriendlyEmphasis)
{
CharHelper.CheckOpenCloseDelimiterCjkFriendly(pc, c, twoPreviousChar, emphasisDesc.EnableWithinWord, out canOpen, out canClose);
}
else
{
CharHelper.CheckOpenCloseDelimiter(pc, c, emphasisDesc.EnableWithinWord, out canOpen, out canClose);
}
// We have potentially an open or close emphasis
if (canOpen || canClose)
{
var delimiterType = DelimiterType.Undefined;
if (canOpen) delimiterType |= DelimiterType.Open;
if (canClose) delimiterType |= DelimiterType.Close;
var delimiter = new EmphasisDelimiterInline(this, emphasisDesc, new StringSlice(slice.Text, startPosition, slice.Start - 1))
{
DelimiterCount = delimiterCount,
Type = delimiterType,
Span = new SourceSpan(processor.GetSourcePosition(startPosition, out int line, out int column), processor.GetSourcePosition(slice.Start - 1)),
Column = column,
Line = line,
};
processor.Inline = delimiter;
return true;
}
// We don't have an emphasis
return false;
}
private void ProcessEmphasis(InlineProcessor processor, List<EmphasisDelimiterInline> delimiters)
{
// The following method is inspired by the "An algorithm for parsing nested emphasis and links"
// at the end of the CommonMark specs.
// TODO: Benchmark difference between using List and LinkedList here since there could be a few Remove calls
// Move current_position forward in the delimiter stack (if needed) until
// we find the first potential closer with delimiter * or _. (This will be the potential closer closest to the beginning of the input – the first one in parse order.)
for (int i = 0; i < delimiters.Count; i++)
{
var closeDelimiter = delimiters[i];
// Skip delimiters not supported by this instance
EmphasisDescriptor? emphasisDesc = emphasisMap![closeDelimiter.DelimiterChar];
if (emphasisDesc is null)
{
continue;
}
if ((closeDelimiter.Type & DelimiterType.Close) != 0)
{
while (closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount)
{
// Now, look back in the stack (staying above stack_bottom and the openers_bottom for this delimiter type)
// for the first matching potential opener (“matching” means same delimiter).
EmphasisDelimiterInline? openDelimiter = null;
int openDelimiterIndex = -1;
for (int j = i - 1; j >= 0; j--)
{
var previousOpenDelimiter = delimiters[j];
var isOddMatch = ((closeDelimiter.Type & DelimiterType.Open) != 0 || (previousOpenDelimiter.Type & DelimiterType.Close) != 0) &&
previousOpenDelimiter.DelimiterCount != closeDelimiter.DelimiterCount &&
(previousOpenDelimiter.DelimiterCount + closeDelimiter.DelimiterCount) % 3 == 0 &&
(previousOpenDelimiter.DelimiterCount % 3 != 0 || closeDelimiter.DelimiterCount % 3 != 0);
if (previousOpenDelimiter.DelimiterChar == closeDelimiter.DelimiterChar &&
(previousOpenDelimiter.Type & DelimiterType.Open) != 0 &&
previousOpenDelimiter.DelimiterCount >= emphasisDesc.MinimumCount && !isOddMatch)
{
openDelimiter = previousOpenDelimiter;
openDelimiterIndex = j;
break;
}
}
if (openDelimiter != null)
{
process_delims:
Debug.Assert(openDelimiter.DelimiterCount >= emphasisDesc.MinimumCount, "Extra emphasis should have been discarded by now");
Debug.Assert(closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount, "Extra emphasis should have been discarded by now");
int delimiterDelta = Math.Min(Math.Min(openDelimiter.DelimiterCount, closeDelimiter.DelimiterCount), emphasisDesc.MaximumCount);
// Insert an emph or strong emph node accordingly, after the text node corresponding to the opener.
EmphasisInline? emphasis = null;
// Go in backwards order to give priority to newer delegates
for (int delegateIndex = TryCreateEmphasisInlineList.Count - 1; delegateIndex >= 0; delegateIndex--)
{
emphasis = TryCreateEmphasisInlineList[delegateIndex].Invoke(closeDelimiter.DelimiterChar, delimiterDelta);
if (emphasis != null) break;
}
emphasis ??= new EmphasisInline
{
DelimiterChar = closeDelimiter.DelimiterChar,
DelimiterCount = delimiterDelta
};
// Update position for emphasis
var openDelimitercount = openDelimiter.DelimiterCount;
var closeDelimitercount = closeDelimiter.DelimiterCount;
emphasis!.Span.Start = openDelimiter.Span.Start + openDelimitercount - delimiterDelta;
emphasis.Line = openDelimiter.Line;
emphasis.Column = openDelimiter.Column + openDelimitercount - delimiterDelta;
emphasis.Span.End = closeDelimiter.Span.End - closeDelimitercount + delimiterDelta;
openDelimiter.Span.End -= delimiterDelta;
openDelimiter.Content.End -= delimiterDelta;
closeDelimiter.Content.Start += delimiterDelta;
closeDelimiter.Span.Start += delimiterDelta;
closeDelimiter.Column += delimiterDelta;
openDelimiter.DelimiterCount -= delimiterDelta;
closeDelimiter.DelimiterCount -= delimiterDelta;
var embracer = (ContainerInline)openDelimiter;
// Copy attributes attached to delimiter to the emphasis
var attributes = closeDelimiter.TryGetAttributes();
if (attributes != null)
{
emphasis.SetAttributes(attributes);
}
// Embrace all delimiters
embracer.EmbraceChildrenBy(emphasis);
// Remove any intermediate emphasis
for (int k = i - 1; k >= openDelimiterIndex + 1; k--)
{
var literalDelimiter = delimiters[k];
literalDelimiter.ReplaceBy(literalDelimiter.AsLiteralInline());
delimiters.RemoveAt(k);
i--;
}
if (closeDelimiter.DelimiterCount == 0)
{
var newParent = openDelimiter.DelimiterCount > 0 ? emphasis : emphasis.Parent!;
closeDelimiter.MoveChildrenAfter(newParent);
closeDelimiter.Remove();
delimiters.RemoveAt(i);
i--;
// Remove the open delimiter if it is also empty
if (openDelimiter.DelimiterCount == 0)
{
openDelimiter.MoveChildrenAfter(openDelimiter);
openDelimiter.Remove();
delimiters.RemoveAt(openDelimiterIndex);
i--;
}
break;
}
// The current delimiters are matching
if (openDelimiter.DelimiterCount >= emphasisDesc.MinimumCount &&
closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount)
{
goto process_delims;
}
else if (openDelimiter.DelimiterCount > 0)
{
// There are still delimiter characters left, there's just not enough of them
openDelimiter.ReplaceBy(openDelimiter.AsLiteralInline());
delimiters.RemoveAt(openDelimiterIndex);
i--;
}
else
{
// Remove the open delimiter if it is also empty
var firstChild = openDelimiter.FirstChild!;
firstChild.Remove();
openDelimiter.ReplaceBy(firstChild);
firstChild.IsClosed = true;
closeDelimiter.Remove();
firstChild.InsertAfter(closeDelimiter);
delimiters.RemoveAt(openDelimiterIndex);
i--;
}
}
else if ((closeDelimiter.Type & DelimiterType.Open) == 0)
{
closeDelimiter.ReplaceBy(closeDelimiter.AsLiteralInline());
delimiters.RemoveAt(i);
i--;
break;
}
else
{
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);
}
}
}
// Any delimiters left must be literal
for (int i = 0; i < delimiters.Count; i++)
{
var delimiter = delimiters[i];
delimiter.ReplaceBy(delimiter.AsLiteralInline());
}
delimiters.Clear();
}
/// <summary>
/// Represents the DelimitersObjectCache type.
/// </summary>
public class DelimitersObjectCache : ObjectCache<List<EmphasisDelimiterInline>>
{
/// <summary>
/// Performs the new instance operation.
/// </summary>
protected override List<EmphasisDelimiterInline> NewInstance()
{
return new List<EmphasisDelimiterInline>(4);
}
/// <summary>
/// Performs the reset operation.
/// </summary>
protected override void Reset(List<EmphasisDelimiterInline> instance)
{
instance.Clear();
}
}
}