From 7bfe23eb4da0d732302f80b82774c808f1f832a1 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Tue, 14 Apr 2026 22:16:44 +0200 Subject: [PATCH 01/13] Prevent running pipeline twice when creating a new PR --- .github/workflows/dotnet.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 85a9e28..f983d84 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -5,14 +5,20 @@ name: 'dotnet.yml' on: push: - branches: [ "dev" ] + branches: + - dev paths-ignore: - 'docs/**' - '**/*.md' pull_request: + branches: + - master + - dev jobs: - net5_above: + build: + if: github.event_name != 'push' || github.event.pull_request == null + net: runs-on: ubuntu-latest defaults: run: @@ -26,7 +32,7 @@ jobs: uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ matrix.dotnet-version }}.x - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.sln') }} @@ -39,7 +45,7 @@ jobs: - name: Run tests run: dotnet test --framework net${{ matrix.dotnet-version }} --configuration Release --no-build --verbosity normal - net462: + netfx462: runs-on: windows-latest steps: - uses: actions/checkout@v5 From bc5b94735ccce582c6f795046e9ba5d9517028e2 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Wed, 15 Apr 2026 09:05:27 +0200 Subject: [PATCH 02/13] Fix yaml file --- .github/workflows/dotnet.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f983d84..282d4cf 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,9 +16,8 @@ on: - dev jobs: - build: - if: github.event_name != 'push' || github.event.pull_request == null net: + if: github.event_name != 'push' || github.event.pull_request == null runs-on: ubuntu-latest defaults: run: @@ -46,6 +45,7 @@ jobs: run: dotnet test --framework net${{ matrix.dotnet-version }} --configuration Release --no-build --verbosity normal netfx462: + if: github.event_name != 'push' || github.event.pull_request == null runs-on: windows-latest steps: - uses: actions/checkout@v5 From 6fe937516deb6c3fb624bd13aa600fe54b2c24bf Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Fri, 24 Apr 2026 12:54:43 +0200 Subject: [PATCH 03/13] Internal micro-optimisation and ignore casing for style attribute name --- .../Collections/HtmlAttributeCollection.cs | 15 +++++++++++---- src/Html2OpenXml/Expressions/BodyExpression.cs | 4 ++-- src/Html2OpenXml/Utilities/SpanExtensions.cs | 2 +- .../Primitives/StyleParserTests.cs | 2 +- test/HtmlToOpenXml.Tests/StyleTests.cs | 2 +- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs index f149267..340ae61 100755 --- a/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs +++ b/src/Html2OpenXml/Collections/HtmlAttributeCollection.cs @@ -73,7 +73,7 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) } else if (separator == ':' && !foundKey) { - key = span.Slice(0, index).Trim().ToString(); + key = span.Slice(0, index).Trim().ToString().ToLowerInvariant(); foundKey = true; index++; } @@ -87,7 +87,7 @@ public static HtmlAttributeCollection ParseStyle(string? htmlStyles) } else if (!foundKey && span.Slice(index).StartsWith(['&','#','5','8',';'])) { - key = span.Slice(0, index).Trim().ToString(); + key = span.Slice(0, index).Trim().ToString().ToLowerInvariant(); foundKey = true; index += 5; // length of ":" } @@ -120,9 +120,15 @@ public ReadOnlySpan this[string name] /// /// Determines whether the collection contains the specified key. /// - public bool ContainsKey(string name) + public bool TryGetValue(string name, out ReadOnlySpan value) { - return attributes.ContainsKey(name); + if (attributes.TryGetValue(name, out var range)) + { + value = rawValue.AsSpan().Slice(range).Trim(); + return true; + } + value = default; + return false; } /// @@ -168,6 +174,7 @@ public Unit GetUnit(string name, UnitMetric defaultMetric = UnitMetric.Unitless) public Margin GetMargin(string name) { Margin margin = Margin.Empty; + // shortcut to avoid resolving each individual side when we know this collection is empty if (IsEmpty) return margin; if (attributes.TryGetValue(name, out var range)) diff --git a/src/Html2OpenXml/Expressions/BodyExpression.cs b/src/Html2OpenXml/Expressions/BodyExpression.cs index 4a1803e..5b643c3 100644 --- a/src/Html2OpenXml/Expressions/BodyExpression.cs +++ b/src/Html2OpenXml/Expressions/BodyExpression.cs @@ -69,10 +69,10 @@ protected override void ComposeStyles(ParsingContext context) // Unsupported W3C attribute but claimed by users. Specified at level, the page // orientation is applied on the whole document - if (styleAttributes.ContainsKey("page-orientation")) + if (styleAttributes.TryGetValue("page-orientation", out var attrValue)) { PageOrientationValues orientation = PageOrientationValues.Portrait; - if (styleAttributes.HasKeyEqualsTo("page-orientation", "landscape")) + if ("landscape".Equals(attrValue, StringComparison.InvariantCultureIgnoreCase)) orientation = PageOrientationValues.Landscape; var sectionProperties = mainPart.Document!.Body!.GetFirstChild(); diff --git a/src/Html2OpenXml/Utilities/SpanExtensions.cs b/src/Html2OpenXml/Utilities/SpanExtensions.cs index fdaa528..1af88e1 100644 --- a/src/Html2OpenXml/Utilities/SpanExtensions.cs +++ b/src/Html2OpenXml/Utilities/SpanExtensions.cs @@ -160,7 +160,7 @@ public static int SplitCompositeAttribute(this ReadOnlySpan span, Span Date: Fri, 15 May 2026 21:14:32 +0200 Subject: [PATCH 04/13] Migrate to slnx --- HtmlToOpenXml.sln | 54 ---------------------------------------------- HtmlToOpenXml.slnx | 10 +++++++++ 2 files changed, 10 insertions(+), 54 deletions(-) delete mode 100644 HtmlToOpenXml.sln create mode 100644 HtmlToOpenXml.slnx diff --git a/HtmlToOpenXml.sln b/HtmlToOpenXml.sln deleted file mode 100644 index aa68e29..0000000 --- a/HtmlToOpenXml.sln +++ /dev/null @@ -1,54 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34511.84 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlToOpenXml", "src\Html2OpenXml\HtmlToOpenXml.csproj", "{EF700F30-C9BB-49A6-912C-E3B77857B514}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{58520A98-BA53-4BA4-AAE3-786AA21331D6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{84EA02ED-2E97-47D2-992E-32CC104A3A7A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo", "examples\Demo\Demo.csproj", "{A1ECC760-B9F7-4A00-AF5F-568B5FD6F09F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlToOpenXml.Tests", "test\HtmlToOpenXml.Tests\HtmlToOpenXml.Tests.csproj", "{CA0A68E0-45A0-4A01-A061-F951D93D6906}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmark", "examples\Benchmark\Benchmark.csproj", "{143A3684-FAEB-43D0-A895-09BE5FDF85F6}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {EF700F30-C9BB-49A6-912C-E3B77857B514}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EF700F30-C9BB-49A6-912C-E3B77857B514}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EF700F30-C9BB-49A6-912C-E3B77857B514}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EF700F30-C9BB-49A6-912C-E3B77857B514}.Release|Any CPU.Build.0 = Release|Any CPU - {A1ECC760-B9F7-4A00-AF5F-568B5FD6F09F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1ECC760-B9F7-4A00-AF5F-568B5FD6F09F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1ECC760-B9F7-4A00-AF5F-568B5FD6F09F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1ECC760-B9F7-4A00-AF5F-568B5FD6F09F}.Release|Any CPU.Build.0 = Release|Any CPU - {CA0A68E0-45A0-4A01-A061-F951D93D6906}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CA0A68E0-45A0-4A01-A061-F951D93D6906}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CA0A68E0-45A0-4A01-A061-F951D93D6906}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CA0A68E0-45A0-4A01-A061-F951D93D6906}.Release|Any CPU.Build.0 = Release|Any CPU - {143A3684-FAEB-43D0-A895-09BE5FDF85F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {143A3684-FAEB-43D0-A895-09BE5FDF85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {143A3684-FAEB-43D0-A895-09BE5FDF85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {143A3684-FAEB-43D0-A895-09BE5FDF85F6}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {EF700F30-C9BB-49A6-912C-E3B77857B514} = {58520A98-BA53-4BA4-AAE3-786AA21331D6} - {A1ECC760-B9F7-4A00-AF5F-568B5FD6F09F} = {84EA02ED-2E97-47D2-992E-32CC104A3A7A} - {CA0A68E0-45A0-4A01-A061-F951D93D6906} = {84EA02ED-2E97-47D2-992E-32CC104A3A7A} - {143A3684-FAEB-43D0-A895-09BE5FDF85F6} = {84EA02ED-2E97-47D2-992E-32CC104A3A7A} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {14EE1026-6507-4295-9FEE-67A55C3849CE} - SolutionGuid = {194D4CBE-A20A-4E32-967B-E1BBD3922C29} - EndGlobalSection -EndGlobal diff --git a/HtmlToOpenXml.slnx b/HtmlToOpenXml.slnx new file mode 100644 index 0000000..f1a0880 --- /dev/null +++ b/HtmlToOpenXml.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + From 3ce18a7945adbdd9e6dc79d221ce782a852bfa58 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Fri, 15 May 2026 21:15:55 +0200 Subject: [PATCH 05/13] Ignore csproj.lscache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8dd4607..31b666d 100644 --- a/.gitignore +++ b/.gitignore @@ -383,6 +383,7 @@ FodyWeavers.xsd !.vscode/launch.json !.vscode/extensions.json *.code-workspace +*.csproj.lscache # Local History for Visual Studio Code .history/ From 710df27b59656df03efe7237f7d9b36f2a93ab7d Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Sun, 17 May 2026 22:20:07 +0200 Subject: [PATCH 06/13] Remove HtmlDecode code from net8 compilation as that will be served by native framework --- src/Html2OpenXml/Utilities/HttpUtility.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Html2OpenXml/Utilities/HttpUtility.cs b/src/Html2OpenXml/Utilities/HttpUtility.cs index c8de042..548ab7e 100755 --- a/src/Html2OpenXml/Utilities/HttpUtility.cs +++ b/src/Html2OpenXml/Utilities/HttpUtility.cs @@ -19,11 +19,9 @@ namespace HtmlToOpenXml; /// static class HttpUtility { - /// The common characters considered as white space. - internal static readonly char[] WhiteSpaces = [' ', '\t', '\r', '\u00A0', '\u0085']; +#if !NET5_0_OR_GREATER private static readonly char[] entityEndingChars = [';', '&']; - static class HtmlEntities { private static readonly string[] entitiesList = [ @@ -146,6 +144,7 @@ private static void HtmlDecode(string? s, TextWriter output) } } } +#endif /// /// Converts a string that represents an Html-encoded URL to a decoded string. From 2249cdf422215f0b4ddca9730cc8a9015e5e171b Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Sun, 17 May 2026 22:22:41 +0200 Subject: [PATCH 07/13] Use ConcurrentDictionary for faster thread-safe cache --- src/Html2OpenXml/IO/HtmlImageInfo.cs | 18 +----------------- src/Html2OpenXml/IO/ImagePrefetcher.cs | 25 ++++++++----------------- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/src/Html2OpenXml/IO/HtmlImageInfo.cs b/src/Html2OpenXml/IO/HtmlImageInfo.cs index a5b20df..0ed2c27 100755 --- a/src/Html2OpenXml/IO/HtmlImageInfo.cs +++ b/src/Html2OpenXml/IO/HtmlImageInfo.cs @@ -16,13 +16,8 @@ namespace HtmlToOpenXml.IO; /// /// Represents an image and its metadata. /// -sealed class HtmlImageInfo(string source, string partId) +sealed class HtmlImageInfo(string partId) { - /// - /// The URI identifying this cached image information. - /// - public string Source { get; set; } = source; - /// /// The Unique identifier of the ImagePart in the . /// @@ -44,14 +39,3 @@ sealed class HtmlImageInfo(string source, string partId) /// public bool IsExternal { get; set; } } - -/// -/// Typed dictionary of where the Source URI is the identifier. -/// -sealed class HtmlImageInfoCollection : System.Collections.ObjectModel.KeyedCollection -{ - protected override string GetKeyForItem(HtmlImageInfo item) - { - return item.Source; - } -} diff --git a/src/Html2OpenXml/IO/ImagePrefetcher.cs b/src/Html2OpenXml/IO/ImagePrefetcher.cs index 5ead754..36f21d3 100644 --- a/src/Html2OpenXml/IO/ImagePrefetcher.cs +++ b/src/Html2OpenXml/IO/ImagePrefetcher.cs @@ -9,6 +9,7 @@ * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A * PARTICULAR PURPOSE. */ +using System.Collections.Concurrent; using DocumentFormat.OpenXml.Packaging; namespace HtmlToOpenXml.IO; @@ -46,7 +47,7 @@ sealed class ImagePrefetcher : IImageLoader }; private readonly T hostingPart; private readonly IWebRequest resourceLoader; - private readonly HtmlImageInfoCollection prefetchedImages; + private readonly ConcurrentDictionary prefetchedImages; private readonly object lockObject = new(); private readonly ImageProcessingMode processingMode; @@ -76,13 +77,9 @@ public ImagePrefetcher(T hostingPart, IWebRequest resourceLoader, ImageProcessin public async Task Download(string imageUri, CancellationToken cancellationToken) { // Check if image is already cached using thread-safe operation - lock (lockObject) - { - if (prefetchedImages.Contains(imageUri)) - return prefetchedImages[imageUri]; - } + if (prefetchedImages.TryGetValue(imageUri, out var iinfo)) + return iinfo; - HtmlImageInfo? iinfo; if (DataUri.IsWellFormed(imageUri)) // data inline, encoded in base64 { iinfo = ReadDataUri(imageUri); @@ -110,14 +107,8 @@ public ImagePrefetcher(T hostingPart, IWebRequest resourceLoader, ImageProcessin // Add to cache using thread-safe operation if (iinfo != null) { - lock (lockObject) - { - // Double-check pattern to prevent duplicate adds during concurrent access - if (!prefetchedImages.Contains(imageUri)) - { - prefetchedImages.Add(iinfo); - } - } + // Double-check pattern to prevent duplicate adds during concurrent access + prefetchedImages.TryAdd(imageUri, iinfo); } return iinfo; @@ -183,7 +174,7 @@ public ImagePrefetcher(T hostingPart, IWebRequest resourceLoader, ImageProcessin // Return image info with external flag set // Note: Size will be empty as we don't download the image - return new HtmlImageInfo(src, relationshipId) { + return new HtmlImageInfo(relationshipId) { IsExternal = true, Size = Size.Empty, TypeInfo = ImagePartType.Png // Default type, actual type doesn't matter for external links @@ -223,7 +214,7 @@ private HtmlImageInfo SaveImageAssert(string src, PartTypeInfo type, Action Date: Sun, 17 May 2026 22:54:48 +0200 Subject: [PATCH 08/13] No need to Seek stream if the file type is not recognized. --- src/Html2OpenXml/IO/ImageHeader.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Html2OpenXml/IO/ImageHeader.cs b/src/Html2OpenXml/IO/ImageHeader.cs index 851bfad..53fb1b0 100755 --- a/src/Html2OpenXml/IO/ImageHeader.cs +++ b/src/Html2OpenXml/IO/ImageHeader.cs @@ -57,7 +57,11 @@ public static bool TryDetectFileType(Stream stream, out FileType type) { using var reader = new SequentialBinaryReader(stream, leaveOpen: true); type = DetectFileType(reader); - stream.Seek(0L, SeekOrigin.Begin); + if (type != FileType.Unrecognized) + { + stream.Seek(0L, SeekOrigin.Begin); + } + return type != FileType.Unrecognized; } @@ -71,6 +75,10 @@ public static Size GetDimensions(Stream stream) { using var reader = new SequentialBinaryReader(stream, leaveOpen: true); FileType type = DetectFileType(reader); + + if (type == FileType.Unrecognized) + return Size.Empty; + stream.Seek(0L, SeekOrigin.Begin); return type switch { From 3e5700f535bf7866da255cf2bf83428dfe382549 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Sun, 17 May 2026 23:23:20 +0200 Subject: [PATCH 09/13] Refactor KeepAspectRatio to avoid float multiplication and cleaner code intent --- src/Html2OpenXml/IO/ImageHeader.cs | 22 ++++++++++----- .../ImageFormats/ImageHeaderTests.cs | 27 +++++++++++++++++-- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/Html2OpenXml/IO/ImageHeader.cs b/src/Html2OpenXml/IO/ImageHeader.cs index 53fb1b0..013da2a 100755 --- a/src/Html2OpenXml/IO/ImageHeader.cs +++ b/src/Html2OpenXml/IO/ImageHeader.cs @@ -99,19 +99,29 @@ public static Size KeepAspectRatio(Size actualSize, Size preferredSize) { int width, height; + // Handle edge cases where dimensions are zero or negative + if (actualSize.Width <= 0 || actualSize.Height <= 0) + return Size.Empty; + // Resize by the highest difference ratio between constrained dimension and real one. - bool forceResizeByWidth = preferredSize.Height <= 0 && preferredSize.Width > 0; - bool forceResizeByHeight = preferredSize.Width <= 0 && preferredSize.Height > 0; - if (forceResizeByWidth || (!forceResizeByHeight && - Math.Abs(preferredSize.Width - actualSize.Width) > Math.Abs(preferredSize.Height - actualSize.Height))) + bool scaleByWidth = preferredSize.Height <= 0 && preferredSize.Width > 0; + bool scaleByHeight = preferredSize.Width <= 0 && preferredSize.Height > 0; + if (!scaleByHeight && !scaleByWidth) + { + int widthDiff = Math.Abs(preferredSize.Width - actualSize.Width); + int heightDiff = Math.Abs(preferredSize.Height - actualSize.Height); + scaleByWidth = widthDiff >= heightDiff; + } + + if (scaleByWidth) { width = preferredSize.Width; - height = (int) ((float) actualSize.Height / actualSize.Width * width); + height = actualSize.Height * width / actualSize.Width; } else { height = preferredSize.Height; - width = (int) ((float) actualSize.Width / actualSize.Height * height); + width = actualSize.Width * height / actualSize.Height; } return new Size(width, height); diff --git a/test/HtmlToOpenXml.Tests/ImageFormats/ImageHeaderTests.cs b/test/HtmlToOpenXml.Tests/ImageFormats/ImageHeaderTests.cs index 90d2ec7..1549bd0 100644 --- a/test/HtmlToOpenXml.Tests/ImageFormats/ImageHeaderTests.cs +++ b/test/HtmlToOpenXml.Tests/ImageFormats/ImageHeaderTests.cs @@ -60,7 +60,7 @@ public ImageHeader.FileType GuessFormat_ReturnsFileType(string resourceName) using var imageStream = ResourceHelper.GetStream(resourceName); bool success = ImageHeader.TryDetectFileType(imageStream, out var guessType); - Assert.That(success, Is.EqualTo(true)); + Assert.That(success, Is.True); return guessType; } @@ -70,8 +70,31 @@ public ImageHeader.FileType GuessFormat_WithEmpty_ReturnsFileType() using var memoryStream = new MemoryStream(); bool success = ImageHeader.TryDetectFileType(memoryStream, out var guessType); - Assert.That(success, Is.EqualTo(false)); + Assert.That(success, Is.False); return guessType; } + + [TestCaseSource(nameof(ResizedImageTestCases))] + public void KeepAspectRatio_Returns((Size actualSize, Size preferredSize, Size expectedSize) td) + { + var resized = ImageHeader.KeepAspectRatio(td.actualSize, td.preferredSize); + using (Assert.EnterMultipleScope()) + { + Assert.That(resized.Width, Is.EqualTo(td.expectedSize.Width)); + Assert.That(resized.Height, Is.EqualTo(td.expectedSize.Height)); + } + } + + private static IEnumerable<(Size, Size, Size)> ResizedImageTestCases() + { + yield return (new Size(255, 0), new Size(255, 255), Size.Empty); + yield return (new Size(255, 255), new Size(255, 255), new Size(255, 255)); + yield return (new Size(500, 255), new Size(125, 255), new Size(125, 63)); + yield return (new Size(500, 255), new Size(255, 125), new Size(255, 130)); + yield return (new Size(255, 500), new Size(255, 125), new Size(63, 125)); + yield return (new Size(500, 255), new Size(500, 750), new Size(1470, 750)); + yield return (new Size(9999, 7499), new Size(100, 75), new Size(100, 74)); + yield return (new Size(1000, 1498), new Size(0, 642), new Size(428, 642)); + } } } \ No newline at end of file From e6d658ab508cf6a1c243f375d2321e853a69061d Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 27 May 2026 01:39:41 +0300 Subject: [PATCH 10/13] Add support for custom list bullets --- examples/Demo/Program.cs | 2 +- examples/Demo/Resources/UlStyles.html | 31 ++++++++++++ .../Expressions/Numbering/ListExpression.cs | 10 +++- .../Numbering/NumberingExpressionBase.cs | 48 ++++++++++++++++++- 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 examples/Demo/Resources/UlStyles.html diff --git a/examples/Demo/Program.cs b/examples/Demo/Program.cs index d1ee82e..fccd469 100644 --- a/examples/Demo/Program.cs +++ b/examples/Demo/Program.cs @@ -12,7 +12,7 @@ static class Program static async Task Main(string[] args) { const string filename = "test.docx"; - string html = ResourceHelper.GetString("Resources.CompleteRunTest.html"); + string html = ResourceHelper.GetString("Resources.UlStyles.html"); if (File.Exists(filename)) File.Delete(filename); using (MemoryStream generatedDocument = new()) diff --git a/examples/Demo/Resources/UlStyles.html b/examples/Demo/Resources/UlStyles.html new file mode 100644 index 0000000..fc72b27 --- /dev/null +++ b/examples/Demo/Resources/UlStyles.html @@ -0,0 +1,31 @@ + + + + + + +
    +
  • test 2
  • +
  • test 3
  • +
  •  
  • +
