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); + } + } } }