diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 523c45ce..50eeb036 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,6 +27,22 @@ updates: directory: "/" schedule: interval: "daily" + # SkiaSharp and its native assets must always have the same version - separate PRs each fail + # the build until the other one is merged, so update them together in one PR. SkiaSharpCompare + # (test dependency) is built against a specific SkiaSharp version and joins the same PR. + groups: + skiasharp: + patterns: + - "SkiaSharp" + - "SkiaSharp.*" + - "Codeuctivity.SkiaSharpCompare" + # Wait a few days before picking up a new SkiaSharp, so SkiaSharpCompare has time to publish a + # release built against it and both land in the same grouped PR. + cooldown: + default-days: 3 + include: + - "SkiaSharp" + - "SkiaSharp.*" ignore: - dependency-name: "SixLabors.ImageSharp" versions: ["3.x", "4.x"] diff --git a/OpenXmlPowerTools.Tests/OpenXmlPowerTools.Tests.csproj b/OpenXmlPowerTools.Tests/OpenXmlPowerTools.Tests.csproj index 4ce43ecd..9e367cc1 100644 --- a/OpenXmlPowerTools.Tests/OpenXmlPowerTools.Tests.csproj +++ b/OpenXmlPowerTools.Tests/OpenXmlPowerTools.Tests.csproj @@ -6,14 +6,13 @@ - - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/OpenXmlPowerTools.Tests/OpenXmlRegexTests.cs b/OpenXmlPowerTools.Tests/OpenXmlRegexTests.cs index 7c303e10..daaabaa3 100644 --- a/OpenXmlPowerTools.Tests/OpenXmlRegexTests.cs +++ b/OpenXmlPowerTools.Tests/OpenXmlRegexTests.cs @@ -405,5 +405,26 @@ public void CanMatchDespiteInvisibleLayoutMarkers() } } + [Theory] + [InlineData(null)] + [InlineData("default")] + [InlineData("preserve")] + public void CanReplaceSplitMatchesWithNonBreakingSpaces(string space) + { + XElement Text(string value) => new XElement(W.t, + space == null ? null : new XAttribute(XNamespace.Xml + "space", space), value); + + var paragraph = new XElement(W.p, + new XElement(W.r, Text("Prefix\u00A0{[if")), + new XElement(W.r, Text("\u00A0Ready]}\u00A0Middle\u00A0{[if")), + new XElement(W.r, Text("\u00A0Ready]}\u00A0Suffix"))); + var count = OpenXmlRegex.Replace(new[] { paragraph }, + new Regex(Regex.Escape("{[if\u00A0Ready]}")), "Selected", null); + + Assert.Equal(2, count); + Assert.Equal("Prefix\u00A0Selected\u00A0Middle\u00A0Selected\u00A0Suffix", + string.Concat(paragraph.Descendants(W.t).Select(t => t.Value))); + } + } } \ No newline at end of file diff --git a/OpenXmlPowerTools.Tests/UnicodeMapperTests.cs b/OpenXmlPowerTools.Tests/UnicodeMapperTests.cs index 8532dc2d..731e2eed 100644 --- a/OpenXmlPowerTools.Tests/UnicodeMapperTests.cs +++ b/OpenXmlPowerTools.Tests/UnicodeMapperTests.cs @@ -69,6 +69,20 @@ public void CanCreateCoalescedRuns() Assert.Equal("Third", mixedRuns.Elements(W.t).Skip(2).First().Value); } + [Theory] + [InlineData("\u00A0")] + [InlineData("\u00A0text")] + [InlineData("text\u00A0")] + [InlineData("\u00A0text\u00A0")] + public void CanRoundTripNonBreakingSpaces(string text) + { + var runs = UnicodeMapper.StringToCoalescedRunList(text, null); + + // NBSP is content; the mapper's own writer does not require xml:space for it. + Assert.All(runs.Elements(W.t), t => Assert.Null(t.Attribute(XNamespace.Xml + "space"))); + Assert.Equal(text, runs.Select(UnicodeMapper.RunToString).StringConcatenate()); + } + [Fact] public void CanMapSymbols() { @@ -201,25 +215,32 @@ public void HonorsXmlSpace() // into a canonical form when displaying or saving the document. These tests // compare RunToString’s output against Word’s canonicalized output to ensure // that whitespace is treated as semantic content in the same way Word treats it. - [Fact] - public void TreatsXmlSpaceLikeWord() + [Theory] + [InlineData("UM-Whitespace", 107)] + [InlineData("UM-Whitespace-Unicode", 43)] + public void TreatsXmlSpaceLikeWord(string fixture, int caseCount) { var sourceDir = new System.IO.DirectoryInfo("../../../../TestFiles/"); // Test document: crafted to include many whitespace patterns that Word accepts as valid input - var testDoc = new System.IO.FileInfo(System.IO.Path.Combine(sourceDir.FullName, "UM-Whitespace-test.docx")); + var testDoc = new System.IO.FileInfo(System.IO.Path.Combine(sourceDir.FullName, fixture + "-test.docx")); var testWmlDoc = new WmlDocument(testDoc.FullName); var testParagraphs = testWmlDoc.MainDocumentPart .Element(W.body) .Elements(W.p).ToList(); // Canonical document: the same test document after being opened and saved by Word, // representing Word's own normalized interpretation of that whitespace - var expectedDoc = new System.IO.FileInfo(System.IO.Path.Combine(sourceDir.FullName, "UM-Whitespace-Word-saved.docx")); + var expectedDoc = new System.IO.FileInfo(System.IO.Path.Combine(sourceDir.FullName, fixture + "-Word-saved.docx")); var expectedWmlDoc = new WmlDocument(expectedDoc.FullName); var expectedParagraphs = expectedWmlDoc.MainDocumentPart .Element(W.body) .Elements(W.p).ToList(); + // Each fixture ends with one blank paragraph after its name/content pairs. + Assert.Equal(caseCount * 2 + 1, testParagraphs.Count); + Assert.Equal(testParagraphs.Count, expectedParagraphs.Count); + Assert.Empty(testParagraphs.Last().Descendants(W.t)); + Assert.Empty(expectedParagraphs.Last().Descendants(W.t)); // Iterate through pairs of paragraphs (test name, test content, expected result) - for (int i = 0; i < testParagraphs.Count - 1; i += 2) + for (int i = 0; i < caseCount * 2; i += 2) { var testNameParagraph = testParagraphs[i]; var testContentParagraph = testParagraphs[i + 1]; @@ -227,6 +248,7 @@ public void TreatsXmlSpaceLikeWord() var testName = testNameParagraph.Descendants(W.t) .Select(t => (string)t) .StringConcatenate(); + Assert.Equal(testName, ExtractExpectedFromWord(expectedParagraphs[i])); // Get the actual result by calling UnicodeMapper.RunToString on the test content runs var actualResult = testContentParagraph.Descendants(W.r) .Select(UnicodeMapper.RunToString) @@ -242,7 +264,7 @@ public void TreatsXmlSpaceLikeWord() // Extracts the expected text from Word’s canonicalized output for the whitespace tests. // This helper intentionally handles *only* the constructs that Word emits in the saved - // version of UM-whitespace-test.docx: + // versions of the UM-Whitespace fixtures: // • → literal text // • → '\t' // • (intentionally ignored) diff --git a/OpenXmlPowerTools/OpenXmlPowerTools.csproj b/OpenXmlPowerTools/OpenXmlPowerTools.csproj index aaa4cb38..a3828a71 100644 --- a/OpenXmlPowerTools/OpenXmlPowerTools.csproj +++ b/OpenXmlPowerTools/OpenXmlPowerTools.csproj @@ -43,9 +43,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/OpenXmlPowerTools/UnicodeMapper.cs b/OpenXmlPowerTools/UnicodeMapper.cs index ee4c46ae..efb8dee3 100644 --- a/OpenXmlPowerTools/UnicodeMapper.cs +++ b/OpenXmlPowerTools/UnicodeMapper.cs @@ -155,10 +155,10 @@ private static string NormalizeWhitespace(string text, bool preserve) { if (string.IsNullOrEmpty(text)) return string.Empty; - // Trim leading & trailing whitespace when NOT preserving + // Word trims XML whitespace at text boundaries, not Unicode spaces such as NBSP. ReadOnlySpan span = preserve ? text.AsSpan() - : text.AsSpan().Trim(); + : text.AsSpan().Trim(" \t\r\n"); if (span.Length == 0) return string.Empty; var sb = new System.Text.StringBuilder(span.Length); diff --git a/OpenXmlPowerToolsExamples/MarkupSimplifierApp/MarkupSimplifierApp.csproj b/OpenXmlPowerToolsExamples/MarkupSimplifierApp/MarkupSimplifierApp.csproj index e8ea0dc6..28a26125 100644 --- a/OpenXmlPowerToolsExamples/MarkupSimplifierApp/MarkupSimplifierApp.csproj +++ b/OpenXmlPowerToolsExamples/MarkupSimplifierApp/MarkupSimplifierApp.csproj @@ -7,7 +7,7 @@ - + diff --git a/TestFiles/UM-Whitespace-Unicode-Word-saved.docx b/TestFiles/UM-Whitespace-Unicode-Word-saved.docx new file mode 100644 index 00000000..a48b5c37 Binary files /dev/null and b/TestFiles/UM-Whitespace-Unicode-Word-saved.docx differ diff --git a/TestFiles/UM-Whitespace-Unicode-test.docx b/TestFiles/UM-Whitespace-Unicode-test.docx new file mode 100644 index 00000000..286479e0 Binary files /dev/null and b/TestFiles/UM-Whitespace-Unicode-test.docx differ diff --git a/TestFiles/UM-Whitespace-Unicode-test.xml b/TestFiles/UM-Whitespace-Unicode-test.xml new file mode 100644 index 00000000..1e61614c --- /dev/null +++ b/TestFiles/UM-Whitespace-Unicode-test.xml @@ -0,0 +1,4 @@ + + + NBSP leading xml:space absent fooNBSP trailing xml:space absentfoo NBSP only xml:space absent NBSP mixed xml:space absent  foo  NBSP split field xml:space absent{[elseif TakingTitle == "Unequal"]}NBSP leading xml:space default fooNBSP trailing xml:space defaultfoo NBSP only xml:space default NBSP mixed xml:space default  foo  NBSP split field xml:space default{[elseif TakingTitle == "Unequal"]}NBSP leading xml:space preserve fooNBSP trailing xml:space preservefoo NBSP only xml:space preserve NBSP mixed xml:space preserve  foo  NBSP split field xml:space preserve{[elseif TakingTitle == "Unequal"]}NBSP trailing keyword split field{[elseif TakingTitle == "Unequal"]}NBSP standalone separator run{[elseif TakingTitle == "Unequal"]}Unicode U+0085 mixed boundaries …foo… Unicode U+1680 mixed boundaries  foo  Unicode U+2000 mixed boundaries  foo  Unicode U+2001 mixed boundaries  foo  Unicode U+2002 mixed boundaries  foo  Unicode U+2003 mixed boundaries  foo  Unicode U+2004 mixed boundaries  foo  Unicode U+2005 mixed boundaries  foo  Unicode U+2006 mixed boundaries  foo  Unicode U+2007 mixed boundaries  foo  Unicode U+2008 mixed boundaries  foo  Unicode U+2009 mixed boundaries  foo  Unicode U+200A mixed boundaries  foo  Unicode U+2028 mixed boundaries 
foo
 Unicode U+2029 mixed boundaries 
