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.