From 4409090c31e01a15723f506ede3216d4aa4ddc8b Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Mon, 19 Oct 2026 21:59:36 +0200 Subject: [PATCH 01/15] Revisit the DataUri to avoid Regex parsing and maximize the usage of Span --- .../Expressions/HyperlinkExpression.cs | 2 +- .../Expressions/Image/ImageExpression.cs | 13 +- src/Html2OpenXml/HtmlConverter.cs | 8 +- src/Html2OpenXml/HtmlToOpenXml.csproj | 3 + src/Html2OpenXml/IO/DataUri.cs | 129 +++---- src/Html2OpenXml/IO/ImagePrefetcher.cs | 4 +- .../CollectionExtensions.cs | 41 +-- src/Html2OpenXml/Shim/System.Text.Rune.cs | 50 +++ .../Utilities/AngleSharpExtensions.cs | 2 +- .../Utilities/HttpUtility.HtmlEntities.cs | 323 ++++++++++++++++++ src/Html2OpenXml/Utilities/HttpUtility.cs | 256 +++++++------- src/Html2OpenXml/Utilities/SpanExtensions.cs | 14 + .../Primitives/DataUriTests.cs | 15 +- .../Primitives/HttpUtilityTests.cs | 23 ++ 14 files changed, 658 insertions(+), 225 deletions(-) rename src/Html2OpenXml/{Utilities => Shim}/CollectionExtensions.cs (66%) create mode 100644 src/Html2OpenXml/Shim/System.Text.Rune.cs create mode 100644 src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs create mode 100644 test/HtmlToOpenXml.Tests/Primitives/HttpUtilityTests.cs diff --git a/src/Html2OpenXml/Expressions/HyperlinkExpression.cs b/src/Html2OpenXml/Expressions/HyperlinkExpression.cs index a34c826f..fe9750f3 100644 --- a/src/Html2OpenXml/Expressions/HyperlinkExpression.cs +++ b/src/Html2OpenXml/Expressions/HyperlinkExpression.cs @@ -112,7 +112,7 @@ public override IEnumerable Interpret (ParsingContext context) // ensure the links does not start with javascript: else if (AngleSharpExtensions.TryParseUrl(att, UriKind.Absolute, out var uri)) { - var extLink = context.HostingPart.AddHyperlinkRelationship(uri!, true); + var extLink = context.HostingPart.AddHyperlinkRelationship(uri, true); h = new Hyperlink( ) { History = true, Id = extLink.Id }; diff --git a/src/Html2OpenXml/Expressions/Image/ImageExpression.cs b/src/Html2OpenXml/Expressions/Image/ImageExpression.cs index fe03d36d..ca12e9eb 100644 --- a/src/Html2OpenXml/Expressions/Image/ImageExpression.cs +++ b/src/Html2OpenXml/Expressions/Image/ImageExpression.cs @@ -37,8 +37,12 @@ class ImageExpression(IHtmlImageElement node) : ImageExpressionBase(node) // Bug reported by Erik2014. Inline 64 bit images can be too big and Uri.TryCreate will fail silently with a SizeLimit error. // To circumvent this buffer size, we will work either on the Uri, either on the original src. - if (src == null || - (!DataUri.IsWellFormed(src) && !AngleSharpExtensions.TryParseUrl(src, UriKind.RelativeOrAbsolute, out var _))) + if (src == null) + return null; + + var isDataUri = DataUri.IsDataUri(src); + if (!isDataUri && + !AngleSharpExtensions.TryParseUrl(src, UriKind.RelativeOrAbsolute, out var _)) { return null; } @@ -118,10 +122,9 @@ class ImageExpression(IHtmlImageElement node) : ImageExpressionBase(node) new pic.Picture( new pic.NonVisualPictureProperties { - NonVisualDrawingProperties = new pic.NonVisualDrawingProperties() - { + NonVisualDrawingProperties = new pic.NonVisualDrawingProperties() { Id = imageObjId, - Name = DataUri.IsWellFormed(src) ? string.Empty : src, + Name = isDataUri ? string.Empty : src, Description = alt }, NonVisualPictureDrawingProperties = new pic.NonVisualPictureDrawingProperties( diff --git a/src/Html2OpenXml/HtmlConverter.cs b/src/Html2OpenXml/HtmlConverter.cs index 2769fbee..c62bc5b3 100755 --- a/src/Html2OpenXml/HtmlConverter.cs +++ b/src/Html2OpenXml/HtmlConverter.cs @@ -335,9 +335,13 @@ private static async Task PreloadImages(AngleSharp.Dom.IDocument htmlDocument, if (!imageUris.Any()) return; +#if NET5_0_OR_GREATER + await Parallel.ForEachAsync(imageUris, +#else await imageUris.ForEachAsync( - async (img, cts) => await imageLoader.Download(img, cts), - parallelOptions).ConfigureAwait(false); +#endif + parallelOptions, + async (img, cts) => await imageLoader.Download(img, cts)).ConfigureAwait(false); } /// diff --git a/src/Html2OpenXml/HtmlToOpenXml.csproj b/src/Html2OpenXml/HtmlToOpenXml.csproj index ac0e0717..be8aeaa6 100644 --- a/src/Html2OpenXml/HtmlToOpenXml.csproj +++ b/src/Html2OpenXml/HtmlToOpenXml.csproj @@ -31,6 +31,9 @@ $(DefineConstants);NET5_0_OR_GREATER + + $(DefineConstants);NET9_0_OR_GREATER + diff --git a/src/Html2OpenXml/IO/DataUri.cs b/src/Html2OpenXml/IO/DataUri.cs index 0a402228..3c726479 100755 --- a/src/Html2OpenXml/IO/DataUri.cs +++ b/src/Html2OpenXml/IO/DataUri.cs @@ -9,8 +9,7 @@ * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A * PARTICULAR PURPOSE. */ -using System.Text; -using System.Text.RegularExpressions; +using System.Diagnostics.CodeAnalysis; namespace HtmlToOpenXml.IO; @@ -24,16 +23,9 @@ namespace HtmlToOpenXml.IO; [System.Diagnostics.DebuggerDisplay("{Mime,nq}")] public sealed class DataUri { - // mime-type can be: svg+xml, x-png - private readonly static Regex dataUriRegex = new( - @"data\:(?\w+/[\w\-\+\.]+)?(?:;charset=(?[a-zA-Z_0-9-]+))?(?;base64)?,(?.*)", - RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled, - TimeSpan.FromMilliseconds(200)); - - private DataUri(string mime, byte[] data) + private DataUri(string mimeType) { - Mime = mime; - Data = data; + Mime = mimeType; } /// @@ -44,7 +36,7 @@ private DataUri(string mime, byte[] data) /// from . This parameter is passed uninitialized. /// A value that is true if the DataUri was /// successfully created; otherwise, false. - public static bool TryCreate(string uri, out DataUri? result) + public static bool TryCreate(string uri, [NotNullWhen(true)] out DataUri? result) { // expected format: data:[][;charset=][;base64], // The encoding is indicated by ;base64. If it's present the data is encoded as base64. Without it the data (as a sequence of octets) @@ -55,54 +47,73 @@ public static bool TryCreate(string uri, out DataUri? result) // while Internet Explorer requires that the charset's specification must precede the base64 token. // http://en.wikipedia.org/wiki/Data_URI_scheme - Match match; result = null; - try - { - match = dataUriRegex.Match(uri); - if (!match.Success) return false; - } - catch (RegexMatchTimeoutException) - { + + if (uri.Length < 5) return false; - } - byte[] rawData; - string mime; - Encoding charSet = Encoding.ASCII; + var uriSpan = uri.AsSpan(); + int commaIndex = uriSpan.IndexOf(','); + + // 6 is minimal valid data URI: `data:,` + // 128 to avoid allocating too much memory + if (commaIndex < 5 || commaIndex > 128) + return false; + + Span loweredValue = stackalloc char[commaIndex]; + uriSpan.Slice(0, commaIndex).ToLowerInvariant(loweredValue); + + // must starts with `data:` + if (!loweredValue.StartsWith("data:")) + return false; // if mime is omitted, set default value as it stands in the norm - if (match.Groups["mime"].Length == 0) - mime = "text/plain"; - else - mime = match.Groups["mime"].Value; + string mime = "text/plain"; + string charSet = "ascii"; + bool isBase64 = false; - if (match.Groups["charset"].Length > 0) + Span tokens = stackalloc Range[5]; + var tokenCount = loweredValue.Split(tokens, ';', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < tokenCount; i++) { - try + var token = loweredValue[tokens[i]]; + if (token is "base64") isBase64 = true; + else if (token.StartsWith("data:") && token.Length > 6) { - charSet = Encoding.GetEncoding(match.Groups["charset"].Value); + mime = token.Slice(5).Trim().ToString(); } - catch (ArgumentException) + else if (token.StartsWith("charset=")) { - // charSet was not recognized - return false; + try + { + charSet = System.Text.Encoding.GetEncoding(token.Slice(8 /* 'charset=' */).ToString()).WebName; + } + catch (ArgumentException) + { + // charSet was not recognized + return false; + } } + + // else ignore, arbitrary value not needed in this context + } + + var data = uriSpan.Slice(commaIndex + 1).Trim(); + if (data.IsEmpty) + { + result = new(mime); + return true; // this is still a valid data URI } - // is it encoded in base64? - if (match.Groups["base64"].Length > 0) + if (isBase64) { // be careful that the raw data is encoded for url (standard %xx hex encoding) -#if NET5_0_OR_GREATER - string base64 = System.Web.HttpUtility.HtmlDecode(match.Groups["data"].Value); -#else - string base64 = HttpUtility.HtmlDecode(match.Groups["data"].Value); -#endif + string base64 = HttpUtility.HtmlDecode(data); try { - rawData = Convert.FromBase64String(base64); + result = new(mime) { Data = Convert.FromBase64String(base64) }; + return true; } catch (FormatException) { @@ -110,26 +121,18 @@ public static bool TryCreate(string uri, out DataUri? result) return false; } } - else - { - // the represents some text (like html snippet) and must be decoded. - string? raw = HttpUtility.UrlDecode(match.Groups["data"].Value)!; - if (string.IsNullOrEmpty(raw)) - return false; - // we convert back to UTF-8 for easier processing later and to have a "referential" encoding - rawData = Encoding.Convert(charSet, Encoding.UTF8, charSet.GetBytes(raw)); - } - result = new DataUri(mime, rawData); + // the represents some text (like html snippet) and must be decoded. + result = new(mime) { Encoding = charSet, Content = HttpUtility.UrlDecode(data) }; return true; } /// - /// Indicates whether the string is well-formed by attempting to construct a DataUri with the string. + /// Determines whether the uri looks like a Data Uri. /// - public static bool IsWellFormed(string uri) + public static bool IsDataUri(string uri) { - return dataUriRegex.IsMatch(uri); + return uri.StartsWith("data:", StringComparison.OrdinalIgnoreCase); } //____________________________________________________________________ @@ -138,10 +141,20 @@ public static bool IsWellFormed(string uri) /// /// Gets the MIME type declared by the Data URI, such as image/png or image/jpeg. /// - public string Mime { get; private set; } + public string Mime { get; } + + /// + /// Gets the decoded binary content. + /// + public byte[]? Data { get; init; } + + /// + /// Gets the encoding of the content when the data is text (e.g., UTF-8). + /// + public string? Encoding { get; init; } /// - /// Gets the decoded binary content extracted from the Data URI. + /// Gets the decoded human-readable content. /// - public byte[] Data { get; private set; } + public string? Content { get; init; } } diff --git a/src/Html2OpenXml/IO/ImagePrefetcher.cs b/src/Html2OpenXml/IO/ImagePrefetcher.cs index 459bd809..fbcde05b 100644 --- a/src/Html2OpenXml/IO/ImagePrefetcher.cs +++ b/src/Html2OpenXml/IO/ImagePrefetcher.cs @@ -63,7 +63,7 @@ public ImagePrefetcher(T hostingPart, IWebRequest resourceLoader, ImageProcessin if (prefetchedImages.TryGetValue(imageUri, out var iinfo)) return iinfo; - if (DataUri.IsWellFormed(imageUri)) // data inline, encoded in base64 + if (DataUri.IsDataUri(imageUri)) // data inline, encoded in base64 { iinfo = ReadDataUri(imageUri); } @@ -169,7 +169,7 @@ public ImagePrefetcher(T hostingPart, IWebRequest resourceLoader, ImageProcessin /// private HtmlImageInfo? ReadDataUri(string src) { - if (DataUri.TryCreate(src, out var dataUri)) + if (DataUri.TryCreate(src, out var dataUri) && dataUri.Data != null) { TryInspectMimeType(dataUri!.Mime, out PartTypeInfo type); diff --git a/src/Html2OpenXml/Utilities/CollectionExtensions.cs b/src/Html2OpenXml/Shim/CollectionExtensions.cs similarity index 66% rename from src/Html2OpenXml/Utilities/CollectionExtensions.cs rename to src/Html2OpenXml/Shim/CollectionExtensions.cs index 1c878bfb..b2eddc07 100644 --- a/src/Html2OpenXml/Utilities/CollectionExtensions.cs +++ b/src/Html2OpenXml/Shim/CollectionExtensions.cs @@ -12,6 +12,8 @@ namespace HtmlToOpenXml; +#if !NET5_0_OR_GREATER + /// /// Helper class that provide some extension methods to API. /// @@ -20,19 +22,12 @@ static class CollectionExtensions /// /// Executes a for-each operation on an in which iterations may run in parallelc. /// - public static Task ForEachAsync(this IEnumerable source, -#if NET5_0_OR_GREATER - Func asyncAction, -#else - Func asyncAction, -#endif - ParallelOptions parallelOptions) + public static Task ForEachAsync(this IEnumerable source, + ParallelOptions parallelOptions, + Func asyncAction) { -#if NET5_0_OR_GREATER - return Parallel.ForEachAsync(source, parallelOptions, asyncAction); -#else var throttler = new SemaphoreSlim(initialCount: Math.Max(1, parallelOptions.MaxDegreeOfParallelism)); - var tasks = System.Linq.Enumerable.Select(source, async item => + var tasks = Enumerable.Select(source, async item => { await throttler.WaitAsync(parallelOptions.CancellationToken).ConfigureAwait(false); if (parallelOptions.CancellationToken.IsCancellationRequested) return; @@ -47,7 +42,6 @@ public static Task ForEachAsync(this IEnumerable source, } }); return Task.WhenAll(tasks); -#endif } #if NET462 @@ -64,20 +58,19 @@ public static IEnumerable Append(this IEnumerable sou } #endif -#if !NET5_0_OR_GREATER - /// - /// Attempts to add the specified key and value to the dictionary. - /// - /// The dictionary in which to insert the item. - /// The key of the element to add. - /// The value of the element to add. It can be . - /// if the key/value pair was added to the dictionary successfully; otherwise, . - public static bool TryAdd(this IDictionary dictionary, TKey key, TValue value) - { + /// + /// Attempts to add the specified key and value to the dictionary. + /// + /// The dictionary in which to insert the item. + /// The key of the element to add. + /// The value of the element to add. It can be . + /// if the key/value pair was added to the dictionary successfully; otherwise, . + public static bool TryAdd(this IDictionary dictionary, TKey key, TValue value) + { if (dictionary.ContainsKey(key)) return false; dictionary.Add(key, value); return true; - } -#endif + } } +#endif diff --git a/src/Html2OpenXml/Shim/System.Text.Rune.cs b/src/Html2OpenXml/Shim/System.Text.Rune.cs new file mode 100644 index 00000000..8d8a093b --- /dev/null +++ b/src/Html2OpenXml/Shim/System.Text.Rune.cs @@ -0,0 +1,50 @@ +/* Copyright (C) Olivier Nizet https://github.com/onizet/html2openxml - All Rights Reserved + * + * This source is subject to the Microsoft Permissive License. + * Please see the License.txt file for more information. + * All other rights reserved. + * + * THIS CODE AND INFORMATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + * KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A + * PARTICULAR PURPOSE. + */ +using System.Runtime.CompilerServices; + +#if !NET5_0_OR_GREATER + +namespace System.Text; + +readonly record struct Rune(uint Value) +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsValid(uint codePoint) + { + return codePoint <= 0x10FFFF && + (codePoint < 0xD800 || codePoint > 0xDFFF); + } + + /// + /// Encodes this Rune to a UTF-16 destination buffer. + /// + /// The number of values written to destination. + public int EncodeToUtf16(Span destination) + { + if (Value < 0xFFFFu) + { + if (destination.Length < 1) + throw new ArgumentException("Destination is too short."); + + destination[0] = (char) Value; + return 1; + } + + if (destination.Length < 2) + throw new ArgumentException("Destination is too short."); + + destination[0] = (char) ((Value + ((0xD800u - 0x40u) << 10)) >> 10); + destination[1] = (char) ((Value & 0x3FFu) + 0xDC00u); + return 2; + } +} +#endif diff --git a/src/Html2OpenXml/Utilities/AngleSharpExtensions.cs b/src/Html2OpenXml/Utilities/AngleSharpExtensions.cs index c927f10a..908d6dfc 100644 --- a/src/Html2OpenXml/Utilities/AngleSharpExtensions.cs +++ b/src/Html2OpenXml/Utilities/AngleSharpExtensions.cs @@ -84,7 +84,7 @@ public static bool IsPrecededByListElement(this INode child, out IElement? prece public static bool TryParseUrl(string? uriString, UriKind uriKind, [NotNullWhen(true)] out Uri? result) { - if (string.IsNullOrEmpty(uriString) || IO.DataUri.IsWellFormed(uriString!)) + if (string.IsNullOrEmpty(uriString) || IO.DataUri.IsDataUri(uriString!)) { result = null; return false; diff --git a/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs b/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs new file mode 100644 index 00000000..d61fca38 --- /dev/null +++ b/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs @@ -0,0 +1,323 @@ +using System.Buffers.Binary; +#if NET5_0_OR_GREATER +using System.Collections.Frozen; +#endif + +namespace HtmlToOpenXml; + +static partial class HttpUtility +{ + // This code comes from the .Net API; For .Net462, this avoid to add a reference to System.Web + // https://github.com/dotnet/dotnet/blob/main/src/runtime/src/libraries/System.Private.CoreLib/src/System/Net/WebUtility.cs + static class HtmlEntities + { + private static readonly Lazy> lookupTable = new(() => InitializeLookupTable()); + + // The list is from http://www.w3.org/TR/REC-html40/sgml/entities.html, except for ', which + // is defined in http://www.w3.org/TR/2008/REC-xml-20081126/#sec-predefined-ent. + private static IReadOnlyDictionary InitializeLookupTable() + { + ReadOnlySpan tableData = [ + 0x74, 0x6F, 0x75, 0x71, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("quot")*/ 0x22, 0x00, /*'\x0022'*/ + 0x70, 0x6D, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("amp")*/ 0x26, 0x00, /*'\x0026'*/ + 0x73, 0x6F, 0x70, 0x61, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("apos")*/ 0x27, 0x00, /*'\x0027'*/ + 0x74, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("lt")*/ 0x3C, 0x00, /*'\x003c'*/ + 0x74, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("gt")*/ 0x3E, 0x00, /*'\x003e'*/ + 0x70, 0x73, 0x62, 0x6E, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("nbsp")*/ 0xA0, 0x00, /*'\x00a0'*/ + 0x6C, 0x63, 0x78, 0x65, 0x69, 0x00, 0x00, 0x00, /*ToUInt64Key("iexcl")*/ 0xA1, 0x00, /*'\x00a1'*/ + 0x74, 0x6E, 0x65, 0x63, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("cent")*/ 0xA2, 0x00, /*'\x00a2'*/ + 0x64, 0x6E, 0x75, 0x6F, 0x70, 0x00, 0x00, 0x00, /*ToUInt64Key("pound")*/ 0xA3, 0x00, /*'\x00a3'*/ + 0x6E, 0x65, 0x72, 0x72, 0x75, 0x63, 0x00, 0x00, /*ToUInt64Key("curren")*/ 0xA4, 0x00, /*'\x00a4'*/ + 0x6E, 0x65, 0x79, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("yen")*/ 0xA5, 0x00, /*'\x00a5'*/ + 0x72, 0x61, 0x62, 0x76, 0x72, 0x62, 0x00, 0x00, /*ToUInt64Key("brvbar")*/ 0xA6, 0x00, /*'\x00a6'*/ + 0x74, 0x63, 0x65, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sect")*/ 0xA7, 0x00, /*'\x00a7'*/ + 0x6C, 0x6D, 0x75, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("uml")*/ 0xA8, 0x00, /*'\x00a8'*/ + 0x79, 0x70, 0x6F, 0x63, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("copy")*/ 0xA9, 0x00, /*'\x00a9'*/ + 0x66, 0x64, 0x72, 0x6F, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ordf")*/ 0xAA, 0x00, /*'\x00aa'*/ + 0x6F, 0x75, 0x71, 0x61, 0x6C, 0x00, 0x00, 0x00, /*ToUInt64Key("laquo")*/ 0xAB, 0x00, /*'\x00ab'*/ + 0x74, 0x6F, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("not")*/ 0xAC, 0x00, /*'\x00ac'*/ + 0x79, 0x68, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("shy")*/ 0xAD, 0x00, /*'\x00ad'*/ + 0x67, 0x65, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("reg")*/ 0xAE, 0x00, /*'\x00ae'*/ + 0x72, 0x63, 0x61, 0x6D, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("macr")*/ 0xAF, 0x00, /*'\x00af'*/ + 0x67, 0x65, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("deg")*/ 0xB0, 0x00, /*'\x00b0'*/ + 0x6E, 0x6D, 0x73, 0x75, 0x6C, 0x70, 0x00, 0x00, /*ToUInt64Key("plusmn")*/ 0xB1, 0x00, /*'\x00b1'*/ + 0x32, 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sup2")*/ 0xB2, 0x00, /*'\x00b2'*/ + 0x33, 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sup3")*/ 0xB3, 0x00, /*'\x00b3'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("acute")*/ 0xB4, 0x00, /*'\x00b4'*/ + 0x6F, 0x72, 0x63, 0x69, 0x6D, 0x00, 0x00, 0x00, /*ToUInt64Key("micro")*/ 0xB5, 0x00, /*'\x00b5'*/ + 0x61, 0x72, 0x61, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("para")*/ 0xB6, 0x00, /*'\x00b6'*/ + 0x74, 0x6F, 0x64, 0x64, 0x69, 0x6D, 0x00, 0x00, /*ToUInt64Key("middot")*/ 0xB7, 0x00, /*'\x00b7'*/ + 0x6C, 0x69, 0x64, 0x65, 0x63, 0x00, 0x00, 0x00, /*ToUInt64Key("cedil")*/ 0xB8, 0x00, /*'\x00b8'*/ + 0x31, 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sup1")*/ 0xB9, 0x00, /*'\x00b9'*/ + 0x6D, 0x64, 0x72, 0x6F, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ordm")*/ 0xBA, 0x00, /*'\x00ba'*/ + 0x6F, 0x75, 0x71, 0x61, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("raquo")*/ 0xBB, 0x00, /*'\x00bb'*/ + 0x34, 0x31, 0x63, 0x61, 0x72, 0x66, 0x00, 0x00, /*ToUInt64Key("frac14")*/ 0xBC, 0x00, /*'\x00bc'*/ + 0x32, 0x31, 0x63, 0x61, 0x72, 0x66, 0x00, 0x00, /*ToUInt64Key("frac12")*/ 0xBD, 0x00, /*'\x00bd'*/ + 0x34, 0x33, 0x63, 0x61, 0x72, 0x66, 0x00, 0x00, /*ToUInt64Key("frac34")*/ 0xBE, 0x00, /*'\x00be'*/ + 0x74, 0x73, 0x65, 0x75, 0x71, 0x69, 0x00, 0x00, /*ToUInt64Key("iquest")*/ 0xBF, 0x00, /*'\x00bf'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x41, 0x00, 0x00, /*ToUInt64Key("Agrave")*/ 0xC0, 0x00, /*'\x00c0'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x41, 0x00, 0x00, /*ToUInt64Key("Aacute")*/ 0xC1, 0x00, /*'\x00c1'*/ + 0x63, 0x72, 0x69, 0x63, 0x41, 0x00, 0x00, 0x00, /*ToUInt64Key("Acirc")*/ 0xC2, 0x00, /*'\x00c2'*/ + 0x65, 0x64, 0x6C, 0x69, 0x74, 0x41, 0x00, 0x00, /*ToUInt64Key("Atilde")*/ 0xC3, 0x00, /*'\x00c3'*/ + 0x6C, 0x6D, 0x75, 0x41, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Auml")*/ 0xC4, 0x00, /*'\x00c4'*/ + 0x67, 0x6E, 0x69, 0x72, 0x41, 0x00, 0x00, 0x00, /*ToUInt64Key("Aring")*/ 0xC5, 0x00, /*'\x00c5'*/ + 0x67, 0x69, 0x6C, 0x45, 0x41, 0x00, 0x00, 0x00, /*ToUInt64Key("AElig")*/ 0xC6, 0x00, /*'\x00c6'*/ + 0x6C, 0x69, 0x64, 0x65, 0x63, 0x43, 0x00, 0x00, /*ToUInt64Key("Ccedil")*/ 0xC7, 0x00, /*'\x00c7'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x45, 0x00, 0x00, /*ToUInt64Key("Egrave")*/ 0xC8, 0x00, /*'\x00c8'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x45, 0x00, 0x00, /*ToUInt64Key("Eacute")*/ 0xC9, 0x00, /*'\x00c9'*/ + 0x63, 0x72, 0x69, 0x63, 0x45, 0x00, 0x00, 0x00, /*ToUInt64Key("Ecirc")*/ 0xCA, 0x00, /*'\x00ca'*/ + 0x6C, 0x6D, 0x75, 0x45, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Euml")*/ 0xCB, 0x00, /*'\x00cb'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x49, 0x00, 0x00, /*ToUInt64Key("Igrave")*/ 0xCC, 0x00, /*'\x00cc'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x49, 0x00, 0x00, /*ToUInt64Key("Iacute")*/ 0xCD, 0x00, /*'\x00cd'*/ + 0x63, 0x72, 0x69, 0x63, 0x49, 0x00, 0x00, 0x00, /*ToUInt64Key("Icirc")*/ 0xCE, 0x00, /*'\x00ce'*/ + 0x6C, 0x6D, 0x75, 0x49, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Iuml")*/ 0xCF, 0x00, /*'\x00cf'*/ + 0x48, 0x54, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ETH")*/ 0xD0, 0x00, /*'\x00d0'*/ + 0x65, 0x64, 0x6C, 0x69, 0x74, 0x4E, 0x00, 0x00, /*ToUInt64Key("Ntilde")*/ 0xD1, 0x00, /*'\x00d1'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x4F, 0x00, 0x00, /*ToUInt64Key("Ograve")*/ 0xD2, 0x00, /*'\x00d2'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x4F, 0x00, 0x00, /*ToUInt64Key("Oacute")*/ 0xD3, 0x00, /*'\x00d3'*/ + 0x63, 0x72, 0x69, 0x63, 0x4F, 0x00, 0x00, 0x00, /*ToUInt64Key("Ocirc")*/ 0xD4, 0x00, /*'\x00d4'*/ + 0x65, 0x64, 0x6C, 0x69, 0x74, 0x4F, 0x00, 0x00, /*ToUInt64Key("Otilde")*/ 0xD5, 0x00, /*'\x00d5'*/ + 0x6C, 0x6D, 0x75, 0x4F, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Ouml")*/ 0xD6, 0x00, /*'\x00d6'*/ + 0x73, 0x65, 0x6D, 0x69, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("times")*/ 0xD7, 0x00, /*'\x00d7'*/ + 0x68, 0x73, 0x61, 0x6C, 0x73, 0x4F, 0x00, 0x00, /*ToUInt64Key("Oslash")*/ 0xD8, 0x00, /*'\x00d8'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x55, 0x00, 0x00, /*ToUInt64Key("Ugrave")*/ 0xD9, 0x00, /*'\x00d9'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x55, 0x00, 0x00, /*ToUInt64Key("Uacute")*/ 0xDA, 0x00, /*'\x00da'*/ + 0x63, 0x72, 0x69, 0x63, 0x55, 0x00, 0x00, 0x00, /*ToUInt64Key("Ucirc")*/ 0xDB, 0x00, /*'\x00db'*/ + 0x6C, 0x6D, 0x75, 0x55, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Uuml")*/ 0xDC, 0x00, /*'\x00dc'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x59, 0x00, 0x00, /*ToUInt64Key("Yacute")*/ 0xDD, 0x00, /*'\x00dd'*/ + 0x4E, 0x52, 0x4F, 0x48, 0x54, 0x00, 0x00, 0x00, /*ToUInt64Key("THORN")*/ 0xDE, 0x00, /*'\x00de'*/ + 0x67, 0x69, 0x6C, 0x7A, 0x73, 0x00, 0x00, 0x00, /*ToUInt64Key("szlig")*/ 0xDF, 0x00, /*'\x00df'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x61, 0x00, 0x00, /*ToUInt64Key("agrave")*/ 0xE0, 0x00, /*'\x00e0'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x61, 0x00, 0x00, /*ToUInt64Key("aacute")*/ 0xE1, 0x00, /*'\x00e1'*/ + 0x63, 0x72, 0x69, 0x63, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("acirc")*/ 0xE2, 0x00, /*'\x00e2'*/ + 0x65, 0x64, 0x6C, 0x69, 0x74, 0x61, 0x00, 0x00, /*ToUInt64Key("atilde")*/ 0xE3, 0x00, /*'\x00e3'*/ + 0x6C, 0x6D, 0x75, 0x61, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("auml")*/ 0xE4, 0x00, /*'\x00e4'*/ + 0x67, 0x6E, 0x69, 0x72, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("aring")*/ 0xE5, 0x00, /*'\x00e5'*/ + 0x67, 0x69, 0x6C, 0x65, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("aelig")*/ 0xE6, 0x00, /*'\x00e6'*/ + 0x6C, 0x69, 0x64, 0x65, 0x63, 0x63, 0x00, 0x00, /*ToUInt64Key("ccedil")*/ 0xE7, 0x00, /*'\x00e7'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x65, 0x00, 0x00, /*ToUInt64Key("egrave")*/ 0xE8, 0x00, /*'\x00e8'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x65, 0x00, 0x00, /*ToUInt64Key("eacute")*/ 0xE9, 0x00, /*'\x00e9'*/ + 0x63, 0x72, 0x69, 0x63, 0x65, 0x00, 0x00, 0x00, /*ToUInt64Key("ecirc")*/ 0xEA, 0x00, /*'\x00ea'*/ + 0x6C, 0x6D, 0x75, 0x65, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("euml")*/ 0xEB, 0x00, /*'\x00eb'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x69, 0x00, 0x00, /*ToUInt64Key("igrave")*/ 0xEC, 0x00, /*'\x00ec'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x69, 0x00, 0x00, /*ToUInt64Key("iacute")*/ 0xED, 0x00, /*'\x00ed'*/ + 0x63, 0x72, 0x69, 0x63, 0x69, 0x00, 0x00, 0x00, /*ToUInt64Key("icirc")*/ 0xEE, 0x00, /*'\x00ee'*/ + 0x6C, 0x6D, 0x75, 0x69, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("iuml")*/ 0xEF, 0x00, /*'\x00ef'*/ + 0x68, 0x74, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("eth")*/ 0xF0, 0x00, /*'\x00f0'*/ + 0x65, 0x64, 0x6C, 0x69, 0x74, 0x6E, 0x00, 0x00, /*ToUInt64Key("ntilde")*/ 0xF1, 0x00, /*'\x00f1'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x6F, 0x00, 0x00, /*ToUInt64Key("ograve")*/ 0xF2, 0x00, /*'\x00f2'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x6F, 0x00, 0x00, /*ToUInt64Key("oacute")*/ 0xF3, 0x00, /*'\x00f3'*/ + 0x63, 0x72, 0x69, 0x63, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("ocirc")*/ 0xF4, 0x00, /*'\x00f4'*/ + 0x65, 0x64, 0x6C, 0x69, 0x74, 0x6F, 0x00, 0x00, /*ToUInt64Key("otilde")*/ 0xF5, 0x00, /*'\x00f5'*/ + 0x6C, 0x6D, 0x75, 0x6F, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ouml")*/ 0xF6, 0x00, /*'\x00f6'*/ + 0x65, 0x64, 0x69, 0x76, 0x69, 0x64, 0x00, 0x00, /*ToUInt64Key("divide")*/ 0xF7, 0x00, /*'\x00f7'*/ + 0x68, 0x73, 0x61, 0x6C, 0x73, 0x6F, 0x00, 0x00, /*ToUInt64Key("oslash")*/ 0xF8, 0x00, /*'\x00f8'*/ + 0x65, 0x76, 0x61, 0x72, 0x67, 0x75, 0x00, 0x00, /*ToUInt64Key("ugrave")*/ 0xF9, 0x00, /*'\x00f9'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x75, 0x00, 0x00, /*ToUInt64Key("uacute")*/ 0xFA, 0x00, /*'\x00fa'*/ + 0x63, 0x72, 0x69, 0x63, 0x75, 0x00, 0x00, 0x00, /*ToUInt64Key("ucirc")*/ 0xFB, 0x00, /*'\x00fb'*/ + 0x6C, 0x6D, 0x75, 0x75, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("uuml")*/ 0xFC, 0x00, /*'\x00fc'*/ + 0x65, 0x74, 0x75, 0x63, 0x61, 0x79, 0x00, 0x00, /*ToUInt64Key("yacute")*/ 0xFD, 0x00, /*'\x00fd'*/ + 0x6E, 0x72, 0x6F, 0x68, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("thorn")*/ 0xFE, 0x00, /*'\x00fe'*/ + 0x6C, 0x6D, 0x75, 0x79, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("yuml")*/ 0xFF, 0x00, /*'\x00ff'*/ + 0x67, 0x69, 0x6C, 0x45, 0x4F, 0x00, 0x00, 0x00, /*ToUInt64Key("OElig")*/ 0x52, 0x01, /*'\x0152'*/ + 0x67, 0x69, 0x6C, 0x65, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("oelig")*/ 0x53, 0x01, /*'\x0153'*/ + 0x6E, 0x6F, 0x72, 0x61, 0x63, 0x53, 0x00, 0x00, /*ToUInt64Key("Scaron")*/ 0x60, 0x01, /*'\x0160'*/ + 0x6E, 0x6F, 0x72, 0x61, 0x63, 0x73, 0x00, 0x00, /*ToUInt64Key("scaron")*/ 0x61, 0x01, /*'\x0161'*/ + 0x6C, 0x6D, 0x75, 0x59, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Yuml")*/ 0x78, 0x01, /*'\x0178'*/ + 0x66, 0x6F, 0x6E, 0x66, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("fnof")*/ 0x92, 0x01, /*'\x0192'*/ + 0x63, 0x72, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("circ")*/ 0xC6, 0x02, /*'\x02c6'*/ + 0x65, 0x64, 0x6C, 0x69, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("tilde")*/ 0xDC, 0x02, /*'\x02dc'*/ + 0x61, 0x68, 0x70, 0x6C, 0x41, 0x00, 0x00, 0x00, /*ToUInt64Key("Alpha")*/ 0x91, 0x03, /*'\x0391'*/ + 0x61, 0x74, 0x65, 0x42, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Beta")*/ 0x92, 0x03, /*'\x0392'*/ + 0x61, 0x6D, 0x6D, 0x61, 0x47, 0x00, 0x00, 0x00, /*ToUInt64Key("Gamma")*/ 0x93, 0x03, /*'\x0393'*/ + 0x61, 0x74, 0x6C, 0x65, 0x44, 0x00, 0x00, 0x00, /*ToUInt64Key("Delta")*/ 0x94, 0x03, /*'\x0394'*/ + 0x6E, 0x6F, 0x6C, 0x69, 0x73, 0x70, 0x45, 0x00, /*ToUInt64Key("Epsilon")*/ 0x95, 0x03, /*'\x0395'*/ + 0x61, 0x74, 0x65, 0x5A, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Zeta")*/ 0x96, 0x03, /*'\x0396'*/ + 0x61, 0x74, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Eta")*/ 0x97, 0x03, /*'\x0397'*/ + 0x61, 0x74, 0x65, 0x68, 0x54, 0x00, 0x00, 0x00, /*ToUInt64Key("Theta")*/ 0x98, 0x03, /*'\x0398'*/ + 0x61, 0x74, 0x6F, 0x49, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Iota")*/ 0x99, 0x03, /*'\x0399'*/ + 0x61, 0x70, 0x70, 0x61, 0x4B, 0x00, 0x00, 0x00, /*ToUInt64Key("Kappa")*/ 0x9A, 0x03, /*'\x039a'*/ + 0x61, 0x64, 0x62, 0x6D, 0x61, 0x4C, 0x00, 0x00, /*ToUInt64Key("Lambda")*/ 0x9B, 0x03, /*'\x039b'*/ + 0x75, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Mu")*/ 0x9C, 0x03, /*'\x039c'*/ + 0x75, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Nu")*/ 0x9D, 0x03, /*'\x039d'*/ + 0x69, 0x58, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Xi")*/ 0x9E, 0x03, /*'\x039e'*/ + 0x6E, 0x6F, 0x72, 0x63, 0x69, 0x6D, 0x4F, 0x00, /*ToUInt64Key("Omicron")*/ 0x9F, 0x03, /*'\x039f'*/ + 0x69, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Pi")*/ 0xA0, 0x03, /*'\x03a0'*/ + 0x6F, 0x68, 0x52, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Rho")*/ 0xA1, 0x03, /*'\x03a1'*/ + 0x61, 0x6D, 0x67, 0x69, 0x53, 0x00, 0x00, 0x00, /*ToUInt64Key("Sigma")*/ 0xA3, 0x03, /*'\x03a3'*/ + 0x75, 0x61, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Tau")*/ 0xA4, 0x03, /*'\x03a4'*/ + 0x6E, 0x6F, 0x6C, 0x69, 0x73, 0x70, 0x55, 0x00, /*ToUInt64Key("Upsilon")*/ 0xA5, 0x03, /*'\x03a5'*/ + 0x69, 0x68, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Phi")*/ 0xA6, 0x03, /*'\x03a6'*/ + 0x69, 0x68, 0x43, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Chi")*/ 0xA7, 0x03, /*'\x03a7'*/ + 0x69, 0x73, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Psi")*/ 0xA8, 0x03, /*'\x03a8'*/ + 0x61, 0x67, 0x65, 0x6D, 0x4F, 0x00, 0x00, 0x00, /*ToUInt64Key("Omega")*/ 0xA9, 0x03, /*'\x03a9'*/ + 0x61, 0x68, 0x70, 0x6C, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("alpha")*/ 0xB1, 0x03, /*'\x03b1'*/ + 0x61, 0x74, 0x65, 0x62, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("beta")*/ 0xB2, 0x03, /*'\x03b2'*/ + 0x61, 0x6D, 0x6D, 0x61, 0x67, 0x00, 0x00, 0x00, /*ToUInt64Key("gamma")*/ 0xB3, 0x03, /*'\x03b3'*/ + 0x61, 0x74, 0x6C, 0x65, 0x64, 0x00, 0x00, 0x00, /*ToUInt64Key("delta")*/ 0xB4, 0x03, /*'\x03b4'*/ + 0x6E, 0x6F, 0x6C, 0x69, 0x73, 0x70, 0x65, 0x00, /*ToUInt64Key("epsilon")*/ 0xB5, 0x03, /*'\x03b5'*/ + 0x61, 0x74, 0x65, 0x7A, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("zeta")*/ 0xB6, 0x03, /*'\x03b6'*/ + 0x61, 0x74, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("eta")*/ 0xB7, 0x03, /*'\x03b7'*/ + 0x61, 0x74, 0x65, 0x68, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("theta")*/ 0xB8, 0x03, /*'\x03b8'*/ + 0x61, 0x74, 0x6F, 0x69, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("iota")*/ 0xB9, 0x03, /*'\x03b9'*/ + 0x61, 0x70, 0x70, 0x61, 0x6B, 0x00, 0x00, 0x00, /*ToUInt64Key("kappa")*/ 0xBA, 0x03, /*'\x03ba'*/ + 0x61, 0x64, 0x62, 0x6D, 0x61, 0x6C, 0x00, 0x00, /*ToUInt64Key("lambda")*/ 0xBB, 0x03, /*'\x03bb'*/ + 0x75, 0x6D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("mu")*/ 0xBC, 0x03, /*'\x03bc'*/ + 0x75, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("nu")*/ 0xBD, 0x03, /*'\x03bd'*/ + 0x69, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("xi")*/ 0xBE, 0x03, /*'\x03be'*/ + 0x6E, 0x6F, 0x72, 0x63, 0x69, 0x6D, 0x6F, 0x00, /*ToUInt64Key("omicron")*/ 0xBF, 0x03, /*'\x03bf'*/ + 0x69, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("pi")*/ 0xC0, 0x03, /*'\x03c0'*/ + 0x6F, 0x68, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rho")*/ 0xC1, 0x03, /*'\x03c1'*/ + 0x66, 0x61, 0x6D, 0x67, 0x69, 0x73, 0x00, 0x00, /*ToUInt64Key("sigmaf")*/ 0xC2, 0x03, /*'\x03c2'*/ + 0x61, 0x6D, 0x67, 0x69, 0x73, 0x00, 0x00, 0x00, /*ToUInt64Key("sigma")*/ 0xC3, 0x03, /*'\x03c3'*/ + 0x75, 0x61, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("tau")*/ 0xC4, 0x03, /*'\x03c4'*/ + 0x6E, 0x6F, 0x6C, 0x69, 0x73, 0x70, 0x75, 0x00, /*ToUInt64Key("upsilon")*/ 0xC5, 0x03, /*'\x03c5'*/ + 0x69, 0x68, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("phi")*/ 0xC6, 0x03, /*'\x03c6'*/ + 0x69, 0x68, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("chi")*/ 0xC7, 0x03, /*'\x03c7'*/ + 0x69, 0x73, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("psi")*/ 0xC8, 0x03, /*'\x03c8'*/ + 0x61, 0x67, 0x65, 0x6D, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("omega")*/ 0xC9, 0x03, /*'\x03c9'*/ + 0x6D, 0x79, 0x73, 0x61, 0x74, 0x65, 0x68, 0x74, /*ToUInt64Key("thetasym")*/0xD1, 0x03, /*'\x03d1'*/ + 0x68, 0x69, 0x73, 0x70, 0x75, 0x00, 0x00, 0x00, /*ToUInt64Key("upsih")*/ 0xD2, 0x03, /*'\x03d2'*/ + 0x76, 0x69, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("piv")*/ 0xD6, 0x03, /*'\x03d6'*/ + 0x70, 0x73, 0x6E, 0x65, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ensp")*/ 0x02, 0x20, /*'\x2002'*/ + 0x70, 0x73, 0x6D, 0x65, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("emsp")*/ 0x03, 0x20, /*'\x2003'*/ + 0x70, 0x73, 0x6E, 0x69, 0x68, 0x74, 0x00, 0x00, /*ToUInt64Key("thinsp")*/ 0x09, 0x20, /*'\x2009'*/ + 0x6A, 0x6E, 0x77, 0x7A, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("zwnj")*/ 0x0C, 0x20, /*'\x200c'*/ + 0x6A, 0x77, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("zwj")*/ 0x0D, 0x20, /*'\x200d'*/ + 0x6D, 0x72, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("lrm")*/ 0x0E, 0x20, /*'\x200e'*/ + 0x6D, 0x6C, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rlm")*/ 0x0F, 0x20, /*'\x200f'*/ + 0x68, 0x73, 0x61, 0x64, 0x6E, 0x00, 0x00, 0x00, /*ToUInt64Key("ndash")*/ 0x13, 0x20, /*'\x2013'*/ + 0x68, 0x73, 0x61, 0x64, 0x6D, 0x00, 0x00, 0x00, /*ToUInt64Key("mdash")*/ 0x14, 0x20, /*'\x2014'*/ + 0x6F, 0x75, 0x71, 0x73, 0x6C, 0x00, 0x00, 0x00, /*ToUInt64Key("lsquo")*/ 0x18, 0x20, /*'\x2018'*/ + 0x6F, 0x75, 0x71, 0x73, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("rsquo")*/ 0x19, 0x20, /*'\x2019'*/ + 0x6F, 0x75, 0x71, 0x62, 0x73, 0x00, 0x00, 0x00, /*ToUInt64Key("sbquo")*/ 0x1A, 0x20, /*'\x201a'*/ + 0x6F, 0x75, 0x71, 0x64, 0x6C, 0x00, 0x00, 0x00, /*ToUInt64Key("ldquo")*/ 0x1C, 0x20, /*'\x201c'*/ + 0x6F, 0x75, 0x71, 0x64, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("rdquo")*/ 0x1D, 0x20, /*'\x201d'*/ + 0x6F, 0x75, 0x71, 0x64, 0x62, 0x00, 0x00, 0x00, /*ToUInt64Key("bdquo")*/ 0x1E, 0x20, /*'\x201e'*/ + 0x72, 0x65, 0x67, 0x67, 0x61, 0x64, 0x00, 0x00, /*ToUInt64Key("dagger")*/ 0x20, 0x20, /*'\x2020'*/ + 0x72, 0x65, 0x67, 0x67, 0x61, 0x44, 0x00, 0x00, /*ToUInt64Key("Dagger")*/ 0x21, 0x20, /*'\x2021'*/ + 0x6C, 0x6C, 0x75, 0x62, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("bull")*/ 0x22, 0x20, /*'\x2022'*/ + 0x70, 0x69, 0x6C, 0x6C, 0x65, 0x68, 0x00, 0x00, /*ToUInt64Key("hellip")*/ 0x26, 0x20, /*'\x2026'*/ + 0x6C, 0x69, 0x6D, 0x72, 0x65, 0x70, 0x00, 0x00, /*ToUInt64Key("permil")*/ 0x30, 0x20, /*'\x2030'*/ + 0x65, 0x6D, 0x69, 0x72, 0x70, 0x00, 0x00, 0x00, /*ToUInt64Key("prime")*/ 0x32, 0x20, /*'\x2032'*/ + 0x65, 0x6D, 0x69, 0x72, 0x50, 0x00, 0x00, 0x00, /*ToUInt64Key("Prime")*/ 0x33, 0x20, /*'\x2033'*/ + 0x6F, 0x75, 0x71, 0x61, 0x73, 0x6C, 0x00, 0x00, /*ToUInt64Key("lsaquo")*/ 0x39, 0x20, /*'\x2039'*/ + 0x6F, 0x75, 0x71, 0x61, 0x73, 0x72, 0x00, 0x00, /*ToUInt64Key("rsaquo")*/ 0x3A, 0x20, /*'\x203a'*/ + 0x65, 0x6E, 0x69, 0x6C, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("oline")*/ 0x3E, 0x20, /*'\x203e'*/ + 0x6C, 0x73, 0x61, 0x72, 0x66, 0x00, 0x00, 0x00, /*ToUInt64Key("frasl")*/ 0x44, 0x20, /*'\x2044'*/ + 0x6F, 0x72, 0x75, 0x65, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("euro")*/ 0xAC, 0x20, /*'\x20ac'*/ + 0x65, 0x67, 0x61, 0x6D, 0x69, 0x00, 0x00, 0x00, /*ToUInt64Key("image")*/ 0x11, 0x21, /*'\x2111'*/ + 0x70, 0x72, 0x65, 0x69, 0x65, 0x77, 0x00, 0x00, /*ToUInt64Key("weierp")*/ 0x18, 0x21, /*'\x2118'*/ + 0x6C, 0x61, 0x65, 0x72, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("real")*/ 0x1C, 0x21, /*'\x211c'*/ + 0x65, 0x64, 0x61, 0x72, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("trade")*/ 0x22, 0x21, /*'\x2122'*/ + 0x6D, 0x79, 0x73, 0x66, 0x65, 0x6C, 0x61, 0x00, /*ToUInt64Key("alefsym")*/ 0x35, 0x21, /*'\x2135'*/ + 0x72, 0x72, 0x61, 0x6C, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("larr")*/ 0x90, 0x21, /*'\x2190'*/ + 0x72, 0x72, 0x61, 0x75, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("uarr")*/ 0x91, 0x21, /*'\x2191'*/ + 0x72, 0x72, 0x61, 0x72, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rarr")*/ 0x92, 0x21, /*'\x2192'*/ + 0x72, 0x72, 0x61, 0x64, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("darr")*/ 0x93, 0x21, /*'\x2193'*/ + 0x72, 0x72, 0x61, 0x68, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("harr")*/ 0x94, 0x21, /*'\x2194'*/ + 0x72, 0x72, 0x61, 0x72, 0x63, 0x00, 0x00, 0x00, /*ToUInt64Key("crarr")*/ 0xB5, 0x21, /*'\x21b5'*/ + 0x72, 0x72, 0x41, 0x6C, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("lArr")*/ 0xD0, 0x21, /*'\x21d0'*/ + 0x72, 0x72, 0x41, 0x75, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("uArr")*/ 0xD1, 0x21, /*'\x21d1'*/ + 0x72, 0x72, 0x41, 0x72, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rArr")*/ 0xD2, 0x21, /*'\x21d2'*/ + 0x72, 0x72, 0x41, 0x64, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("dArr")*/ 0xD3, 0x21, /*'\x21d3'*/ + 0x72, 0x72, 0x41, 0x68, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("hArr")*/ 0xD4, 0x21, /*'\x21d4'*/ + 0x6C, 0x6C, 0x61, 0x72, 0x6F, 0x66, 0x00, 0x00, /*ToUInt64Key("forall")*/ 0x00, 0x22, /*'\x2200'*/ + 0x74, 0x72, 0x61, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("part")*/ 0x02, 0x22, /*'\x2202'*/ + 0x74, 0x73, 0x69, 0x78, 0x65, 0x00, 0x00, 0x00, /*ToUInt64Key("exist")*/ 0x03, 0x22, /*'\x2203'*/ + 0x79, 0x74, 0x70, 0x6D, 0x65, 0x00, 0x00, 0x00, /*ToUInt64Key("empty")*/ 0x05, 0x22, /*'\x2205'*/ + 0x61, 0x6C, 0x62, 0x61, 0x6E, 0x00, 0x00, 0x00, /*ToUInt64Key("nabla")*/ 0x07, 0x22, /*'\x2207'*/ + 0x6E, 0x69, 0x73, 0x69, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("isin")*/ 0x08, 0x22, /*'\x2208'*/ + 0x6E, 0x69, 0x74, 0x6F, 0x6E, 0x00, 0x00, 0x00, /*ToUInt64Key("notin")*/ 0x09, 0x22, /*'\x2209'*/ + 0x69, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ni")*/ 0x0B, 0x22, /*'\x220b'*/ + 0x64, 0x6F, 0x72, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("prod")*/ 0x0F, 0x22, /*'\x220f'*/ + 0x6D, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sum")*/ 0x11, 0x22, /*'\x2211'*/ + 0x73, 0x75, 0x6E, 0x69, 0x6D, 0x00, 0x00, 0x00, /*ToUInt64Key("minus")*/ 0x12, 0x22, /*'\x2212'*/ + 0x74, 0x73, 0x61, 0x77, 0x6F, 0x6C, 0x00, 0x00, /*ToUInt64Key("lowast")*/ 0x17, 0x22, /*'\x2217'*/ + 0x63, 0x69, 0x64, 0x61, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("radic")*/ 0x1A, 0x22, /*'\x221a'*/ + 0x70, 0x6F, 0x72, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("prop")*/ 0x1D, 0x22, /*'\x221d'*/ + 0x6E, 0x69, 0x66, 0x6E, 0x69, 0x00, 0x00, 0x00, /*ToUInt64Key("infin")*/ 0x1E, 0x22, /*'\x221e'*/ + 0x67, 0x6E, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ang")*/ 0x20, 0x22, /*'\x2220'*/ + 0x64, 0x6E, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("and")*/ 0x27, 0x22, /*'\x2227'*/ + 0x72, 0x6F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("or")*/ 0x28, 0x22, /*'\x2228'*/ + 0x70, 0x61, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("cap")*/ 0x29, 0x22, /*'\x2229'*/ + 0x70, 0x75, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("cup")*/ 0x2A, 0x22, /*'\x222a'*/ + 0x74, 0x6E, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("int")*/ 0x2B, 0x22, /*'\x222b'*/ + 0x34, 0x65, 0x72, 0x65, 0x68, 0x74, 0x00, 0x00, /*ToUInt64Key("there4")*/ 0x34, 0x22, /*'\x2234'*/ + 0x6D, 0x69, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sim")*/ 0x3C, 0x22, /*'\x223c'*/ + 0x67, 0x6E, 0x6F, 0x63, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("cong")*/ 0x45, 0x22, /*'\x2245'*/ + 0x70, 0x6D, 0x79, 0x73, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("asymp")*/ 0x48, 0x22, /*'\x2248'*/ + 0x65, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ne")*/ 0x60, 0x22, /*'\x2260'*/ + 0x76, 0x69, 0x75, 0x71, 0x65, 0x00, 0x00, 0x00, /*ToUInt64Key("equiv")*/ 0x61, 0x22, /*'\x2261'*/ + 0x65, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("le")*/ 0x64, 0x22, /*'\x2264'*/ + 0x65, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ge")*/ 0x65, 0x22, /*'\x2265'*/ + 0x62, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sub")*/ 0x82, 0x22, /*'\x2282'*/ + 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sup")*/ 0x83, 0x22, /*'\x2283'*/ + 0x62, 0x75, 0x73, 0x6E, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("nsub")*/ 0x84, 0x22, /*'\x2284'*/ + 0x65, 0x62, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sube")*/ 0x86, 0x22, /*'\x2286'*/ + 0x65, 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("supe")*/ 0x87, 0x22, /*'\x2287'*/ + 0x73, 0x75, 0x6C, 0x70, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("oplus")*/ 0x95, 0x22, /*'\x2295'*/ + 0x73, 0x65, 0x6D, 0x69, 0x74, 0x6F, 0x00, 0x00, /*ToUInt64Key("otimes")*/ 0x97, 0x22, /*'\x2297'*/ + 0x70, 0x72, 0x65, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("perp")*/ 0xA5, 0x22, /*'\x22a5'*/ + 0x74, 0x6F, 0x64, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sdot")*/ 0xC5, 0x22, /*'\x22c5'*/ + 0x6C, 0x69, 0x65, 0x63, 0x6C, 0x00, 0x00, 0x00, /*ToUInt64Key("lceil")*/ 0x08, 0x23, /*'\x2308'*/ + 0x6C, 0x69, 0x65, 0x63, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("rceil")*/ 0x09, 0x23, /*'\x2309'*/ + 0x72, 0x6F, 0x6F, 0x6C, 0x66, 0x6C, 0x00, 0x00, /*ToUInt64Key("lfloor")*/ 0x0A, 0x23, /*'\x230a'*/ + 0x72, 0x6F, 0x6F, 0x6C, 0x66, 0x72, 0x00, 0x00, /*ToUInt64Key("rfloor")*/ 0x0B, 0x23, /*'\x230b'*/ + 0x67, 0x6E, 0x61, 0x6C, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("lang")*/ 0x29, 0x23, /*'\x2329'*/ + 0x67, 0x6E, 0x61, 0x72, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rang")*/ 0x2A, 0x23, /*'\x232a'*/ + 0x7A, 0x6F, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("loz")*/ 0xCA, 0x25, /*'\x25ca'*/ + 0x73, 0x65, 0x64, 0x61, 0x70, 0x73, 0x00, 0x00, /*ToUInt64Key("spades")*/ 0x60, 0x26, /*'\x2660'*/ + 0x73, 0x62, 0x75, 0x6C, 0x63, 0x00, 0x00, 0x00, /*ToUInt64Key("clubs")*/ 0x63, 0x26, /*'\x2663'*/ + 0x73, 0x74, 0x72, 0x61, 0x65, 0x68, 0x00, 0x00, /*ToUInt64Key("hearts")*/ 0x65, 0x26, /*'\x2665'*/ + 0x73, 0x6D, 0x61, 0x69, 0x64, 0x00, 0x00, 0x00, /*ToUInt64Key("diams")*/ 0x66, 0x26, /*'\x2666'*/ + ]; + + var dictionary = new Dictionary(tableData.Length / (sizeof(ulong) + sizeof(char))); + while (!tableData.IsEmpty) + { + ulong key = BinaryPrimitives.ReadUInt64LittleEndian(tableData); + char value = (char) BinaryPrimitives.ReadUInt16LittleEndian(tableData.Slice(sizeof(ulong))); + dictionary[key] = value; + tableData = tableData.Slice(sizeof(ulong) + sizeof(char)); + } + +#if NET5_0_OR_GREATER + return dictionary.ToFrozenDictionary(); +#else + return dictionary; +#endif + } + + public static bool Lookup(ReadOnlySpan entity, out char result) + { + // To avoid an allocation, keys of type "ulong" are used in the lookup table. + // Since all entity strings comprise 8 characters or less and are ASCII-only, they "fit" into an ulong (8 bytes). + if (entity.Length <= 8) + { + return lookupTable.Value.TryGetValue(ToUInt64Key(entity), out result); + } + + // Currently, there are no entities that are longer than 8 characters. + result = '\0'; + return false; + } + + private static ulong ToUInt64Key(ReadOnlySpan entity) + { + // The ulong key is the reversed single-byte character representation of the actual entity string. + ulong key = 0; + for (int i = 0; i < entity.Length; i++) + { + if (entity[i] > 0xFF) + { + return 0; + } + + key = (key << 8) | entity[i]; + } + + return key; + } + } +} \ No newline at end of file diff --git a/src/Html2OpenXml/Utilities/HttpUtility.cs b/src/Html2OpenXml/Utilities/HttpUtility.cs index 548ab7ec..fb0a8d06 100755 --- a/src/Html2OpenXml/Utilities/HttpUtility.cs +++ b/src/Html2OpenXml/Utilities/HttpUtility.cs @@ -9,172 +9,172 @@ * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A * PARTICULAR PURPOSE. */ -using System.Text; +using System.Buffers; using System.Globalization; namespace HtmlToOpenXml; /// -/// Helper class that can be found in System.Web.HttpUtility. This class allows us to avoid a reference to System.Web. +/// Custom utility providing compatible encoding and decoding functionality. +/// This implementation embraces the modern API Span<char> for maximum efficiency. +/// +/// +/// For .Net 462 and .Net Standard 2.0, this implementation avoid reliance on the legacy System.Web library. +/// /// -static class HttpUtility +static partial class HttpUtility { -#if !NET5_0_OR_GREATER - private static readonly char[] entityEndingChars = [';', '&']; - - static class HtmlEntities - { - private static readonly string[] entitiesList = [ - "\"-quot", "&-amp", "<-lt", ">-gt", "\x00a0-nbsp", "\x00a1-iexcl", "\x00a2-cent", "\x00a3-pound", "\x00a4-curren", "\x00a5-yen", "\x00a6-brvbar", "\x00a7-sect", "\x00a8-uml", "\x00a9-copy", "\x00aa-ordf", "\x00ab-laquo", - "\x00ac-not", "\x00ad-shy", "\x00ae-reg", "\x00af-macr", "\x00b0-deg", "\x00b1-plusmn", "\x00b2-sup2", "\x00b3-sup3", "\x00b4-acute", "\x00b5-micro", "\x00b6-para", "\x00b7-middot", "\x00b8-cedil", "\x00b9-sup1", "\x00ba-ordm", "\x00bb-raquo", - "\x00bc-frac14", "\x00bd-frac12", "\x00be-frac34", "\x00bf-iquest", "\x00c0-Agrave", "\x00c1-Aacute", "\x00c2-Acirc", "\x00c3-Atilde", "\x00c4-Auml", "\x00c5-Aring", "\x00c6-AElig", "\x00c7-Ccedil", "\x00c8-Egrave", "\x00c9-Eacute", "\x00ca-Ecirc", "\x00cb-Euml", - "\x00cc-Igrave", "\x00cd-Iacute", "\x00ce-Icirc", "\x00cf-Iuml", "\x00d0-ETH", "\x00d1-Ntilde", "\x00d2-Ograve", "\x00d3-Oacute", "\x00d4-Ocirc", "\x00d5-Otilde", "\x00d6-Ouml", "\x00d7-times", "\x00d8-Oslash", "\x00d9-Ugrave", "\x00da-Uacute", "\x00db-Ucirc", - "\x00dc-Uuml", "\x00dd-Yacute", "\x00de-THORN", "\x00df-szlig", "\x00e0-agrave", "\x00e1-aacute", "\x00e2-acirc", "\x00e3-atilde", "\x00e4-auml", "\x00e5-aring", "\x00e6-aelig", "\x00e7-ccedil", "\x00e8-egrave", "\x00e9-eacute", "\x00ea-ecirc", "\x00eb-euml", - "\x00ec-igrave", "\x00ed-iacute", "\x00ee-icirc", "\x00ef-iuml", "\x00f0-eth", "\x00f1-ntilde", "\x00f2-ograve", "\x00f3-oacute", "\x00f4-ocirc", "\x00f5-otilde", "\x00f6-ouml", "\x00f7-divide", "\x00f8-oslash", "\x00f9-ugrave", "\x00fa-uacute", "\x00fb-ucirc", - "\x00fc-uuml", "\x00fd-yacute", "\x00fe-thorn", "\x00ff-yuml", "Œ-OElig", "œ-oelig", "Š-Scaron", "š-scaron", "Ÿ-Yuml", "ƒ-fnof", "ˆ-circ", "˜-tilde", "Α-Alpha", "Β-Beta", "Γ-Gamma", "Δ-Delta", - "Ε-Epsilon", "Ζ-Zeta", "Η-Eta", "Θ-Theta", "Ι-Iota", "Κ-Kappa", "Λ-Lambda", "Μ-Mu", "Ν-Nu", "Ξ-Xi", "Ο-Omicron", "Π-Pi", "Ρ-Rho", "Σ-Sigma", "Τ-Tau", "Υ-Upsilon", - "Φ-Phi", "Χ-Chi", "Ψ-Psi", "Ω-Omega", "α-alpha", "β-beta", "γ-gamma", "δ-delta", "ε-epsilon", "ζ-zeta", "η-eta", "θ-theta", "ι-iota", "κ-kappa", "λ-lambda", "μ-mu", - "ν-nu", "ξ-xi", "ο-omicron", "π-pi", "ρ-rho", "ς-sigmaf", "σ-sigma", "τ-tau", "υ-upsilon", "φ-phi", "χ-chi", "ψ-psi", "ω-omega", "ϑ-thetasym", "ϒ-upsih", "ϖ-piv", - " -ensp", " -emsp", " -thinsp", "‌-zwnj", "‍-zwj", "‎-lrm", "‏-rlm", "–-ndash", "—-mdash", "‘-lsquo", "’-rsquo", "‚-sbquo", "“-ldquo", "”-rdquo", "„-bdquo", "†-dagger", - "‡-Dagger", "•-bull", "…-hellip", "‰-permil", "′-prime", "″-Prime", "‹-lsaquo", "›-rsaquo", "‾-oline", "⁄-frasl", "€-euro", "ℑ-image", "℘-weierp", "ℜ-real", "™-trade", "ℵ-alefsym", - "←-larr", "↑-uarr", "→-rarr", "↓-darr", "↔-harr", "↵-crarr", "⇐-lArr", "⇑-uArr", "⇒-rArr", "⇓-dArr", "⇔-hArr", "∀-forall", "∂-part", "∃-exist", "∅-empty", "∇-nabla", - "∈-isin", "∉-notin", "∋-ni", "∏-prod", "∑-sum", "−-minus", "∗-lowast", "√-radic", "∝-prop", "∞-infin", "∠-ang", "∧-and", "∨-or", "∩-cap", "∪-cup", "∫-int", - "∴-there4", "∼-sim", "≅-cong", "≈-asymp", "≠-ne", "≡-equiv", "≤-le", "≥-ge", "⊂-sub", "⊃-sup", "⊄-nsub", "⊆-sube", "⊇-supe", "⊕-oplus", "⊗-otimes", "⊥-perp", - "⋅-sdot", "⌈-lceil", "⌉-rceil", "⌊-lfloor", "⌋-rfloor", "〈-lang", "〉-rang", "◊-loz", "♠-spades", "♣-clubs", "♥-hearts", "♦-diams" - ]; - private static Dictionary? entitiesLookupTable; - private static readonly object SyncObject = new(); - - internal static char Lookup(string entity) - { - if (entitiesLookupTable == null) - { - lock (SyncObject) - { - if (entitiesLookupTable == null) - { - var hashtable = new Dictionary(entitiesList.Length); - foreach (string str in entitiesList) - hashtable[str.Substring(2)] = str[0]; - - entitiesLookupTable = hashtable; - } - } - } - - if (!entitiesLookupTable.TryGetValue(entity, out char ch)) return '\0'; - return ch; - } - } - /// /// Converts a string that has been HTML-encoded for HTTP transmission into a decoded string. /// - public static string HtmlDecode(string? s) + public static string HtmlDecode(ReadOnlySpan span) { - if (s == null) return string.Empty; + if (span.IsEmpty) + return string.Empty; - if (s.IndexOf('&') < 0) return s; + if (span.IndexOf('&') < 0) + return span.ToString(); - StringBuilder sb = new StringBuilder(); - StringWriter output = new StringWriter(sb, CultureInfo.InvariantCulture); - HtmlDecode(s, output); - return sb.ToString(); - } + var buffer = ArrayPool.Shared.Rent(span.Length); + var length = 0; - /// - /// Converts a string that has been HTML-encoded into a decoded string, and sends - /// the decoded string to a output stream. - /// - private static void HtmlDecode(string? s, TextWriter output) - { - if (s == null) return; - if (s.IndexOf('&') < 0) output.Write(s); - - int length = s.Length; - for (int i = 0; i < length; i++) + while (!span.IsEmpty) { - char ch = s[i]; - if (ch != '&') + int ampIndex = span.IndexOf('&'), + endIndex; + + if (ampIndex == -1 || ((endIndex = span.Slice(ampIndex+1).IndexOfAny(';', '&')) == -1)) + { + // Dangling '&' at EOF or sequence end. Treat it literally. + span.CopyTo(buffer.AsSpan(length)); + length += span.Length; + break; + } + + endIndex += ampIndex + 1; + + // have we found another '&' while expecting ';'? If so, this is not an entity + // but this new ampersan might be + if (span[endIndex] == '&') { - output.Write(ch); + span.Slice(0, endIndex).CopyTo(buffer.AsSpan(length)); + length += endIndex; + span = span.Slice(endIndex); continue; } + span.Slice(0, ampIndex).CopyTo(buffer.AsSpan(length)); + length += ampIndex; - int endIndex = s.IndexOfAny(entityEndingChars, i + 1); - if (endIndex > 0 && s[endIndex] == ';') + var entity = span.Slice(ampIndex + 1, endIndex - ampIndex - 1); + if (entity.Length > 0 && entity[0] == '#') { - string entity = s.Substring(i + 1, endIndex - i - 1); - if (entity.Length > 0 && entity[0] == '#') + if (TryDecodeNumericEntity(entity, out uint codePoint)) { - bool success; - int result; - if (entity[1] == 'x' || entity[1] == 'X') - { - success = int.TryParse(entity.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out result); - } - else + if (System.Text.Rune.IsValid(codePoint)) { - success = int.TryParse(entity.Substring(1), out result); + length += new System.Text.Rune(codePoint) + .EncodeToUtf16(buffer.AsSpan(length)); } - if (success) - { - if (IsLegalXmlChar(result)) output.Write((char) result); - i = endIndex; - } - else - { - i++; - } - } - else - { - i = endIndex; - ch = HtmlEntities.Lookup(entity); - if (ch != '\0') output.Write(ch); - else - { - output.Write('&'); - output.Write(entity); - output.Write(';'); - } + span = span.Slice(entity.Length + 1); + continue; } + + buffer[length++] = '&'; + span = span.Slice(1); + continue; } + + if (IsKnownEntities(entity, out var ch)) + buffer[length++] = ch; + else + span.Slice(endIndex).CopyTo(buffer); + + // move to next token + span = span.Slice(endIndex + 1); } + + var result = new String(buffer, 0, length); + + ArrayPool.Shared.Return(buffer); + return result; } -#endif /// - /// Converts a string that represents an Html-encoded URL to a decoded string. + /// Try to parse an HTML nnumeric entity and returns its Unicode code point. /// - public static string? UrlDecode(string? text) + /// The entity text without the surrounding & and ;. + /// The Unicode point representing that entity. + private static bool TryDecodeNumericEntity(ReadOnlySpan entity, out uint codePoint) { - // pre-process for + sign space formatting since System.Uri doesn't handle it - // plus literals are encoded as %2b normally so this should be safe - // http://www.west-wind.com/weblog/posts/2009/Feb/05/Html-and-Uri-String-Encoding-without-SystemWeb - if (string.IsNullOrEmpty(text)) return string.Empty; - text = text!.Replace("+", " "); - return Uri.UnescapeDataString(text); + if (entity.Length < 2 || entity[0] != '#') + { + codePoint = 0; + return false; + } + + NumberStyles numberStyles = NumberStyles.Integer; + if (entity[1] is 'x' or 'X') + { + numberStyles = NumberStyles.AllowHexSpecifier; + entity = entity.Slice(2); + } + else + { + entity = entity.Slice(1); + } + + return uint.TryParse( +#if NET5_0_OR_GREATER + entity, +#else + entity.ToString(), +#endif + numberStyles, CultureInfo.InvariantCulture, out codePoint); } - // Utilities methods + private static bool IsKnownEntities(ReadOnlySpan entity, out char ch) + { + // avoid to allocate a new string for most common entities + // and this is faster to use this pattern matching + ch = entity switch { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + "nbsp" => '\x00a0', + "copy" => '©', + "reg" => '®', + "trade" => '™', + _ => '\0' + }; + + if (ch != '\0') + return true; + + // fallback on the hard-coded list + return HtmlEntities.Lookup(entity, out ch); + } /// - /// Gets whether a given character is allowed by XML 1.0. + /// Converts a string that represents an Html-encoded URL to a decoded string. /// - private static bool IsLegalXmlChar(int character) + public static string UrlDecode(ReadOnlySpan text) { - // http://seattlesoftware.wordpress.com/2008/09/11/hexadecimal-value-0-is-an-invalid-character/ - - return ( - character == 0x9 /* == '\t' == 9 */ || - character == 0xA /* == '\n' == 10 */ || - character == 0xD /* == '\r' == 13 */ || - (character >= 0x20 && character <= 0xD7FF) || - (character >= 0xE000 && character <= 0xFFFD) || - (character >= 0x10000 && character <= 0x10FFFF) - ); + if (text.IsEmpty) return string.Empty; + + // pre-process for + sign space formatting since System.Uri doesn't handle it + // plus literals are encoded as %2b normally so this should be safe + // http://www.west-wind.com/weblog/posts/2009/Feb/05/Html-and-Uri-String-Encoding-without-SystemWeb + if (text.IndexOf('+') > 0) + return Uri.UnescapeDataString(text.ToString().Replace("+", " ")); + +#if NET9_0_OR_GREATER + return Uri.UnescapeDataString(text); +#else + return Uri.UnescapeDataString(text.ToString()); +#endif } } \ No newline at end of file diff --git a/src/Html2OpenXml/Utilities/SpanExtensions.cs b/src/Html2OpenXml/Utilities/SpanExtensions.cs index 4e4c394a..c3b3775f 100644 --- a/src/Html2OpenXml/Utilities/SpanExtensions.cs +++ b/src/Html2OpenXml/Utilities/SpanExtensions.cs @@ -33,6 +33,20 @@ public static byte AsByte(this ReadOnlySpan span, NumberStyles style) #endif } + /// + /// Shim method to convert to . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [System.Diagnostics.DebuggerHidden] + public static int AsInt32(this ReadOnlySpan span, NumberStyles style) + { +#if NET5_0_OR_GREATER + return int.Parse(span, style, CultureInfo.InvariantCulture); +#else + return int.Parse(span.ToString(), style, CultureInfo.InvariantCulture); +#endif + } + /// /// Shim method to convert to . /// diff --git a/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs b/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs index 7633580b..d9e0dd55 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs @@ -9,13 +9,19 @@ namespace HtmlToOpenXml.Tests.Primitives [TestFixture] public class DataUriTests { - [TestCase("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", Description = "red dot")] - [TestCase("data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E")] - [TestCase("data:text/plain;charset=UTF-8,the%20data:1234,5678")] - public void InlineData_ShouldSucceed(string uri) + [TestCase("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", "image/png", null, true, Description = "red dot")] + [TestCase("data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E", "text/html", "ascii", false)] + [TestCase("data:text/plain;charset=UTF-8,the%20data:1234,5678", "text/plain", "utf-8", false)] + [TestCase("data:,foo", "text/plain", "ascii", false)] + [TestCase("data:,", "text/plain", null, false)] + [TestCase("data: image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", "image/png", null, true)] + public void InlineData_ShouldSucceed(string uri, string expectedMimeType, string? expectedEncoding, bool expectedData) { DataUri.TryCreate(uri, out DataUri? result); Assert.That(result, Is.Not.Null); + Assert.That(result.Mime, Is.EqualTo(expectedMimeType)); + Assert.That(result.Encoding, Is.EqualTo(expectedEncoding)); + Assert.That(result.Data, expectedData? Is.Not.Null : Is.Null); } [Test] @@ -69,6 +75,7 @@ public void MultilineData_ShouldSucceed() [TestCase("data:text/plain;charset=utf-123,the%20data:1234,5678")] [TestCase("data:image/png;base64,abc")] + [TestCase("data,")] public void InvalidData_ShouldFail(string uri) { DataUri.TryCreate(uri, out DataUri ?result); diff --git a/test/HtmlToOpenXml.Tests/Primitives/HttpUtilityTests.cs b/test/HtmlToOpenXml.Tests/Primitives/HttpUtilityTests.cs new file mode 100644 index 00000000..e7b03f53 --- /dev/null +++ b/test/HtmlToOpenXml.Tests/Primitives/HttpUtilityTests.cs @@ -0,0 +1,23 @@ +using NUnit.Framework; + +namespace HtmlToOpenXml.Tests.Primitives; + +/// +/// Tests inline image (base 64). +/// +[TestFixture] +public class HttpUtilityTests +{ + [TestCase("Apples & Bananas <3 Here!", "Apples & Bananas <3 Here!")] + [TestCase("Apples ¬_an_entity &", "Apples ¬_an_entity &")] + [TestCase("<Tag> & \"Quotes\" here.", " & \"Quotes\" here.", Description = "All 5 most common entities")] + [TestCase("A line with (&#38;).", "A line with (-).", Description = "Numeric entities")] + [TestCase("<a href="#page&&2">Click Here</a>", "Click Here", Description = "Mixed encoding")] + [TestCase("This text has no codes.", "This text has no codes.", Description = "No entities present, idempotency check")] + [TestCase("One»Two", "One»Two")] + public void Decode_ShouldSucceed(string encoded, string expected) + { + string actual = HtmlToOpenXml.HttpUtility.HtmlDecode(encoded); + Assert.That(actual, Is.EqualTo(expected)); + } +} From c89c1d18a35e2791d5b75c8cc3e7dd79c90a58a2 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 17:17:22 +0200 Subject: [PATCH 02/15] Move Shim to separate folder and class --- .../Shim/MemoryExtensions.Split.cs | 66 +++++++++++++++++++ src/Html2OpenXml/Utilities/SpanExtensions.cs | 48 +------------- 2 files changed, 67 insertions(+), 47 deletions(-) create mode 100644 src/Html2OpenXml/Shim/MemoryExtensions.Split.cs diff --git a/src/Html2OpenXml/Shim/MemoryExtensions.Split.cs b/src/Html2OpenXml/Shim/MemoryExtensions.Split.cs new file mode 100644 index 00000000..cff485a4 --- /dev/null +++ b/src/Html2OpenXml/Shim/MemoryExtensions.Split.cs @@ -0,0 +1,66 @@ +/* Copyright (C) Olivier Nizet https://github.com/onizet/html2openxml - All Rights Reserved + * + * This source is subject to the Microsoft Permissive License. + * Please see the License.txt file for more information. + * All other rights reserved. + * + * THIS CODE AND INFORMATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + * KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A + * PARTICULAR PURPOSE. + */ + +namespace HtmlToOpenXml; + +#if !NET5_0_OR_GREATER + +/// +/// Polyfill helper class to provide extension methods for . +/// +static partial class SpanExtensions +{ + /// + /// Parses the source for the specified , + /// populating the span with instances + /// representing the regions between the separators. + /// + /// The source span to parse. + /// The destination span into which the resulting ranges are written. + /// A character that delimits the regions in this instance. + /// A bitwise combination of the enumeration values that specifies whether to trim whitespace and include empty ranges. + /// The number of ranges written into . + public static int Split(this ReadOnlySpan span, Span destination, + char separator, StringSplitOptions options = StringSplitOptions.None) + { + // If the destination is empty, there's nothing to do. + if (destination.IsEmpty) + return 0; + + int matches = 0; + int startIndex = 0; + while (span.Length > 0) + { + int index = span.IndexOf(separator); + if (index == -1) index = span.Length; + if (options == StringSplitOptions.RemoveEmptyEntries && index == 0) + { + span = span.Slice(1); + startIndex++; + continue; + } + + destination[matches] = new Range(startIndex, startIndex + index); + matches++; + + if (matches >= destination.Length || span.Length <= index) + break; + + // move to next token + span = span.Slice(index + 1); + startIndex += index + 1; + } + + return matches; + } +} +#endif diff --git a/src/Html2OpenXml/Utilities/SpanExtensions.cs b/src/Html2OpenXml/Utilities/SpanExtensions.cs index c3b3775f..45956b67 100644 --- a/src/Html2OpenXml/Utilities/SpanExtensions.cs +++ b/src/Html2OpenXml/Utilities/SpanExtensions.cs @@ -17,7 +17,7 @@ namespace HtmlToOpenXml; /// /// Polyfill helper class to provide extension methods for . /// -static class SpanExtensions +static partial class SpanExtensions { /// /// Shim method to convert to . @@ -79,52 +79,6 @@ public static double AsPercent (this ReadOnlySpan span) return span.AsDouble(); } -#if !NET5_0_OR_GREATER - /// - /// Parses the source for the specified , - /// populating the span with instances - /// representing the regions between the separators. - /// - /// The source span to parse. - /// The destination span into which the resulting ranges are written. - /// A character that delimits the regions in this instance. - /// A bitwise combination of the enumeration values that specifies whether to trim whitespace and include empty ranges. - /// The number of ranges written into . - public static int Split(this ReadOnlySpan span, Span destination, - char separator, StringSplitOptions options = StringSplitOptions.None) - { - // If the destination is empty, there's nothing to do. - if (destination.IsEmpty) - return 0; - - int matches = 0; - int startIndex = 0; - while (span.Length > 0) - { - int index = span.IndexOf(separator); - if (index == -1) index = span.Length; - if (options == StringSplitOptions.RemoveEmptyEntries && index == 0) - { - span = span.Slice(1); - startIndex++; - continue; - } - - destination[matches] = new Range(startIndex, startIndex + index); - matches++; - - if (matches >= destination.Length || span.Length <= index) - break; - - // move to next token - span = span.Slice(index + 1); - startIndex += index + 1; - } - - return matches; - } -#endif - /// /// Parses the source for the specified style attribute separators, /// populating the span with instances From 0e456249a69310c25656cbfbe4eea47b33a88e02 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 20:53:22 +0200 Subject: [PATCH 03/15] Fix the parsing on DataUri and improve when only images is expected --- src/Html2OpenXml/IO/DataUri.cs | 132 ++++++++++++------ src/Html2OpenXml/IO/ImagePrefetcher.cs | 6 +- .../Primitives/DataUriTests.cs | 66 ++++++--- 3 files changed, 143 insertions(+), 61 deletions(-) diff --git a/src/Html2OpenXml/IO/DataUri.cs b/src/Html2OpenXml/IO/DataUri.cs index 3c726479..92111d10 100755 --- a/src/Html2OpenXml/IO/DataUri.cs +++ b/src/Html2OpenXml/IO/DataUri.cs @@ -10,6 +10,7 @@ * PARTICULAR PURPOSE. */ using System.Diagnostics.CodeAnalysis; +using System.Globalization; namespace HtmlToOpenXml.IO; @@ -20,12 +21,26 @@ namespace HtmlToOpenXml.IO; /// Data URIs are commonly used to embed images in HTML without referencing an external /// resource, for example: data:image/png;base64,... /// -[System.Diagnostics.DebuggerDisplay("{Mime,nq}")] -public sealed class DataUri +/// The MIME type declared by the Data URI, such as image/png or image/jpeg. +/// The encoding of the content when the data is text (e.g., UTF-8). +/// The decoded binary content. +/// The decoded human-readable content. +readonly record struct DataUri(string MimeType, string? Charset = null, byte[]? Data = null, string? Content = null) { - private DataUri(string mimeType) + public static readonly DataUri Invalid = new(null!); + + /// + /// Try to parse an instance of the Data URI scheme but stop if it detects it's not an embedded images. + /// This method avoid excessive memory allocations, as the purpose of this library is reading base64 images. + /// + /// The inline Data URI to parse. + /// When this method returns, contains a DataUri constructed + /// from . This parameter is passed uninitialized. + /// Returns if the DataUri was successfully created; + /// otherwise, . + public static bool TryCreateImage(string uri, [NotNullWhen(true)] out DataUri result) { - Mime = mimeType; + return TryCreate(uri, breakIfNotImage: true, out result); } /// @@ -34,9 +49,14 @@ private DataUri(string mimeType) /// The inline Data URI to parse. /// When this method returns, contains a DataUri constructed /// from . This parameter is passed uninitialized. - /// A value that is true if the DataUri was - /// successfully created; otherwise, false. - public static bool TryCreate(string uri, [NotNullWhen(true)] out DataUri? result) + /// Returns if the DataUri was successfully created; + /// otherwise, . + public static bool TryCreate(string uri, [NotNullWhen(true)] out DataUri result) + { + return TryCreate(uri, breakIfNotImage: false, out result); + } + + private static bool TryCreate(string uri, bool breakIfNotImage, [NotNullWhen(true)] out DataUri result) { // expected format: data:[][;charset=][;base64], // The encoding is indicated by ;base64. If it's present the data is encoded as base64. Without it the data (as a sequence of octets) @@ -47,7 +67,7 @@ public static bool TryCreate(string uri, [NotNullWhen(true)] out DataUri? result // while Internet Explorer requires that the charset's specification must precede the base64 token. // http://en.wikipedia.org/wiki/Data_URI_scheme - result = null; + result = Invalid; if (uri.Length < 5) return false; @@ -69,7 +89,8 @@ public static bool TryCreate(string uri, [NotNullWhen(true)] out DataUri? result // if mime is omitted, set default value as it stands in the norm string mime = "text/plain"; - string charSet = "ascii"; + string charset = "ascii"; + System.Text.Encoding encoding = System.Text.Encoding.ASCII; bool isBase64 = false; Span tokens = stackalloc Range[5]; @@ -86,7 +107,8 @@ public static bool TryCreate(string uri, [NotNullWhen(true)] out DataUri? result { try { - charSet = System.Text.Encoding.GetEncoding(token.Slice(8 /* 'charset=' */).ToString()).WebName; + charset = token.Slice(8 /* 'charset=' */).ToString(); + encoding = System.Text.Encoding.GetEncoding(charset); } catch (ArgumentException) { @@ -101,60 +123,90 @@ public static bool TryCreate(string uri, [NotNullWhen(true)] out DataUri? result var data = uriSpan.Slice(commaIndex + 1).Trim(); if (data.IsEmpty) { - result = new(mime); + result = new(mime, charset); return true; // this is still a valid data URI } if (isBase64) { - // be careful that the raw data is encoded for url (standard %xx hex encoding) - string base64 = HttpUtility.HtmlDecode(data); - + byte[] dataBinary; + var base64 = data; +#if NET5_0_OR_GREATER + int maxDecodedLength = (base64.Length * 3 + 3) / 4; + dataBinary = GC.AllocateUninitializedArray(maxDecodedLength); + if (!Convert.TryFromBase64Chars(base64, dataBinary, out int bytesWritten)) + return false; + Array.Resize(ref dataBinary, bytesWritten); +#else try { - result = new(mime) { Data = Convert.FromBase64String(base64) }; - return true; + dataBinary = Convert.FromBase64String(base64.ToString()); } catch (FormatException) { // Base64 data is invalid return false; } +#endif + result = new(mime, Data: dataBinary); + return true; } - // the represents some text (like html snippet) and must be decoded. - result = new(mime) { Encoding = charSet, Content = HttpUtility.UrlDecode(data) }; + if (breakIfNotImage) + return false; + + // the represents some text (like html snippet) + // and must be decoded respecting the provided charset. + var bytes = UnescapePercentEncodedBytes(data); + result = new(mime, charset, Content: encoding.GetString(bytes)); return true; } - /// - /// Determines whether the uri looks like a Data Uri. - /// - public static bool IsDataUri(string uri) + private static byte[] UnescapePercentEncodedBytes(ReadOnlySpan span) { - return uri.StartsWith("data:", StringComparison.OrdinalIgnoreCase); - } + if (span.IsEmpty) + return []; - //____________________________________________________________________ - // +#if NET5_0_OR_GREATER + byte[] buffer = GC.AllocateUninitializedArray(span.Length); +#else + byte[] buffer = new byte[span.Length]; +#endif + int bytesWritten = 0; - /// - /// Gets the MIME type declared by the Data URI, such as image/png or image/jpeg. - /// - public string Mime { get; } + for (int i = 0; i < span.Length; i++) + { + if (span[i] == '%' && i + 2 < span.Length && + byte.TryParse( +#if NET5_0_OR_GREATER + span.Slice(i + 1, 2), +#else + span.Slice(i + 1, 2).ToString(), +#endif + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var b)) + { + buffer[bytesWritten++] = b; + i += 2; + } + else + { + if (span[i] > 0xFF) + throw new InvalidOperationException("Invalid percent-encoded data."); - /// - /// Gets the decoded binary content. - /// - public byte[]? Data { get; init; } + buffer[bytesWritten++] = (byte) span[i]; + } + } - /// - /// Gets the encoding of the content when the data is text (e.g., UTF-8). - /// - public string? Encoding { get; init; } + return bytesWritten == span.Length? buffer : + buffer.AsSpan(0, bytesWritten).ToArray(); + } /// - /// Gets the decoded human-readable content. + /// Determines whether the uri looks like a Data Uri. /// - public string? Content { get; init; } + public static bool IsDataUri(string uri) + { + return uri.StartsWith("data:", StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/Html2OpenXml/IO/ImagePrefetcher.cs b/src/Html2OpenXml/IO/ImagePrefetcher.cs index fbcde05b..744c4f65 100644 --- a/src/Html2OpenXml/IO/ImagePrefetcher.cs +++ b/src/Html2OpenXml/IO/ImagePrefetcher.cs @@ -169,11 +169,11 @@ public ImagePrefetcher(T hostingPart, IWebRequest resourceLoader, ImageProcessin /// private HtmlImageInfo? ReadDataUri(string src) { - if (DataUri.TryCreate(src, out var dataUri) && dataUri.Data != null) + if (DataUri.TryCreateImage(src, out var dataUri)) { - TryInspectMimeType(dataUri!.Mime, out PartTypeInfo type); + TryInspectMimeType(dataUri.MimeType, out PartTypeInfo type); - return SaveImageAssert(type, stream => stream.Write(dataUri.Data, 0, dataUri.Data.Length)); + return SaveImageAssert(type, stream => stream.Write(dataUri.Data, 0, dataUri.Data!.Length)); } return null; diff --git a/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs b/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs index d9e0dd55..de7d17a4 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs @@ -9,23 +9,50 @@ namespace HtmlToOpenXml.Tests.Primitives [TestFixture] public class DataUriTests { - [TestCase("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", "image/png", null, true, Description = "red dot")] - [TestCase("data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E", "text/html", "ascii", false)] - [TestCase("data:text/plain;charset=UTF-8,the%20data:1234,5678", "text/plain", "utf-8", false)] - [TestCase("data:,foo", "text/plain", "ascii", false)] - [TestCase("data:,", "text/plain", null, false)] - [TestCase("data: image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", "image/png", null, true)] - public void InlineData_ShouldSucceed(string uri, string expectedMimeType, string? expectedEncoding, bool expectedData) + [TestCase("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==")] + [TestCase("data: image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==")] + public void ParseBase64_ShouldSucceed(string uri) { - DataUri.TryCreate(uri, out DataUri? result); - Assert.That(result, Is.Not.Null); - Assert.That(result.Mime, Is.EqualTo(expectedMimeType)); - Assert.That(result.Encoding, Is.EqualTo(expectedEncoding)); - Assert.That(result.Data, expectedData? Is.Not.Null : Is.Null); + var success = DataUri.TryCreate(uri, out var result); + Assert.That(success, Is.True); + Assert.That(result, Is.Not.EqualTo(DataUri.Invalid)); + Assert.That(result.MimeType, Is.EqualTo("image/png")); + Assert.That(result.Charset, Is.Null); + Assert.That(result.Data, Is.Not.Null); + } + + [TestCase("data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E", "text/html", "ascii", "

