Skip to content

Edit PDF pages in C# and .NET

Since 1.0View as Markdown

Open a PDF, change its pages, metadata and bookmarks, and save a new file that is optimized and verified on the way out.

PdfDocument.Open reads a file, doc.Pages is an ordered list you can change, and doc.Save writes a new single revision PDF. Pages.Import copies pages from any other document, with shared fonts and images copied once.

PdfDocument doc = PdfDocument.Open("contract.pdf");
doc.Pages.RemoveAt(0); // drop the cover
doc.Pages.Move(2, 0); // the third page becomes the first
doc.Pages[0].Rotate(90); // clockwise, in steps of 90 degrees
doc.Pages.Add(PdfRectangle.A4); // a blank page at the end
doc.Pages.Insert(1, PdfRectangle.Letter); // or anywhere
PdfDocument annex = PdfDocument.Open("annex.pdf");
doc.Pages.Import(annex.Pages); // shared fonts and images are copied once
doc.Save("contract-edited.pdf"); // optimized and verified by default
Terminal window
dotnet add package PdfNetStack

The same edits on the command line are pdfnetstack merge, split, extract, rotate and meta, see Command line.

CallWhat it does
PdfDocument.Open(path, password)Reads a file. The password is only needed for encrypted files
PdfDocument.Open(stream, password)Reads a stream to its end
PdfDocument.Open(bytes, password, maxDecodedStreamBytes)Opens from memory. The buffer is referenced, not copied, so it must not change while the document is in use
PdfDocument.Create()An empty document. Add at least one page before saving

A file that is not a readable PDF raises PdfFormatException. A wrong password raises PdfPasswordException, see Encryption.

doc.Pages is a PdfPageCollection, zero based.

MemberEffect
Add(PdfRectangle.A4), Insert(index, size)Blank page at the end or at a position
RemoveAt(index), Remove(page), Clear()Removes pages. Links and bookmarks pointing to them become empty
Move(from, to)Reorders
Import(pages, index)Copies pages from this or another document to the end, or to index, and returns the new pages
page.Rotate(90)Rotates clockwise by a multiple of 90. page.Rotation reads the result: 0, 90, 180 or 270
page.Width, page.Height, page.MediaBox, page.CropBoxPage geometry, in points

Inherited page attributes (resources, media box, rotation) are copied onto each page when the document opens, so a page stays correct when it moves within the document or to another one.

PdfDocument merged = PdfDocument.Merge([
PdfDocument.Open("a.pdf"),
PdfDocument.Open("b.pdf"),
PdfDocument.Open("c.pdf"),
]);
merged.Save("merged.pdf");

Merge imports every page of every document, in order. Split returns one new document per page:

int number = 0;
foreach (PdfDocument single in PdfDocument.Open("report.pdf").Split())
{
single.Save($"report-page-{++number}.pdf");
}

To extract a page range, import the pages you want into a new document. Any order works, and the same page may appear twice:

PdfDocument source = PdfDocument.Open("manual.pdf");
PdfDocument excerpt = PdfDocument.Create();
excerpt.Pages.Import([source.Pages[4], source.Pages[0], source.Pages[1]]); // any order
excerpt.Save("manual-excerpt.pdf");

Annotations travel with their page and are copied fresh for every import. Links to pages imported in the same call are remapped to the copies; links to pages left behind become empty. Since 1.1, form fields travel with their pages too, renamed with a _2 suffix when the target form already has the name, see Forms. A document opened with only its user password gives up its pages only when its permissions allow copying.

doc.Info holds the document information dictionary: title, author, subject, keywords, creator, producer and the two dates, plus any custom entry by name.

Console.WriteLine($"Title: {doc.Info.Title}, PDF {doc.Version}, {doc.Pages.Count} pages");
doc.Info.Title = "Quarterly report, final";
doc.Info.Author = "Finance";
doc.Info.Keywords = "report, quarterly";
doc.Info.ModificationDate = DateTimeOffset.Now;
doc.Info["Department"] = "Controlling"; // custom entries too
foreach (string key in doc.Info.Keys)
{
Console.WriteLine($" {key}: {doc.Info[key]}");
}

