commit 165ebe1cf18e078807d151b240fe6a312625e7e9 Author: upstream Date: Wed Sep 2 11:59:02 2026 +0200 Upstream xoofx/markdig state of the touched files before 51b0655f4c diff --git a/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs b/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs new file mode 100644 index 0000000..10fb4e3 --- /dev/null +++ b/src/Markdig/Parsers/Inlines/EmphasisInlineParser.cs @@ -0,0 +1,455 @@ +// 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; + +/// +/// An inline parser for . +/// +/// +/// +public class EmphasisInlineParser : InlineParser, IPostInlineProcessor +{ + private CharacterMap? emphasisMap; + private readonly DelimitersObjectCache inlinesCache = new(); + /// + /// Represents the EmphasisInline type. + /// + public delegate EmphasisInline? TryCreateEmphasisInlineDelegate(char emphasisChar, int delimiterCount); + + /// + /// Initializes a new instance of the class. + /// + public EmphasisInlineParser() + { + EmphasisDescriptors = + [ + new EmphasisDescriptor('*', 1, 2, true), + new EmphasisDescriptor('_', 1, 2, false) + ]; + } + + /// + /// Gets the emphasis descriptors. + /// + public List EmphasisDescriptors { get; } + + /// + /// Gets or toggles whether the emphasis parser should be CJK-friendly. + /// + /// + public bool CjkFriendlyEmphasis { get; set; } = false; + + /// + /// Determines whether this parser is using the specified character as an emphasis delimiter. + /// + /// The character to look for. + /// true if this parser is using the specified character as an emphasis delimiter; otherwise false + public bool HasEmphasisChar(char c) + { + foreach (var emphasis in EmphasisDescriptors) + { + if (emphasis.Character == c) + { + return true; + } + } + return false; + } + + /// + /// Gets or sets the try create emphasis inline list. + /// + public readonly List TryCreateEmphasisInlineList = []; + + /// + /// Performs the initialize operation. + /// + public override void Initialize() + { + OpeningCharacters = new char[EmphasisDescriptors.Count]; + + var tempMap = new List>(); + 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(emphasis.Character, emphasis)); + } + + emphasisMap = new CharacterMap(tempMap); + } + + /// + /// Performs the post process operation. + /// + 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(root); + + List? 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; + } + + /// + /// Attempts to match the parser at the current position. + /// + 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?) 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 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; + } + } + } + } + + // Any delimiters left must be literal + for (int i = 0; i < delimiters.Count; i++) + { + var delimiter = delimiters[i]; + delimiter.ReplaceBy(delimiter.AsLiteralInline()); + } + delimiters.Clear(); + } + + /// + /// Represents the DelimitersObjectCache type. + /// + public class DelimitersObjectCache : ObjectCache> + { + /// + /// Performs the new instance operation. + /// + protected override List NewInstance() + { + return new List(4); + } + + /// + /// Performs the reset operation. + /// + protected override void Reset(List instance) + { + instance.Clear(); + } + } +}