Recursive AI

Merge PDF files in C#

Merge PDF files in C# with OnePDF: files, byte arrays and ASP.NET Core uploads, chosen pages, and bookmarks, links and form fields kept. MIT or Apache-2.0.

To merge PDF files in C#, open each file with PdfDocument.Open, pass them to PdfDocument.Merge and save the result. OnePDF copies more than the pages: bookmarks, links between pages, form fields, tagged structure and page labels come along, and each shared font or image is copied once. It is pure C# with no native binaries, licensed MIT OR Apache-2.0. This page merges files on disk, byte arrays and ASP.NET Core uploads, inserts chosen pages, and covers the case that breaks naive merges: two copies of the same form. Every sample was compiled and run against OnePDF 1.11.0 on .NET 10.

Before you start

TerminalNuGet
dotnet new console -o PdfMerge
cd PdfMerge
dotnet add package RecursiveAI.OnePdf --version 1.11.0

OnePDF targets .NET 10 (net10.0). The samples merge chapter-1.pdf and chapter-2.pdf, two-page files with a bookmark per part and a link from page 1 to page 2, with the six-page IRS Form W-9, which has 23 form fields. To make the chapter files, run this once with RecursiveAI.OnePdf.Layout added, or use any PDFs of your own:

MakeChapters.csOnePdf.Layout 1.11.0
using OnePdf.Layout;
 
// Two short chapters, each with bookmarks and a link to its second page.
foreach (int n in new[] { 1, 2 })
{
    LayoutDocument.Compose(doc =>
    {
        doc.Title($"Chapter {n}").Language("en-US");
        doc.Page(page =>
        {
            page.Size(PageSize.Letter);
            page.Margin(72);
            page.Content().Column(col =>
            {
                col.Spacing(12);
                col.Item().Bookmark($"Chapter {n}, part 1").Heading(1).Text($"Chapter {n}, part 1").FontSize(20).Bold();
                col.Item().Text($"The opening of chapter {n}.");
                col.Item().LinkToAnchor($"part2-{n}").Text("Skip to part 2");
                col.Item().PageBreak();
                col.Item().Bookmark($"Chapter {n}, part 2").Anchor($"part2-{n}").Heading(1).Text($"Chapter {n}, part 2").FontSize(20).Bold();
                col.Item().Text($"The end of chapter {n}.");
            });
        });
    }).Save($"chapter-{n}.pdf");
}

Merge PDF files

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
string[] files = ["chapter-1.pdf", "chapter-2.pdf", "fw9.pdf"];
 
List<PdfDocument> sources = files.Select(f => PdfDocument.Open(f)).ToList();
try
{
    using PdfDocument merged = PdfDocument.Merge(sources);
    merged.SetInfo("Title", "Vendor handbook");   // Merge copies no metadata
    merged.Save("merged.pdf");
 
    Console.WriteLine($"{merged.PageCount} pages, {merged.Form?.TerminalFields.Count() ?? 0} form fields");
    foreach (PdfOutlineItem bookmark in merged.Outline)
        Console.WriteLine($"bookmark: {bookmark.Title}");
}
finally
{
    foreach (PdfDocument source in sources)
        source.Dispose();
}
OutputConsole
10 pages, 23 form fields
bookmark: Chapter 1, part 1
bookmark: Chapter 1, part 2
bookmark: Chapter 2, part 1
bookmark: Chapter 2, part 2

merged.pdf has the ten pages in order, the four bookmarks, and the W-9’s fillable fields, which sit on its first page, now page 5. The “Skip to part 2” links still work: they now lead to pages 2 and 4 of the merged file.

  • Merge builds a new document in memory that doesn’t refer to its sources, so you can dispose the sources as soon as it returns, as the finally block does.
  • It writes no title, author or other metadata, so set what you need with SetInfo.
  • PdfDocument.Open accepts a path, a Stream or a ReadOnlyMemory<byte>. Sources don’t need AllowEditing: merging only reads them.

What a merge keeps

  • Bookmarks that lead to copied pages, appended in source order.
  • Links between copied pages, with the named destinations they use. A name already taken by a different place gets a _2 suffix. A link to a page you left out keeps its rectangle but loses its target.
  • Form fields with the field tree above them. Appearances are generated and stored for each copied text and choice field, so values display the same in the merged file.
  • Tagged structure (for accessibility), page labels and optional content of the copied pages.
  • Resources once: each font or image the sources share is copied once, however many pages use it.

