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( + """ + + + + + +

A

B

+

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( + """ + + + + + +
AB
+

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( + """ + + + +
A
BC
+ """); + + 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

+ + + + + + +
header
mx
yz
+
  1. one
  2. 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;