foo
 Unicode U+202F mixed boundaries  foo  Unicode U+205F mixed boundaries  foo  Unicode U+3000 mixed boundaries  foo  XML whitespace edges xml:space absent foo referenced CR xml:space absentfoo barreferenced CRLF xml:space absentfoo barinternal tab xml:space absentfoo barXML whitespace edges xml:space preserve foo referenced CR xml:space preservefoo barreferenced CRLF xml:space preservefoo barinternal tab xml:space preservefoo bar + diff --git a/TestFiles/UM-Whitespace.md b/TestFiles/UM-Whitespace.md new file mode 100644 index 00000000..8ad1ce51 --- /dev/null +++ b/TestFiles/UM-Whitespace.md @@ -0,0 +1,56 @@ +# UnicodeMapper whitespace fixtures + +`UnicodeMapperTests.TreatsXmlSpaceLikeWord` compares each crafted input paragraph +with the literal text and tabs in the corresponding paragraph saved by Microsoft +Word. Each case has a name paragraph followed by a content paragraph. Each document +ends with one blank paragraph. The tests check paragraph counts and matching names +so a missing or reordered case cannot silently pass. + +The original `UM-Whitespace-test.docx` and `UM-Whitespace-Word-saved.docx` contain +107 cases from the January 2026 investigation. They are unchanged. + +The supplementary `UM-Whitespace-Unicode-test.docx` and +`UM-Whitespace-Unicode-Word-saved.docx` contain 43 cases. The expected document was +opened and saved without content edits using Microsoft Word for Mac 16.114 on +October 21, 2026. These are synthetic documents; no customer document was used. + +The supplementary cases cover: + +- Leading, trailing, standalone, and mixed-boundary NBSP with `xml:space` absent, + `default`, and `preserve`. +- An NBSP separator at either side of a run boundary and in a standalone run. +- All other XML-valid characters recognized as whitespace by .NET: U+0085, + U+1680, U+2000 through U+200A, U+2028, U+2029, U+202F, U+205F, and U+3000. + Each is surrounded by ordinary XML whitespace to distinguish trimming the XML + whitespace from trimming the Unicode character. +- Ordinary spaces, tabs, CR, and CRLF, with preservation absent and present. + CR and LF use character references so the XML parser does not normalize them + before Word or UnicodeMapper sees them. Literal XML line endings in the original + fixture are normalized by XML parsing and do not make that distinction. + +Word preserves the tested Unicode characters, trims only boundary space/tab/CR/LF +when preservation is absent or `default`, converts internal line endings to spaces, +and retains tabs when preservation is enabled. These observations cover the listed +inputs and Word version, not every possible whitespace or formatting context. + +`UM-Whitespace-Unicode-test.xml` is the exact `word/document.xml` payload in the +supplementary input DOCX, retained for review and reproduction. To regenerate the +input from the repository root using Python's standard library: + +```python +from pathlib import Path +from zipfile import ZipFile + +root = Path("TestFiles") +xml = (root / "UM-Whitespace-Unicode-test.xml").read_bytes() +with ZipFile(root / "UM-Whitespace-test.docx") as source: + with ZipFile(root / "UM-Whitespace-Unicode-test.docx", "w") as target: + for entry in source.infolist(): + data = xml if entry.filename == "word/document.xml" else source.read(entry.filename) + target.writestr(entry, data) +``` + +Keep the crafted input unchanged. Open it in Word and use Save As to create the +separate `UM-Whitespace-Unicode-Word-saved.docx`. Do not manufacture expected output +with UnicodeMapper or another word processor. Review any changes in the Word-saved +text before updating expectations.