From 3f5d87ecb4325410e89fbf85e3f0127ebffe420f Mon Sep 17 00:00:00 2001 From: Seva Rakesh Babu Date: Tue, 2 Jun 2026 10:54:49 +0530 Subject: [PATCH] Fix missing rels content-type registration causing null MainDocumentPart Some OOXML generators omit the required `` entry from `[Content_Types].xml`. System.IO.Packaging is strict: without that registration it cannot resolve .rels parts, so the package relationship walk never finds the officeDocument relationship and MainDocumentPart comes back null. RepairRelsContentType() in StreamPackageFeature detects this deviation before Package.Open and rebuilds the stream with the missing entry injected into [Content_Types].xml. All other ZIP entries are streamed verbatim. The repair is a no-op for well-formed packages. Gated on !NETFRAMEWORK because ZipArchive is not available on net35/net40 and the old System.IO.Packaging on those targets is already lenient. Fixes: WordprocessingDocument.Open returns null MainDocumentPart when [Content_Types].xml omits Default Extension="rels" --- .../Features/StreamPackageFeature.cs | 105 ++++++++++++++++++ .../OpenXmlPackageTests.cs | 65 +++++++++++ 2 files changed, 170 insertions(+) diff --git a/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs b/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs index 2edb9a890..a6fde6c31 100644 --- a/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs +++ b/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs @@ -7,6 +7,9 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; +#if !NETFRAMEWORK +using System.IO.Compression; +#endif using System.IO.Packaging; namespace DocumentFormat.OpenXml.Features; @@ -128,6 +131,7 @@ private void InitializePackage(FileMode? mode = default, FileAccess? access = de try { + _stream = RepairRelsContentType(_stream); _package = Package.Open(_stream, mode.Value, access.Value); } catch (ArgumentException ex) @@ -170,6 +174,107 @@ public void Dispose() GC.SuppressFinalize(this); } + // Some OOXML producers omit the required entry from + // [Content_Types].xml (a known OPC spec deviation). System.IO.Packaging is strict: without + // that registration it cannot resolve .rels parts, so package-level relationships are never + // loaded and MainDocumentPart (and equivalents) come back null. Repair the stream in-memory + // before handing it to Package.Open so the SDK behaves like Word Desktop and the MIP SDK, + // both of which recover silently from this deviation. + // + // Gated on !NETFRAMEWORK because ZipArchive requires net45+ and the old System.IO.Packaging + // on net35/net40/net46 is already lenient about missing content-type registrations. +#if !NETFRAMEWORK + private static Stream RepairRelsContentType(Stream input) + { + const string Marker = "Extension=\"rels\""; + const string Closing = ""; + const string Injection = + ""; + + if (!input.CanRead || !input.CanSeek) + { + return input; + } + + long originalPosition = input.Position; + + try + { + string ctXml; + using (var readZip = new ZipArchive(input, ZipArchiveMode.Read, leaveOpen: true)) + { + var ctEntry = readZip.GetEntry("[Content_Types].xml"); + if (ctEntry is null) + { + input.Position = originalPosition; + return input; + } + + using var reader = new StreamReader(ctEntry.Open()); + ctXml = reader.ReadToEnd(); + } + +#if NET6_0_OR_GREATER + if (ctXml.Contains(Marker, StringComparison.Ordinal)) + { + input.Position = originalPosition; + return input; + } + + int closingIdx = ctXml.IndexOf(Closing, StringComparison.Ordinal); + string patched = closingIdx >= 0 + ? string.Concat(ctXml.AsSpan(0, closingIdx), Injection, ctXml.AsSpan(closingIdx)) + : ctXml + Injection; +#else + if (ctXml.IndexOf(Marker, StringComparison.Ordinal) >= 0) + { + input.Position = originalPosition; + return input; + } + + int closingIdx = ctXml.IndexOf(Closing, StringComparison.Ordinal); + string patched = closingIdx >= 0 + ? ctXml.Substring(0, closingIdx) + Injection + ctXml.Substring(closingIdx) + : ctXml + Injection; +#endif + + input.Position = originalPosition; + var output = new MemoryStream(); + using (var inZip = new ZipArchive(input, ZipArchiveMode.Read, leaveOpen: false)) + using (var outZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var entry in inZip.Entries) + { + var outEntry = outZip.CreateEntry(entry.FullName, CompressionLevel.Fastest); + outEntry.LastWriteTime = entry.LastWriteTime; + using var inStream = entry.Open(); + using var outStream = outEntry.Open(); + if (entry.FullName == "[Content_Types].xml") + { + using var sw = new StreamWriter(outStream); + sw.Write(patched); + } + else + { + inStream.CopyTo(outStream); + } + } + } + + output.Position = 0; + return output; + } + catch + { + input.Position = originalPosition; + return input; + } + } +#else + private static Stream RepairRelsContentType(Stream input) => input; +#endif + protected override void Register(IFeatureCollection features) { base.Register(features); diff --git a/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs b/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs index f1b957bd8..159122cb1 100644 --- a/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs +++ b/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using System.Xml.Linq; using Xunit; @@ -398,5 +399,69 @@ public void IsEncryptedOfficeFile_ReturnsFalse_ForUnencryptedFile_FromString() // Clean up the test file path File.Delete(filePath); } + + [Fact] + public void Open_MissingRelsContentType_MainDocumentPartIsNotNull() + { + using var stream = CreateDocxMissingRelsContentType(); + using var doc = WordprocessingDocument.Open(stream, isEditable: false); + Assert.NotNull(doc.MainDocumentPart); + } + + [Fact] + public void Open_MissingRelsContentType_IsIdempotent() + { + // Opening a file that already has the rels Default entry must not alter it. + var wellFormed = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(wellFormed, WordprocessingDocumentType.Document, autoSave: false)) + { + var main = doc.AddMainDocumentPart(); + main.Document = new Document(new Body(new Paragraph())); + doc.Save(); + } + + wellFormed.Position = 0; + using var reopened = WordprocessingDocument.Open(wellFormed, isEditable: false); + Assert.NotNull(reopened.MainDocumentPart); + } + + private static MemoryStream CreateDocxMissingRelsContentType() + { + var stream = new MemoryStream(); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + Write(zip, "[Content_Types].xml", """ + + + + + + """); + Write(zip, "_rels/.rels", """ + + + + + """); + Write(zip, "word/document.xml", """ + + + + + """); + } + + stream.Position = 0; + return stream; + + static void Write(ZipArchive zip, string name, string content) + { + using var w = new StreamWriter(zip.CreateEntry(name).Open()); + w.Write(content); + } + } } }