Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.*"
Comment on lines +43 to +45
ignore:
- dependency-name: "SixLabors.ImageSharp"
versions: ["3.x", "4.x"]
Expand Down
9 changes: 4 additions & 5 deletions OpenXmlPowerTools.Tests/OpenXmlPowerTools.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Codeuctivity.HtmlRenderer" Version="4.0.529" />
<PackageReference Include="Codeuctivity.SkiaSharpCompare" Version="4.0.331" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit.analyzers" Version="2.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="Codeuctivity.SkiaSharpCompare" Version="4.0.342" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="4.0.0" />
<PackageReference Include="xunit.v3" Version="4.0.2" />
</ItemGroup>

<ItemGroup>
Expand Down
21 changes: 21 additions & 0 deletions OpenXmlPowerTools.Tests/OpenXmlRegexTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}

}
}
34 changes: 28 additions & 6 deletions OpenXmlPowerTools.Tests/UnicodeMapperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -201,32 +215,40 @@ 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];
// Get the test name from the first paragraph
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)
Expand All @@ -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:
// • <w:t> → literal text
// • <w:tab/> → '\t'
// • <w:lastRenderedPageBreak/> (intentionally ignored)
Expand Down
6 changes: 3 additions & 3 deletions OpenXmlPowerTools/OpenXmlPowerTools.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.152.0" />
<PackageReference Include="SkiaSharp" Version="4.152.0" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.25.0.139117">
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.153.1" />
<PackageReference Include="SkiaSharp" Version="4.153.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.35.0.4138">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand Down
4 changes: 2 additions & 2 deletions OpenXmlPowerTools/UnicodeMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<char> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.14" />
</ItemGroup>

<ItemGroup>
Expand Down
Binary file added TestFiles/UM-Whitespace-Unicode-Word-saved.docx
Binary file not shown.
Binary file added TestFiles/UM-Whitespace-Unicode-test.docx
Binary file not shown.
4 changes: 4 additions & 0 deletions TestFiles/UM-Whitespace-Unicode-test.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>NBSP leading xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t> foo</w:t></w:r></w:p><w:p><w:r><w:t>NBSP trailing xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t>foo </w:t></w:r></w:p><w:p><w:r><w:t>NBSP only xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t> </w:t></w:r></w:p><w:p><w:r><w:t>NBSP mixed xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>NBSP split field xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t>{[elseif</w:t></w:r><w:r><w:t> TakingTitle == "Unequal"]}</w:t></w:r></w:p><w:p><w:r><w:t>NBSP leading xml:space default</w:t></w:r></w:p><w:p><w:r><w:t xml:space="default"> foo</w:t></w:r></w:p><w:p><w:r><w:t>NBSP trailing xml:space default</w:t></w:r></w:p><w:p><w:r><w:t xml:space="default">foo </w:t></w:r></w:p><w:p><w:r><w:t>NBSP only xml:space default</w:t></w:r></w:p><w:p><w:r><w:t xml:space="default"> </w:t></w:r></w:p><w:p><w:r><w:t>NBSP mixed xml:space default</w:t></w:r></w:p><w:p><w:r><w:t xml:space="default"> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>NBSP split field xml:space default</w:t></w:r></w:p><w:p><w:r><w:t>{[elseif</w:t></w:r><w:r><w:t xml:space="default"> TakingTitle == "Unequal"]}</w:t></w:r></w:p><w:p><w:r><w:t>NBSP leading xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve"> foo</w:t></w:r></w:p><w:p><w:r><w:t>NBSP trailing xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve">foo </w:t></w:r></w:p><w:p><w:r><w:t>NBSP only xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve"> </w:t></w:r></w:p><w:p><w:r><w:t>NBSP mixed xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve"> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>NBSP split field xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t>{[elseif</w:t></w:r><w:r><w:t xml:space="preserve"> TakingTitle == "Unequal"]}</w:t></w:r></w:p><w:p><w:r><w:t>NBSP trailing keyword split field</w:t></w:r></w:p><w:p><w:r><w:t>{[elseif </w:t></w:r><w:r><w:t>TakingTitle == "Unequal"]}</w:t></w:r></w:p><w:p><w:r><w:t>NBSP standalone separator run</w:t></w:r></w:p><w:p><w:r><w:t>{[elseif</w:t></w:r><w:r><w:t> </w:t></w:r><w:r><w:t>TakingTitle == "Unequal"]}</w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+0085 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10;…foo…&#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+1680 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2000 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2001 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2002 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2003 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2004 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2005 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2006 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2007 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2008 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2009 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+200A mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2028 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10;
foo
&#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+2029 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10;
foo
&#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+202F mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+205F mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>Unicode U+3000 mixed boundaries</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10; foo &#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>XML whitespace edges xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t> &#9;&#13;&#10;foo&#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>referenced CR xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t>foo&#13;bar</w:t></w:r></w:p><w:p><w:r><w:t>referenced CRLF xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t>foo&#13;&#10;bar</w:t></w:r></w:p><w:p><w:r><w:t>internal tab xml:space absent</w:t></w:r></w:p><w:p><w:r><w:t>foo&#9;bar</w:t></w:r></w:p><w:p><w:r><w:t>XML whitespace edges xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve"> &#9;&#13;&#10;foo&#13;&#10;&#9; </w:t></w:r></w:p><w:p><w:r><w:t>referenced CR xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve">foo&#13;bar</w:t></w:r></w:p><w:p><w:r><w:t>referenced CRLF xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve">foo&#13;&#10;bar</w:t></w:r></w:p><w:p><w:r><w:t>internal tab xml:space preserve</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve">foo&#9;bar</w:t></w:r></w:p><w:p/></w:body>
</w:document>
56 changes: 56 additions & 0 deletions TestFiles/UM-Whitespace.md
Original file line number Diff line number Diff line change
@@ -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.
Loading