Files
markdig/src/Markdig/Helpers/HtmlHelper.cs
2020-12-17 10:14:17 +01:00

618 lines
20 KiB
C#

// 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;
using System.Text;
namespace Markdig.Helpers
{
/// <summary>
/// Helper to parse several HTML tags.
/// </summary>
public static class HtmlHelper
{
private static readonly char[] SearchBackAndAmp = { '\\', '&' };
private static readonly char[] SearchAmp = { '&' };
private static readonly string[] EscapeUrlsForAscii = new string[128];
static HtmlHelper()
{
for (int i = 0; i < EscapeUrlsForAscii.Length; i++)
{
if (i <= 32 || @"""'<>[\]^`{|}~".IndexOf((char)i) >= 0 || i == 127)
{
EscapeUrlsForAscii[i] = $"%{i:X2}";
}
else if ((char) i == '&')
{
EscapeUrlsForAscii[i] = "&amp;";
}
}
}
public static string EscapeUrlCharacter(char c)
{
return c < 128 ? EscapeUrlsForAscii[c] : null;
}
public static bool TryParseHtmlTag(StringSlice text, out string htmlTag)
{
return TryParseHtmlTag(ref text, out htmlTag);
}
public static bool TryParseHtmlTag(ref StringSlice text, out string htmlTag)
{
var builder = StringBuilderCache.Local();
var result = TryParseHtmlTag(ref text, builder);
htmlTag = builder.ToString();
builder.Length = 0;
return result;
}
public static bool TryParseHtmlTag(ref StringSlice text, StringBuilder builder)
{
if (builder == null) ThrowHelper.ArgumentNullException(nameof(builder));
var c = text.CurrentChar;
if (c != '<')
{
return false;
}
c = text.NextChar();
builder.Append('<');
switch (c)
{
case '/':
return TryParseHtmlCloseTag(ref text, builder);
case '?':
return TryParseHtmlTagProcessingInstruction(ref text, builder);
case '!':
builder.Append(c);
c = text.NextChar();
if (c == '-')
{
return TryParseHtmlTagHtmlComment(ref text, builder);
}
if (c == '[')
{
return TryParseHtmlTagCData(ref text, builder);
}
return TryParseHtmlTagDeclaration(ref text, builder);
}
return TryParseHtmlTagOpenTag(ref text, builder);
}
internal static bool TryParseHtmlTagOpenTag(ref StringSlice text, StringBuilder builder)
{
var c = text.CurrentChar;
// Parse the tagname
if (!c.IsAlpha())
{
return false;
}
builder.Append(c);
while (true)
{
c = text.NextChar();
if (c.IsAlphaNumeric() || c == '-')
{
builder.Append(c);
}
else
{
break;
}
}
bool hasAttribute = false;
while (true)
{
var hasWhitespaces = false;
// Skip any whitespaces
while (c.IsWhitespace())
{
builder.Append(c);
c = text.NextChar();
hasWhitespaces = true;
}
switch (c)
{
case '\0':
return false;
case '>':
text.SkipChar();
builder.Append(c);
return true;
case '/':
builder.Append('/');
c = text.NextChar();
if (c != '>')
{
return false;
}
text.SkipChar();
builder.Append('>');
return true;
case '=':
if (!hasAttribute)
{
return false;
}
builder.Append('=');
// Skip any spaces after
c = text.NextChar();
while (c.IsWhitespace())
{
builder.Append(c);
c = text.NextChar();
}
// Parse a quoted string
if (c == '\'' || c == '\"')
{
builder.Append(c);
char openingStringChar = c;
while (true)
{
c = text.NextChar();
if (c == '\0')
{
return false;
}
if (c != openingStringChar)
{
builder.Append(c);
}
else
{
break;
}
}
builder.Append(c);
c = text.NextChar();
}
else
{
// Parse until we match a space or a special html character
int matchCount = 0;
while (true)
{
if (c == '\0')
{
return false;
}
if (c == ' ' || c == '\n' || c == '"' || c == '\'' || c == '=' || c == '<' || c == '>' || c == '`')
{
break;
}
matchCount++;
builder.Append(c);
c = text.NextChar();
}
// We need at least one char after '='
if (matchCount == 0)
{
return false;
}
}
hasAttribute = false;
continue;
default:
if (!hasWhitespaces)
{
return false;
}
// Parse the attribute name
if (!(c.IsAlpha() || c == '_' || c == ':'))
{
return false;
}
builder.Append(c);
while (true)
{
c = text.NextChar();
if (c.IsAlphaNumeric() || c == '_' || c == ':' || c == '.' || c == '-')
{
builder.Append(c);
}
else
{
break;
}
}
hasAttribute = true;
break;
}
}
}
private static bool TryParseHtmlTagDeclaration(ref StringSlice text, StringBuilder builder)
{
var c = text.CurrentChar;
bool hasAlpha = false;
while (c.IsAlphaUpper())
{
builder.Append(c);
c = text.NextChar();
hasAlpha = true;
}
if (!hasAlpha || !c.IsWhitespace())
{
return false;
}
// Regexp: "\\![A-Z]+\\s+[^>\\x00]*>"
while (true)
{
builder.Append(c);
c = text.NextChar();
if (c == '\0')
{
return false;
}
if (c == '>')
{
text.SkipChar();
builder.Append('>');
return true;
}
}
}
private static bool TryParseHtmlTagCData(ref StringSlice text, StringBuilder builder)
{
builder.Append('[');
var c = text.NextChar();
if (c == 'C' &&
text.NextChar() == 'D' &&
text.NextChar() == 'A' &&
text.NextChar() == 'T' &&
text.NextChar() == 'A' &&
(c = text.NextChar()) == '[')
{
builder.Append("CDATA[");
while (true)
{
var pc = c;
c = text.NextChar();
if (c == '\0')
{
return false;
}
if (c == ']' && pc == ']')
{
builder.Append(']');
c = text.NextChar();
if (c == '>')
{
builder.Append('>');
text.SkipChar();
return true;
}
if (c == '\0')
{
return false;
}
}
builder.Append(c);
}
}
return false;
}
internal static bool TryParseHtmlCloseTag(ref StringSlice text, StringBuilder builder)
{
// </[A-Za-z][A-Za-z0-9]+\s*>
builder.Append('/');
var c = text.NextChar();
if (!c.IsAlpha())
{
return false;
}
builder.Append(c);
bool skipSpaces = false;
while (true)
{
c = text.NextChar();
if (c == '>')
{
text.SkipChar();
builder.Append('>');
return true;
}
if (skipSpaces)
{
if (c != ' ')
{
break;
}
}
else if (c == ' ')
{
skipSpaces = true;
}
else if (!(c.IsAlphaNumeric() || c == '-'))
{
break;
}
builder.Append(c);
}
return false;
}
private static bool TryParseHtmlTagHtmlComment(ref StringSlice text, StringBuilder builder)
{
var c = text.NextChar();
if (c != '-')
{
return false;
}
builder.Append('-');
builder.Append('-');
if (text.PeekChar() == '>')
{
return false;
}
var countHyphen = 0;
while (true)
{
c = text.NextChar();
if (c == '\0')
{
return false;
}
if (countHyphen == 2)
{
if (c == '>')
{
builder.Append('>');
text.SkipChar();
return true;
}
return false;
}
countHyphen = c == '-' ? countHyphen + 1 : 0;
builder.Append(c);
}
}
private static bool TryParseHtmlTagProcessingInstruction(ref StringSlice text, StringBuilder builder)
{
builder.Append('?');
var prevChar = '\0';
while (true)
{
var c = text.NextChar();
if (c == '\0')
{
return false;
}
if (c == '>' && prevChar == '?')
{
builder.Append('>');
text.SkipChar();
return true;
}
prevChar = c;
builder.Append(c);
}
}
/// <summary>
/// Destructively unescape a string: remove backslashes before punctuation or symbol characters.
/// </summary>
/// <param name="text">The string data that will be changed by unescaping any punctuation or symbol characters.</param>
/// <param name="removeBackSlash">if set to <c>true</c> [remove back slash].</param>
/// <returns></returns>
public static string Unescape(string text, bool removeBackSlash = true)
{
// Credits: code from CommonMark.NET
// Copyright (c) 2014, Kārlis Gaņģis All rights reserved.
// See license for details: https://github.com/Knagis/CommonMark.NET/blob/master/LICENSE.md
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
// remove backslashes before punctuation chars:
int searchPos = 0;
int lastPos = 0;
char c;
char[] search = removeBackSlash ? SearchBackAndAmp : SearchAmp;
StringBuilder sb = null;
while ((searchPos = text.IndexOfAny(search, searchPos)) != -1)
{
sb ??= StringBuilderCache.Local();
c = text[searchPos];
if (removeBackSlash && c == '\\')
{
searchPos++;
if (text.Length == searchPos)
break;
c = text[searchPos];
if (c.IsEscapableSymbol())
{
sb.Append(text, lastPos, searchPos - lastPos - 1);
lastPos = searchPos;
}
}
else if (c == '&')
{
var match = ScanEntity(new StringSlice(text, searchPos, text.Length - 1), out int numericEntity, out int entityNameStart, out int entityNameLength);
if (match == 0)
{
searchPos++;
}
else
{
searchPos += match;
if (entityNameLength > 0)
{
var decoded = EntityHelper.DecodeEntity(text.AsSpan(entityNameStart, entityNameLength));
if (decoded != null)
{
sb.Append(text, lastPos, searchPos - match - lastPos);
sb.Append(decoded);
lastPos = searchPos;
}
}
else if (numericEntity >= 0)
{
sb.Append(text, lastPos, searchPos - match - lastPos);
EntityHelper.DecodeEntity(numericEntity, sb);
lastPos = searchPos;
}
}
}
}
if (sb == null || lastPos == 0)
return text;
sb.Append(text, lastPos, text.Length - lastPos);
return sb.GetStringAndReset();
}
/// <summary>
/// Scans an entity.
/// Returns number of chars matched.
/// </summary>
public static int ScanEntity<T>(T slice, out int numericEntity, out int namedEntityStart, out int namedEntityLength) where T : ICharIterator
{
// Credits: code from CommonMark.NET
// Copyright (c) 2014, Kārlis Gaņģis All rights reserved.
// See license for details: https://github.com/Knagis/CommonMark.NET/blob/master/LICENSE.md
numericEntity = 0;
namedEntityStart = 0;
namedEntityLength = 0;
if (slice.CurrentChar != '&' || slice.PeekChar(3) == '\0')
{
return 0;
}
var start = slice.Start;
char c = slice.NextChar();
int counter = 0;
if (c == '#')
{
c = slice.PeekChar();
if (c == 'x' || c == 'X')
{
slice.SkipChar(); // skip #
// expect 1-6 hex digits starting from pos+3
while (c != '\0')
{
c = slice.NextChar();
if (c >= '0' && c <= '9')
{
if (++counter == 7) return 0;
numericEntity = numericEntity*16 + (c - '0');
continue;
}
else if (c >= 'A' && c <= 'F')
{
if (++counter == 7) return 0;
numericEntity = numericEntity*16 + (c - 'A' + 10);
continue;
}
else if (c >= 'a' && c <= 'f')
{
if (++counter == 7) return 0;
numericEntity = numericEntity*16 + (c - 'a' + 10);
continue;
}
if (c == ';')
return counter == 0 ? 0 : slice.Start - start + 1;
return 0;
}
}
else
{
// expect 1-7 digits starting from pos+2
while (c != '\0')
{
c = slice.NextChar();
if (c >= '0' && c <= '9')
{
if (++counter == 8) return 0;
numericEntity = numericEntity*10 + (c - '0');
continue;
}
if (c == ';')
return counter == 0 ? 0 : slice.Start - start + 1;
return 0;
}
}
}
else
{
// expect a letter and 1-31 letters or digits
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')))
return 0;
namedEntityStart = slice.Start;
namedEntityLength++;
while (c != '\0')
{
c = slice.NextChar();
if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
{
if (++counter == 32)
return 0;
namedEntityLength++;
continue;
}
if (c == ';')
{
return counter == 0 ? 0 : slice.Start - start + 1;
}
return 0;
}
}
return 0;
}
}
}