Consider a typical business scenario: you need to generate a final report for a client and deliver it as a single file. This report is compiled from various sources:

  1. A dynamic cover page (generated as a DOCX file from a template).
  2. Analytics, including charts (exported as a PDF from a third-party BI tool).
  3. A standard legal appendix (a static PDF).

Your task as a developer is to seamlessly merge these into a single, final PDF file and deliver it to the user via an HTTP response.

Common approaches to this problem in .NET often lead to complications:

  • Microsoft Office Interop: Does not work in Linux/Docker environments and frequently causes memory leaks or orphaned WINWORD.EXE processes.
  • Temporary files: Writing individual documents to disk, merging them, and then deleting them creates I/O bottlenecks, file-locking issues, and security risks.
  • Naive byte concatenation: Simply joining byte[] arrays together will inevitably result in a corrupted, unreadable file.

There is a cleaner, more professional approach: merging documents in-memory using a unified Document Object Model (DOM).

In this article, I will walk through a production-ready C# solution using the Document .Net library by SautinSoft, breaking the process down into clear, manageable steps.

Step 1: Data Preparation (Zero I/O)
The first rule of in-memory processing is that we do not create files on disk. We assume that our source documents have already been retrieved as byte arrays (for example, from a database, S3 cloud storage, or a previous generation step).
We collect them into a dictionary, where the key is the filename (needed to determine the file extension) and the value is the data itself:

var documentsData = new Dictionary<string, byte[]> { { "cover.docx", GetFileFromDatabase("cover_template_id") }, { "analytics.pdf", GetFileFromStorage("analytics_report_id") } }; 

Step 2: Smart Loading and the Font Issue
We create an empty "master document" into which we will assemble everything else. Then, we process our data.
This is where the first important nuance arises: when loading a PDF on a Linux server (for example, in Docker), issues with fonts often occur. The library attempts to locate a font within the system; failing to find it, it substitutes a default font, which breaks the document layout.
We resolve this using PdfLoadOptions:

DocumentCore dcSingle = new DocumentCore(); foreach (KeyValuePair<string, byte[]> document in documentsData) { using (MemoryStream msDoc = new MemoryStream(document.Value)) { LoadOptions lo = null; string ext = Path.GetExtension(document.Key).ToLower(); if (ext == ".docx") { lo = new DocxLoadOptions(); } else if (ext == ".pdf") { lo = new PdfLoadOptions() { PreserveEmbeddedFonts = PropertyState.Auto }; } DocumentCore dc = DocumentCore.Load(msDoc, lo); 

Step 3: The Magic of ImportSession (Preserving Styles)
Why can’t you simply use dcSingle.Sections.Add(dc.Sections[0])? Because styles (fonts, colors, indents) in a document are stored in a centralized repository. If you were to move a section directly, its styles would point to the original-now non-existent-context, causing the formatting to break.
The solution is to use ImportSession. It creates a safe copy of an element and reconfigures all internal references-such as those for styles and bookmarks-to align with the new document.

 ImportSession session = new ImportSession(dc, dcSingle, StyleImportingMode.KeepSourceFormatting); foreach (Section sourceSection in dc.Sections) { Section importedSection = dcSingle.Import<Section>(sourceSection, true, session); if (dc.Sections.IndexOf(sourceSection) == 0) { importedSection.PageSetup.SectionStart = SectionStart.NewPage; } dcSingle.Sections.Add(importedSection); } } } 

Step 4: Final assembly and returning the result
Once all sections have been gathered into dcSingle, we need to return the result. In a web application, we do not save the file to disk; instead, we return a stream of bytes directly within the HTTP response.

using (MemoryStream msPdf = new MemoryStream()) { dcSingle.Save(msPdf, new PdfSaveOptions()); return msPdf.ToArray(); } 

How this looks in an actual ASP.NET Core controller
Since our logic is encapsulated and operates solely in memory, using it within a controller becomes both trivial and safe:

[HttpGet("download-merged-report")] public IActionResult DownloadMergedReport() { var documentsData = GetDocumentsData(); try { byte[] finalPdfBytes = DocumentMerger.MergeDocumentsInMemory(documentsData); return File(finalPdfBytes, "application/pdf", "Complete_Report.pdf"); } catch (Exception ex) { _logger.LogError(ex, "Error while merging documents"); return StatusCode(500, "Internal Server Error"); } } 