Values are decoded from PDFDocEncoding or UTF-16 and written back in whichever of the two fits. Changing them does not update XMP metadata; PDF/A output keeps both in sync.

doc.Outlines reads and writes the bookmark tree. A bookmark has a title, the page it opens, and child bookmarks. Existing bookmarks keep their original styling and actions when you edit others.

PdfOutline summary = doc.Outlines.Add("Summary", doc.Pages[0]);
summary.Add("Revenue", doc.Pages[1]); // nested under Summary
summary.Add("Costs", doc.Pages[2]);
summary.IsOpen = true; // expanded when the file opens
foreach (PdfOutline bookmark in doc.Outlines)
{
Console.WriteLine($"{bookmark.Title}: page {bookmark.Destination?.Index + 1}, {bookmark.Children.Count} children");
}

Available since 1.1. Bookmarks and links often point at a name instead of a page, and the document maps each name to a page. doc.NamedDestinations lists the names of the name tree and of the older catalog dictionary, resolves them to pages, and renames them. A rename changes every bookmark, link and GoTo action that refers to the name, in its own string or name form, and rewrites the name tree as one sorted leaf. A rename that would collide with an existing name is refused. PdfOutline.DestinationName gives the name a bookmark points at.

PdfDocument doc = PdfDocument.Open("guide.pdf");
foreach (string name in doc.NamedDestinations.Names)
{
Console.WriteLine($"{name}: page {doc.NamedDestinations.GetPage(name)?.Index + 1}");
}
foreach (PdfOutline bookmark in doc.Outlines)
{
Console.WriteLine($"Bookmark {bookmark.Title} points at {bookmark.DestinationName}");
}
// Every bookmark, link and GoTo action that uses the old names follows the rename.
int changed = doc.NamedDestinations.Rename(new Dictionary<string, string>
{
["_TOC_d1e10"] = "introduction",
["_TOC_d1e20"] = "installation",
});
Console.WriteLine($"{changed} references renamed");
doc.Save("guide-renamed.pdf");

Save writes a new single revision file. By default it runs the lossless optimizer on the result, with verification, because merged documents often share fonts and images. Save(path) writes through a temporary file and replaces the target atomically.

doc.Save("manual-fast.pdf", new PdfSaveOptions
{
Optimize = false, // skip the optimizer pass, save sooner
Compression = CompressionEffort.Fast, // zlib level 1 for the streams the writer compresses
});
byte[] bytes = doc.ToArray(); // in memory, with the default options
using (var stream = new MemoryStream())
{
doc.Save(stream); // or to any writable stream
}
PdfSaveOptionsDefaultNotes
OptimizetrueRuns the lossless optimizer, with verification, on the saved file
CompressionMaximumFlate effort: Fast (zlib level 1), Balanced (level 6) or Maximum (level 9)
UseObjectStreamstruePacks objects into compressed object streams (PDF 1.5). Off writes a classic xref table
ConformanceNoneSince 1.1: PDF/A-1b, 2b, 2u, 3b, 3u, 4, 4e or 4f, see PDF/A
  • Saving in full with Save writes a single revision: incremental updates of the source are not kept, and the signatures of a signed document become invalid. SaveIncremental (1.1) appends the changes instead and keeps them valid, see Large files. To add a signature without breaking earlier ones, see Signatures.
  • A document opened with only the user password of an encrypted file can be read but not saved.
  • Open reads the whole file into memory. OpenOnDemand (1.1) reads objects as they are used, see Large files and Memory and performance.

Questions

Does merging PDFs duplicate shared fonts and images?
No. Resources shared by several imported pages are copied once, and saving runs the optimizer, which merges byte identical fonts and images across the merged documents.
What happens to links and bookmarks when pages are removed?
Links and bookmarks that point to a removed page become empty. Links between pages imported in the same call keep working.
Can PdfNetStack edit a signed PDF?
Yes. Saving in full writes a new single revision file and invalidates the signatures. From version 1.1, SaveIncremental appends the changes after the signed bytes instead, so every signature stays intact.