Not carried: the sources’ metadata, signatures (a signature signs the bytes of the original file), document-level JavaScript and permissions. An XFA form description is dropped, because it would no longer describe the fields. The full rules are in page operations.

Merge PDF byte arrays or streams

When the PDFs come from a database, a queue or an HTTP call, open them from memory and save the result to a MemoryStream:

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
byte[] merged = MergePdfs([File.ReadAllBytes("chapter-1.pdf"), File.ReadAllBytes("chapter-2.pdf")]);
File.WriteAllBytes("merged-from-bytes.pdf", merged);
Console.WriteLine($"{merged.Length:N0} bytes");
 
// Merge PDFs held in memory (uploads, database blobs, HTTP responses) and get bytes back.
static byte[] MergePdfs(IEnumerable<byte[]> pdfs)
{
    var sources = new List<PdfDocument>();
    try
    {
        foreach (byte[] pdf in pdfs)
            sources.Add(PdfDocument.Open(pdf));
 
        using PdfDocument merged = PdfDocument.Merge(sources);
        using var output = new MemoryStream();
        merged.Save(output);
        return output.ToArray();
    }
    finally
    {
        foreach (PdfDocument source in sources)
            source.Dispose();
    }
}
OutputConsole
28,590 bytes

Merge uploaded PDFs in ASP.NET Core

A web endpoint that takes several uploads and returns one PDF. It uses TryOpen, which returns an error instead of throwing when an upload isn’t a PDF it can read. Create the project with dotnet new web and add the package as above:

Program.csOnePDF 1.11.0 · ASP.NET Core 10
using OnePdf;
 
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
// POST two or more PDFs as multipart/form-data (field "files"); get one merged PDF back.
app.MapPost("/pdf/merge", async (IFormFileCollection files) =>
{
    var sources = new List<PdfDocument>();
    try
    {
        foreach (IFormFile file in files)
        {
            using var buffer = new MemoryStream();
            await file.CopyToAsync(buffer);
            if (!PdfDocument.TryOpen(buffer.ToArray(), out PdfDocument? doc, out PdfException? error))
                return Results.BadRequest($"{file.FileName}: {error?.Message}");
            sources.Add(doc);
        }
 
        using PdfDocument merged = PdfDocument.Merge(sources);
        var output = new MemoryStream();
        merged.Save(output);
        return Results.File(output.ToArray(), "application/pdf", "merged.pdf");
    }
    finally
    {
        foreach (PdfDocument source in sources)
            source.Dispose();
    }
}).DisableAntiforgery();
 
app.Run();
Terminalcurl
# APP_URL is the address that dotnet run prints
curl -F [email protected] -F [email protected] -o merged.pdf "$APP_URL/pdf/merge"

That returns an eight-page PDF. If one of the uploads isn’t a PDF, the response is a 400 that names the file, for example "bad.txt: The file has no usable cross-reference data, trailer or document catalog." OnePDF is built for files you didn’t write: limits on decoded size, nesting and object count are on by default, and it never runs a document’s scripts. Damaged files are repaired where possible (see repair a damaged PDF).

Append or insert chosen pages

To add pages to an existing document instead of building a new one, open the target with AllowEditing and call ImportPages with zero-based page indexes, and optionally the position to insert at:

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
using PdfDocument report = PdfDocument.Open("chapter-1.pdf", new PdfOpenOptions { AllowEditing = true });
using PdfDocument w9 = PdfDocument.Open("fw9.pdf");
using PdfDocument other = PdfDocument.Open("chapter-2.pdf");
 
report.ImportPages(w9, [0]);                     // append page 1 of the W-9
report.ImportPages(other, [1], insertAt: 1);     // insert page 2 of chapter 2 after the first page
report.Save("chapter-1-plus.pdf");
 
Console.WriteLine($"{report.PageCount} pages");
for (int i = 0; i < report.PageCount; i++)
    Console.WriteLine($"page {i + 1}: {report.Pages[i].ExtractText().Text.Split('\n')[0]}");