Why this approach wins in production:

  1. Zero I/O: Everything takes place in RAM. There is no contention for file access, and no need to write background tasks to clean up the /tmp directory.
  2. Cross-platform compatibility: Managed .NET code runs identically on Windows, Linux, and macOS, keeping your Docker containers lightweight.
  3. Layout fidelity: Using ImportSession and PreserveEmbeddedFonts resolves 99% of the issues developers typically encounter when attempting to merge disparate documents on a server.

Complete code:

using System; using System.IO; using System.Collections.Generic; using SautinSoft.Document; namespace Sample { class Sample { static void Main(string[] args) { // Get your free 30-day key here:  // https://sautinsoft.com/start-for-free/ MergeDocumentsInMem(); } /// <summary> /// This sample shows how to merge multiple files DOCX, PDF into single document in memory. /// </summary> /// <remarks> /// Details: https://sautinsoft.com/products/document/help/net/developer-guide/merge-multiple-documents-docx-pdf-in-memory-net-csharp-vb.php /// </remarks> public static void MergeDocumentsInMem() { // We'll use these files only to get data and show the result. // The whole merging process will be done in memory. string[] documents = new string[] { @"..\..\..\one.docx", @"..\..\..\two.pdf" }; string singleDocumentPath = "Result.pdf"; // Read the files and retrieve the file data into this Dictionary. // Thus we'll have input data completely in memory. Dictionary<string, byte[]> documentsData = new Dictionary<string, byte[]>(); foreach (string file in documents) { documentsData.Add(file, File.ReadAllBytes(file)); } // Merge documents in memory (using MemoryStream) // 1. Create a single document. DocumentCore dcSingle = new DocumentCore(); foreach (KeyValuePair<string, byte[]> document in documentsData) { // Create new MemoryStream based on document byte array. using (MemoryStream msDoc = new MemoryStream(document.Value)) { LoadOptions lo = null; if (Path.GetExtension(document.Key).ToLower() == ".docx") lo = new DocxLoadOptions(); else if (Path.GetExtension(document.Key).ToLower() == ".pdf") lo = new PdfLoadOptions() { // 'Disabled' - Never load embedded fonts in PDF. Use the fonts with the same name installed at the system or similar by font metrics. // 'Enabled' - Always load embedded fonts in PDF. // 'Auto' - Load only embedded fonts missing in the system. In other case, use the system fonts. PreserveEmbeddedFonts = PropertyState.Auto }; DocumentCore dc = DocumentCore.Load(msDoc, lo); Console.WriteLine("Adding: {0}...", Path.GetFileName(document.Key)); // Create import session. ImportSession session = new ImportSession(dc, dcSingle, StyleImportingMode.KeepSourceFormatting); // Loop through all sections in the source document. foreach (Section sourceSection in dc.Sections) { // Because we are copying a section from one document to another, // it is required to import the Section into the destination document. // This adjusts any document-specific references to styles, bookmarks, etc. // // Importing a element creates a copy of the original element, but the copy // is ready to be inserted into the destination document. Section importedSection = dcSingle.Import<Section>(sourceSection, true, session); // First section start from new page. if (dc.Sections.IndexOf(sourceSection) == 0) importedSection.PageSetup.SectionStart = SectionStart.NewPage; // Now the new section can be appended to the destination document. dcSingle.Sections.Add(importedSection); } } } // Save the resulting document as PDF into MemoryStream. using (MemoryStream msPdf = new MemoryStream()) { dcSingle.Save(msPdf, new PdfSaveOptions()); // Let's also save our document to PDF file for showing the result. File.WriteAllBytes(singleDocumentPath, msPdf.ToArray()); // Open the result for demonstration purposes. System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(singleDocumentPath) { UseShellExecute = true }); } } } } 

In short, document merging shouldn't be a "kludge." By treating documents as structured objects in memory and utilizing proper import sessions, you gain speed, security, and stability.

If you would like to test this code using your own complex, real-world documents, you can explore the full documentation, which includes examples .

How do you handle document generation and merging in your .NET projects? Have you managed to move away from Office Interop entirely? Share your experience in the comments!👇