Recursive AI

Split a PDF in C#

Split a PDF in C# into single pages, fixed-size parts, page ranges or one file per bookmark, keeping bookmarks, links and form fields. Pure .NET, MIT or Apache-2.0.

To split a PDF in C#, open it with OnePDF and call PdfDocument.Split, either with a page count per part or with the page ranges you want. Each part is a new document in memory: save it to a file or a stream and dispose it. Bookmarks, links and form fields go with the pages they belong to. OnePDF is pure C# with no native binaries, licensed MIT OR Apache-2.0. This page splits into single pages, fixed-size parts, chosen ranges and one file per chapter bookmark, and returns pages as a zip from memory. Every sample was compiled and run against OnePDF 1.11.0 on .NET 10, and every output passes qpdf --check.

Before you start

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

OnePDF targets .NET 10. The samples split handbook.pdf, six pages in three chapters of two, three and one pages, with a bookmark for each chapter and each section. To make it, run this once with RecursiveAI.OnePdf.Layout added, or use a PDF of your own:

MakeHandbook.csOnePdf.Layout 1.11.0
using OnePdf.Layout;
 
// A handbook with three chapters of 2, 3 and 1 pages, a bookmark per chapter and per section.
(string Title, int Pages)[] chapters = [("Getting started", 2), ("Safety on the quay", 3), ("Contacts", 1)];
 
LayoutDocument.Compose(doc =>
{
    doc.Title("Harbor handbook").Language("en-US");
    doc.Page(page =>
    {
        page.Size(PageSize.Letter);
        page.Margin(72);
        page.Content().Column(col =>
        {
            col.Spacing(12);
            for (int c = 0; c < chapters.Length; c++)
            {
                if (c > 0) col.Item().PageBreak();
                col.Item().Bookmark(chapters[c].Title).Heading(1).Text(chapters[c].Title).FontSize(22).Bold();
                for (int p = 1; p <= chapters[c].Pages; p++)
                {
                    if (p > 1) col.Item().PageBreak();
                    col.Item().Bookmark($"Section {c + 1}.{p}", level: 1).Heading(2).Text($"Section {c + 1}.{p}").FontSize(15).Bold();
                    col.Item().Text($"Text of section {c + 1}.{p}.");
                }
            }
        });
    });
}).Save("handbook.pdf");

Split into single pages or fixed-size parts

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
using PdfDocument doc = PdfDocument.Open("handbook.pdf");
 
// One file per page.
IReadOnlyList<PdfDocument> pages = PdfDocument.Split(doc, pagesPerPart: 1);
for (int i = 0; i < pages.Count; i++)
{
    using PdfDocument part = pages[i];
    part.Save($"handbook-page-{i + 1}.pdf");
}
Console.WriteLine($"{pages.Count} single-page files");
 
// Fixed-size parts: 4 pages each, the last one shorter.
IReadOnlyList<PdfDocument> chunks = PdfDocument.Split(doc, pagesPerPart: 4);
for (int i = 0; i < chunks.Count; i++)
{
    using PdfDocument part = chunks[i];
    part.Save($"handbook-part-{i + 1}.pdf");
    Console.WriteLine($"handbook-part-{i + 1}.pdf: {part.PageCount} pages");
}
OutputConsole
6 single-page files
handbook-part-1.pdf: 4 pages
handbook-part-2.pdf: 2 pages

Split(doc, pagesPerPart: n) cuts the document into parts of n pages, and the last part takes what is left. The source only needs to be open for reading. Each part is independent of it, so dispose each one when it is saved, as the using inside the loop does.

Extract a range of pages

To pull out some pages, pass the ranges you want. PdfPageRange takes a zero-based start page and a count, so pages 3 to 5 are new PdfPageRange(2, 3):

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
using PdfDocument doc = PdfDocument.Open("handbook.pdf");
 
// Extract pages 3 to 5 (zero-based start 2, count 3) into a new file.
IReadOnlyList<PdfDocument> parts = PdfDocument.Split(doc, [new PdfPageRange(2, 3)]);
using PdfDocument safety = parts[0];
safety.SetInfo("Title", "Harbor handbook: safety on the quay");   // parts carry no metadata from the source
safety.Save("safety.pdf");
 
Console.WriteLine($"safety.pdf: {safety.PageCount} pages");
foreach (PdfOutlineItem item in safety.Outline)
    Console.WriteLine($"  {item.Title} ({item.Children.Count} sections)");
OutputConsole
safety.pdf: 3 pages
  Safety on the quay (3 sections)

The extracted chapter keeps its bookmark and the three section bookmarks under it. Parts carry no title, author or other metadata from the source, so set what you need with SetInfo. Pass several ranges to get several files in one call. Ranges may overlap, for example to give each part a shared cover page.