OutputConsole
4 pages
page 1: Chapter 1, part 1
page 2: Chapter 2, part 2
page 3: Chapter 1, part 2
page 4: Form W-9

Pages added by ImportPages bring the same things a merge does. Take doc.Pages again after a page operation, since page objects read before it are out of date. To go the other way, see split a PDF. To delete, rotate or reorder pages, see rotate, delete and reorder pages.

Merge filled copies of the same form

A PDF form field has one value however many times it appears. iText’s knowledge base puts it plainly: if you merge identical forms, “you have multiple visualizations of the same field, but each visualization will show the same value”, so fields have to be renamed (merging forms). OnePDF renames them for you: a top-level field name already used in the merged document gets a _2, _3 suffix.

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
// Fill two copies of the same form, then merge them.
string[] people = ["Lovelace, Ada", "Hopper, Grace"];
var copies = new List<PdfDocument>();
foreach (string name in people)
{
    var copy = PdfDocument.Open("sf2823.pdf", new PdfOpenOptions { AllowEditing = true });
    copy.Form!.SetValue("Name", name);
    copies.Add(copy);
}
 
using (PdfDocument both = PdfDocument.Merge(copies))
{
    both.Save("two-forms.pdf");
    foreach (PdfFormField field in both.Form!.TerminalFields.Where(f => f.FullName.StartsWith("Name")))
        Console.WriteLine($"{field.FullName} = {field.Value}");
}
foreach (PdfDocument copy in copies)
    copy.Dispose();
OutputConsole
Name = Lovelace, Ada
Name_2 = Hopper, Grace

The form is OPM’s SF 2823, three pages with 61 fields. Each copy in two-forms.pdf keeps its own value and stays fillable. If the merged file is for printing or archiving, flatten each copy before merging instead, so the values become page content and no fields are left: see flatten a PDF form in C#. Filling is covered in fill PDF form fields in C#.

Encrypted PDFs

Open an encrypted source with its password (new PdfOpenOptions { Password = "…" }) and merge it like any other. Its pages arrive as plain content, so the merged file is unencrypted unless you save it with PdfSaveOptions.Encryption = PdfEncryptionAction.Set. See decrypt or encrypt a PDF.

The same merge with iText or PDFsharp

iText’s API reference describes PdfMerger as a helper that copies tags and outlines by default. For form fields, iText’s knowledge base says to copy pages with CopyPagesTo and a PdfPageFormCopier, “or the fields won’t be copied”, and its example calls InitializeOutlines on the target first (how to merge documents correctly). Written from those pages and run on the same three files with iText 9.8.0 (AGPL or commercial):

Program.csiText 9.8.0
using iText.Forms;
using iText.Kernel.Pdf;
 
string[] files = ["chapter-1.pdf", "chapter-2.pdf", "fw9.pdf"];
 
using PdfDocument merged = new PdfDocument(new PdfWriter("merged-itext-forms.pdf"));
merged.InitializeOutlines();                       // copy bookmarks too
foreach (string file in files)
{
    using PdfDocument source = new PdfDocument(new PdfReader(file));
    source.CopyPagesTo(1, source.GetNumberOfPages(), merged, new PdfPageFormCopier());
}
Console.WriteLine($"{merged.GetNumberOfPages()} pages");

The result has the ten pages, the four bookmarks, working links and the 23 fields. With a plain PdfMerger.Merge loop instead, the bookmarks and links came across but the form fields did not, in line with iText’s note. iText 9.8.0 under the AGPL also writes “iText® Core 9.8.0 (AGPL version)” into the file’s producer: see iText license cost and AGPL terms.

PDFsharp merges by opening each file for import and adding its pages to another document (reading existing files). Its FAQ notes that “PDFsharp does not yet write object streams”, and that processing files that contain them “can lead to increased file size after saving the file”. OnePDF reads and writes object streams. The PDFsharp version of the merge, side by side with OnePDF’s, is in migrating from PDFsharp. PDFsharp 6.5.0 is AGPL or commercial: see PDFsharp license changes.

Check the merged file

Two command-line checks catch most problems: qpdf --check merged.pdf reports structural errors, and pdfinfo merged.pdf shows the page count, the title you set and whether a form is present. All the files on this page pass qpdf --check with “No syntax or stream encoding errors found”.

Where next