+

 

+
    +
  • test 2
  • +
  • test 3
  • +
  •  
  • +
+

 

+
    +
  • test 2
  • +
  • test 3
  • +
  •  
  • +
+

 

+
    +
  • test 2
  • +
  • test 3
  • +
  •  
  • +
+ + \ No newline at end of file diff --git a/src/Html2OpenXml/Expressions/Numbering/ListExpression.cs b/src/Html2OpenXml/Expressions/Numbering/ListExpression.cs index 9ce33e3..0056d1b 100644 --- a/src/Html2OpenXml/Expressions/Numbering/ListExpression.cs +++ b/src/Html2OpenXml/Expressions/Numbering/ListExpression.cs @@ -227,7 +227,15 @@ private static string GetListName(IElement listNode, string? parentName = null) if (parentName != null && IsCascadingStyle(parentName)) return parentName!; - type = orderedList? "decimal" : "disc"; + // If a specific list-style-type is provided for an unordered list (e.g., a custom string like "'-'"), + // we strip the surrounding quotes to extract the raw symbol. + // Otherwise, we fallback to the default "decimal" or "disc" styles. + if (!orderedList && !string.IsNullOrEmpty(type)) + { + return type!.Trim('\'', '\"'); + } + + return orderedList ? "decimal" : "disc"; } return type!; diff --git a/src/Html2OpenXml/Expressions/Numbering/NumberingExpressionBase.cs b/src/Html2OpenXml/Expressions/Numbering/NumberingExpressionBase.cs index b41c4ee..23a6719 100644 --- a/src/Html2OpenXml/Expressions/Numbering/NumberingExpressionBase.cs +++ b/src/Html2OpenXml/Expressions/Numbering/NumberingExpressionBase.cs @@ -50,8 +50,20 @@ protected int GetOrCreateListTemplate(ParsingContext context, string listName) Numbering numberingPart = context.MainPart.NumberingDefinitionsPart!.Numbering!; + AbstractNum abstractNum; + // at this stage, we have sanitized the list style so it's safe to grab them from the predefined template lists - var abstractNum = predefinedNumberingLists[listName]; + if (predefinedNumberingLists.TryGetValue(listName, out var predefined)) + { + // If it's a predefined style, we clone it as usual + abstractNum = (AbstractNum)predefined.CloneNode(true); + } + else + { + // If not found in predefined lists, listName contains a custom bullet character (e.g., "-") + abstractNum = CreateCustomBulletAbstractNum(listName); + } + abstractNum = (AbstractNum) abstractNum.CloneNode(true); abstractNum.AbstractNumberId = IncrementAbstractNumId(context, numberingPart); var level1 = abstractNum.GetFirstChild()!; @@ -216,6 +228,38 @@ private void InitNumberingIds(ParsingContext context) isInitialized = true; } + /// + /// Generates a custom abstract numbering definition for unordered lists using a specific symbol. + /// + /// The custom character or string to be used as the list bullet. + /// An instance configured with the custom bullet symbol across all levels. + private AbstractNum CreateCustomBulletAbstractNum(string customSymbol) + { + var abstractNum = new AbstractNum { + AbstractNumDefinitionName = new() { Val = customSymbol }, + MultiLevelType = new() { Val = MultiLevelValues.HybridMultilevel } + }; + + for (var lvlIndex = 0; lvlIndex <= MaxLevel; lvlIndex++) + { + abstractNum.Append(new Level { + StartNumberingValue = new() { Val = 1 }, + NumberingFormat = new() { Val = NumberFormatValues.Bullet }, + LevelIndex = lvlIndex, + LevelText = new() { Val = string.Format(customSymbol, lvlIndex+1) }, + LevelJustification = new() { Val = LevelJustificationValues.Left }, + PreviousParagraphProperties = new() { + Indentation = new() { + Left = ((lvlIndex + 1) * Indentation * 2).ToString(), + Hanging = Indentation.ToString() + } + } + }); + } + + return abstractNum; + } + /// /// Predefined template of lists. /// @@ -299,4 +343,4 @@ private static IReadOnlyDictionary InitKnownLists() return knownAbstractNums; #endif } -} \ No newline at end of file +} From f0924cf896f0503c67eb9501bcd0c92466a459f1 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 27 May 2026 20:27:19 +0300 Subject: [PATCH 11/13] Add dash to supportedListTypes --- examples/Demo/Resources/UlStyles.html | 9 ++++++++- src/Html2OpenXml/Expressions/Numbering/ListExpression.cs | 2 +- .../Expressions/Numbering/NumberingExpressionBase.cs | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/Demo/Resources/UlStyles.html b/examples/Demo/Resources/UlStyles.html index fc72b27..bfe2fc6 100644 --- a/examples/Demo/Resources/UlStyles.html +++ b/examples/Demo/Resources/UlStyles.html @@ -10,7 +10,7 @@
  •  
  •  

    -
      +
      • test 2
      • test 3
      •  
      • @@ -27,5 +27,12 @@
      • test 3
      •  
      + +

       

      +
        +
      • test 2
      • +
      • test 3
      • +
      •  
      • +
      \ No newline at end of file diff --git a/src/Html2OpenXml/Expressions/Numbering/ListExpression.cs b/src/Html2OpenXml/Expressions/Numbering/ListExpression.cs index 0056d1b..199d0ae 100644 --- a/src/Html2OpenXml/Expressions/Numbering/ListExpression.cs +++ b/src/Html2OpenXml/Expressions/Numbering/ListExpression.cs @@ -38,7 +38,7 @@ readonly struct ListContext(string listName, int absNumId, int instanceId, int l // https://answers.microsoft.com/en-us/msoffice/forum/all/custom-list-number-style/21a54399-4404-4c37-8843-2ccaaf827485 // Image bullet: http://officeopenxml.com/WPnumbering-imagesAsSymbol.php private static readonly HashSet supportedListTypes = - ["disc", "decimal", "square", "circle", + ["disc", "decimal", "square", "circle", "dash", "lower-alpha", "upper-alpha", "lower-latin", "upper-latin", "lower-greek", "upper-greek", "lower-roman", "upper-roman", diff --git a/src/Html2OpenXml/Expressions/Numbering/NumberingExpressionBase.cs b/src/Html2OpenXml/Expressions/Numbering/NumberingExpressionBase.cs index 23a6719..95c953c 100644 --- a/src/Html2OpenXml/Expressions/Numbering/NumberingExpressionBase.cs +++ b/src/Html2OpenXml/Expressions/Numbering/NumberingExpressionBase.cs @@ -274,6 +274,7 @@ private static IReadOnlyDictionary InitKnownLists() ("disc", NumberFormatValues.Bullet, "•"), ("square", NumberFormatValues.Bullet, "▪"), ("circle", NumberFormatValues.Bullet, "o"), + ("dash", NumberFormatValues.Bullet, "-"), ("upper-alpha", NumberFormatValues.UpperLetter, "%{0}."), ("lower-alpha", NumberFormatValues.LowerLetter, "%{0}."), ("upper-roman", NumberFormatValues.UpperRoman, "%{0}."), From cf5cce3281ae5d091ae914ee5c5ec99c54862f38 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Thu, 28 May 2026 18:19:40 +0200 Subject: [PATCH 12/13] Provide unit test for custom list style --- test/HtmlToOpenXml.Tests/NumberingTests.cs | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/HtmlToOpenXml.Tests/NumberingTests.cs b/test/HtmlToOpenXml.Tests/NumberingTests.cs index f80a0a6..db0907e 100644 --- a/test/HtmlToOpenXml.Tests/NumberingTests.cs +++ b/test/HtmlToOpenXml.Tests/NumberingTests.cs @@ -698,5 +698,38 @@ await converter.ParseBody(@" Assert.That(level.LevelText?.Val?.Value, Is.EqualTo("%1.")); } } + + [Test] + public async Task CustomBulletList_ReturnsListWithCustomStyle() + { + await converter.ParseBody(@"
        +
      • Item 1
      • +
      "); + + var elements = mainPart.Document!.Body!.ChildElements; + Assert.That(elements, Is.Not.Empty); + Assert.That(elements, Is.All.TypeOf()); + var numId = ((Paragraph) elements[0]).ParagraphProperties?.NumberingProperties?.NumberingId?.Val?.Value; + Assert.That(numId, Is.Not.Null); + + var numInst = mainPart.NumberingDefinitionsPart!.Numbering! + .Elements() + .Single(i => i.NumberID?.Value == numId); + Assert.That(numInst.AbstractNumId?.Val?.Value, Is.Not.Null); + + var absNums = mainPart.NumberingDefinitionsPart.Numbering! + .Elements(); + var absNum = absNums.FirstOrDefault(a => a.AbstractNumberId == numInst.AbstractNumId.Val); + Assert.That(absNum, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(absNum.AbstractNumDefinitionName?.Val?.Value, Is.EqualTo("😀")); + Assert.That(absNum.MultiLevelType?.Val?.InnerText, Is.AnyOf("hybridMultilevel", "multilevel")); + Assert.That(absNum.Elements().Count(), Is.AtLeast(2), "At least 2 level registred"); + Assert.That(absNum.GetFirstChild()?.NumberingFormat?.Val?.Value, Is.EqualTo(NumberFormatValues.Bullet)); + } + + AssertThatOpenXmlDocumentIsValid(); + } } } From 9ee7503e996c77a44d29e0c4bf94781c2c9a20a7 Mon Sep 17 00:00:00 2001 From: Olivier Nizet Date: Thu, 28 May 2026 18:41:46 +0200 Subject: [PATCH 13/13] Upgrade assembly version and changelog --- CHANGELOG.md | 6 ++++++ Directory.Build.props | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a551e9f..24566d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.4.0 + +- Numbering list now support `list-style-type: dash` +- Add support for custom bullet symbols `list-style-type: '👍'` thanks to @AlexAbd1990 +- Minor internal optimisations + ## 3.3.2 - Supports Greek numbering in ordered lists (upper-greek / lower-greek) #227 diff --git a/Directory.Build.props b/Directory.Build.props index 4a5bbb3..df3b3be 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,10 +4,10 @@ enable latest enable - 3.3.2 + 3.4.0 - 3.4.1 + 3.5.1 \ No newline at end of file