Hello, World!

")] + [TestCase("data:text/plain;charset=UTF-8,the%20data:1234,5678", "text/plain", "utf-8", "the data:1234,5678")] + [TestCase("data:,foo", "text/plain", "ascii", "foo")] + [TestCase("data:,", "text/plain", "ascii", null!)] + public void ParseCharset_ShouldSucceed(string uri, string expectedMimeType, string? expectedEncoding, string expectedContent) + { + var success = DataUri.TryCreate(uri, out var result); + Assert.That(success, Is.True); + Assert.That(result.MimeType, Is.EqualTo(expectedMimeType)); + Assert.That(result.Charset, Is.EqualTo(expectedEncoding)); + Assert.That(result.Content, Is.EqualTo(expectedContent)); + } + + [Test] + public void ContentWithPlus_ShouldReturns_UnescapedContent() + { + var success = DataUri.TryCreate("data:,C++", out var result); + Assert.That(success, Is.True); + Assert.That(result, Is.Not.EqualTo(DataUri.Invalid)); + Assert.That(result.MimeType, Is.EqualTo("text/plain")); + Assert.That(result.Content, Is.EqualTo("C++")); + } + + [TestCase("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", ExpectedResult = true)] + [TestCase("data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E", ExpectedResult = false)] + public bool ParseImageOnly_ShouldSucceed(string uri) + { + return DataUri.TryCreateImage(uri, out var _); } [Test] - public void MultilineData_ShouldSucceed() + public void Multiline_ShouldSucceed() { string uri = @"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8l JCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/wAALCABVAEABAREA/8QAHwAA @@ -69,17 +96,20 @@ public void MultilineData_ShouldSucceed() 1KjnqAByfXFezMiv95Q2PUZoCKOigY9BSkA9QDjmgqGBDAEHsaia0tnQxtbxMjdVKAg0W9pb Wilba3ihB6iNAufyoltLaZw8tvFIw6FkBNSgADA4FLRRRRRRRX//2Q== "; - DataUri.TryCreate(uri, out DataUri? result); - Assert.That(result, Is.Not.Null); + var success = DataUri.TryCreate(uri, out var result); + Assert.That(success, Is.True); + Assert.That(result.Data, Is.Not.Null); + Assert.That(result.MimeType, Is.EqualTo("image/jpeg")); } [TestCase("data:text/plain;charset=utf-123,the%20data:1234,5678")] [TestCase("data:image/png;base64,abc")] [TestCase("data,")] - public void InvalidData_ShouldFail(string uri) + public void ParseMalformed_ShouldFail(string uri) { - DataUri.TryCreate(uri, out DataUri ?result); - Assert.That(result, Is.Null); + var success = DataUri.TryCreate(uri, out var result); + Assert.That(success, Is.False); + Assert.That(result, Is.EqualTo(DataUri.Invalid)); } } } From 59c78967632104c6f08f39625fc1f3b9bb657100 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 21:38:58 +0200 Subject: [PATCH 04/15] Array.Resize allocate a new buffer instead of shrinking it. Precompute the array length. --- src/Html2OpenXml/IO/DataUri.cs | 94 ++++++++++++++++---------- src/Html2OpenXml/IO/ImagePrefetcher.cs | 4 +- 2 files changed, 60 insertions(+), 38 deletions(-) diff --git a/src/Html2OpenXml/IO/DataUri.cs b/src/Html2OpenXml/IO/DataUri.cs index 92111d10..e053d76b 100755 --- a/src/Html2OpenXml/IO/DataUri.cs +++ b/src/Html2OpenXml/IO/DataUri.cs @@ -30,7 +30,7 @@ readonly record struct DataUri(string MimeType, string? Charset = null, byte[]? public static readonly DataUri Invalid = new(null!); /// - /// Try to parse an instance of the Data URI scheme but stop if it detects it's not an embedded images. + /// Try to parse an instance of the Data URI scheme but stop if it detects that the URI does not contain an embedded image. /// This method avoid excessive memory allocations, as the purpose of this library is reading base64 images. /// /// The inline Data URI to parse. @@ -120,6 +120,7 @@ private static bool TryCreate(string uri, bool breakIfNotImage, [NotNullWhen(tru // else ignore, arbitrary value not needed in this context } + // use the uriSpan to preserve the casing var data = uriSpan.Slice(commaIndex + 1).Trim(); if (data.IsEmpty) { @@ -129,59 +130,80 @@ private static bool TryCreate(string uri, bool breakIfNotImage, [NotNullWhen(tru if (isBase64) { - byte[] dataBinary; - var base64 = data; -#if NET5_0_OR_GREATER - int maxDecodedLength = (base64.Length * 3 + 3) / 4; - dataBinary = GC.AllocateUninitializedArray(maxDecodedLength); - if (!Convert.TryFromBase64Chars(base64, dataBinary, out int bytesWritten)) - return false; - Array.Resize(ref dataBinary, bytesWritten); -#else - try - { - dataBinary = Convert.FromBase64String(base64.ToString()); - } - catch (FormatException) - { - // Base64 data is invalid - return false; - } -#endif + byte[] dataBinary = TryDecodeBase64(data); result = new(mime, Data: dataBinary); - return true; + return dataBinary.Length > 0; } - if (breakIfNotImage) + // ignoreCase is not needed as this span was lowered + if (breakIfNotImage && !mime.StartsWith("image/", StringComparison.Ordinal)) return false; // the represents some text (like html snippet) // and must be decoded respecting the provided charset. - var bytes = UnescapePercentEncodedBytes(data); - result = new(mime, charset, Content: encoding.GetString(bytes)); + try + { + var bytes = UnescapePercentEncodedBytes(data); + result = new(mime, charset, Content: encoding.GetString(bytes)); + } + catch (FormatException) + { + return false; + } return true; } - private static byte[] UnescapePercentEncodedBytes(ReadOnlySpan span) + private static byte[] TryDecodeBase64(ReadOnlySpan value) + { +#if NET5_0_OR_GREATER + int decodedLength = (value.Length / 4) + 3; + if (value.EndsWith("==")) + decodedLength -= 2; + else if (value[^1] == '=') + decodedLength -= 1; + + var buffer = GC.AllocateUninitializedArray(decodedLength); + if (!Convert.TryFromBase64Chars(value, buffer, out int bytesWritten)) + return []; + + return buffer; +#else + try + { + return Convert.FromBase64String(value.ToString()); + } + catch (FormatException) + { + // Base64 data is invalid + return []; + } +#endif + } + + /// + /// Convert the data portion of a non-base64 Data URI from percent-encoded form (%xx) + /// back into its original byte. + /// + private static byte[] UnescapePercentEncodedBytes(ReadOnlySpan value) { - if (span.IsEmpty) + if (value.IsEmpty) return []; #if NET5_0_OR_GREATER - byte[] buffer = GC.AllocateUninitializedArray(span.Length); + byte[] buffer = GC.AllocateUninitializedArray(value.Length); #else - byte[] buffer = new byte[span.Length]; + byte[] buffer = new byte[value.Length]; #endif int bytesWritten = 0; - for (int i = 0; i < span.Length; i++) + for (int i = 0; i < value.Length; i++) { - if (span[i] == '%' && i + 2 < span.Length && + if (value[i] == '%' && i + 2 < value.Length && byte.TryParse( #if NET5_0_OR_GREATER - span.Slice(i + 1, 2), + value.Slice(i + 1, 2), #else - span.Slice(i + 1, 2).ToString(), + value.Slice(i + 1, 2).ToString(), #endif NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) @@ -191,14 +213,14 @@ private static byte[] UnescapePercentEncodedBytes(ReadOnlySpan span) } else { - if (span[i] > 0xFF) - throw new InvalidOperationException("Invalid percent-encoded data."); + if (value[i] > 0xFF) + throw new FormatException("Invalid percent-encoded data."); - buffer[bytesWritten++] = (byte) span[i]; + buffer[bytesWritten++] = (byte) value[i]; } } - return bytesWritten == span.Length? buffer : + return bytesWritten == value.Length? buffer : buffer.AsSpan(0, bytesWritten).ToArray(); } diff --git a/src/Html2OpenXml/IO/ImagePrefetcher.cs b/src/Html2OpenXml/IO/ImagePrefetcher.cs index 744c4f65..042addfc 100644 --- a/src/Html2OpenXml/IO/ImagePrefetcher.cs +++ b/src/Html2OpenXml/IO/ImagePrefetcher.cs @@ -169,11 +169,11 @@ public ImagePrefetcher(T hostingPart, IWebRequest resourceLoader, ImageProcessin ///
private HtmlImageInfo? ReadDataUri(string src) { - if (DataUri.TryCreateImage(src, out var dataUri)) + if (DataUri.TryCreateImage(src, out var dataUri) && dataUri.Data != null) { TryInspectMimeType(dataUri.MimeType, out PartTypeInfo type); - return SaveImageAssert(type, stream => stream.Write(dataUri.Data, 0, dataUri.Data!.Length)); + return SaveImageAssert(type, stream => stream.Write(dataUri.Data, 0, dataUri.Data.Length)); } return null; From 9a180ff9f83dcc90cf0348008cc069f7f499ab03 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 22:01:05 +0200 Subject: [PATCH 05/15] Finally Array.Resize is mandatory for multiline data uri --- src/Html2OpenXml/IO/DataUri.cs | 15 +++++++++------ .../Primitives/DataUriTests.cs | 1 - 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Html2OpenXml/IO/DataUri.cs b/src/Html2OpenXml/IO/DataUri.cs index e053d76b..223c987c 100755 --- a/src/Html2OpenXml/IO/DataUri.cs +++ b/src/Html2OpenXml/IO/DataUri.cs @@ -93,7 +93,7 @@ private static bool TryCreate(string uri, bool breakIfNotImage, [NotNullWhen(tru System.Text.Encoding encoding = System.Text.Encoding.ASCII; bool isBase64 = false; - Span tokens = stackalloc Range[5]; + Span tokens = stackalloc Range[8]; var tokenCount = loweredValue.Split(tokens, ';', StringSplitOptions.RemoveEmptyEntries); for (var i = 0; i < tokenCount; i++) { @@ -156,16 +156,19 @@ private static bool TryCreate(string uri, bool breakIfNotImage, [NotNullWhen(tru private static byte[] TryDecodeBase64(ReadOnlySpan value) { #if NET5_0_OR_GREATER - int decodedLength = (value.Length / 4) + 3; - if (value.EndsWith("==")) - decodedLength -= 2; - else if (value[^1] == '=') - decodedLength -= 1; + int decodedLength = (value.Length * 3 + 3) / 4; + if (value[^1] == '=') + { + decodedLength -= value[^2] == '='? 2 : 1; + } var buffer = GC.AllocateUninitializedArray(decodedLength); if (!Convert.TryFromBase64Chars(value, buffer, out int bytesWritten)) return []; + // mainly for multiline data URI where we waste bytes + if (buffer.Length != bytesWritten) + Array.Resize(ref buffer, bytesWritten); return buffer; #else try diff --git a/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs b/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs index de7d17a4..c1cee6b3 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/DataUriTests.cs @@ -109,7 +109,6 @@ public void ParseMalformed_ShouldFail(string uri) { var success = DataUri.TryCreate(uri, out var result); Assert.That(success, Is.False); - Assert.That(result, Is.EqualTo(DataUri.Invalid)); } } } From 07a3e8b0bb26e4253f99f894cba8fa5c48be97a7 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 22:09:37 +0200 Subject: [PATCH 06/15] Typo and defensive code on DecodeBase64 --- src/Html2OpenXml/IO/DataUri.cs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Html2OpenXml/IO/DataUri.cs b/src/Html2OpenXml/IO/DataUri.cs index 223c987c..eeebbdd4 100755 --- a/src/Html2OpenXml/IO/DataUri.cs +++ b/src/Html2OpenXml/IO/DataUri.cs @@ -31,7 +31,7 @@ readonly record struct DataUri(string MimeType, string? Charset = null, byte[]? /// /// Try to parse an instance of the Data URI scheme but stop if it detects that the URI does not contain an embedded image. - /// This method avoid excessive memory allocations, as the purpose of this library is reading base64 images. + /// This method avoids excessive memory allocations, as the purpose of this library is reading base64 images. /// /// The inline Data URI to parse. /// When this method returns, contains a DataUri constructed @@ -120,7 +120,11 @@ private static bool TryCreate(string uri, bool breakIfNotImage, [NotNullWhen(tru // else ignore, arbitrary value not needed in this context } - // use the uriSpan to preserve the casing + // ignoreCase is not needed as this span was lowered + if (breakIfNotImage && !mime.StartsWith("image/", StringComparison.Ordinal)) + return false; + + // use the uriSpan to preserve the casing for the data segment var data = uriSpan.Slice(commaIndex + 1).Trim(); if (data.IsEmpty) { @@ -130,15 +134,11 @@ private static bool TryCreate(string uri, bool breakIfNotImage, [NotNullWhen(tru if (isBase64) { - byte[] dataBinary = TryDecodeBase64(data); + byte[] dataBinary = DecodeBase64(data); result = new(mime, Data: dataBinary); return dataBinary.Length > 0; } - // ignoreCase is not needed as this span was lowered - if (breakIfNotImage && !mime.StartsWith("image/", StringComparison.Ordinal)) - return false; - // the represents some text (like html snippet) // and must be decoded respecting the provided charset. try @@ -153,8 +153,12 @@ private static bool TryCreate(string uri, bool breakIfNotImage, [NotNullWhen(tru return true; } - private static byte[] TryDecodeBase64(ReadOnlySpan value) + private static byte[] DecodeBase64(ReadOnlySpan value) { + // 4 is the minimal length for a base64 string + if (value.Length < 4) + return []; + #if NET5_0_OR_GREATER int decodedLength = (value.Length * 3 + 3) / 4; if (value[^1] == '=') @@ -184,8 +188,7 @@ private static byte[] TryDecodeBase64(ReadOnlySpan value) } /// - /// Convert the data portion of a non-base64 Data URI from percent-encoded form (%xx) - /// back into its original byte. + /// Convert the data portion of a non-base64 Data URI from percent-encoded form (%xx) back into its original bytes. /// private static byte[] UnescapePercentEncodedBytes(ReadOnlySpan value) { From 2a854d90efbc564b40798f75213082cbb4a0c6d0 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 22:52:17 +0200 Subject: [PATCH 07/15] Parse style supports named encoded entities: semi-colon and colon --- .../Collections/HtmlAttributeCollection.cs | 45 +++++++++++++++++-- .../Primitives/StyleParserTests.cs | 1 + 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs index 2bcf9499..9c165a1b 100755 --- a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs +++ b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs @@ -9,6 +9,7 @@ * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A * PARTICULAR PURPOSE. */ +using System.Runtime.CompilerServices; using DocumentFormat.OpenXml.Wordprocessing; namespace HtmlToOpenXml; @@ -78,18 +79,18 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) index++; } // html-encoded semicolon - else if (foundKey && span.Slice(index).StartsWith(['&','#','5','9',';'])) + else if (foundKey && IsSemiColon(span.Slice(index), out var charsConsumed)) { if (index > 0) collection.attributes[key!] = new Range(startIndex, startIndex + index); foundKey = false; - index += 5; // length of ":" + index += charsConsumed; } - else if (!foundKey && span.Slice(index).StartsWith(['&','#','5','8',';'])) + else if (!foundKey && IsColon(span.Slice(index), out charsConsumed)) { key = span.Slice(0, index).Trim().ToString().ToLowerInvariant(); foundKey = true; - index += 5; // length of ":" + index += charsConsumed; } else { @@ -104,6 +105,42 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) return collection; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsSemiColon(ReadOnlySpan span, out int charsConsumed) + { + if (span.StartsWith(['&','#','5','9',';'])) + { + charsConsumed = 5; + return true; + } + if (span.StartsWith(['&','s','e','m','i',';'])) + { + charsConsumed = 6; + return true; + } + + charsConsumed = 0; + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsColon(ReadOnlySpan span, out int charsConsumed) + { + if (span.StartsWith(['&','#','5','8',';'])) + { + charsConsumed = 5; + return true; + } + if (span.StartsWith(['&','c','o','l','o','n',';'])) + { + charsConsumed = 7; + return true; + } + + charsConsumed = 0; + return false; + } + /// /// Gets the named attribute. /// diff --git a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs index febbd71b..abef1686 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs @@ -10,6 +10,7 @@ public class StyleParserTests { [TestCase("text-decoration:underline; color: red ")] [TestCase("text-decoration : underline ;color :red")] + [TestCase("text-decoration : underline ;color :red")] public void ParseStyle_ShouldSucceed(string htmlStyle) { var styles = HtmlAttributeCollection.ParseStyle(htmlStyle); From 71829d561ad8507e0a10844fc7be91131f0202a0 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 23:29:47 +0200 Subject: [PATCH 08/15] Simplified parser and supports also hex html entities --- .../Collections/HtmlAttributeCollection.cs | 56 +-------- .../Utilities/HttpUtility.HtmlEntities.cs | 8 +- src/Html2OpenXml/Utilities/HttpUtility.cs | 114 +++++++++++++----- .../Primitives/StyleParserTests.cs | 2 + 4 files changed, 96 insertions(+), 84 deletions(-) diff --git a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs index 9c165a1b..f1a1af81 100755 --- a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs +++ b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs @@ -60,33 +60,19 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) } var separator = span[index]; - if (separator == ';' && foundKey) + if (foundKey && HttpUtility.IsSemiColonEntity(span.Slice(index), out var charsConsumed)) { if (index > 0) collection.attributes[key!] = new Range(startIndex, startIndex + index); foundKey = false; - index++; + index += charsConsumed; } else if (separator == ';' && !foundKey) { // unexpected semicolon (ie, key with no value) -> ignore this style index++; } - else if (separator == ':' && !foundKey) - { - key = span.Slice(0, index).Trim().ToString().ToLowerInvariant(); - foundKey = true; - index++; - } - // html-encoded semicolon - else if (foundKey && IsSemiColon(span.Slice(index), out var charsConsumed)) - { - if (index > 0) - collection.attributes[key!] = new Range(startIndex, startIndex + index); - foundKey = false; - index += charsConsumed; - } - else if (!foundKey && IsColon(span.Slice(index), out charsConsumed)) + else if (!foundKey && HttpUtility.IsColonEntity(span.Slice(index), out charsConsumed)) { key = span.Slice(0, index).Trim().ToString().ToLowerInvariant(); foundKey = true; @@ -105,42 +91,6 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) return collection; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsSemiColon(ReadOnlySpan span, out int charsConsumed) - { - if (span.StartsWith(['&','#','5','9',';'])) - { - charsConsumed = 5; - return true; - } - if (span.StartsWith(['&','s','e','m','i',';'])) - { - charsConsumed = 6; - return true; - } - - charsConsumed = 0; - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsColon(ReadOnlySpan span, out int charsConsumed) - { - if (span.StartsWith(['&','#','5','8',';'])) - { - charsConsumed = 5; - return true; - } - if (span.StartsWith(['&','c','o','l','o','n',';'])) - { - charsConsumed = 7; - return true; - } - - charsConsumed = 0; - return false; - } - /// /// Gets the named attribute. /// diff --git a/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs b/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs index d61fca38..df2fe6e9 100644 --- a/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs +++ b/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs @@ -7,11 +7,11 @@ namespace HtmlToOpenXml; static partial class HttpUtility { - // This code comes from the .Net API; For .Net462, this avoid to add a reference to System.Web - // https://github.com/dotnet/dotnet/blob/main/src/runtime/src/libraries/System.Private.CoreLib/src/System/Net/WebUtility.cs + // Copies from WebUtility.HtmlEntities implementation. + // Source: https://github.com/dotnet/dotnet/blob/main/src/runtime/src/libraries/System.Private.CoreLib/src/System/Net/WebUtility.cs static class HtmlEntities { - private static readonly Lazy> lookupTable = new(() => InitializeLookupTable()); + private static readonly IReadOnlyDictionary lookupTable = InitializeLookupTable(); // The list is from http://www.w3.org/TR/REC-html40/sgml/entities.html, except for ', which // is defined in http://www.w3.org/TR/2008/REC-xml-20081126/#sec-predefined-ent. @@ -295,7 +295,7 @@ public static bool Lookup(ReadOnlySpan entity, out char result) // Since all entity strings comprise 8 characters or less and are ASCII-only, they "fit" into an ulong (8 bytes). if (entity.Length <= 8) { - return lookupTable.Value.TryGetValue(ToUInt64Key(entity), out result); + return lookupTable.TryGetValue(ToUInt64Key(entity), out result); } // Currently, there are no entities that are longer than 8 characters. diff --git a/src/Html2OpenXml/Utilities/HttpUtility.cs b/src/Html2OpenXml/Utilities/HttpUtility.cs index fb0a8d06..fee41be7 100755 --- a/src/Html2OpenXml/Utilities/HttpUtility.cs +++ b/src/Html2OpenXml/Utilities/HttpUtility.cs @@ -11,6 +11,7 @@ */ using System.Buffers; using System.Globalization; +using System.Runtime.CompilerServices; namespace HtmlToOpenXml; @@ -24,6 +25,62 @@ namespace HtmlToOpenXml; ///
static partial class HttpUtility { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSemiColonEntity(ReadOnlySpan span, out int charsConsumed) + { + if (span[0] == ';') + { + charsConsumed = 1; + return true; + } + if (span.StartsWith(['&','#','5','9',';'])) + { + charsConsumed = 5; + return true; + } + else if (span.StartsWith(";", StringComparison.OrdinalIgnoreCase)) + { + charsConsumed = 6; + return true; + } + else if (span.StartsWith(";", StringComparison.OrdinalIgnoreCase)) + { + charsConsumed = 6; + return true; + } + + charsConsumed = 0; + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsColonEntity(ReadOnlySpan span, out int charsConsumed) + { + if (span[0] == ':') + { + charsConsumed = 1; + return true; + } + else if (span.StartsWith(['&','#','5','8',';'])) + { + charsConsumed = 5; + return true; + } + else if (span.StartsWith(":", StringComparison.OrdinalIgnoreCase)) + { + charsConsumed = 6; + return true; + } + else if (span.StartsWith(":", StringComparison.OrdinalIgnoreCase)) + { + charsConsumed = 7; + return true; + } + + charsConsumed = 0; + return false; + } + /// /// Converts a string that has been HTML-encoded for HTTP transmission into a decoded string. /// @@ -36,6 +93,21 @@ public static string HtmlDecode(ReadOnlySpan span) return span.ToString(); var buffer = ArrayPool.Shared.Rent(span.Length); + string result; + try + { + var length = HtmlDecode(span, buffer); + result = new String(buffer, 0, length); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + return result; + } + + private static int HtmlDecode(ReadOnlySpan span, char[] buffer) + { var length = 0; while (!span.IsEmpty) @@ -86,19 +158,19 @@ public static string HtmlDecode(ReadOnlySpan span) continue; } - if (IsKnownEntities(entity, out var ch)) + if (TryDecodeNamedEntity(entity, out var ch)) buffer[length++] = ch; else - span.Slice(endIndex).CopyTo(buffer); + { + span.Slice(endIndex).CopyTo(buffer.AsSpan(length)); + length += endIndex; + } // move to next token span = span.Slice(endIndex + 1); } - var result = new String(buffer, 0, length); - - ArrayPool.Shared.Return(buffer); - return result; + return length; } /// @@ -134,7 +206,13 @@ private static bool TryDecodeNumericEntity(ReadOnlySpan entity, out uint c numberStyles, CultureInfo.InvariantCulture, out codePoint); } - private static bool IsKnownEntities(ReadOnlySpan entity, out char ch) + /// + /// Try to parse UTF8 + /// + /// + /// + /// + private static bool TryDecodeNamedEntity(ReadOnlySpan entity, out char ch) { // avoid to allocate a new string for most common entities // and this is faster to use this pattern matching @@ -145,6 +223,8 @@ private static bool IsKnownEntities(ReadOnlySpan entity, out char ch) "quot" => '"', "apos" => '\'', "nbsp" => '\x00a0', + "colon" => ':', + "semi" => ';', "copy" => '©', "reg" => '®', "trade" => '™', @@ -157,24 +237,4 @@ private static bool IsKnownEntities(ReadOnlySpan entity, out char ch) // fallback on the hard-coded list return HtmlEntities.Lookup(entity, out ch); } - - /// - /// Converts a string that represents an Html-encoded URL to a decoded string. - /// - public static string UrlDecode(ReadOnlySpan text) - { - if (text.IsEmpty) return string.Empty; - - // pre-process for + sign space formatting since System.Uri doesn't handle it - // plus literals are encoded as %2b normally so this should be safe - // http://www.west-wind.com/weblog/posts/2009/Feb/05/Html-and-Uri-String-Encoding-without-SystemWeb - if (text.IndexOf('+') > 0) - return Uri.UnescapeDataString(text.ToString().Replace("+", " ")); - -#if NET9_0_OR_GREATER - return Uri.UnescapeDataString(text); -#else - return Uri.UnescapeDataString(text.ToString()); -#endif - } } \ No newline at end of file diff --git a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs index abef1686..499cdea3 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs @@ -10,7 +10,9 @@ public class StyleParserTests { [TestCase("text-decoration:underline; color: red ")] [TestCase("text-decoration : underline ;color :red")] + [TestCase("text-decoration : underline ;color :red")] [TestCase("text-decoration : underline ;color :red")] + [TestCase("text-decoration &COLON; underline &SEMI;color :red")] public void ParseStyle_ShouldSucceed(string htmlStyle) { var styles = HtmlAttributeCollection.ParseStyle(htmlStyle); From 273fbf8b150392854334fb6042812065feb5317b Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 23:55:15 +0200 Subject: [PATCH 09/15] Avoid an extra string allocation and use a ignore case lookup dictionary --- src/Html2OpenXml/Collections/HtmlAttributeCollection.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs index f1a1af81..48828b06 100755 --- a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs +++ b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs @@ -9,7 +9,6 @@ * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A * PARTICULAR PURPOSE. */ -using System.Runtime.CompilerServices; using DocumentFormat.OpenXml.Wordprocessing; namespace HtmlToOpenXml; @@ -20,7 +19,7 @@ namespace HtmlToOpenXml; readonly struct HtmlAttributeCollection { // Style key associated with a pointer to rawValue. - private readonly Dictionary attributes = []; + private readonly Dictionary attributes = [with(comparer: StringComparer.OrdinalIgnoreCase)]; private readonly string rawValue; @@ -74,7 +73,7 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) } else if (!foundKey && HttpUtility.IsColonEntity(span.Slice(index), out charsConsumed)) { - key = span.Slice(0, index).Trim().ToString().ToLowerInvariant(); + key = span.Slice(0, index).Trim().ToString(); foundKey = true; index += charsConsumed; } From 12756c8919fab46ddbb3a4bc38d353d28771d0ac Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 20 Oct 2026 23:56:23 +0200 Subject: [PATCH 10/15] Fix unknown entity which breaks the range of the next token pair --- src/Html2OpenXml/Collections/HtmlAttributeCollection.cs | 1 + test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs index 48828b06..b2f9c9f1 100755 --- a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs +++ b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs @@ -80,6 +80,7 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) else { span = span.Slice(index + 1); + startIndex += index + 1; continue; } diff --git a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs index 499cdea3..b8cda77e 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs @@ -41,6 +41,14 @@ public void InvalidStyle_ShouldBeEmpty(string htmlStyle) Assert.That(styles.TryGetValue("color", out var _), Is.False); } + [Test] + public void WithUnknownEntity_ContinueOnNextKey() + { + var styles = HtmlAttributeCollection.ParseStyle("color:red&green;text-decoration:underline"); + Assert.That(styles.IsEmpty, Is.False); + Assert.That(styles.HasKeyEqualsTo("text-decoration", "underline"), Is.True); + } + [Test] public void WithMultipleTextDecoration_ReturnsAllValues() { From b35cb6568fbda84bc94b67c2cc9af4a91664fe13 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Wed, 21 Oct 2026 00:06:59 +0200 Subject: [PATCH 11/15] SplitCompositeAttribute now also supports the hexa entity --- src/Html2OpenXml/Utilities/SpanExtensions.cs | 28 +++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/Html2OpenXml/Utilities/SpanExtensions.cs b/src/Html2OpenXml/Utilities/SpanExtensions.cs index 45956b67..df855dab 100644 --- a/src/Html2OpenXml/Utilities/SpanExtensions.cs +++ b/src/Html2OpenXml/Utilities/SpanExtensions.cs @@ -33,20 +33,6 @@ public static byte AsByte(this ReadOnlySpan span, NumberStyles style) #endif } - /// - /// Shim method to convert to . - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [System.Diagnostics.DebuggerHidden] - public static int AsInt32(this ReadOnlySpan span, NumberStyles style) - { -#if NET5_0_OR_GREATER - return int.Parse(span, style, CultureInfo.InvariantCulture); -#else - return int.Parse(span.ToString(), style, CultureInfo.InvariantCulture); -#endif - } - /// /// Shim method to convert to . /// @@ -205,11 +191,16 @@ private static bool IsRecognizedEntity(ReadOnlySpan span, out EntityMatchR result = new EntityMatchResult(5, '"'); return true; } - if (span.StartsWith(""".AsSpan())) + else if (span.StartsWith(""".AsSpan())) { result = new EntityMatchResult(4, '"'); return true; } + else if (span.StartsWith(""".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + result = new EntityMatchResult(5, '"'); + return true; + } // look for single quote if (span.StartsWith("'".AsSpan())) @@ -217,11 +208,16 @@ private static bool IsRecognizedEntity(ReadOnlySpan span, out EntityMatchR result = new EntityMatchResult(5, '\''); return true; } - if (span.StartsWith("'".AsSpan())) + else if (span.StartsWith("'".AsSpan())) { result = new EntityMatchResult(4, '\''); return true; } + else if (span.StartsWith("'".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + result = new EntityMatchResult(5, '\''); + return true; + } result = new EntityMatchResult(0, '\0'); return false; From 5362bf4c7c4ca8b60b9e2111a6d3907b8dcfa641 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Wed, 21 Oct 2026 21:29:32 +0200 Subject: [PATCH 12/15] Simplify the detection of some known html entites --- .../Collections/HtmlAttributeCollection.cs | 6 +- src/Html2OpenXml/Utilities/HtmlEntities.cs | 73 ++++ .../Utilities/HttpUtility.HtmlEntities.cs | 323 ------------------ src/Html2OpenXml/Utilities/HttpUtility.cs | 240 ------------- src/Html2OpenXml/Utilities/SpanExtensions.cs | 36 +- .../Primitives/HttpUtilityTests.cs | 23 -- .../Primitives/StyleParserTests.cs | 16 + test/HtmlToOpenXml.Tests/StyleTests.cs | 23 -- 8 files changed, 100 insertions(+), 640 deletions(-) create mode 100755 src/Html2OpenXml/Utilities/HtmlEntities.cs delete mode 100644 src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs delete mode 100755 src/Html2OpenXml/Utilities/HttpUtility.cs delete mode 100644 test/HtmlToOpenXml.Tests/Primitives/HttpUtilityTests.cs diff --git a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs index b2f9c9f1..cda0dd6b 100755 --- a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs +++ b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs @@ -39,7 +39,7 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) if (string.IsNullOrWhiteSpace(htmlStyles)) return collection; var span = htmlStyles.AsSpan(); - int startIndex = 0; + int startIndex = 0, charsConsumed; bool foundKey = false; string? key = null; @@ -59,7 +59,7 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) } var separator = span[index]; - if (foundKey && HttpUtility.IsSemiColonEntity(span.Slice(index), out var charsConsumed)) + if (foundKey && (charsConsumed = HtmlEntities.IsSemiColon(span.Slice(index))) > 0) { if (index > 0) collection.attributes[key!] = new Range(startIndex, startIndex + index); @@ -71,7 +71,7 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) // unexpected semicolon (ie, key with no value) -> ignore this style index++; } - else if (!foundKey && HttpUtility.IsColonEntity(span.Slice(index), out charsConsumed)) + else if (!foundKey && (charsConsumed = HtmlEntities.IsColon(span.Slice(index))) > 0) { key = span.Slice(0, index).Trim().ToString(); foundKey = true; diff --git a/src/Html2OpenXml/Utilities/HtmlEntities.cs b/src/Html2OpenXml/Utilities/HtmlEntities.cs new file mode 100755 index 00000000..17696e37 --- /dev/null +++ b/src/Html2OpenXml/Utilities/HtmlEntities.cs @@ -0,0 +1,73 @@ +/* Copyright (C) Olivier Nizet https://github.com/onizet/html2openxml - All Rights Reserved + * + * This source is subject to the Microsoft Permissive License. + * Please see the License.txt file for more information. + * All other rights reserved. + * + * THIS CODE AND INFORMATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY + * KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A + * PARTICULAR PURPOSE. + */ +using System.Runtime.CompilerServices; + +namespace HtmlToOpenXml; + +/// +/// Custom utility providing compatible decoding functionality. +/// This implementation embraces the modern API Span<char> for maximum efficiency. +/// +static partial class HtmlEntities +{ + /// Returns the number of consumed or 0 if not matched. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IsSemiColon(ReadOnlySpan span) + { + return span switch { + var s when s[0] == ';' => 1, + var s when s.StartsWith(";") => 5, + var s when s.StartsWith(";", StringComparison.OrdinalIgnoreCase) + || s.StartsWith(";", StringComparison.OrdinalIgnoreCase) => 6, + _ => 0 + }; + } + + /// Returns the number of consumed or 0 if not matched. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IsColon(ReadOnlySpan span) + { + return span switch { + var s when s[0] == ':' => 1, + var s when s.StartsWith(":") => 5, + var s when s.StartsWith(":", StringComparison.OrdinalIgnoreCase) => 6, + var s when s.StartsWith(":", StringComparison.OrdinalIgnoreCase) => 7, + _ => 0 + }; + } + + /// Returns the number of consumed or 0 if not matched. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IsDoubleQuote(ReadOnlySpan span) + { + return span switch { + var s when s[0] == '"' => 1, + var s when s.StartsWith(""") => 5, + var s when s.StartsWith(""", StringComparison.OrdinalIgnoreCase) + || s.StartsWith(""", StringComparison.OrdinalIgnoreCase) => 6, + _ => 0 + }; + } + + /// Returns the number of consumed or 0 if not matched. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IsSingleQuote(ReadOnlySpan span) + { + return span switch { + var s when s[0] == '"' => 1, + var s when s.StartsWith("'") => 5, + var s when s.StartsWith("'", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("'", StringComparison.OrdinalIgnoreCase) => 6, + _ => 0 + }; + } +} \ No newline at end of file diff --git a/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs b/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs deleted file mode 100644 index df2fe6e9..00000000 --- a/src/Html2OpenXml/Utilities/HttpUtility.HtmlEntities.cs +++ /dev/null @@ -1,323 +0,0 @@ -using System.Buffers.Binary; -#if NET5_0_OR_GREATER -using System.Collections.Frozen; -#endif - -namespace HtmlToOpenXml; - -static partial class HttpUtility -{ - // Copies from WebUtility.HtmlEntities implementation. - // Source: https://github.com/dotnet/dotnet/blob/main/src/runtime/src/libraries/System.Private.CoreLib/src/System/Net/WebUtility.cs - static class HtmlEntities - { - private static readonly IReadOnlyDictionary lookupTable = InitializeLookupTable(); - - // The list is from http://www.w3.org/TR/REC-html40/sgml/entities.html, except for ', which - // is defined in http://www.w3.org/TR/2008/REC-xml-20081126/#sec-predefined-ent. - private static IReadOnlyDictionary InitializeLookupTable() - { - ReadOnlySpan tableData = [ - 0x74, 0x6F, 0x75, 0x71, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("quot")*/ 0x22, 0x00, /*'\x0022'*/ - 0x70, 0x6D, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("amp")*/ 0x26, 0x00, /*'\x0026'*/ - 0x73, 0x6F, 0x70, 0x61, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("apos")*/ 0x27, 0x00, /*'\x0027'*/ - 0x74, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("lt")*/ 0x3C, 0x00, /*'\x003c'*/ - 0x74, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("gt")*/ 0x3E, 0x00, /*'\x003e'*/ - 0x70, 0x73, 0x62, 0x6E, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("nbsp")*/ 0xA0, 0x00, /*'\x00a0'*/ - 0x6C, 0x63, 0x78, 0x65, 0x69, 0x00, 0x00, 0x00, /*ToUInt64Key("iexcl")*/ 0xA1, 0x00, /*'\x00a1'*/ - 0x74, 0x6E, 0x65, 0x63, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("cent")*/ 0xA2, 0x00, /*'\x00a2'*/ - 0x64, 0x6E, 0x75, 0x6F, 0x70, 0x00, 0x00, 0x00, /*ToUInt64Key("pound")*/ 0xA3, 0x00, /*'\x00a3'*/ - 0x6E, 0x65, 0x72, 0x72, 0x75, 0x63, 0x00, 0x00, /*ToUInt64Key("curren")*/ 0xA4, 0x00, /*'\x00a4'*/ - 0x6E, 0x65, 0x79, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("yen")*/ 0xA5, 0x00, /*'\x00a5'*/ - 0x72, 0x61, 0x62, 0x76, 0x72, 0x62, 0x00, 0x00, /*ToUInt64Key("brvbar")*/ 0xA6, 0x00, /*'\x00a6'*/ - 0x74, 0x63, 0x65, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sect")*/ 0xA7, 0x00, /*'\x00a7'*/ - 0x6C, 0x6D, 0x75, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("uml")*/ 0xA8, 0x00, /*'\x00a8'*/ - 0x79, 0x70, 0x6F, 0x63, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("copy")*/ 0xA9, 0x00, /*'\x00a9'*/ - 0x66, 0x64, 0x72, 0x6F, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ordf")*/ 0xAA, 0x00, /*'\x00aa'*/ - 0x6F, 0x75, 0x71, 0x61, 0x6C, 0x00, 0x00, 0x00, /*ToUInt64Key("laquo")*/ 0xAB, 0x00, /*'\x00ab'*/ - 0x74, 0x6F, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("not")*/ 0xAC, 0x00, /*'\x00ac'*/ - 0x79, 0x68, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("shy")*/ 0xAD, 0x00, /*'\x00ad'*/ - 0x67, 0x65, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("reg")*/ 0xAE, 0x00, /*'\x00ae'*/ - 0x72, 0x63, 0x61, 0x6D, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("macr")*/ 0xAF, 0x00, /*'\x00af'*/ - 0x67, 0x65, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("deg")*/ 0xB0, 0x00, /*'\x00b0'*/ - 0x6E, 0x6D, 0x73, 0x75, 0x6C, 0x70, 0x00, 0x00, /*ToUInt64Key("plusmn")*/ 0xB1, 0x00, /*'\x00b1'*/ - 0x32, 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sup2")*/ 0xB2, 0x00, /*'\x00b2'*/ - 0x33, 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sup3")*/ 0xB3, 0x00, /*'\x00b3'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("acute")*/ 0xB4, 0x00, /*'\x00b4'*/ - 0x6F, 0x72, 0x63, 0x69, 0x6D, 0x00, 0x00, 0x00, /*ToUInt64Key("micro")*/ 0xB5, 0x00, /*'\x00b5'*/ - 0x61, 0x72, 0x61, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("para")*/ 0xB6, 0x00, /*'\x00b6'*/ - 0x74, 0x6F, 0x64, 0x64, 0x69, 0x6D, 0x00, 0x00, /*ToUInt64Key("middot")*/ 0xB7, 0x00, /*'\x00b7'*/ - 0x6C, 0x69, 0x64, 0x65, 0x63, 0x00, 0x00, 0x00, /*ToUInt64Key("cedil")*/ 0xB8, 0x00, /*'\x00b8'*/ - 0x31, 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sup1")*/ 0xB9, 0x00, /*'\x00b9'*/ - 0x6D, 0x64, 0x72, 0x6F, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ordm")*/ 0xBA, 0x00, /*'\x00ba'*/ - 0x6F, 0x75, 0x71, 0x61, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("raquo")*/ 0xBB, 0x00, /*'\x00bb'*/ - 0x34, 0x31, 0x63, 0x61, 0x72, 0x66, 0x00, 0x00, /*ToUInt64Key("frac14")*/ 0xBC, 0x00, /*'\x00bc'*/ - 0x32, 0x31, 0x63, 0x61, 0x72, 0x66, 0x00, 0x00, /*ToUInt64Key("frac12")*/ 0xBD, 0x00, /*'\x00bd'*/ - 0x34, 0x33, 0x63, 0x61, 0x72, 0x66, 0x00, 0x00, /*ToUInt64Key("frac34")*/ 0xBE, 0x00, /*'\x00be'*/ - 0x74, 0x73, 0x65, 0x75, 0x71, 0x69, 0x00, 0x00, /*ToUInt64Key("iquest")*/ 0xBF, 0x00, /*'\x00bf'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x41, 0x00, 0x00, /*ToUInt64Key("Agrave")*/ 0xC0, 0x00, /*'\x00c0'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x41, 0x00, 0x00, /*ToUInt64Key("Aacute")*/ 0xC1, 0x00, /*'\x00c1'*/ - 0x63, 0x72, 0x69, 0x63, 0x41, 0x00, 0x00, 0x00, /*ToUInt64Key("Acirc")*/ 0xC2, 0x00, /*'\x00c2'*/ - 0x65, 0x64, 0x6C, 0x69, 0x74, 0x41, 0x00, 0x00, /*ToUInt64Key("Atilde")*/ 0xC3, 0x00, /*'\x00c3'*/ - 0x6C, 0x6D, 0x75, 0x41, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Auml")*/ 0xC4, 0x00, /*'\x00c4'*/ - 0x67, 0x6E, 0x69, 0x72, 0x41, 0x00, 0x00, 0x00, /*ToUInt64Key("Aring")*/ 0xC5, 0x00, /*'\x00c5'*/ - 0x67, 0x69, 0x6C, 0x45, 0x41, 0x00, 0x00, 0x00, /*ToUInt64Key("AElig")*/ 0xC6, 0x00, /*'\x00c6'*/ - 0x6C, 0x69, 0x64, 0x65, 0x63, 0x43, 0x00, 0x00, /*ToUInt64Key("Ccedil")*/ 0xC7, 0x00, /*'\x00c7'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x45, 0x00, 0x00, /*ToUInt64Key("Egrave")*/ 0xC8, 0x00, /*'\x00c8'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x45, 0x00, 0x00, /*ToUInt64Key("Eacute")*/ 0xC9, 0x00, /*'\x00c9'*/ - 0x63, 0x72, 0x69, 0x63, 0x45, 0x00, 0x00, 0x00, /*ToUInt64Key("Ecirc")*/ 0xCA, 0x00, /*'\x00ca'*/ - 0x6C, 0x6D, 0x75, 0x45, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Euml")*/ 0xCB, 0x00, /*'\x00cb'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x49, 0x00, 0x00, /*ToUInt64Key("Igrave")*/ 0xCC, 0x00, /*'\x00cc'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x49, 0x00, 0x00, /*ToUInt64Key("Iacute")*/ 0xCD, 0x00, /*'\x00cd'*/ - 0x63, 0x72, 0x69, 0x63, 0x49, 0x00, 0x00, 0x00, /*ToUInt64Key("Icirc")*/ 0xCE, 0x00, /*'\x00ce'*/ - 0x6C, 0x6D, 0x75, 0x49, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Iuml")*/ 0xCF, 0x00, /*'\x00cf'*/ - 0x48, 0x54, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ETH")*/ 0xD0, 0x00, /*'\x00d0'*/ - 0x65, 0x64, 0x6C, 0x69, 0x74, 0x4E, 0x00, 0x00, /*ToUInt64Key("Ntilde")*/ 0xD1, 0x00, /*'\x00d1'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x4F, 0x00, 0x00, /*ToUInt64Key("Ograve")*/ 0xD2, 0x00, /*'\x00d2'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x4F, 0x00, 0x00, /*ToUInt64Key("Oacute")*/ 0xD3, 0x00, /*'\x00d3'*/ - 0x63, 0x72, 0x69, 0x63, 0x4F, 0x00, 0x00, 0x00, /*ToUInt64Key("Ocirc")*/ 0xD4, 0x00, /*'\x00d4'*/ - 0x65, 0x64, 0x6C, 0x69, 0x74, 0x4F, 0x00, 0x00, /*ToUInt64Key("Otilde")*/ 0xD5, 0x00, /*'\x00d5'*/ - 0x6C, 0x6D, 0x75, 0x4F, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Ouml")*/ 0xD6, 0x00, /*'\x00d6'*/ - 0x73, 0x65, 0x6D, 0x69, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("times")*/ 0xD7, 0x00, /*'\x00d7'*/ - 0x68, 0x73, 0x61, 0x6C, 0x73, 0x4F, 0x00, 0x00, /*ToUInt64Key("Oslash")*/ 0xD8, 0x00, /*'\x00d8'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x55, 0x00, 0x00, /*ToUInt64Key("Ugrave")*/ 0xD9, 0x00, /*'\x00d9'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x55, 0x00, 0x00, /*ToUInt64Key("Uacute")*/ 0xDA, 0x00, /*'\x00da'*/ - 0x63, 0x72, 0x69, 0x63, 0x55, 0x00, 0x00, 0x00, /*ToUInt64Key("Ucirc")*/ 0xDB, 0x00, /*'\x00db'*/ - 0x6C, 0x6D, 0x75, 0x55, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Uuml")*/ 0xDC, 0x00, /*'\x00dc'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x59, 0x00, 0x00, /*ToUInt64Key("Yacute")*/ 0xDD, 0x00, /*'\x00dd'*/ - 0x4E, 0x52, 0x4F, 0x48, 0x54, 0x00, 0x00, 0x00, /*ToUInt64Key("THORN")*/ 0xDE, 0x00, /*'\x00de'*/ - 0x67, 0x69, 0x6C, 0x7A, 0x73, 0x00, 0x00, 0x00, /*ToUInt64Key("szlig")*/ 0xDF, 0x00, /*'\x00df'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x61, 0x00, 0x00, /*ToUInt64Key("agrave")*/ 0xE0, 0x00, /*'\x00e0'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x61, 0x00, 0x00, /*ToUInt64Key("aacute")*/ 0xE1, 0x00, /*'\x00e1'*/ - 0x63, 0x72, 0x69, 0x63, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("acirc")*/ 0xE2, 0x00, /*'\x00e2'*/ - 0x65, 0x64, 0x6C, 0x69, 0x74, 0x61, 0x00, 0x00, /*ToUInt64Key("atilde")*/ 0xE3, 0x00, /*'\x00e3'*/ - 0x6C, 0x6D, 0x75, 0x61, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("auml")*/ 0xE4, 0x00, /*'\x00e4'*/ - 0x67, 0x6E, 0x69, 0x72, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("aring")*/ 0xE5, 0x00, /*'\x00e5'*/ - 0x67, 0x69, 0x6C, 0x65, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("aelig")*/ 0xE6, 0x00, /*'\x00e6'*/ - 0x6C, 0x69, 0x64, 0x65, 0x63, 0x63, 0x00, 0x00, /*ToUInt64Key("ccedil")*/ 0xE7, 0x00, /*'\x00e7'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x65, 0x00, 0x00, /*ToUInt64Key("egrave")*/ 0xE8, 0x00, /*'\x00e8'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x65, 0x00, 0x00, /*ToUInt64Key("eacute")*/ 0xE9, 0x00, /*'\x00e9'*/ - 0x63, 0x72, 0x69, 0x63, 0x65, 0x00, 0x00, 0x00, /*ToUInt64Key("ecirc")*/ 0xEA, 0x00, /*'\x00ea'*/ - 0x6C, 0x6D, 0x75, 0x65, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("euml")*/ 0xEB, 0x00, /*'\x00eb'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x69, 0x00, 0x00, /*ToUInt64Key("igrave")*/ 0xEC, 0x00, /*'\x00ec'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x69, 0x00, 0x00, /*ToUInt64Key("iacute")*/ 0xED, 0x00, /*'\x00ed'*/ - 0x63, 0x72, 0x69, 0x63, 0x69, 0x00, 0x00, 0x00, /*ToUInt64Key("icirc")*/ 0xEE, 0x00, /*'\x00ee'*/ - 0x6C, 0x6D, 0x75, 0x69, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("iuml")*/ 0xEF, 0x00, /*'\x00ef'*/ - 0x68, 0x74, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("eth")*/ 0xF0, 0x00, /*'\x00f0'*/ - 0x65, 0x64, 0x6C, 0x69, 0x74, 0x6E, 0x00, 0x00, /*ToUInt64Key("ntilde")*/ 0xF1, 0x00, /*'\x00f1'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x6F, 0x00, 0x00, /*ToUInt64Key("ograve")*/ 0xF2, 0x00, /*'\x00f2'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x6F, 0x00, 0x00, /*ToUInt64Key("oacute")*/ 0xF3, 0x00, /*'\x00f3'*/ - 0x63, 0x72, 0x69, 0x63, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("ocirc")*/ 0xF4, 0x00, /*'\x00f4'*/ - 0x65, 0x64, 0x6C, 0x69, 0x74, 0x6F, 0x00, 0x00, /*ToUInt64Key("otilde")*/ 0xF5, 0x00, /*'\x00f5'*/ - 0x6C, 0x6D, 0x75, 0x6F, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ouml")*/ 0xF6, 0x00, /*'\x00f6'*/ - 0x65, 0x64, 0x69, 0x76, 0x69, 0x64, 0x00, 0x00, /*ToUInt64Key("divide")*/ 0xF7, 0x00, /*'\x00f7'*/ - 0x68, 0x73, 0x61, 0x6C, 0x73, 0x6F, 0x00, 0x00, /*ToUInt64Key("oslash")*/ 0xF8, 0x00, /*'\x00f8'*/ - 0x65, 0x76, 0x61, 0x72, 0x67, 0x75, 0x00, 0x00, /*ToUInt64Key("ugrave")*/ 0xF9, 0x00, /*'\x00f9'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x75, 0x00, 0x00, /*ToUInt64Key("uacute")*/ 0xFA, 0x00, /*'\x00fa'*/ - 0x63, 0x72, 0x69, 0x63, 0x75, 0x00, 0x00, 0x00, /*ToUInt64Key("ucirc")*/ 0xFB, 0x00, /*'\x00fb'*/ - 0x6C, 0x6D, 0x75, 0x75, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("uuml")*/ 0xFC, 0x00, /*'\x00fc'*/ - 0x65, 0x74, 0x75, 0x63, 0x61, 0x79, 0x00, 0x00, /*ToUInt64Key("yacute")*/ 0xFD, 0x00, /*'\x00fd'*/ - 0x6E, 0x72, 0x6F, 0x68, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("thorn")*/ 0xFE, 0x00, /*'\x00fe'*/ - 0x6C, 0x6D, 0x75, 0x79, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("yuml")*/ 0xFF, 0x00, /*'\x00ff'*/ - 0x67, 0x69, 0x6C, 0x45, 0x4F, 0x00, 0x00, 0x00, /*ToUInt64Key("OElig")*/ 0x52, 0x01, /*'\x0152'*/ - 0x67, 0x69, 0x6C, 0x65, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("oelig")*/ 0x53, 0x01, /*'\x0153'*/ - 0x6E, 0x6F, 0x72, 0x61, 0x63, 0x53, 0x00, 0x00, /*ToUInt64Key("Scaron")*/ 0x60, 0x01, /*'\x0160'*/ - 0x6E, 0x6F, 0x72, 0x61, 0x63, 0x73, 0x00, 0x00, /*ToUInt64Key("scaron")*/ 0x61, 0x01, /*'\x0161'*/ - 0x6C, 0x6D, 0x75, 0x59, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Yuml")*/ 0x78, 0x01, /*'\x0178'*/ - 0x66, 0x6F, 0x6E, 0x66, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("fnof")*/ 0x92, 0x01, /*'\x0192'*/ - 0x63, 0x72, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("circ")*/ 0xC6, 0x02, /*'\x02c6'*/ - 0x65, 0x64, 0x6C, 0x69, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("tilde")*/ 0xDC, 0x02, /*'\x02dc'*/ - 0x61, 0x68, 0x70, 0x6C, 0x41, 0x00, 0x00, 0x00, /*ToUInt64Key("Alpha")*/ 0x91, 0x03, /*'\x0391'*/ - 0x61, 0x74, 0x65, 0x42, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Beta")*/ 0x92, 0x03, /*'\x0392'*/ - 0x61, 0x6D, 0x6D, 0x61, 0x47, 0x00, 0x00, 0x00, /*ToUInt64Key("Gamma")*/ 0x93, 0x03, /*'\x0393'*/ - 0x61, 0x74, 0x6C, 0x65, 0x44, 0x00, 0x00, 0x00, /*ToUInt64Key("Delta")*/ 0x94, 0x03, /*'\x0394'*/ - 0x6E, 0x6F, 0x6C, 0x69, 0x73, 0x70, 0x45, 0x00, /*ToUInt64Key("Epsilon")*/ 0x95, 0x03, /*'\x0395'*/ - 0x61, 0x74, 0x65, 0x5A, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Zeta")*/ 0x96, 0x03, /*'\x0396'*/ - 0x61, 0x74, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Eta")*/ 0x97, 0x03, /*'\x0397'*/ - 0x61, 0x74, 0x65, 0x68, 0x54, 0x00, 0x00, 0x00, /*ToUInt64Key("Theta")*/ 0x98, 0x03, /*'\x0398'*/ - 0x61, 0x74, 0x6F, 0x49, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Iota")*/ 0x99, 0x03, /*'\x0399'*/ - 0x61, 0x70, 0x70, 0x61, 0x4B, 0x00, 0x00, 0x00, /*ToUInt64Key("Kappa")*/ 0x9A, 0x03, /*'\x039a'*/ - 0x61, 0x64, 0x62, 0x6D, 0x61, 0x4C, 0x00, 0x00, /*ToUInt64Key("Lambda")*/ 0x9B, 0x03, /*'\x039b'*/ - 0x75, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Mu")*/ 0x9C, 0x03, /*'\x039c'*/ - 0x75, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Nu")*/ 0x9D, 0x03, /*'\x039d'*/ - 0x69, 0x58, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Xi")*/ 0x9E, 0x03, /*'\x039e'*/ - 0x6E, 0x6F, 0x72, 0x63, 0x69, 0x6D, 0x4F, 0x00, /*ToUInt64Key("Omicron")*/ 0x9F, 0x03, /*'\x039f'*/ - 0x69, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Pi")*/ 0xA0, 0x03, /*'\x03a0'*/ - 0x6F, 0x68, 0x52, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Rho")*/ 0xA1, 0x03, /*'\x03a1'*/ - 0x61, 0x6D, 0x67, 0x69, 0x53, 0x00, 0x00, 0x00, /*ToUInt64Key("Sigma")*/ 0xA3, 0x03, /*'\x03a3'*/ - 0x75, 0x61, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Tau")*/ 0xA4, 0x03, /*'\x03a4'*/ - 0x6E, 0x6F, 0x6C, 0x69, 0x73, 0x70, 0x55, 0x00, /*ToUInt64Key("Upsilon")*/ 0xA5, 0x03, /*'\x03a5'*/ - 0x69, 0x68, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Phi")*/ 0xA6, 0x03, /*'\x03a6'*/ - 0x69, 0x68, 0x43, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Chi")*/ 0xA7, 0x03, /*'\x03a7'*/ - 0x69, 0x73, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("Psi")*/ 0xA8, 0x03, /*'\x03a8'*/ - 0x61, 0x67, 0x65, 0x6D, 0x4F, 0x00, 0x00, 0x00, /*ToUInt64Key("Omega")*/ 0xA9, 0x03, /*'\x03a9'*/ - 0x61, 0x68, 0x70, 0x6C, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("alpha")*/ 0xB1, 0x03, /*'\x03b1'*/ - 0x61, 0x74, 0x65, 0x62, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("beta")*/ 0xB2, 0x03, /*'\x03b2'*/ - 0x61, 0x6D, 0x6D, 0x61, 0x67, 0x00, 0x00, 0x00, /*ToUInt64Key("gamma")*/ 0xB3, 0x03, /*'\x03b3'*/ - 0x61, 0x74, 0x6C, 0x65, 0x64, 0x00, 0x00, 0x00, /*ToUInt64Key("delta")*/ 0xB4, 0x03, /*'\x03b4'*/ - 0x6E, 0x6F, 0x6C, 0x69, 0x73, 0x70, 0x65, 0x00, /*ToUInt64Key("epsilon")*/ 0xB5, 0x03, /*'\x03b5'*/ - 0x61, 0x74, 0x65, 0x7A, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("zeta")*/ 0xB6, 0x03, /*'\x03b6'*/ - 0x61, 0x74, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("eta")*/ 0xB7, 0x03, /*'\x03b7'*/ - 0x61, 0x74, 0x65, 0x68, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("theta")*/ 0xB8, 0x03, /*'\x03b8'*/ - 0x61, 0x74, 0x6F, 0x69, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("iota")*/ 0xB9, 0x03, /*'\x03b9'*/ - 0x61, 0x70, 0x70, 0x61, 0x6B, 0x00, 0x00, 0x00, /*ToUInt64Key("kappa")*/ 0xBA, 0x03, /*'\x03ba'*/ - 0x61, 0x64, 0x62, 0x6D, 0x61, 0x6C, 0x00, 0x00, /*ToUInt64Key("lambda")*/ 0xBB, 0x03, /*'\x03bb'*/ - 0x75, 0x6D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("mu")*/ 0xBC, 0x03, /*'\x03bc'*/ - 0x75, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("nu")*/ 0xBD, 0x03, /*'\x03bd'*/ - 0x69, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("xi")*/ 0xBE, 0x03, /*'\x03be'*/ - 0x6E, 0x6F, 0x72, 0x63, 0x69, 0x6D, 0x6F, 0x00, /*ToUInt64Key("omicron")*/ 0xBF, 0x03, /*'\x03bf'*/ - 0x69, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("pi")*/ 0xC0, 0x03, /*'\x03c0'*/ - 0x6F, 0x68, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rho")*/ 0xC1, 0x03, /*'\x03c1'*/ - 0x66, 0x61, 0x6D, 0x67, 0x69, 0x73, 0x00, 0x00, /*ToUInt64Key("sigmaf")*/ 0xC2, 0x03, /*'\x03c2'*/ - 0x61, 0x6D, 0x67, 0x69, 0x73, 0x00, 0x00, 0x00, /*ToUInt64Key("sigma")*/ 0xC3, 0x03, /*'\x03c3'*/ - 0x75, 0x61, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("tau")*/ 0xC4, 0x03, /*'\x03c4'*/ - 0x6E, 0x6F, 0x6C, 0x69, 0x73, 0x70, 0x75, 0x00, /*ToUInt64Key("upsilon")*/ 0xC5, 0x03, /*'\x03c5'*/ - 0x69, 0x68, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("phi")*/ 0xC6, 0x03, /*'\x03c6'*/ - 0x69, 0x68, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("chi")*/ 0xC7, 0x03, /*'\x03c7'*/ - 0x69, 0x73, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("psi")*/ 0xC8, 0x03, /*'\x03c8'*/ - 0x61, 0x67, 0x65, 0x6D, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("omega")*/ 0xC9, 0x03, /*'\x03c9'*/ - 0x6D, 0x79, 0x73, 0x61, 0x74, 0x65, 0x68, 0x74, /*ToUInt64Key("thetasym")*/0xD1, 0x03, /*'\x03d1'*/ - 0x68, 0x69, 0x73, 0x70, 0x75, 0x00, 0x00, 0x00, /*ToUInt64Key("upsih")*/ 0xD2, 0x03, /*'\x03d2'*/ - 0x76, 0x69, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("piv")*/ 0xD6, 0x03, /*'\x03d6'*/ - 0x70, 0x73, 0x6E, 0x65, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ensp")*/ 0x02, 0x20, /*'\x2002'*/ - 0x70, 0x73, 0x6D, 0x65, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("emsp")*/ 0x03, 0x20, /*'\x2003'*/ - 0x70, 0x73, 0x6E, 0x69, 0x68, 0x74, 0x00, 0x00, /*ToUInt64Key("thinsp")*/ 0x09, 0x20, /*'\x2009'*/ - 0x6A, 0x6E, 0x77, 0x7A, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("zwnj")*/ 0x0C, 0x20, /*'\x200c'*/ - 0x6A, 0x77, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("zwj")*/ 0x0D, 0x20, /*'\x200d'*/ - 0x6D, 0x72, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("lrm")*/ 0x0E, 0x20, /*'\x200e'*/ - 0x6D, 0x6C, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rlm")*/ 0x0F, 0x20, /*'\x200f'*/ - 0x68, 0x73, 0x61, 0x64, 0x6E, 0x00, 0x00, 0x00, /*ToUInt64Key("ndash")*/ 0x13, 0x20, /*'\x2013'*/ - 0x68, 0x73, 0x61, 0x64, 0x6D, 0x00, 0x00, 0x00, /*ToUInt64Key("mdash")*/ 0x14, 0x20, /*'\x2014'*/ - 0x6F, 0x75, 0x71, 0x73, 0x6C, 0x00, 0x00, 0x00, /*ToUInt64Key("lsquo")*/ 0x18, 0x20, /*'\x2018'*/ - 0x6F, 0x75, 0x71, 0x73, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("rsquo")*/ 0x19, 0x20, /*'\x2019'*/ - 0x6F, 0x75, 0x71, 0x62, 0x73, 0x00, 0x00, 0x00, /*ToUInt64Key("sbquo")*/ 0x1A, 0x20, /*'\x201a'*/ - 0x6F, 0x75, 0x71, 0x64, 0x6C, 0x00, 0x00, 0x00, /*ToUInt64Key("ldquo")*/ 0x1C, 0x20, /*'\x201c'*/ - 0x6F, 0x75, 0x71, 0x64, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("rdquo")*/ 0x1D, 0x20, /*'\x201d'*/ - 0x6F, 0x75, 0x71, 0x64, 0x62, 0x00, 0x00, 0x00, /*ToUInt64Key("bdquo")*/ 0x1E, 0x20, /*'\x201e'*/ - 0x72, 0x65, 0x67, 0x67, 0x61, 0x64, 0x00, 0x00, /*ToUInt64Key("dagger")*/ 0x20, 0x20, /*'\x2020'*/ - 0x72, 0x65, 0x67, 0x67, 0x61, 0x44, 0x00, 0x00, /*ToUInt64Key("Dagger")*/ 0x21, 0x20, /*'\x2021'*/ - 0x6C, 0x6C, 0x75, 0x62, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("bull")*/ 0x22, 0x20, /*'\x2022'*/ - 0x70, 0x69, 0x6C, 0x6C, 0x65, 0x68, 0x00, 0x00, /*ToUInt64Key("hellip")*/ 0x26, 0x20, /*'\x2026'*/ - 0x6C, 0x69, 0x6D, 0x72, 0x65, 0x70, 0x00, 0x00, /*ToUInt64Key("permil")*/ 0x30, 0x20, /*'\x2030'*/ - 0x65, 0x6D, 0x69, 0x72, 0x70, 0x00, 0x00, 0x00, /*ToUInt64Key("prime")*/ 0x32, 0x20, /*'\x2032'*/ - 0x65, 0x6D, 0x69, 0x72, 0x50, 0x00, 0x00, 0x00, /*ToUInt64Key("Prime")*/ 0x33, 0x20, /*'\x2033'*/ - 0x6F, 0x75, 0x71, 0x61, 0x73, 0x6C, 0x00, 0x00, /*ToUInt64Key("lsaquo")*/ 0x39, 0x20, /*'\x2039'*/ - 0x6F, 0x75, 0x71, 0x61, 0x73, 0x72, 0x00, 0x00, /*ToUInt64Key("rsaquo")*/ 0x3A, 0x20, /*'\x203a'*/ - 0x65, 0x6E, 0x69, 0x6C, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("oline")*/ 0x3E, 0x20, /*'\x203e'*/ - 0x6C, 0x73, 0x61, 0x72, 0x66, 0x00, 0x00, 0x00, /*ToUInt64Key("frasl")*/ 0x44, 0x20, /*'\x2044'*/ - 0x6F, 0x72, 0x75, 0x65, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("euro")*/ 0xAC, 0x20, /*'\x20ac'*/ - 0x65, 0x67, 0x61, 0x6D, 0x69, 0x00, 0x00, 0x00, /*ToUInt64Key("image")*/ 0x11, 0x21, /*'\x2111'*/ - 0x70, 0x72, 0x65, 0x69, 0x65, 0x77, 0x00, 0x00, /*ToUInt64Key("weierp")*/ 0x18, 0x21, /*'\x2118'*/ - 0x6C, 0x61, 0x65, 0x72, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("real")*/ 0x1C, 0x21, /*'\x211c'*/ - 0x65, 0x64, 0x61, 0x72, 0x74, 0x00, 0x00, 0x00, /*ToUInt64Key("trade")*/ 0x22, 0x21, /*'\x2122'*/ - 0x6D, 0x79, 0x73, 0x66, 0x65, 0x6C, 0x61, 0x00, /*ToUInt64Key("alefsym")*/ 0x35, 0x21, /*'\x2135'*/ - 0x72, 0x72, 0x61, 0x6C, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("larr")*/ 0x90, 0x21, /*'\x2190'*/ - 0x72, 0x72, 0x61, 0x75, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("uarr")*/ 0x91, 0x21, /*'\x2191'*/ - 0x72, 0x72, 0x61, 0x72, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rarr")*/ 0x92, 0x21, /*'\x2192'*/ - 0x72, 0x72, 0x61, 0x64, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("darr")*/ 0x93, 0x21, /*'\x2193'*/ - 0x72, 0x72, 0x61, 0x68, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("harr")*/ 0x94, 0x21, /*'\x2194'*/ - 0x72, 0x72, 0x61, 0x72, 0x63, 0x00, 0x00, 0x00, /*ToUInt64Key("crarr")*/ 0xB5, 0x21, /*'\x21b5'*/ - 0x72, 0x72, 0x41, 0x6C, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("lArr")*/ 0xD0, 0x21, /*'\x21d0'*/ - 0x72, 0x72, 0x41, 0x75, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("uArr")*/ 0xD1, 0x21, /*'\x21d1'*/ - 0x72, 0x72, 0x41, 0x72, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rArr")*/ 0xD2, 0x21, /*'\x21d2'*/ - 0x72, 0x72, 0x41, 0x64, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("dArr")*/ 0xD3, 0x21, /*'\x21d3'*/ - 0x72, 0x72, 0x41, 0x68, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("hArr")*/ 0xD4, 0x21, /*'\x21d4'*/ - 0x6C, 0x6C, 0x61, 0x72, 0x6F, 0x66, 0x00, 0x00, /*ToUInt64Key("forall")*/ 0x00, 0x22, /*'\x2200'*/ - 0x74, 0x72, 0x61, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("part")*/ 0x02, 0x22, /*'\x2202'*/ - 0x74, 0x73, 0x69, 0x78, 0x65, 0x00, 0x00, 0x00, /*ToUInt64Key("exist")*/ 0x03, 0x22, /*'\x2203'*/ - 0x79, 0x74, 0x70, 0x6D, 0x65, 0x00, 0x00, 0x00, /*ToUInt64Key("empty")*/ 0x05, 0x22, /*'\x2205'*/ - 0x61, 0x6C, 0x62, 0x61, 0x6E, 0x00, 0x00, 0x00, /*ToUInt64Key("nabla")*/ 0x07, 0x22, /*'\x2207'*/ - 0x6E, 0x69, 0x73, 0x69, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("isin")*/ 0x08, 0x22, /*'\x2208'*/ - 0x6E, 0x69, 0x74, 0x6F, 0x6E, 0x00, 0x00, 0x00, /*ToUInt64Key("notin")*/ 0x09, 0x22, /*'\x2209'*/ - 0x69, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ni")*/ 0x0B, 0x22, /*'\x220b'*/ - 0x64, 0x6F, 0x72, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("prod")*/ 0x0F, 0x22, /*'\x220f'*/ - 0x6D, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sum")*/ 0x11, 0x22, /*'\x2211'*/ - 0x73, 0x75, 0x6E, 0x69, 0x6D, 0x00, 0x00, 0x00, /*ToUInt64Key("minus")*/ 0x12, 0x22, /*'\x2212'*/ - 0x74, 0x73, 0x61, 0x77, 0x6F, 0x6C, 0x00, 0x00, /*ToUInt64Key("lowast")*/ 0x17, 0x22, /*'\x2217'*/ - 0x63, 0x69, 0x64, 0x61, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("radic")*/ 0x1A, 0x22, /*'\x221a'*/ - 0x70, 0x6F, 0x72, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("prop")*/ 0x1D, 0x22, /*'\x221d'*/ - 0x6E, 0x69, 0x66, 0x6E, 0x69, 0x00, 0x00, 0x00, /*ToUInt64Key("infin")*/ 0x1E, 0x22, /*'\x221e'*/ - 0x67, 0x6E, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ang")*/ 0x20, 0x22, /*'\x2220'*/ - 0x64, 0x6E, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("and")*/ 0x27, 0x22, /*'\x2227'*/ - 0x72, 0x6F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("or")*/ 0x28, 0x22, /*'\x2228'*/ - 0x70, 0x61, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("cap")*/ 0x29, 0x22, /*'\x2229'*/ - 0x70, 0x75, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("cup")*/ 0x2A, 0x22, /*'\x222a'*/ - 0x74, 0x6E, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("int")*/ 0x2B, 0x22, /*'\x222b'*/ - 0x34, 0x65, 0x72, 0x65, 0x68, 0x74, 0x00, 0x00, /*ToUInt64Key("there4")*/ 0x34, 0x22, /*'\x2234'*/ - 0x6D, 0x69, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sim")*/ 0x3C, 0x22, /*'\x223c'*/ - 0x67, 0x6E, 0x6F, 0x63, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("cong")*/ 0x45, 0x22, /*'\x2245'*/ - 0x70, 0x6D, 0x79, 0x73, 0x61, 0x00, 0x00, 0x00, /*ToUInt64Key("asymp")*/ 0x48, 0x22, /*'\x2248'*/ - 0x65, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ne")*/ 0x60, 0x22, /*'\x2260'*/ - 0x76, 0x69, 0x75, 0x71, 0x65, 0x00, 0x00, 0x00, /*ToUInt64Key("equiv")*/ 0x61, 0x22, /*'\x2261'*/ - 0x65, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("le")*/ 0x64, 0x22, /*'\x2264'*/ - 0x65, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("ge")*/ 0x65, 0x22, /*'\x2265'*/ - 0x62, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sub")*/ 0x82, 0x22, /*'\x2282'*/ - 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sup")*/ 0x83, 0x22, /*'\x2283'*/ - 0x62, 0x75, 0x73, 0x6E, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("nsub")*/ 0x84, 0x22, /*'\x2284'*/ - 0x65, 0x62, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sube")*/ 0x86, 0x22, /*'\x2286'*/ - 0x65, 0x70, 0x75, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("supe")*/ 0x87, 0x22, /*'\x2287'*/ - 0x73, 0x75, 0x6C, 0x70, 0x6F, 0x00, 0x00, 0x00, /*ToUInt64Key("oplus")*/ 0x95, 0x22, /*'\x2295'*/ - 0x73, 0x65, 0x6D, 0x69, 0x74, 0x6F, 0x00, 0x00, /*ToUInt64Key("otimes")*/ 0x97, 0x22, /*'\x2297'*/ - 0x70, 0x72, 0x65, 0x70, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("perp")*/ 0xA5, 0x22, /*'\x22a5'*/ - 0x74, 0x6F, 0x64, 0x73, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("sdot")*/ 0xC5, 0x22, /*'\x22c5'*/ - 0x6C, 0x69, 0x65, 0x63, 0x6C, 0x00, 0x00, 0x00, /*ToUInt64Key("lceil")*/ 0x08, 0x23, /*'\x2308'*/ - 0x6C, 0x69, 0x65, 0x63, 0x72, 0x00, 0x00, 0x00, /*ToUInt64Key("rceil")*/ 0x09, 0x23, /*'\x2309'*/ - 0x72, 0x6F, 0x6F, 0x6C, 0x66, 0x6C, 0x00, 0x00, /*ToUInt64Key("lfloor")*/ 0x0A, 0x23, /*'\x230a'*/ - 0x72, 0x6F, 0x6F, 0x6C, 0x66, 0x72, 0x00, 0x00, /*ToUInt64Key("rfloor")*/ 0x0B, 0x23, /*'\x230b'*/ - 0x67, 0x6E, 0x61, 0x6C, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("lang")*/ 0x29, 0x23, /*'\x2329'*/ - 0x67, 0x6E, 0x61, 0x72, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("rang")*/ 0x2A, 0x23, /*'\x232a'*/ - 0x7A, 0x6F, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, /*ToUInt64Key("loz")*/ 0xCA, 0x25, /*'\x25ca'*/ - 0x73, 0x65, 0x64, 0x61, 0x70, 0x73, 0x00, 0x00, /*ToUInt64Key("spades")*/ 0x60, 0x26, /*'\x2660'*/ - 0x73, 0x62, 0x75, 0x6C, 0x63, 0x00, 0x00, 0x00, /*ToUInt64Key("clubs")*/ 0x63, 0x26, /*'\x2663'*/ - 0x73, 0x74, 0x72, 0x61, 0x65, 0x68, 0x00, 0x00, /*ToUInt64Key("hearts")*/ 0x65, 0x26, /*'\x2665'*/ - 0x73, 0x6D, 0x61, 0x69, 0x64, 0x00, 0x00, 0x00, /*ToUInt64Key("diams")*/ 0x66, 0x26, /*'\x2666'*/ - ]; - - var dictionary = new Dictionary(tableData.Length / (sizeof(ulong) + sizeof(char))); - while (!tableData.IsEmpty) - { - ulong key = BinaryPrimitives.ReadUInt64LittleEndian(tableData); - char value = (char) BinaryPrimitives.ReadUInt16LittleEndian(tableData.Slice(sizeof(ulong))); - dictionary[key] = value; - tableData = tableData.Slice(sizeof(ulong) + sizeof(char)); - } - -#if NET5_0_OR_GREATER - return dictionary.ToFrozenDictionary(); -#else - return dictionary; -#endif - } - - public static bool Lookup(ReadOnlySpan entity, out char result) - { - // To avoid an allocation, keys of type "ulong" are used in the lookup table. - // Since all entity strings comprise 8 characters or less and are ASCII-only, they "fit" into an ulong (8 bytes). - if (entity.Length <= 8) - { - return lookupTable.TryGetValue(ToUInt64Key(entity), out result); - } - - // Currently, there are no entities that are longer than 8 characters. - result = '\0'; - return false; - } - - private static ulong ToUInt64Key(ReadOnlySpan entity) - { - // The ulong key is the reversed single-byte character representation of the actual entity string. - ulong key = 0; - for (int i = 0; i < entity.Length; i++) - { - if (entity[i] > 0xFF) - { - return 0; - } - - key = (key << 8) | entity[i]; - } - - return key; - } - } -} \ No newline at end of file diff --git a/src/Html2OpenXml/Utilities/HttpUtility.cs b/src/Html2OpenXml/Utilities/HttpUtility.cs deleted file mode 100755 index fee41be7..00000000 --- a/src/Html2OpenXml/Utilities/HttpUtility.cs +++ /dev/null @@ -1,240 +0,0 @@ -/* Copyright (C) Olivier Nizet https://github.com/onizet/html2openxml - All Rights Reserved - * - * This source is subject to the Microsoft Permissive License. - * Please see the License.txt file for more information. - * All other rights reserved. - * - * THIS CODE AND INFORMATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY - * KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A - * PARTICULAR PURPOSE. - */ -using System.Buffers; -using System.Globalization; -using System.Runtime.CompilerServices; - -namespace HtmlToOpenXml; - -/// -/// Custom utility providing compatible encoding and decoding functionality. -/// This implementation embraces the modern API Span<char> for maximum efficiency. -/// -/// -/// For .Net 462 and .Net Standard 2.0, this implementation avoid reliance on the legacy System.Web library. -/// -/// -static partial class HttpUtility -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsSemiColonEntity(ReadOnlySpan span, out int charsConsumed) - { - if (span[0] == ';') - { - charsConsumed = 1; - return true; - } - if (span.StartsWith(['&','#','5','9',';'])) - { - charsConsumed = 5; - return true; - } - else if (span.StartsWith(";", StringComparison.OrdinalIgnoreCase)) - { - charsConsumed = 6; - return true; - } - else if (span.StartsWith(";", StringComparison.OrdinalIgnoreCase)) - { - charsConsumed = 6; - return true; - } - - charsConsumed = 0; - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsColonEntity(ReadOnlySpan span, out int charsConsumed) - { - if (span[0] == ':') - { - charsConsumed = 1; - return true; - } - else if (span.StartsWith(['&','#','5','8',';'])) - { - charsConsumed = 5; - return true; - } - else if (span.StartsWith(":", StringComparison.OrdinalIgnoreCase)) - { - charsConsumed = 6; - return true; - } - else if (span.StartsWith(":", StringComparison.OrdinalIgnoreCase)) - { - charsConsumed = 7; - return true; - } - - charsConsumed = 0; - return false; - } - - /// - /// Converts a string that has been HTML-encoded for HTTP transmission into a decoded string. - /// - public static string HtmlDecode(ReadOnlySpan span) - { - if (span.IsEmpty) - return string.Empty; - - if (span.IndexOf('&') < 0) - return span.ToString(); - - var buffer = ArrayPool.Shared.Rent(span.Length); - string result; - try - { - var length = HtmlDecode(span, buffer); - result = new String(buffer, 0, length); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - return result; - } - - private static int HtmlDecode(ReadOnlySpan span, char[] buffer) - { - var length = 0; - - while (!span.IsEmpty) - { - int ampIndex = span.IndexOf('&'), - endIndex; - - if (ampIndex == -1 || ((endIndex = span.Slice(ampIndex+1).IndexOfAny(';', '&')) == -1)) - { - // Dangling '&' at EOF or sequence end. Treat it literally. - span.CopyTo(buffer.AsSpan(length)); - length += span.Length; - break; - } - - endIndex += ampIndex + 1; - - // have we found another '&' while expecting ';'? If so, this is not an entity - // but this new ampersan might be - if (span[endIndex] == '&') - { - span.Slice(0, endIndex).CopyTo(buffer.AsSpan(length)); - length += endIndex; - span = span.Slice(endIndex); - continue; - } - - span.Slice(0, ampIndex).CopyTo(buffer.AsSpan(length)); - length += ampIndex; - - var entity = span.Slice(ampIndex + 1, endIndex - ampIndex - 1); - if (entity.Length > 0 && entity[0] == '#') - { - if (TryDecodeNumericEntity(entity, out uint codePoint)) - { - if (System.Text.Rune.IsValid(codePoint)) - { - length += new System.Text.Rune(codePoint) - .EncodeToUtf16(buffer.AsSpan(length)); - } - - span = span.Slice(entity.Length + 1); - continue; - } - - buffer[length++] = '&'; - span = span.Slice(1); - continue; - } - - if (TryDecodeNamedEntity(entity, out var ch)) - buffer[length++] = ch; - else - { - span.Slice(endIndex).CopyTo(buffer.AsSpan(length)); - length += endIndex; - } - - // move to next token - span = span.Slice(endIndex + 1); - } - - return length; - } - - /// - /// Try to parse an HTML nnumeric entity and returns its Unicode code point. - /// - /// The entity text without the surrounding & and ;. - /// The Unicode point representing that entity. - private static bool TryDecodeNumericEntity(ReadOnlySpan entity, out uint codePoint) - { - if (entity.Length < 2 || entity[0] != '#') - { - codePoint = 0; - return false; - } - - NumberStyles numberStyles = NumberStyles.Integer; - if (entity[1] is 'x' or 'X') - { - numberStyles = NumberStyles.AllowHexSpecifier; - entity = entity.Slice(2); - } - else - { - entity = entity.Slice(1); - } - - return uint.TryParse( -#if NET5_0_OR_GREATER - entity, -#else - entity.ToString(), -#endif - numberStyles, CultureInfo.InvariantCulture, out codePoint); - } - - /// - /// Try to parse UTF8 - /// - /// - /// - /// - private static bool TryDecodeNamedEntity(ReadOnlySpan entity, out char ch) - { - // avoid to allocate a new string for most common entities - // and this is faster to use this pattern matching - ch = entity switch { - "amp" => '&', - "lt" => '<', - "gt" => '>', - "quot" => '"', - "apos" => '\'', - "nbsp" => '\x00a0', - "colon" => ':', - "semi" => ';', - "copy" => '©', - "reg" => '®', - "trade" => '™', - _ => '\0' - }; - - if (ch != '\0') - return true; - - // fallback on the hard-coded list - return HtmlEntities.Lookup(entity, out ch); - } -} \ No newline at end of file diff --git a/src/Html2OpenXml/Utilities/SpanExtensions.cs b/src/Html2OpenXml/Utilities/SpanExtensions.cs index df855dab..38a4f80f 100644 --- a/src/Html2OpenXml/Utilities/SpanExtensions.cs +++ b/src/Html2OpenXml/Utilities/SpanExtensions.cs @@ -115,8 +115,8 @@ public static int SplitCompositeAttribute(this ReadOnlySpan span, Span span, Span private static bool IsRecognizedEntity(ReadOnlySpan span, out EntityMatchResult result) { - // look for double quote - if (span.StartsWith(""".AsSpan())) + int charsConsumed = HtmlEntities.IsDoubleQuote(span); + if (charsConsumed > 0) { - result = new EntityMatchResult(5, '"'); - return true; - } - else if (span.StartsWith(""".AsSpan())) - { - result = new EntityMatchResult(4, '"'); - return true; - } - else if (span.StartsWith(""".AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - result = new EntityMatchResult(5, '"'); + result = new EntityMatchResult(charsConsumed, '"'); return true; } - // look for single quote - if (span.StartsWith("'".AsSpan())) - { - result = new EntityMatchResult(5, '\''); - return true; - } - else if (span.StartsWith("'".AsSpan())) - { - result = new EntityMatchResult(4, '\''); - return true; - } - else if (span.StartsWith("'".AsSpan(), StringComparison.OrdinalIgnoreCase)) + charsConsumed = HtmlEntities.IsSingleQuote(span); + if (charsConsumed > 0) { - result = new EntityMatchResult(5, '\''); + result = new EntityMatchResult(charsConsumed, '\''); return true; } diff --git a/test/HtmlToOpenXml.Tests/Primitives/HttpUtilityTests.cs b/test/HtmlToOpenXml.Tests/Primitives/HttpUtilityTests.cs deleted file mode 100644 index e7b03f53..00000000 --- a/test/HtmlToOpenXml.Tests/Primitives/HttpUtilityTests.cs +++ /dev/null @@ -1,23 +0,0 @@ -using NUnit.Framework; - -namespace HtmlToOpenXml.Tests.Primitives; - -/// -/// Tests inline image (base 64). -/// -[TestFixture] -public class HttpUtilityTests -{ - [TestCase("Apples & Bananas <3 Here!", "Apples & Bananas <3 Here!")] - [TestCase("Apples ¬_an_entity &", "Apples ¬_an_entity &")] - [TestCase("<Tag> & \"Quotes\" here.", " & \"Quotes\" here.", Description = "All 5 most common entities")] - [TestCase("A line with (&#38;).", "A line with (-).", Description = "Numeric entities")] - [TestCase("<a href="#page&&2">Click Here</a>", "Click Here", Description = "Mixed encoding")] - [TestCase("This text has no codes.", "This text has no codes.", Description = "No entities present, idempotency check")] - [TestCase("One»Two", "One»Two")] - public void Decode_ShouldSucceed(string encoded, string expected) - { - string actual = HtmlToOpenXml.HttpUtility.HtmlDecode(encoded); - Assert.That(actual, Is.EqualTo(expected)); - } -} diff --git a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs index b8cda77e..1b93d3d8 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs @@ -56,5 +56,21 @@ public void WithMultipleTextDecoration_ReturnsAllValues() var decorations = styles.GetTextDecorations("text-decoration"); Assert.That(decorations, Is.EquivalentTo([TextDecoration.Underline, TextDecoration.Dotted, TextDecoration.Wave])); } + + [Test(Description = "Encoded ':' and ';' characters are valid")] + public void EncodedStyle_ShouldSucceed() + { + var styleAttributes = HtmlAttributeCollection.ParseStyle("text-decoration:underline;color:red"); + Assert.That(styleAttributes["text-decoration"].ToString(), Is.EqualTo("underline")); + Assert.That(styleAttributes["color"].ToString(), Is.EqualTo("red")); + } + + [Test(Description = "Key style with no value should be ignored")] + public void EmptyStyle_ShouldBeIgnored() + { + var styleAttributes = HtmlAttributeCollection.ParseStyle("text-decoration;color:red"); + Assert.That(styleAttributes.TryGetValue("text-decoration", out var _), Is.False); + Assert.That(styleAttributes["color"].ToString(), Is.EqualTo("red")); + } } } diff --git a/test/HtmlToOpenXml.Tests/StyleTests.cs b/test/HtmlToOpenXml.Tests/StyleTests.cs index 3c1dd2eb..693e9afb 100644 --- a/test/HtmlToOpenXml.Tests/StyleTests.cs +++ b/test/HtmlToOpenXml.Tests/StyleTests.cs @@ -158,28 +158,5 @@ public void ManualAddStyle_ThenRefreshStyles_ShouldSucceed() Assert.That(paragraph.ParagraphProperties, Is.Not.Null); Assert.That(paragraph.ParagraphProperties?.ParagraphStyleId?.Val?.Value, Is.EqualTo("CustomIntenseQuoteStyle")); } - - [Test(Description = "Parser should consider the last occurence of a style")] - public void DuplicateStyle_ReturnsLatter() - { - var styleAttributes = HtmlAttributeCollection.ParseStyle("color:red;color:blue"); - Assert.That(styleAttributes["color"].ToString(), Is.EqualTo("blue")); - } - - [Test(Description = "Encoded ':' and ';' characters are valid")] - public void EncodedStyle_ShouldSucceed() - { - var styleAttributes = HtmlAttributeCollection.ParseStyle("text-decoration:underline;color:red"); - Assert.That(styleAttributes["text-decoration"].ToString(), Is.EqualTo("underline")); - Assert.That(styleAttributes["color"].ToString(), Is.EqualTo("red")); - } - - [Test(Description = "Key style with no value should be ignored")] - public void EmptyStyle_ShouldBeIgnored() - { - var styleAttributes = HtmlAttributeCollection.ParseStyle("text-decoration;color:red"); - Assert.That(styleAttributes.TryGetValue("text-decoration", out var _), Is.False); - Assert.That(styleAttributes["color"].ToString(), Is.EqualTo("red")); - } } } From 5d9b45b9ef586b3341c655c24cb28c4081dfd219 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Fri, 23 Oct 2026 09:26:39 +0200 Subject: [PATCH 13/15] Refactor HtmlAttributeCollection.Parse to rely on the escape-aware parsing from SplitCompositeAttribute --- .../Collections/HtmlAttributeCollection.cs | 64 ++++----- src/Html2OpenXml/Utilities/HtmlEntities.cs | 122 +++++++++++++----- src/Html2OpenXml/Utilities/SpanExtensions.cs | 46 +++---- .../Primitives/StyleParserTests.cs | 13 ++ 4 files changed, 140 insertions(+), 105 deletions(-) diff --git a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs index cda0dd6b..667fbe91 100755 --- a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs +++ b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs @@ -38,54 +38,38 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) var collection = new HtmlAttributeCollection(htmlStyles!); if (string.IsNullOrWhiteSpace(htmlStyles)) return collection; - var span = htmlStyles.AsSpan(); - int startIndex = 0, charsConsumed; - bool foundKey = false; - string? key = null; + int startIndex = 0; + var stylesSpan = htmlStyles.AsSpan(); - while (span.Length > 0) + Span tokens = stackalloc Range[20]; + var tokenCount = stylesSpan.SplitCompositeAttribute(tokens, ';'); + if (tokenCount == 0) + return collection; + + for (int i = 0; i < tokenCount; i++) { // Encoded ':' and ';' characters are valid for browser // - int index = span.IndexOfAny(';', '&', ':'); + var segmentSpan = stylesSpan[tokens[i]]; + int index = segmentSpan.IndexOfAny('&', ':'); + int charsConsumed = 0; + if (index == -1) - { - if (foundKey) - { - // process the last value - collection.attributes[key!] = new Range(startIndex, startIndex + span.Length); - } - break; - } + continue; // no key:value -> ignore this style - var separator = span[index]; - if (foundKey && (charsConsumed = HtmlEntities.IsSemiColon(span.Slice(index))) > 0) - { - if (index > 0) - collection.attributes[key!] = new Range(startIndex, startIndex + index); - foundKey = false; - index += charsConsumed; - } - else if (separator == ';' && !foundKey) - { - // unexpected semicolon (ie, key with no value) -> ignore this style - index++; - } - else if (!foundKey && (charsConsumed = HtmlEntities.IsColon(span.Slice(index))) > 0) - { - key = span.Slice(0, index).Trim().ToString(); - foundKey = true; - index += charsConsumed; - } - else + if ((charsConsumed = HtmlEntities.IsColon(segmentSpan.Slice(index))) > 0) { - span = span.Slice(index + 1); - startIndex += index + 1; - continue; - } + var key = segmentSpan.Slice(0, index).Trim(); + startIndex = index + charsConsumed; - span = span.Slice(index); - startIndex += index; + if (key.Length == 0) continue; // empty key + + var rng = new Range(tokens[i].Start.Value + startIndex, tokens[i].End); + if (rng.Start.Value == rng.End.Value) continue; // empty value + + var value = stylesSpan[rng].ToString(); + collection.attributes[key.ToString()] = rng; + } } return collection; diff --git a/src/Html2OpenXml/Utilities/HtmlEntities.cs b/src/Html2OpenXml/Utilities/HtmlEntities.cs index 17696e37..49714438 100755 --- a/src/Html2OpenXml/Utilities/HtmlEntities.cs +++ b/src/Html2OpenXml/Utilities/HtmlEntities.cs @@ -9,6 +9,7 @@ * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A * PARTICULAR PURPOSE. */ +using System.Globalization; using System.Runtime.CompilerServices; namespace HtmlToOpenXml; @@ -19,22 +20,69 @@ namespace HtmlToOpenXml; /// static partial class HtmlEntities { - /// Returns the number of consumed or 0 if not matched. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int IsSemiColon(ReadOnlySpan span) + /// The number of consumed or 0 if not matched. + /// The decoded character. + public readonly record struct EntityMatchResult(int EncodedLength, char DecodedChar) { - return span switch { - var s when s[0] == ';' => 1, - var s when s.StartsWith(";") => 5, - var s when s.StartsWith(";", StringComparison.OrdinalIgnoreCase) - || s.StartsWith(";", StringComparison.OrdinalIgnoreCase) => 6, - _ => 0 - }; + public static readonly EntityMatchResult Nil = new(0, '\0'); + } + + /// + /// Try to detect encoded html entities such as single or double quote. + /// + public static bool IsRecognizedEntity(ReadOnlySpan span, out EntityMatchResult result) + { + var semiIndex = span.IndexOf(';'); + if (semiIndex == -1) + { + result = EntityMatchResult.Nil; + return false; + } + + span = span.Slice(0, semiIndex + 1); + + // fast path for common named entities + if (span.Equals(""", StringComparison.OrdinalIgnoreCase)) + { + result = new EntityMatchResult(6, '"'); + return true; + } + else if (span.Equals("'", StringComparison.OrdinalIgnoreCase)) + { + result = new EntityMatchResult(6, '\''); + return true; + } + else if (span.Equals(":", StringComparison.OrdinalIgnoreCase)) + { + result = new EntityMatchResult(7, ':'); + return true; + } + else if (span.Equals(";", StringComparison.OrdinalIgnoreCase)) + { + result = new EntityMatchResult(6, ';'); + return true; + } + else if (span.Equals(" ", StringComparison.OrdinalIgnoreCase)) + { + result = new EntityMatchResult(6, ' '); + return true; + } + + if (TryDecodeNumericEntity(span, out var codePoint) + // not using System.Text.Rune.IsValid() because this parser is only interested + // in a minimal subset: ; : ( ) " ' + && codePoint <= char.MaxValue) + { + result = new EntityMatchResult(span.Length, (char) codePoint); + return true; + } + + result = EntityMatchResult.Nil; + return false; } - /// Returns the number of consumed or 0 if not matched. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int IsColon(ReadOnlySpan span) + internal static int IsColon(ReadOnlySpan span) { return span switch { var s when s[0] == ':' => 1, @@ -45,29 +93,35 @@ public static int IsColon(ReadOnlySpan span) }; } - /// Returns the number of consumed or 0 if not matched. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int IsDoubleQuote(ReadOnlySpan span) + /// + /// Try to parse an HTML nnumeric entity and returns its Unicode code point. + /// + /// The entity text without the surrounding & and ;. + /// The Unicode point representing that entity. + private static bool TryDecodeNumericEntity(ReadOnlySpan entity, out uint codePoint) { - return span switch { - var s when s[0] == '"' => 1, - var s when s.StartsWith(""") => 5, - var s when s.StartsWith(""", StringComparison.OrdinalIgnoreCase) - || s.StartsWith(""", StringComparison.OrdinalIgnoreCase) => 6, - _ => 0 - }; - } + if (entity.Length < 4 || entity[1] != '#') + { + codePoint = 0; + return false; + } - /// Returns the number of consumed or 0 if not matched. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int IsSingleQuote(ReadOnlySpan span) - { - return span switch { - var s when s[0] == '"' => 1, - var s when s.StartsWith("'") => 5, - var s when s.StartsWith("'", StringComparison.OrdinalIgnoreCase) - || s.StartsWith("'", StringComparison.OrdinalIgnoreCase) => 6, - _ => 0 - }; + NumberStyles numberStyles = NumberStyles.Integer; + int start = 2; + if (entity[2] is 'x' or 'X') + { + numberStyles = NumberStyles.AllowHexSpecifier; + start = 3; + } + + entity = entity.Slice(start, entity.Length - 1 - start); + + return uint.TryParse( +#if NET5_0_OR_GREATER + entity, +#else + entity.ToString(), +#endif + numberStyles, CultureInfo.InvariantCulture, out codePoint); } } \ No newline at end of file diff --git a/src/Html2OpenXml/Utilities/SpanExtensions.cs b/src/Html2OpenXml/Utilities/SpanExtensions.cs index 38a4f80f..9848166e 100644 --- a/src/Html2OpenXml/Utilities/SpanExtensions.cs +++ b/src/Html2OpenXml/Utilities/SpanExtensions.cs @@ -110,19 +110,22 @@ public static int SplitCompositeAttribute(this ReadOnlySpan span, Span span, Span span, Span - /// Try to detect encoded html entities such as single or double quote. - /// - private static bool IsRecognizedEntity(ReadOnlySpan span, out EntityMatchResult result) - { - int charsConsumed = HtmlEntities.IsDoubleQuote(span); - if (charsConsumed > 0) - { - result = new EntityMatchResult(charsConsumed, '"'); - return true; - } - - charsConsumed = HtmlEntities.IsSingleQuote(span); - if (charsConsumed > 0) - { - result = new EntityMatchResult(charsConsumed, '\''); - return true; - } - - result = new EntityMatchResult(0, '\0'); - return false; - } - - /// The length of the encoded entity without the '&'. - /// The decoded character. - record struct EntityMatchResult(int EncodedLength, char DecodedChar); + private static bool IsEscapable(char c) + => c is '(' or '"' or '\''; } diff --git a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs index 1b93d3d8..b03090f7 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs @@ -57,6 +57,19 @@ public void WithMultipleTextDecoration_ReturnsAllValues() Assert.That(decorations, Is.EquivalentTo([TextDecoration.Underline, TextDecoration.Dotted, TextDecoration.Wave])); } + [Test] + public void WithEmbedSemicolons_ReturnsLegitimateUrl() + { + var styles = HtmlAttributeCollection.ParseStyle("background:url(data:image/svg+xml;base64;iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==)"); + var exist = styles.TryGetValue("background", out var imageUrl); + Assert.That(exist, Is.True); + if (exist) + { + var success = IO.DataUri.TryCreateImage(imageUrl.ToString(), out var _); + Assert.That(success, Is.True); + } + } + [Test(Description = "Encoded ':' and ';' characters are valid")] public void EncodedStyle_ShouldSucceed() { From 6821a440cbe5511c677ceabac61d9d88bb36e423 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Fri, 23 Oct 2026 16:07:35 +0200 Subject: [PATCH 14/15] Rewrite the logic of SplitCompositeAttribute to use a loop, leading to a more clean and maintainable code (not relying on abs vs relative index). --- .../Collections/HtmlAttributeCollection.cs | 7 +- src/Html2OpenXml/Primitives/HtmlFont.cs | 2 +- src/Html2OpenXml/Primitives/Margin.cs | 2 +- src/Html2OpenXml/Primitives/SideBorder.cs | 2 +- src/Html2OpenXml/Utilities/Converter.cs | 2 +- src/Html2OpenXml/Utilities/SpanExtensions.cs | 151 +++++++++--------- .../Primitives/StyleParserTests.cs | 8 +- 7 files changed, 86 insertions(+), 88 deletions(-) diff --git a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs index 667fbe91..5c278c15 100755 --- a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs +++ b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs @@ -38,7 +38,6 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) var collection = new HtmlAttributeCollection(htmlStyles!); if (string.IsNullOrWhiteSpace(htmlStyles)) return collection; - int startIndex = 0; var stylesSpan = htmlStyles.AsSpan(); Span tokens = stackalloc Range[20]; @@ -60,14 +59,12 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) if ((charsConsumed = HtmlEntities.IsColon(segmentSpan.Slice(index))) > 0) { var key = segmentSpan.Slice(0, index).Trim(); - startIndex = index + charsConsumed; + var startIndex = index + charsConsumed; if (key.Length == 0) continue; // empty key + if (segmentSpan.Length <= startIndex + 1) continue; // empty value var rng = new Range(tokens[i].Start.Value + startIndex, tokens[i].End); - if (rng.Start.Value == rng.End.Value) continue; // empty value - - var value = stylesSpan[rng].ToString(); collection.attributes[key.ToString()] = rng; } } diff --git a/src/Html2OpenXml/Primitives/HtmlFont.cs b/src/Html2OpenXml/Primitives/HtmlFont.cs index f9797b52..57c90b7e 100755 --- a/src/Html2OpenXml/Primitives/HtmlFont.cs +++ b/src/Html2OpenXml/Primitives/HtmlFont.cs @@ -46,7 +46,7 @@ public static HtmlFont Parse(ReadOnlySpan span) if (span.IsEmpty || span.Length < 2) return Empty; - Span tokens = stackalloc Range[6]; + Span tokens = stackalloc Range[10]; var tokenCount = span.SplitCompositeAttribute(tokens, ' ', skipSeparatorIfPrecededBy: ','); if (tokenCount == 0) return Empty; diff --git a/src/Html2OpenXml/Primitives/Margin.cs b/src/Html2OpenXml/Primitives/Margin.cs index d8574919..86e952a9 100755 --- a/src/Html2OpenXml/Primitives/Margin.cs +++ b/src/Html2OpenXml/Primitives/Margin.cs @@ -91,7 +91,7 @@ public static Margin Parse(ReadOnlySpan span) return Empty; Span tokens = stackalloc Range[5]; - return span.SplitCompositeAttribute(tokens) switch + return span.SplitCompositeAttribute(tokens, separator: ' ') switch { 1 => new Margin(Unit.Parse(span[tokens[0]], UnitMetric.Pixel)), 2 => new Margin( diff --git a/src/Html2OpenXml/Primitives/SideBorder.cs b/src/Html2OpenXml/Primitives/SideBorder.cs index 5343f853..f33bf03b 100755 --- a/src/Html2OpenXml/Primitives/SideBorder.cs +++ b/src/Html2OpenXml/Primitives/SideBorder.cs @@ -36,7 +36,7 @@ public static SideBorder Parse(ReadOnlySpan span) return Empty; Span tokens = stackalloc Range[6]; - var tokenCount = span.SplitCompositeAttribute(tokens); + var tokenCount = span.SplitCompositeAttribute(tokens, separator: ' '); if (tokenCount == 0) return Empty; diff --git a/src/Html2OpenXml/Utilities/Converter.cs b/src/Html2OpenXml/Utilities/Converter.cs index ca8ae12d..1bc03d6c 100755 --- a/src/Html2OpenXml/Utilities/Converter.cs +++ b/src/Html2OpenXml/Utilities/Converter.cs @@ -137,7 +137,7 @@ public static Unit ToFontSize(ReadOnlySpan span) Span tokens = stackalloc Range[1]; return span.SplitCompositeAttribute(tokens, ',') switch { - 1 => span[tokens[0]].Trim().ToString(), + 1 => span[tokens[0]].Unquote().Trim().ToString(), _ => null }; } diff --git a/src/Html2OpenXml/Utilities/SpanExtensions.cs b/src/Html2OpenXml/Utilities/SpanExtensions.cs index 9848166e..eca02bfa 100644 --- a/src/Html2OpenXml/Utilities/SpanExtensions.cs +++ b/src/Html2OpenXml/Utilities/SpanExtensions.cs @@ -76,118 +76,115 @@ public static double AsPercent (this ReadOnlySpan span) /// If is preceded by this character, the separator will be treated as a normal character. /// The number of ranges written into . public static int SplitCompositeAttribute(this ReadOnlySpan span, Span destination, - char separator = ' ', char? skipSeparatorIfPrecededBy = null) + char separator, char? skipSeparatorIfPrecededBy = null) { // If the destination is empty, there's nothing to do. - if (destination.IsEmpty) + if (span.IsEmpty || destination.IsEmpty) return 0; - var originalSpan = span; - int matchCount = 0, - startMatchIndex = 0, // begin of match in originalSpan - contentOffset = 0, // running count of decoded entity or enclosed content (pquote or in parenthesis) - totalConsumedLength = 0; // count of input characters read from originalSpan + int matchCount = 0, tokenStart = 0; bool isEscaping = false; char endEscapingChar = '\0'; - ReadOnlySpan searchValues = [separator, '(', '\'', '"', '&']; - while (span.Length > 0) + for (int i = 0; i < span.Length; ) { - bool isContentMatch = true; - - // find the next potential token boundary - int index = isEscaping? - span.IndexOfAny(endEscapingChar, '&') : - span.IndexOfAny(searchValues); - - if (index == -1) - { - // process the reminder match - destination[matchCount] = new Range(startMatchIndex, totalConsumedLength + span.Length); - matchCount++; - break; - } - - // we find the beginning of an escaping sequence - var ch = span[index]; - HtmlEntities.EntityMatchResult entityMatch = HtmlEntities.EntityMatchResult.Nil; + char ch = span[i]; + int consumed = 1; // is this an encoded html entity? - if (ch == '&' && HtmlEntities.IsRecognizedEntity(span.Slice(index), out entityMatch)) + if (ch == '&' && HtmlEntities.IsRecognizedEntity(span.Slice(i), out var entity)) { - ch = entityMatch.DecodedChar; - if (index == 0) startMatchIndex += entityMatch.EncodedLength - 1 /* compensate for the decoded char that must be excluded from the captured range */; - totalConsumedLength += entityMatch.EncodedLength - 1 /* -1 to compensate with +1 on line 122 */; + ch = entity.DecodedChar; + consumed = entity.EncodedLength; } - totalConsumedLength += index + 1; // index is 0-based - - // are we going into escape mode, e.g., matches the separator but enclosed inside boundary // such as quote or parenthesis - if (ch != separator && !isEscaping && IsEscapable(ch)) + if (!isEscaping && IsEscapable(ch)) { - if (ch == '(') - { - endEscapingChar = ')'; - contentOffset += index + 1; // include the parenthesis in the captured range - } - else // ' or " - { - endEscapingChar = ch; - if (index == 0) startMatchIndex++; // exclude the quote from the captured range - } + endEscapingChar = ch is '('? ')' : ch; isEscaping = true; - isContentMatch = false; - } - // end of escaping sequence - else if (ch == endEscapingChar) - { - if (ch == ')') index++; // include that closing parenthesis in the range - isEscaping = false; } // this is a separator but maybe we will need to skip it // eg: "Arial, Verdana bold 1em" -> the space after the comma must be skipped // eg: rgb(233, 233, 233) -> rgb(233,233,233) - else if (ch == separator && index > 0 && - skipSeparatorIfPrecededBy.HasValue && span[index -1] == skipSeparatorIfPrecededBy) + else if (!isEscaping && ch == separator && + (!skipSeparatorIfPrecededBy.HasValue || (i > 0 && span[i - 1] != skipSeparatorIfPrecededBy.Value))) { - contentOffset += index; - isContentMatch = false; + if (i > tokenStart) + { + destination[matchCount++] = new Range(tokenStart, i); + if (matchCount >= destination.Length) + return matchCount; + } + tokenStart = i + consumed; } - else if (ch != separator && span[index] == '&') + // end of escaping sequence + else if (ch == endEscapingChar) { - contentOffset += entityMatch.EncodedLength; - isContentMatch = false; + isEscaping = false; } - else if (index == 0) // empty token + + i += consumed; + } + + // process the reminder match + if (tokenStart < span.Length && matchCount < destination.Length) + { + destination[matchCount++] = new Range(tokenStart, span.Length); + } + + return matchCount; + } + + /// + /// Trim the (single or double) quote around text. + /// Supports also the html-encoded quote entity. + /// + public static ReadOnlySpan Unquote(this ReadOnlySpan span) + { + int start = 0; + while (start < span.Length) + { + var ch = span[start]; + int consumed = 1; + if (ch is '&' && HtmlEntities.IsRecognizedEntity(span.Slice(start), out var entity)) { - startMatchIndex++; - isContentMatch = false; + ch = entity.DecodedChar; + consumed = entity.EncodedLength; } - // index > 0 to exclude empty entries - if (!isEscaping && index > 0 && isContentMatch) - { - var endRange = startMatchIndex + index + contentOffset; - destination[matchCount] = new Range(startMatchIndex, endRange); - startMatchIndex = totalConsumedLength; + if (!IsQuote(ch)) + break; + + start += consumed; + } - // reset pointer for next match - contentOffset = 0; - matchCount++; + int end = span.Length - 1; + while (end >= start) + { + var ch = span[end]; + int consumed = 1, ampIndex; + if (ch is ';' && + (ampIndex = span.Slice(0, end + 1).LastIndexOf('&')) > -1 && + HtmlEntities.IsRecognizedEntity(span.Slice(ampIndex), out var entity)) + { + ch = entity.DecodedChar; + consumed = entity.EncodedLength; } - if (matchCount >= destination.Length || span.Length <= index) - break; + if (!IsQuote(ch)) + break; - // move to next token past what we just consumed - span = originalSpan.Slice(totalConsumedLength); + end -= consumed; } - return matchCount; + return span.Slice(start, end - start + 1); } + private static bool IsQuote(char c) + => c is '"' or '\''; + private static bool IsEscapable(char c) - => c is '(' or '"' or '\''; + => c is '(' or '"' or '\''; } diff --git a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs index b03090f7..7257bbd0 100644 --- a/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs +++ b/test/HtmlToOpenXml.Tests/Primitives/StyleParserTests.cs @@ -18,6 +18,7 @@ public void ParseStyle_ShouldSucceed(string htmlStyle) var styles = HtmlAttributeCollection.ParseStyle(htmlStyle); using (Assert.EnterMultipleScope()) { + Assert.That(styles["text-decoration"].ToString(), Is.EqualTo("underline")); Assert.That(styles.HasKeyEqualsTo("text-decoration", "underline"), Is.True); Assert.That(styles.HasKeyEqualsTo("color", "red"), Is.True); Assert.That(styles["color"].ToString(), Is.EqualTo("red")); @@ -60,12 +61,15 @@ public void WithMultipleTextDecoration_ReturnsAllValues() [Test] public void WithEmbedSemicolons_ReturnsLegitimateUrl() { - var styles = HtmlAttributeCollection.ParseStyle("background:url(data:image/svg+xml;base64;iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==)"); + var styles = HtmlAttributeCollection.ParseStyle("background:url(data:image/svg+xml;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==)"); var exist = styles.TryGetValue("background", out var imageUrl); Assert.That(exist, Is.True); if (exist) { - var success = IO.DataUri.TryCreateImage(imageUrl.ToString(), out var _); + Assert.That(imageUrl.ToString(), Does.StartWith("url(")); + Assert.That(imageUrl.ToString(), Does.EndWith(")")); + var dataUri = imageUrl.Slice(4, imageUrl.Length - 4 - 1).ToString(); + var success = IO.DataUri.TryCreateImage(dataUri, out var _); Assert.That(success, Is.True); } } From 5ec04c5e47b4e59093794a1b10902fb1960e0b65 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Fri, 23 Oct 2026 16:20:20 +0200 Subject: [PATCH 15/15] Use preview langVersion --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index c63921ed..829b1c36 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ Copyright 2009-$([System.DateTime]::Today.Year) Olivier Nizet enable - latest + preview enable 4.0.0