diff --git a/src/OpenXmlHtml.Tests/ColorParserTests.cs b/src/OpenXmlHtml.Tests/ColorParserTests.cs
index a6152ee..2470e14 100644
--- a/src/OpenXmlHtml.Tests/ColorParserTests.cs
+++ b/src/OpenXmlHtml.Tests/ColorParserTests.cs
@@ -16,6 +16,10 @@ public class ColorParserTests
[TestCase("rgba(0, 128, 0, 1)", "008000")]
[TestCase("rgba(0,0,255,0)", "0000FF")]
[TestCase("rgba(100, 200, 50, 0.8)", "64C832")]
+ [TestCase("rgb(100%, 0%, 0%)", "FF0000")]
+ [TestCase("rgb(0%, 100%, 0%)", "00FF00")]
+ [TestCase("rgba(0%, 0%, 100%, 0.5)", "0000FF")]
+ [TestCase("rgb(255.0, 0, 0)", "FF0000")]
public void ValidColors(string input, string expected) =>
Assert.That(ColorParser.Parse(input), Is.EqualTo(expected));
diff --git a/src/OpenXmlHtml.Tests/Samples/WordSamples.EmployeeOnboardingGuide.DotNet.verified.docx b/src/OpenXmlHtml.Tests/Samples/WordSamples.EmployeeOnboardingGuide.DotNet.verified.docx
index 42410d4..e8309d3 100644
Binary files a/src/OpenXmlHtml.Tests/Samples/WordSamples.EmployeeOnboardingGuide.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/Samples/WordSamples.EmployeeOnboardingGuide.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/Samples/WordSamples.EmployeeOnboardingGuide.Net.verified.docx b/src/OpenXmlHtml.Tests/Samples/WordSamples.EmployeeOnboardingGuide.Net.verified.docx
index 478db38..cbead28 100644
Binary files a/src/OpenXmlHtml.Tests/Samples/WordSamples.EmployeeOnboardingGuide.Net.verified.docx and b/src/OpenXmlHtml.Tests/Samples/WordSamples.EmployeeOnboardingGuide.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/Samples/WordSamples.RichDocument.DotNet.verified.xml b/src/OpenXmlHtml.Tests/Samples/WordSamples.RichDocument.DotNet.verified.xml
index cddc8e4..a7b747d 100644
--- a/src/OpenXmlHtml.Tests/Samples/WordSamples.RichDocument.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/Samples/WordSamples.RichDocument.DotNet.verified.xml
@@ -2,4 +2,4 @@
All systems operational.
● Server: OK
● Cache: Down
-Contact ops team (mailto:ops@example.com) for details.
\ No newline at end of file
+Contact ops team (mailto:ops@example.com) for details.
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/Samples/WordSamples.RichDocument.Net.verified.xml b/src/OpenXmlHtml.Tests/Samples/WordSamples.RichDocument.Net.verified.xml
index cddc8e4..a7b747d 100644
--- a/src/OpenXmlHtml.Tests/Samples/WordSamples.RichDocument.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/Samples/WordSamples.RichDocument.Net.verified.xml
@@ -2,4 +2,4 @@
All systems operational.
● Server: OK
● Cache: Down
-Contact ops team (mailto:ops@example.com) for details.
\ No newline at end of file
+Contact ops team (mailto:ops@example.com) for details.
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.BookmarksAndLinksDocx.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordAnchorTests.BookmarksAndLinksDocx.DotNet.verified.docx
index 25ee5c6..a29873d 100644
Binary files a/src/OpenXmlHtml.Tests/WordAnchorTests.BookmarksAndLinksDocx.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordAnchorTests.BookmarksAndLinksDocx.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.BookmarksAndLinksDocx.Net.verified.docx b/src/OpenXmlHtml.Tests/WordAnchorTests.BookmarksAndLinksDocx.Net.verified.docx
index 3f820c2..1f591dd 100644
Binary files a/src/OpenXmlHtml.Tests/WordAnchorTests.BookmarksAndLinksDocx.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordAnchorTests.BookmarksAndLinksDocx.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.InternalAnchorLink.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.InternalAnchorLink.DotNet.verified.xml
index 48bd986..e47af3a 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.InternalAnchorLink.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.InternalAnchorLink.DotNet.verified.xml
@@ -1,3 +1,3 @@
-Jump to Section 2
+Jump to Section 2
Section 2
Content here.
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.InternalAnchorLink.Net.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.InternalAnchorLink.Net.verified.xml
index 48bd986..e47af3a 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.InternalAnchorLink.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.InternalAnchorLink.Net.verified.xml
@@ -1,3 +1,3 @@
-Jump to Section 2
+Jump to Section 2
Section 2
Content here.
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkInText.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkInText.DotNet.verified.xml
index 44d9b52..f4ecec4 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkInText.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkInText.DotNet.verified.xml
@@ -1 +1 @@
-Visit our site (https://example.com) for more info.
\ No newline at end of file
+Visit our site (https://example.com) for more info.
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkInText.Net.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkInText.Net.verified.xml
index 44d9b52..f4ecec4 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkInText.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkInText.Net.verified.xml
@@ -1 +1 @@
-Visit our site (https://example.com) for more info.
\ No newline at end of file
+Visit our site (https://example.com) for more info.
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithFormatting.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithFormatting.DotNet.verified.xml
index 443cc4d..719bf2b 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithFormatting.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithFormatting.DotNet.verified.xml
@@ -1 +1 @@
-Bold Link (https://example.com)
\ No newline at end of file
+Bold Link (https://example.com)
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithFormatting.Net.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithFormatting.Net.verified.xml
index 443cc4d..719bf2b 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithFormatting.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithFormatting.Net.verified.xml
@@ -1 +1 @@
-Bold Link (https://example.com)
\ No newline at end of file
+Bold Link (https://example.com)
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithNoHref.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithNoHref.DotNet.verified.xml
index b61a079..a050219 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithNoHref.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithNoHref.DotNet.verified.xml
@@ -1 +1 @@
-anchor text
\ No newline at end of file
+anchor text
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithNoHref.Net.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithNoHref.Net.verified.xml
index b61a079..a050219 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithNoHref.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithNoHref.Net.verified.xml
@@ -1 +1 @@
-anchor text
\ No newline at end of file
+anchor text
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithSameText.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithSameText.DotNet.verified.xml
index c3cdd07..20c3207 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithSameText.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithSameText.DotNet.verified.xml
@@ -1 +1 @@
-https://example.com
\ No newline at end of file
+https://example.com
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithSameText.Net.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithSameText.Net.verified.xml
index c3cdd07..20c3207 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithSameText.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.LinkWithSameText.Net.verified.xml
@@ -1 +1 @@
-https://example.com
\ No newline at end of file
+https://example.com
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.SimpleLink.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.SimpleLink.DotNet.verified.xml
index a2edd5c..f25c732 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.SimpleLink.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.SimpleLink.DotNet.verified.xml
@@ -1 +1 @@
-Example (https://example.com)
\ No newline at end of file
+Example (https://example.com)
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordAnchorTests.SimpleLink.Net.verified.xml b/src/OpenXmlHtml.Tests/WordAnchorTests.SimpleLink.Net.verified.xml
index a2edd5c..f25c732 100644
--- a/src/OpenXmlHtml.Tests/WordAnchorTests.SimpleLink.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordAnchorTests.SimpleLink.Net.verified.xml
@@ -1 +1 @@
-Example (https://example.com)
\ No newline at end of file
+Example (https://example.com)
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordBookmarkIdTests.cs b/src/OpenXmlHtml.Tests/WordBookmarkIdTests.cs
new file mode 100644
index 0000000..367b189
--- /dev/null
+++ b/src/OpenXmlHtml.Tests/WordBookmarkIdTests.cs
@@ -0,0 +1,33 @@
+[TestFixture]
+public class WordBookmarkIdTests
+{
+ // Bookmarks generated from id/name attributes inside table cells must be document-unique.
+ // Regression: each cell built a fresh context whose bookmark counter restarted at zero,
+ // producing duplicate BookmarkStart IDs (invalid OOXML).
+ [Test]
+ public void BookmarksInTableCellsAreUnique()
+ {
+ using var stream = new MemoryStream();
+ WordHtmlConverter.ConvertToDocx(
+ """
+
+ C
+ """,
+ stream);
+
+ stream.Position = 0;
+ using var document = WordprocessingDocument.Open(stream, false);
+ var bookmarkIds = document.MainDocumentPart!.Document!.Body!
+ .Descendants()
+ .Select(_ => _.Id!.Value)
+ .ToList();
+
+ Assert.That(bookmarkIds, Is.Unique);
+ Assert.That(bookmarkIds.Count, Is.EqualTo(3));
+ }
+}
diff --git a/src/OpenXmlHtml.Tests/WordBorderTests.BorderWithBackground.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordBorderTests.BorderWithBackground.DotNet.verified.docx
index d7912a1..21e3f75 100644
Binary files a/src/OpenXmlHtml.Tests/WordBorderTests.BorderWithBackground.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordBorderTests.BorderWithBackground.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordBorderTests.BorderWithBackground.Net.verified.docx b/src/OpenXmlHtml.Tests/WordBorderTests.BorderWithBackground.Net.verified.docx
index ff1827d..d01e119 100644
Binary files a/src/OpenXmlHtml.Tests/WordBorderTests.BorderWithBackground.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordBorderTests.BorderWithBackground.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkCreatesHyperlink.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkCreatesHyperlink.DotNet.verified.docx
index 3fd37c5..a836baa 100644
Binary files a/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkCreatesHyperlink.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkCreatesHyperlink.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkCreatesHyperlink.Net.verified.docx b/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkCreatesHyperlink.Net.verified.docx
index 2b339a9..94b5286 100644
Binary files a/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkCreatesHyperlink.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkCreatesHyperlink.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkFallbackWithoutMainPart.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkFallbackWithoutMainPart.DotNet.verified.xml
index 9fbd468..247ebb3 100644
--- a/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkFallbackWithoutMainPart.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkFallbackWithoutMainPart.DotNet.verified.xml
@@ -1 +1 @@
-Click here (https://example.com) for details.
\ No newline at end of file
+Click here (https://example.com) for details.
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkFallbackWithoutMainPart.Net.verified.xml b/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkFallbackWithoutMainPart.Net.verified.xml
index 9fbd468..247ebb3 100644
--- a/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkFallbackWithoutMainPart.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordClickableImageTests.ExternalLinkFallbackWithoutMainPart.Net.verified.xml
@@ -1 +1 @@
-Click here (https://example.com) for details.
\ No newline at end of file
+Click here (https://example.com) for details.
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordClickableImageTests.ImageAndTextInsideLink.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordClickableImageTests.ImageAndTextInsideLink.DotNet.verified.docx
index 0a5f8d1..eca3dff 100644
Binary files a/src/OpenXmlHtml.Tests/WordClickableImageTests.ImageAndTextInsideLink.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordClickableImageTests.ImageAndTextInsideLink.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordClickableImageTests.ImageAndTextInsideLink.Net.verified.docx b/src/OpenXmlHtml.Tests/WordClickableImageTests.ImageAndTextInsideLink.Net.verified.docx
index 3209d62..89b4bf6 100644
Binary files a/src/OpenXmlHtml.Tests/WordClickableImageTests.ImageAndTextInsideLink.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordClickableImageTests.ImageAndTextInsideLink.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordColgroupTests.ColWidthWithColspan.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordColgroupTests.ColWidthWithColspan.DotNet.verified.xml
index e6e5b15..d36b290 100644
--- a/src/OpenXmlHtml.Tests/WordColgroupTests.ColWidthWithColspan.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordColgroupTests.ColWidthWithColspan.DotNet.verified.xml
@@ -1 +1 @@
-MergedCABC
\ No newline at end of file
+MergedCABC
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordColgroupTests.ColWidthWithColspan.Net.verified.xml b/src/OpenXmlHtml.Tests/WordColgroupTests.ColWidthWithColspan.Net.verified.xml
index e6e5b15..d36b290 100644
--- a/src/OpenXmlHtml.Tests/WordColgroupTests.ColWidthWithColspan.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordColgroupTests.ColWidthWithColspan.Net.verified.xml
@@ -1 +1 @@
-MergedCABC
\ No newline at end of file
+MergedCABC
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordConvertFileTests.ConvertFullHtmlPage.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordConvertFileTests.ConvertFullHtmlPage.DotNet.verified.docx
index 3ff8716..929594a 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertFileTests.ConvertFullHtmlPage.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertFileTests.ConvertFullHtmlPage.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertFileTests.ConvertFullHtmlPage.Net.verified.docx b/src/OpenXmlHtml.Tests/WordConvertFileTests.ConvertFullHtmlPage.Net.verified.docx
index 1089880..6339199 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertFileTests.ConvertFullHtmlPage.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertFileTests.ConvertFullHtmlPage.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.CssStyles.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.CssStyles.DotNet.verified.docx
index 187ce05..1eaedf9 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.CssStyles.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.CssStyles.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.CssStyles.Net.verified.docx b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.CssStyles.Net.verified.docx
index 00a1529..d213a72 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.CssStyles.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.CssStyles.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.FormattedContent.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.FormattedContent.DotNet.verified.docx
index f344cc2..45aa49e 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.FormattedContent.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.FormattedContent.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.FormattedContent.Net.verified.docx b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.FormattedContent.Net.verified.docx
index 2b69d56..89a2802 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.FormattedContent.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.FormattedContent.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.LinksAndImages.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.LinksAndImages.DotNet.verified.docx
index 6eef17b..3a3d3ed 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.LinksAndImages.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.LinksAndImages.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.LinksAndImages.Net.verified.docx b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.LinksAndImages.Net.verified.docx
index 9d9e568..ee5e371 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.LinksAndImages.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.LinksAndImages.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.RichDocument.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.RichDocument.DotNet.verified.docx
index 16b8468..a3c4fe1 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.RichDocument.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.RichDocument.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.RichDocument.Net.verified.docx b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.RichDocument.Net.verified.docx
index 9161e8d..cf20454 100644
Binary files a/src/OpenXmlHtml.Tests/WordConvertToDocxTests.RichDocument.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordConvertToDocxTests.RichDocument.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordFootnoteIdTests.cs b/src/OpenXmlHtml.Tests/WordFootnoteIdTests.cs
new file mode 100644
index 0000000..b4ea062
--- /dev/null
+++ b/src/OpenXmlHtml.Tests/WordFootnoteIdTests.cs
@@ -0,0 +1,70 @@
+[TestFixture]
+public class WordFootnoteIdTests
+{
+ // Footnotes raised inside table cells must get document-unique IDs. Regression: each cell
+ // built a fresh context whose footnote counter restarted at zero, producing colliding IDs.
+ [Test]
+ public void FootnotesInTableCellsAreUnique()
+ {
+ using var stream = new MemoryStream();
+ WordHtmlConverter.ConvertToDocx(
+ """
+
+ C
+ """,
+ stream);
+
+ var (footnoteIds, referenceIds) = ReadFootnoteIds(stream);
+
+ Assert.That(footnoteIds, Is.Unique);
+ Assert.That(footnoteIds.Count, Is.EqualTo(3));
+ Assert.That(referenceIds, Is.EquivalentTo(footnoteIds));
+ }
+
+ // A second AppendHtml call against the same document must continue footnote numbering instead
+ // of restarting at 1 and colliding with footnotes emitted by the first call.
+ [Test]
+ public void FootnotesAcrossAppendCallsAreUnique()
+ {
+ using var stream = new MemoryStream();
+ using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
+ {
+ var main = document.AddMainDocumentPart();
+ var body = new Body();
+ WordHtmlConverter.AppendHtml(body, """A
""", main);
+ WordHtmlConverter.AppendHtml(body, """B
""", main);
+ main.Document = new(body);
+ }
+
+ var (footnoteIds, _) = ReadFootnoteIds(stream);
+
+ Assert.That(footnoteIds, Is.Unique);
+ Assert.That(footnoteIds.Count, Is.EqualTo(2));
+ }
+
+ static (List FootnoteIds, List ReferenceIds) ReadFootnoteIds(MemoryStream stream)
+ {
+ stream.Position = 0;
+ using var document = WordprocessingDocument.Open(stream, false);
+ var main = document.MainDocumentPart!;
+
+ // Real footnotes only — skip the reserved separator (-1) and continuation (0) entries.
+ var footnoteIds = main.FootnotesPart!.Footnotes!
+ .Elements()
+ .Select(_ => _.Id!.Value)
+ .Where(_ => _ > 0)
+ .ToList();
+
+ var referenceIds = main.Document!.Body!
+ .Descendants()
+ .Select(_ => _.Id!.Value)
+ .ToList();
+
+ return (footnoteIds, referenceIds);
+ }
+}
diff --git a/src/OpenXmlHtml.Tests/WordNestedComboTests.NestedCombinations.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordNestedComboTests.NestedCombinations.DotNet.verified.docx
index e606716..f6cf2bd 100644
Binary files a/src/OpenXmlHtml.Tests/WordNestedComboTests.NestedCombinations.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordNestedComboTests.NestedCombinations.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordNestedComboTests.NestedCombinations.Net.verified.docx b/src/OpenXmlHtml.Tests/WordNestedComboTests.NestedCombinations.Net.verified.docx
index 94bccb1..af7e873 100644
Binary files a/src/OpenXmlHtml.Tests/WordNestedComboTests.NestedCombinations.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordNestedComboTests.NestedCombinations.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordNestedTests.DeeplyNested.DotNet.verified.xml b/src/OpenXmlHtml.Tests/WordNestedTests.DeeplyNested.DotNet.verified.xml
index d29c1e6..2c3f086 100644
--- a/src/OpenXmlHtml.Tests/WordNestedTests.DeeplyNested.DotNet.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordNestedTests.DeeplyNested.DotNet.verified.xml
@@ -1 +1 @@
-all formats
\ No newline at end of file
+all formats
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordNestedTests.DeeplyNested.Net.verified.xml b/src/OpenXmlHtml.Tests/WordNestedTests.DeeplyNested.Net.verified.xml
index d29c1e6..2c3f086 100644
--- a/src/OpenXmlHtml.Tests/WordNestedTests.DeeplyNested.Net.verified.xml
+++ b/src/OpenXmlHtml.Tests/WordNestedTests.DeeplyNested.Net.verified.xml
@@ -1 +1 @@
-all formats
\ No newline at end of file
+all formats
\ No newline at end of file
diff --git a/src/OpenXmlHtml.Tests/WordRowspanTests.cs b/src/OpenXmlHtml.Tests/WordRowspanTests.cs
new file mode 100644
index 0000000..957ce14
--- /dev/null
+++ b/src/OpenXmlHtml.Tests/WordRowspanTests.cs
@@ -0,0 +1,30 @@
+using WTable = DocumentFormat.OpenXml.Wordprocessing.Table;
+
+[TestFixture]
+public class WordRowspanTests
+{
+ // A rowspan originating in an earlier row must not cause cells in a later, wider row to be
+ // dropped. Regression: GetColumnCount ignored carried-over rowspans, so the render loop's
+ // column bound was too small and trailing cells were silently lost.
+ [Test]
+ public void RowspanDoesNotDropTrailingCells()
+ {
+ var elements = WordHtmlConverter.ToElements(
+ """
+
+ """);
+
+ var table = elements.OfType().Single();
+ var rows = table.Elements().ToList();
+
+ Assert.That(table.InnerText, Does.Contain("A"));
+ Assert.That(table.InnerText, Does.Contain("B"));
+ Assert.That(table.InnerText, Does.Contain("C"));
+
+ // Second row is three columns wide: the vMerge continuation of A, then B, then C.
+ Assert.That(rows[1].Elements().Count(), Is.EqualTo(3));
+ }
+}
diff --git a/src/OpenXmlHtml.Tests/WordStyleComboTests.FullFeatureDocx.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordStyleComboTests.FullFeatureDocx.DotNet.verified.docx
index a822a23..bcb00c7 100644
Binary files a/src/OpenXmlHtml.Tests/WordStyleComboTests.FullFeatureDocx.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordStyleComboTests.FullFeatureDocx.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordStyleComboTests.FullFeatureDocx.Net.verified.docx b/src/OpenXmlHtml.Tests/WordStyleComboTests.FullFeatureDocx.Net.verified.docx
index a0298f8..adae2f3 100644
Binary files a/src/OpenXmlHtml.Tests/WordStyleComboTests.FullFeatureDocx.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordStyleComboTests.FullFeatureDocx.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPadding.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPadding.DotNet.verified.docx
index 4be1c48..27bb15d 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPadding.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPadding.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPadding.Net.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPadding.Net.verified.docx
index de47587..07fe491 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPadding.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPadding.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPaddingIndividual.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPaddingIndividual.DotNet.verified.docx
index 88dd6d4..b88811f 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPaddingIndividual.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPaddingIndividual.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPaddingIndividual.Net.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPaddingIndividual.Net.verified.docx
index 0c59644..93ea7e8 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPaddingIndividual.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellPaddingIndividual.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellVerticalAlign.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellVerticalAlign.DotNet.verified.docx
index b6c7345..6dcf607 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellVerticalAlign.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellVerticalAlign.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellVerticalAlign.Net.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellVerticalAlign.Net.verified.docx
index 7d66954..89164c7 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.CellVerticalAlign.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.CellVerticalAlign.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.CombinedCellStyles.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.CombinedCellStyles.DotNet.verified.docx
index 7fa9508..b071c17 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.CombinedCellStyles.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.CombinedCellStyles.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.CombinedCellStyles.Net.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.CombinedCellStyles.Net.verified.docx
index 0c857c8..b49d7f7 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.CombinedCellStyles.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.CombinedCellStyles.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.HtmlCellpaddingAttribute.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.HtmlCellpaddingAttribute.DotNet.verified.docx
index dd4688f..545fe76 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.HtmlCellpaddingAttribute.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.HtmlCellpaddingAttribute.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.HtmlCellpaddingAttribute.Net.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.HtmlCellpaddingAttribute.Net.verified.docx
index 40334cf..9e3382e 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.HtmlCellpaddingAttribute.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.HtmlCellpaddingAttribute.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.TableDefaultCellPadding.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.TableDefaultCellPadding.DotNet.verified.docx
index c5adc2e..49f3abf 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.TableDefaultCellPadding.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.TableDefaultCellPadding.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordTableStyleTests.TableDefaultCellPadding.Net.verified.docx b/src/OpenXmlHtml.Tests/WordTableStyleTests.TableDefaultCellPadding.Net.verified.docx
index 65d39ff..ceb97b7 100644
Binary files a/src/OpenXmlHtml.Tests/WordTableStyleTests.TableDefaultCellPadding.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordTableStyleTests.TableDefaultCellPadding.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordUnderlineTests.UnderlineWithColor.DotNet.verified.docx b/src/OpenXmlHtml.Tests/WordUnderlineTests.UnderlineWithColor.DotNet.verified.docx
index 5828b97..55f271d 100644
Binary files a/src/OpenXmlHtml.Tests/WordUnderlineTests.UnderlineWithColor.DotNet.verified.docx and b/src/OpenXmlHtml.Tests/WordUnderlineTests.UnderlineWithColor.DotNet.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordUnderlineTests.UnderlineWithColor.Net.verified.docx b/src/OpenXmlHtml.Tests/WordUnderlineTests.UnderlineWithColor.Net.verified.docx
index 859437b..8f5c574 100644
Binary files a/src/OpenXmlHtml.Tests/WordUnderlineTests.UnderlineWithColor.Net.verified.docx and b/src/OpenXmlHtml.Tests/WordUnderlineTests.UnderlineWithColor.Net.verified.docx differ
diff --git a/src/OpenXmlHtml.Tests/WordValidationTests.cs b/src/OpenXmlHtml.Tests/WordValidationTests.cs
new file mode 100644
index 0000000..c992afe
--- /dev/null
+++ b/src/OpenXmlHtml.Tests/WordValidationTests.cs
@@ -0,0 +1,43 @@
+using DocumentFormat.OpenXml.Validation;
+
+[TestFixture]
+public class WordValidationTests
+{
+ // The generated docx must satisfy the OOXML schema. This primarily guards the run-property
+ // and cell-property ordering: out-of-sequence children are the most common validity failure
+ // and are silently repaired by Word but rejected by stricter consumers.
+ [Test]
+ public void GeneratedDocumentIsSchemaValid()
+ {
+ using var stream = new MemoryStream();
+ WordHtmlConverter.ConvertToDocx(
+ """
+ Report
+
+ styled then super then
+ spaced then
+ rtl
+
+ term
+
+ - one
- two
+ """,
+ stream);
+
+ stream.Position = 0;
+ using var document = WordprocessingDocument.Open(stream, false);
+ var validator = new OpenXmlValidator();
+ var errors = validator.Validate(document).ToList();
+
+ Assert.That(
+ errors,
+ Is.Empty,
+ () => string.Join("\n", errors.Select(_ => $"{_.Description} ({_.Path?.XPath})")));
+ }
+}
diff --git a/src/OpenXmlHtml/AtPageParser.cs b/src/OpenXmlHtml/AtPageParser.cs
index 5da03bc..edbe27f 100644
--- a/src/OpenXmlHtml/AtPageParser.cs
+++ b/src/OpenXmlHtml/AtPageParser.cs
@@ -14,13 +14,12 @@ static class AtPageParser
return null;
}
- var braceEnd = html.IndexOf('}', braceStart);
- if (braceEnd < 0)
+ var body = ExtractBlockBody(html, braceStart);
+ if (body == null)
{
return null;
}
- var body = html.Substring(braceStart + 1, braceEnd - braceStart - 1);
var declarations = StyleParser.Parse(body);
var layout = new AtPageLayout();
@@ -209,4 +208,59 @@ static bool TryParseNamedSize(ReadOnlySpan name, out int width, out int he
height = 0;
return false;
}
+
+ // Extracts the top-level declarations of the @page block starting at the given '{'.
+ // Nested margin-box rules (e.g. @top-center { ... }) and their selectors are skipped
+ // so sibling declarations such as margin/size are not lost. Returns null if unbalanced.
+ static string? ExtractBlockBody(string html, int braceStart)
+ {
+ var builder = new StringBuilder();
+ var depth = 0;
+ for (var i = braceStart; i < html.Length; i++)
+ {
+ var current = html[i];
+ if (current == '{')
+ {
+ if (depth == 1)
+ {
+ // Drop the buffered selector that precedes this nested block.
+ builder.Length = LastSeparatorIndex(builder) + 1;
+ }
+
+ depth++;
+ continue;
+ }
+
+ if (current == '}')
+ {
+ depth--;
+ if (depth == 0)
+ {
+ return builder.ToString();
+ }
+
+ continue;
+ }
+
+ if (depth == 1)
+ {
+ builder.Append(current);
+ }
+ }
+
+ return null;
+ }
+
+ static int LastSeparatorIndex(StringBuilder builder)
+ {
+ for (var i = builder.Length - 1; i >= 0; i--)
+ {
+ if (builder[i] == ';')
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
}
diff --git a/src/OpenXmlHtml/ColorParser.cs b/src/OpenXmlHtml/ColorParser.cs
index 550d25e..396f05f 100644
--- a/src/OpenXmlHtml/ColorParser.cs
+++ b/src/OpenXmlHtml/ColorParser.cs
@@ -233,14 +233,40 @@ static class ColorParser
bSpan = bSpan[..thirdComma];
}
- if (!int.TryParse(inner[..firstComma].Trim(), out var r) ||
- !int.TryParse(rest[..secondComma].Trim(), out var g) ||
- !int.TryParse(bSpan.Trim(), out var b))
+ if (!TryParseComponent(inner[..firstComma], out var r) ||
+ !TryParseComponent(rest[..secondComma], out var g) ||
+ !TryParseComponent(bSpan, out var b))
{
return null;
}
- return $"{Clamp(r):X2}{Clamp(g):X2}{Clamp(b):X2}";
+ return $"{r:X2}{g:X2}{b:X2}";
+ }
+
+ // Parses a single rgb() component: integer, decimal, or percentage (e.g. "255", "49.5", "50%").
+ static bool TryParseComponent(ReadOnlySpan value, out int component)
+ {
+ value = value.Trim();
+ if (value.EndsWith("%".AsSpan(), StringComparison.Ordinal))
+ {
+ if (double.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent))
+ {
+ component = Clamp((int)Math.Round(percent / 100 * 255));
+ return true;
+ }
+
+ component = 0;
+ return false;
+ }
+
+ if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
+ {
+ component = Clamp((int)Math.Round(number));
+ return true;
+ }
+
+ component = 0;
+ return false;
}
static int Clamp(int value) =>
diff --git a/src/OpenXmlHtml/HtmlParser.cs b/src/OpenXmlHtml/HtmlParser.cs
index 0011c3b..5d0dc1e 100644
--- a/src/OpenXmlHtml/HtmlParser.cs
+++ b/src/OpenXmlHtml/HtmlParser.cs
@@ -18,6 +18,7 @@ internal static List Parse(string html, HtmlConvertSettings? settin
static void ProcessNode(INode node, FormatState format, List segments, bool inPre, HtmlConvertSettings? settings)
{
+ var orderedIndex = 0;
foreach (var child in node.ChildNodes)
{
switch (child)
@@ -35,22 +36,25 @@ static void ProcessNode(INode node, FormatState format, List segmen
break;
}
case IElement element:
- ProcessElement(element, format, segments, inPre, settings);
+ {
+ var listIndex = element.LocalName == "li" ? ++orderedIndex : 0;
+ ProcessElement(element, format, segments, inPre, settings, listIndex);
break;
+ }
}
}
}
- static void ProcessElement(IElement element, FormatState format, List segments, bool inPre, HtmlConvertSettings? settings)
+ static void ProcessElement(IElement element, FormatState format, List segments, bool inPre, HtmlConvertSettings? settings, int listIndex = 0)
{
var tag = element.LocalName;
- if (IsHiddenElement(element, tag))
+ var newFormat = format;
+ ApplyElementFormatting(element, tag, ref newFormat, out var styleDeclarations);
+ if (IsHiddenElement(element, tag, styleDeclarations))
{
return;
}
- var newFormat = format;
- ApplyElementFormatting(element, tag, ref newFormat, out var styleDeclarations);
inPre = ApplyWhiteSpace(styleDeclarations, ref newFormat, inPre);
switch (tag)
@@ -124,21 +128,7 @@ static void ProcessElement(IElement element, FormatState format, List? declarations)
{
// Always skip non-rendered metadata/script/form-control tags.
// (input is intentionally NOT here so checkboxes can be rendered.)
@@ -574,13 +566,11 @@ internal static bool IsHiddenElement(IElement element, string tag)
return true;
}
- var style = element.GetAttribute("style");
- if (style == null)
+ if (declarations == null)
{
return false;
}
- var declarations = StyleParser.Parse(style);
if (declarations.TryGetValue("display", out var display) &&
display.Equals("none", StringComparison.OrdinalIgnoreCase))
{
diff --git a/src/OpenXmlHtml/ImageResolver.cs b/src/OpenXmlHtml/ImageResolver.cs
index 390576a..6bb21b5 100644
--- a/src/OpenXmlHtml/ImageResolver.cs
+++ b/src/OpenXmlHtml/ImageResolver.cs
@@ -1,6 +1,15 @@
static class ImageResolver
{
- static readonly HttpClient sharedClient = new();
+ // The default client does NOT auto-redirect: redirects are followed manually so each hop is
+ // re-checked against the image policy. Otherwise a redirect from an allowed host could point
+ // at an internal address and bypass SafeDomains (an SSRF vector).
+ static readonly HttpClient sharedClient = new(new HttpClientHandler { AllowAutoRedirect = false })
+ {
+ Timeout = TimeSpan.FromSeconds(30)
+ };
+
+ const int maxRedirects = 5;
+ const long maxImageBytes = 50L * 1024 * 1024;
static readonly Dictionary extensionToContentType = new(StringComparer.OrdinalIgnoreCase)
{
@@ -48,14 +57,14 @@ static class ImageResolver
var client = settings.HttpClient ?? sharedClient;
try
{
- using var response = client.GetAsync(src).GetAwaiter().GetResult();
- if (!response.IsSuccessStatusCode)
+ var downloaded = Download(client, src, settings.WebImages);
+ if (downloaded == null)
{
return null;
}
- bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
- contentType = response.Content.Headers.ContentType?.MediaType;
+ bytes = downloaded.Value.Bytes;
+ contentType = downloaded.Value.ContentType;
}
catch
{
@@ -156,7 +165,7 @@ internal static (int? Width, int? Height) ParseImageDimensions(IElement element)
if (width == null)
{
var widthAttr = element.GetAttribute("width");
- if (widthAttr != null && int.TryParse(widthAttr, out var w))
+ if (widthAttr != null && int.TryParse(widthAttr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var w))
{
width = w;
}
@@ -165,7 +174,7 @@ internal static (int? Width, int? Height) ParseImageDimensions(IElement element)
if (height == null)
{
var heightAttr = element.GetAttribute("height");
- if (heightAttr != null && int.TryParse(heightAttr, out var h))
+ if (heightAttr != null && int.TryParse(heightAttr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var h))
{
height = h;
}
@@ -174,6 +183,79 @@ internal static (int? Width, int? Height) ParseImageDimensions(IElement element)
return (width, height);
}
+ // Downloads an image, following redirects manually so each hop is policy-checked, and
+ // capping the response size to guard against unbounded memory use.
+ static (byte[] Bytes, string? ContentType)? Download(HttpClient client, string url, ImagePolicy policy)
+ {
+ for (var redirect = 0; redirect <= maxRedirects; redirect++)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ using var response = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult();
+
+ if (IsRedirect(response))
+ {
+ var location = response.Headers.Location;
+ if (location == null)
+ {
+ return null;
+ }
+
+ url = (location.IsAbsoluteUri ? location : new Uri(new Uri(url), location)).ToString();
+ if (!policy.IsAllowed(url))
+ {
+ return null;
+ }
+
+ continue;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ if (response.Content.Headers.ContentLength > maxImageBytes)
+ {
+ return null;
+ }
+
+ var bytes = ReadCapped(response);
+ if (bytes == null)
+ {
+ return null;
+ }
+
+ return (bytes, response.Content.Headers.ContentType?.MediaType);
+ }
+
+ return null;
+ }
+
+ static byte[]? ReadCapped(HttpResponseMessage response)
+ {
+ using var stream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult();
+ using var memory = new MemoryStream();
+ var buffer = new byte[81920];
+ int read;
+ while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ if (memory.Length + read > maxImageBytes)
+ {
+ return null;
+ }
+
+ memory.Write(buffer, 0, read);
+ }
+
+ return memory.ToArray();
+ }
+
+ static bool IsRedirect(HttpResponseMessage response)
+ {
+ var code = (int)response.StatusCode;
+ return code is >= 300 and <= 399 && code != 304;
+ }
+
static string GuessContentType(string path) =>
extensionToContentType.GetValueOrDefault(Path.GetExtension(path), "image/png");
}
diff --git a/src/OpenXmlHtml/PaddingHelper.cs b/src/OpenXmlHtml/PaddingHelper.cs
index 193164c..9f19813 100644
--- a/src/OpenXmlHtml/PaddingHelper.cs
+++ b/src/OpenXmlHtml/PaddingHelper.cs
@@ -47,6 +47,8 @@ internal static T BuildMargin(int? top, int? right, int? bottom, int? left)
where T : OpenXmlCompositeElement, new()
{
var margin = new T();
+ // Both CT_TcMar (w:tcMar) and CT_TblCellMar (w:tblCellMar) use w:left/w:right; the w:start/w:end
+ // variants are not schema-valid here and are silently dropped by stricter consumers.
if (top != null)
{
margin.Append(
@@ -60,7 +62,7 @@ internal static T BuildMargin(int? top, int? right, int? bottom, int? left)
if (left != null)
{
margin.Append(
- new StartMargin
+ new LeftMargin
{
Width = left.Value.ToString(),
Type = TableWidthUnitValues.Dxa
@@ -80,7 +82,7 @@ internal static T BuildMargin(int? top, int? right, int? bottom, int? left)
if (right != null)
{
margin.Append(
- new EndMargin
+ new RightMargin
{
Width = right.Value.ToString(),
Type = TableWidthUnitValues.Dxa
diff --git a/src/OpenXmlHtml/WordContentBuilder.Lists.cs b/src/OpenXmlHtml/WordContentBuilder.Lists.cs
index d4dfea2..237db08 100644
--- a/src/OpenXmlHtml/WordContentBuilder.Lists.cs
+++ b/src/OpenXmlHtml/WordContentBuilder.Lists.cs
@@ -65,7 +65,7 @@ static void BuildRealNumberedList(IElement element, string tag, FormatState newF
{
if (context.BulletAbstractNumId == null)
{
- var id = WordNumberingBuilder.GetNextId(numbering);
+ var id = context.NextNumId++;
context.BulletAbstractNumId = WordNumberingBuilder.CreateBulletAbstractNum(numbering, id);
}
@@ -73,8 +73,8 @@ static void BuildRealNumberedList(IElement element, string tag, FormatState newF
}
else
{
- // Each ordered format type gets its own abstract num
- var id = WordNumberingBuilder.GetNextId(numbering);
+ // Each ordered list gets its own abstract num (for independent restart/start)
+ var id = context.NextNumId++;
abstractNumId = WordNumberingBuilder.CreateOrderedAbstractNum(numbering, id, format);
}
@@ -87,7 +87,7 @@ static void BuildRealNumberedList(IElement element, string tag, FormatState newF
startOverride = startVal;
}
- var numId = WordNumberingBuilder.GetNextId(numbering);
+ var numId = context.NextNumId++;
WordNumberingBuilder.AddNumberingInstance(numbering, numId, abstractNumId, startOverride);
var ilvl = context.ListStack.Count > 0 ? Math.Min(context.ListStack.Peek().Ilvl + 1, 8) : 0;
var parentInside = context.ListStack.Count > 0 && context.ListStack.Peek().Inside;
@@ -96,7 +96,7 @@ static void BuildRealNumberedList(IElement element, string tag, FormatState newF
context.ListStack.Pop();
}
- static void BuildListItem(IElement element, FormatState newFormat, List elements, WordBuildContext context, bool inPre)
+ static void BuildListItem(IElement element, FormatState newFormat, List elements, WordBuildContext context, bool inPre, int listIndex)
{
FlushParagraph(elements, context);
if (context.ListStack.Count > 0)
@@ -123,20 +123,7 @@ static void BuildListItem(IElement element, FormatState newFormat, List 1)
{
contTcPr.Append(
new GridSpan
- {
- Val = spanInfo.Colspan
- });
+ {
+ Val = spanInfo.Colspan
+ });
}
+ contTcPr.Append(new VerticalMerge());
tableRow.Append(new TableCell(contTcPr, new Paragraph()));
if (spanInfo.Remaining <= 1)
@@ -309,6 +311,7 @@ static TableCell BuildTableCell(IElement cellElement, FormatState format, WordBu
if (cellProperties != null)
{
+ ReorderCellProperties(cellProperties);
tc.Append(cellProperties);
}
@@ -324,6 +327,8 @@ static TableCell BuildTableCell(IElement cellElement, FormatState format, WordBu
StyleMap = parentCtx.StyleMap,
BulletAbstractNumId = parentCtx.BulletAbstractNumId,
NextNumId = parentCtx.NextNumId,
+ FootnoteIndex = parentCtx.FootnoteIndex,
+ BookmarkId = parentCtx.BookmarkId,
ParagraphRightToLeft = cellFormat.RightToLeft
};
ProcessChildren(cellElement, cellFormat, cellElements, cellCtx, false);
@@ -331,6 +336,8 @@ static TableCell BuildTableCell(IElement cellElement, FormatState format, WordBu
parentCtx.ImageIndex = cellCtx.ImageIndex;
parentCtx.NextNumId = cellCtx.NextNumId;
parentCtx.BulletAbstractNumId = cellCtx.BulletAbstractNumId;
+ parentCtx.FootnoteIndex = cellCtx.FootnoteIndex;
+ parentCtx.BookmarkId = cellCtx.BookmarkId;
if (cellElements.Count == 0)
{
@@ -577,18 +584,44 @@ static List GetCells(IElement row)
static int GetColumnCount(List rows)
{
var maxCols = 0;
+ // Mirror the render loop's rowspan tracking so rowspans originating in earlier rows
+ // are counted; otherwise later, wider rows would be truncated and cells lost.
+ var rowspanTracker = new Dictionary();
foreach (var row in rows)
{
- var cols = 0;
- foreach (var cell in row.Children)
+ var cells = GetCells(row);
+ var cellIndex = 0;
+ var colIndex = 0;
+ while (cellIndex < cells.Count || rowspanTracker.ContainsKey(colIndex))
{
- if (cell.LocalName is "td" or "th")
+ if (rowspanTracker.TryGetValue(colIndex, out var spanInfo))
+ {
+ if (spanInfo.Remaining <= 1)
+ {
+ rowspanTracker.Remove(colIndex);
+ }
+ else
+ {
+ rowspanTracker[colIndex] = (spanInfo.Remaining - 1, spanInfo.Colspan);
+ }
+
+ colIndex += spanInfo.Colspan;
+ continue;
+ }
+
+ var cell = cells[cellIndex];
+ cellIndex++;
+ var colspan = ParseIntAttribute(cell, "colspan", 1);
+ var rowspan = ParseIntAttribute(cell, "rowspan", 1);
+ if (rowspan > 1)
{
- cols += ParseIntAttribute(cell, "colspan", 1);
+ rowspanTracker[colIndex] = (rowspan - 1, colspan);
}
+
+ colIndex += colspan;
}
- maxCols = Math.Max(maxCols, cols);
+ maxCols = Math.Max(maxCols, colIndex);
}
return Math.Max(1, maxCols);
@@ -604,4 +637,34 @@ static int ParseIntAttribute(IElement element, string attribute, int defaultValu
return defaultValue;
}
+
+ static readonly Type[] cellPropertyOrder =
+ [
+ typeof(TableCellWidth),
+ typeof(GridSpan),
+ typeof(VerticalMerge),
+ typeof(TableCellBorders),
+ typeof(Shading),
+ typeof(TableCellMargin),
+ typeof(TextDirection),
+ typeof(TableCellVerticalAlignment)
+ ];
+
+ // The various cell-style sources append in a convenient order; CT_TcPr requires a fixed
+ // schema sequence, so normalise the children before emitting the cell.
+ static void ReorderCellProperties(TableCellProperties tcPr)
+ {
+ var sorted = tcPr.ChildElements
+ .OrderBy(_ =>
+ {
+ var index = Array.IndexOf(cellPropertyOrder, _.GetType());
+ return index < 0 ? int.MaxValue : index;
+ })
+ .ToList();
+ tcPr.RemoveAllChildren();
+ foreach (var child in sorted)
+ {
+ tcPr.Append(child);
+ }
+ }
}
diff --git a/src/OpenXmlHtml/WordContentBuilder.cs b/src/OpenXmlHtml/WordContentBuilder.cs
index 823981c..cc98bcd 100644
--- a/src/OpenXmlHtml/WordContentBuilder.cs
+++ b/src/OpenXmlHtml/WordContentBuilder.cs
@@ -22,6 +22,17 @@ internal static List Build(string html, MainDocumentPart? main,
context.NextNumId = 1;
}
+ // Seed the footnote counter from any existing footnotes so a later Build call against
+ // the same document continues numbering instead of emitting colliding footnote IDs.
+ if (main?.FootnotesPart?.Footnotes is { } existingFootnotes)
+ {
+ context.FootnoteIndex = (int)existingFootnotes
+ .Elements()
+ .Select(_ => _.Id?.Value ?? 0)
+ .DefaultIfEmpty(0)
+ .Max();
+ }
+
if (settings?.NumberingSession is { } session)
{
context.BulletAbstractNumId = session.BulletAbstractNumId;
@@ -58,6 +69,7 @@ internal static List Build(string html, MainDocumentPart? main,
static void ProcessChildren(INode node, FormatState format, List elements, WordBuildContext ctx, bool inPre)
{
+ var orderedIndex = 0;
foreach (var child in node.ChildNodes)
{
switch (child)
@@ -75,22 +87,25 @@ static void ProcessChildren(INode node, FormatState format, List
break;
}
case IElement element:
- ProcessElement(element, format, elements, ctx, inPre);
+ {
+ var listIndex = element.LocalName == "li" ? ++orderedIndex : 0;
+ ProcessElement(element, format, elements, ctx, inPre, listIndex);
break;
+ }
}
}
}
- static void ProcessElement(IElement element, FormatState format, List elements, WordBuildContext context, bool inPre)
+ static void ProcessElement(IElement element, FormatState format, List elements, WordBuildContext context, bool inPre, int listIndex = 0)
{
var tag = element.LocalName;
- if (HtmlSegmentParser.IsHiddenElement(element, tag))
+ var newFormat = format;
+ HtmlSegmentParser.ApplyElementFormatting(element, tag, ref newFormat, out var styleDeclarations);
+ if (HtmlSegmentParser.IsHiddenElement(element, tag, styleDeclarations))
{
return;
}
- var newFormat = format;
- HtmlSegmentParser.ApplyElementFormatting(element, tag, ref newFormat, out var styleDeclarations);
inPre = HtmlSegmentParser.ApplyWhiteSpace(styleDeclarations, ref newFormat, inPre);
switch (tag)
@@ -161,7 +176,7 @@ static void ProcessElement(IElement element, FormatState format, List elements, WordBuildContext conte
context.ParagraphRightToLeft = false;
context.ListNumId = null;
context.ListIlvl = null;
+ context.ListInside = false;
+ context.ListDepth = 0;
return;
}
@@ -541,8 +558,9 @@ static void ApplyParagraphFormat(ParagraphProperties props, ParagraphFormatState
pf.MarginRightTwips != null ||
pf.TextIndentTwips != null)
{
- var indent = props.GetFirstChild() ?? new Indentation();
- if (props.GetFirstChild() == null)
+ var existingIndent = props.GetFirstChild();
+ var indent = existingIndent ?? new Indentation();
+ if (existingIndent == null)
{
props.Append(indent);
}
diff --git a/src/OpenXmlHtml/WordHtmlConverter.cs b/src/OpenXmlHtml/WordHtmlConverter.cs
index a26c6d1..2846643 100644
--- a/src/OpenXmlHtml/WordHtmlConverter.cs
+++ b/src/OpenXmlHtml/WordHtmlConverter.cs
@@ -386,6 +386,19 @@ internal static RunProperties BuildWordRunProperties(FormatState format)
});
}
+ // Children are appended in OOXML CT_RPr schema order (rFonts, b, i, smallCaps,
+ // strike, shadow, color, spacing, w, sz, u, bdr, shd, vertAlign, rtl). Out-of-order
+ // children produce schema-invalid runs that strict consumers reject.
+ if (format.FontFamily != null)
+ {
+ props.Append(
+ new RunFonts
+ {
+ Ascii = format.FontFamily,
+ HighAnsi = format.FontFamily
+ });
+ }
+
if (format.Bold)
{
props.Append(new Bold());
@@ -396,18 +409,9 @@ internal static RunProperties BuildWordRunProperties(FormatState format)
props.Append(new Italic());
}
- if (format.UnderlineStyle != null)
+ if (format.SmallCaps)
{
- var underline = new Underline
- {
- Val = format.UnderlineStyle
- };
- if (format.UnderlineColor != null)
- {
- underline.Color = format.UnderlineColor;
- }
-
- props.Append(underline);
+ props.Append(new SmallCaps());
}
if (format.Strikethrough)
@@ -415,14 +419,18 @@ internal static RunProperties BuildWordRunProperties(FormatState format)
props.Append(new Strike());
}
- if (format.SmallCaps)
+ if (format.Shadow)
{
- props.Append(new SmallCaps());
+ props.Append(new Shadow());
}
- if (format.Shadow)
+ if (format.Color != null)
{
- props.Append(new Shadow());
+ props.Append(
+ new Color
+ {
+ Val = format.Color
+ });
}
if (format.CharacterSpacingTwips != null)
@@ -443,37 +451,50 @@ internal static RunProperties BuildWordRunProperties(FormatState format)
});
}
- if (format.RightToLeft)
- {
- props.Append(new RightToLeftText());
- }
-
- if (format.Color != null)
+ if (format.FontSizePt != null)
{
+ var halfPoints = (int)(format.FontSizePt.Value * 2);
props.Append(
- new Color
+ new FontSize
{
- Val = format.Color
+ Val = halfPoints.ToString()
});
}
- if (format.FontSizePt != null)
+ if (format.UnderlineStyle != null)
+ {
+ var underline = new Underline
+ {
+ Val = format.UnderlineStyle
+ };
+ if (format.UnderlineColor != null)
+ {
+ underline.Color = format.UnderlineColor;
+ }
+
+ props.Append(underline);
+ }
+
+ if (format.Border != null &&
+ format.Border.Style != BorderValues.None)
{
- var halfPoints = (int)(format.FontSizePt.Value * 2);
props.Append(
- new FontSize
+ new Border
{
- Val = halfPoints.ToString()
+ Val = format.Border.Style,
+ Size = (uint)format.Border.SizeEighths,
+ Space = 1,
+ Color = format.Border.Color ?? "auto"
});
}
- if (format.FontFamily != null)
+ if (format.BackgroundColor != null)
{
props.Append(
- new RunFonts
+ new Shading
{
- Ascii = format.FontFamily,
- HighAnsi = format.FontFamily
+ Val = ShadingPatternValues.Clear,
+ Fill = format.BackgroundColor
});
}
@@ -494,27 +515,9 @@ internal static RunProperties BuildWordRunProperties(FormatState format)
});
}
- if (format.BackgroundColor != null)
- {
- props.Append(
- new Shading
- {
- Val = ShadingPatternValues.Clear,
- Fill = format.BackgroundColor
- });
- }
-
- if (format.Border != null &&
- format.Border.Style != BorderValues.None)
+ if (format.RightToLeft)
{
- props.Append(
- new Border
- {
- Val = format.Border.Style,
- Size = (uint)format.Border.SizeEighths,
- Space = 1,
- Color = format.Border.Color ?? "auto"
- });
+ props.Append(new RightToLeftText());
}
return props;