Split a PDF by bookmarks

Long documents usually have a bookmark per chapter. Each bookmark’s Destination.PageIndex tells you where its chapter starts, so a chapter runs from there to the next bookmark’s page:

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
using PdfDocument doc = PdfDocument.Open("handbook.pdf");
 
// Each top-level bookmark starts a part that runs to the next one.
var starts = doc.Outline
    .Select(item => (item.Title, Page: item.Destination?.PageIndex))
    .Where(s => s.Page is not null)
    .Select(s => (s.Title, Start: s.Page!.Value))
    .OrderBy(s => s.Start)
    .ToList();
 
var ranges = starts.Select((s, i) =>
    new PdfPageRange(s.Start, (i + 1 < starts.Count ? starts[i + 1].Start : doc.PageCount) - s.Start)).ToList();
 
IReadOnlyList<PdfDocument> parts = PdfDocument.Split(doc, ranges);
for (int i = 0; i < parts.Count; i++)
{
    using PdfDocument part = parts[i];
    string file = $"{i + 1:00}-{Slug(starts[i].Title)}.pdf";
    part.SetInfo("Title", starts[i].Title);
    part.Save(file);
    Console.WriteLine($"{file}: page count {part.PageCount}; bookmarks: {string.Join(", ", Flatten(part.Outline).Select(b => b.Title))}");
}
 
static string Slug(string title) =>
    string.Join("-", title.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries));
 
static IEnumerable<PdfOutlineItem> Flatten(IEnumerable<PdfOutlineItem> items) =>
    items.SelectMany(item => new[] { item }.Concat(Flatten(item.Children)));
OutputConsole
01-getting-started.pdf: page count 2; bookmarks: Getting started, Section 1.1, Section 1.2
02-safety-on-the-quay.pdf: page count 3; bookmarks: Safety on the quay, Section 2.1, Section 2.2, Section 2.3
03-contacts.pdf: page count 1; bookmarks: Contacts, Section 3.1

Each chapter file has its own bookmark tree. A bookmark whose destination is outside the document, or that has none, is skipped by the Where. The same idea splits on anything you can find on a page: run text extraction over each page and start a new part where an invoice number or a heading appears.

Split a PDF from a byte array and return a zip

In a web API or a queue worker the PDF arrives as bytes. PdfDocument.Open takes a byte[] (or a Stream or ReadOnlyMemory<byte>), and each part saves to any stream, here straight into a zip entry:

Program.csOnePDF 1.11.0 · .NET 10
using System.IO.Compression;
using OnePdf;
 
// Split a PDF held in memory (an upload, a blob) and return the pages as a zip.
byte[] zip = SplitToZip(File.ReadAllBytes("handbook.pdf"));
File.WriteAllBytes("handbook-pages.zip", zip);
 
using var archive = new ZipArchive(new MemoryStream(zip));
Console.WriteLine($"{archive.Entries.Count} entries: {string.Join(", ", archive.Entries.Select(e => e.Name))}");
 
static byte[] SplitToZip(byte[] pdf)
{
    using PdfDocument doc = PdfDocument.Open(pdf);
    using var output = new MemoryStream();
    using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
    {
        IReadOnlyList<PdfDocument> parts = PdfDocument.Split(doc, pagesPerPart: 1);
        for (int i = 0; i < parts.Count; i++)
        {
            using PdfDocument part = parts[i];
            using Stream entry = archive.CreateEntry($"page-{i + 1}.pdf").Open();
            part.Save(entry);
        }
    }
    return output.ToArray();
}
OutputConsole
6 entries: page-1.pdf, page-2.pdf, page-3.pdf, page-4.pdf, page-5.pdf, page-6.pdf

In ASP.NET Core, return the bytes with Results.File(zip, "application/zip", "pages.zip").

What each part keeps

  • Bookmarks that lead to the part’s pages, with their nesting.
  • Links between the part’s own pages, and the named destinations they use. A link to a page in another part keeps its rectangle but loses its target, since that page isn’t in the file.
  • Form fields whose widgets are on the part’s pages, with the field tree above them.
  • Tagged structure, page labels and optional content of the copied pages.
  • Resources the pages use, such as fonts and images, copied once per part.

Not carried: metadata, signatures (a signature covers the bytes of the original file), document-level JavaScript and permissions. One case makes parts bigger than you’d expect: some files, slide decks especially, give every page one shared resource dictionary holding all the fonts and images. That dictionary is copied whole into each part. The rules are in page operations.

Encrypted and damaged PDFs

An encrypted PDF opens with its password through PdfOpenOptions, and the parts are written unencrypted unless you set encryption on save. A damaged file is repaired as it is read, so most PDFs that other tools refuse can still be split. See decrypt a PDF and repair a damaged PDF.

Where next