Merge, split and assemble
A PdfDocument can take pages from other PDF files. That one ability covers merging several files
into one, splitting one file into several, and assembling a new document from chosen pages. After
that you can reorder, duplicate and remove pages, and make the result smaller.
All of this is in the core PdfPinata package. The one rule to learn first: a page can only be
copied out of a document that was opened in PdfDocumentOpenMode.Import mode. A document you
created in memory, or opened in Modify mode, cannot give its pages away. Save it and open it again
in Import mode, as every example below does.
Merge documents
Open each source in Import mode and add its pages to a new document. AddPage copies the page,
with its fonts, images and annotations, and returns the copy:
// AddPage(PdfPage) takes a page belonging to another document and copies it in. The
// annotation setting decides what happens to anything interactive on the way: a shallow
// copy keeps the annotation and its destination if the destination is coming too, a deep
// copy drags in what it points at, and DoNotCopy leaves it behind.
foreach (var page in sourceA.Pages)
_ = document.AddPage(page);
foreach (var page in sourceB.Pages)
_ = document.AddPage(page);
To merge files from disk, loop over them:
using PdfPinata.Pdf;
using PdfPinata.Pdf.IO;
using PdfDocument output = new PdfDocument();
foreach (string file in files)
{
using PdfDocument input = PdfReader.Open(file, PdfDocumentOpenMode.Import);
foreach (PdfPage page in input.Pages)
output.AddPage(page);
}
output.Save("merged.pdf");
To copy a run of pages in one call, use output.Pages.InsertRange(index, input, startIndex, pageCount). Other overloads take the whole document, or everything from startIndex to the end.
There are three ways to put a page into a document, and they differ in whether they copy:
| Method | Does |
|---|---|
ImportPage(index, page) | Always copies a page from another document. Refuses a page this document already owns. |
PlacePage(index, page) | Never copies. Places a page this document owns, made with new PdfPage(document), that is not yet in the page list. |
AddPage(page), InsertPage(index, page) | Copies a page from another document, or places a page this document owns. |
Choose what happens to links
The last argument of AddPage, InsertPage, ImportPage and InsertRange is an
AnnotationCopyingType:
ShallowCopy, the default, copies the page's annotations. A link to another page that you also copy is pointed at the copy.DeepCopycopies each annotation together with everything it refers to.DoNotCopyleaves annotations behind.
A link to a page that you did not copy loses its destination. The link stays on the page but does nothing when clicked. A link that names its destination, as Word and LaTeX write them, is resolved against the source document and written out in full, so it works in the merged file.
Reorder, duplicate and remove pages
Page indexes start at zero.
// A duplicate of the first imported page, placed at the end. Within one document, so no
// import is involved and the copy shares what it can with the original.
_ = document.DuplicatePage(1, document.PageCount);
// And a reorder. The pages that follow this report are their own evidence: B2 has moved
// from the end of the run to the front of it.
document.MovePage(5, 1);
MovePage(oldIndex, newIndex)moves a page within the document.DuplicatePage(sourceIndex, index)adds a second page that shows the same content. The copy shares the content of the original, so the file hardly grows. It gets its own resources, so you can draw on either page without changing the other. It does not get the original's annotations.document.Pages.RemoveAt(index)anddocument.Pages.Remove(page)remove a page.
Split a document
Splitting is merging in reverse: open the source in Import mode and add each page to a new
document of its own. The Assemble demo splits the document it has built, so it saves it and
opens it again first:
// The document being assembled cannot be split from directly: its pages belong to a
// document that is open to be written, and AddPage refuses to hand them to somebody else.
// Saving it and reopening in Import mode is the whole of the technique - and is the same
// step the two source documents went through on their way in.
long splitTotal = 0;
var splitCount = 0;
using (var buffer = new MemoryStream())
{
document.Save(buffer, false);
buffer.Position = 0;
using var assembled = PdfReader.Open(buffer, PdfDocumentOpenMode.Import);
foreach (var page in assembled.Pages)
{
using var single = new PdfDocument();
_ = single.AddPage(page);
splitTotal += Bytes(single);
splitCount++;
}
}
To write one file per page:
using PdfDocument input = PdfReader.Open("book.pdf", PdfDocumentOpenMode.Import);
for (int index = 0; index < input.PageCount; index++)
{
using PdfDocument output = new PdfDocument();
output.Info.Title = $"Page {index + 1} of {input.Info.Title}";
output.AddPage(input.Pages[index]);
output.PruneUnusedResources();
output.Save($"book-page-{index + 1}.pdf");
}
Make the result smaller
Merging and splitting can leave a document heavier than it needs to be. Two methods remove the waste. Call them on the document you are about to save:
// Both source documents drew the same photograph, and each loaded it separately, so the
// merged document carries the image twice over. Nothing about the pages changes; one of
// the two XObjects simply stops being referenced.
document.ConsolidateImages();
var bytesConsolidated = Bytes(document);
// Dropping what no page actually draws with. A document this library wrote gives each page
// its own resource dictionary, so there is usually nothing here to find - the saving turns
// up on pages imported from a producer that names every font in the document on every page.
document.PruneUnusedResources();
var bytesPruned = Bytes(document);
ConsolidateImagesfinds images whose bytes are identical and makes every page use one copy. This pays when the merged documents each embedded the same logo or photograph.PruneUnusedResourcesremoves from each page the fonts, images and other resources that the page names but does not draw with. Many producers give every page one shared list of every font and image in the document. A page copied from such a file brings the whole list with it, so without pruning, each file of a split can weigh as much as the whole document. If the library cannot read a page's content completely, for example because it holds an inline image, it leaves that page as it is rather than guess.
Pages that PdfPinata wrote already have their own resources, so pruning finds little on them.
Draw on a copied page
The page that AddPage returns belongs to the new document, so you can draw on it. Pass
XGraphicsPdfPageOptions.Append to draw on top of the existing content, or Prepend to draw
beneath it:
using PdfPinata.Drawing;
PdfPage added = output.AddPage(input.Pages[index]);
using XGraphics gfx = XGraphics.FromPdfPage(added, XGraphicsPdfPageOptions.Append);
gfx.DrawString($"{index + 1}", font, XBrushes.Red, new XPoint(20, 20));
To place a page from another PDF at a smaller size, several to a sheet, draw it as an XPdfForm
instead. It then behaves like an image: it draws the page, but its links and annotations do not
come with it. See Forms, stamps and imposition.
Things to know
- Use the page that
AddPagereturns. When the page comes from another document, the return value is a new object, and the page you passed in still belongs to the source. The source was opened inImportmode, so you cannot draw on its pages. Importmode is the only mode pages leave. Copying from a document opened any other way throws "A PDF document must be opened with PdfDocumentOpenMode.Import to import pages from it." A document opened inImportmode cannot itself be changed or saved. See Opening documents.- Only the page is copied, not the document around it. Bookmarks, the structure tree that makes a document accessible, the interactive form, page labels and the document information are not copied. A merge of tagged documents is not tagged, and form fields on copied pages are no longer part of a working form. Add bookmarks to the result yourself; see Bookmarks and outlines.
- The pieces of a split weigh more than the whole. Each file carries its own copy of every font and image its page uses. The Assemble demo reports the numbers.
- A duplicated page has no annotations. An annotation records the page it belongs to, so it cannot be shared. Add links to the duplicate yourself.
See it in action
The Assemble demo builds two documents, merges them, removes the duplicate photograph, duplicates and moves pages, splits the result, and reports the size of each step.
The full Assemble demo
var heading = new XFont("Liberation Sans", 16, XFontStyle.Bold);
var label = new XFont("Liberation Sans", 9, XFontStyle.Bold);
var body = new XFont("Liberation Sans", 9);
var mono = new XFont("Source Code Pro", 8.5);
var huge = new XFont("Liberation Sans", 48, XFontStyle.Bold);
// Every source page says loudly which document and which page it was, so that the order
// the assembled document ends up in can be read off the pages themselves.
void Stamp(PdfPage page, string name, XColor colour)
{
using var gfx = XGraphics.FromPdfPage(page);
gfx.DrawRectangle(new XSolidBrush(colour), 0, 0, page.Width.Point, 90);
gfx.DrawString(name, huge, XBrushes.White, new XRect(0, 10, page.Width.Point, 70),
XStringFormats.Center);
}
// A source document is a document like any other. Built here, saved to memory and read
// back in Import mode - which is the mode that permits taking pages *out* of a document,
// as against Modify, which permits changing them.
PdfDocument Source(string prefix, int pages, XColor colour, bool withLink, bool withImage,
out long bytes)
{
var source = new PdfDocument();
source.Info.Title = prefix;
for (var index = 1; index <= pages; index++)
{
var page = source.AddPage();
Stamp(page, $"{prefix}{index}", colour);
using var gfx = XGraphics.FromPdfPage(page);
gfx.DrawString($"Page {index} of document {prefix}", body, XBrushes.Black,
new XPoint(50, 130));
if (withImage)
{
// A fresh XImage per page, deliberately. Two pages sharing one XImage already
// share one XObject; two that loaded the same bytes separately do not, and
// that is the case ConsolidateImages exists for.
using var photograph = XImage.FromStream(
() => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg"));
gfx.DrawImage(photograph, 50, 160, 200, 150);
}
if (withLink && index == 1 && pages > 1)
{
gfx.DrawString("This line links to the last page of this document.", body,
XBrushes.MediumBlue, new XPoint(50, 340));
gfx.AddDocumentLink(new XRect(50, 330, 300, 14), pages - 1);
}
}
// Measured here rather than after reopening: a document opened in Import mode is not
// one that can be saved, so its size has to be taken while it is still being written.
using var buffer = new MemoryStream();
source.Save(buffer, false);
bytes = buffer.Length;
buffer.Position = 0;
return PdfReader.Open(buffer, PdfDocumentOpenMode.Import);
}
long Bytes(PdfDocument document)
{
using var buffer = new MemoryStream();
document.Save(buffer, false);
return buffer.Length;
}
using var sourceA = Source("A", 3, XColor.FromArgb(70, 130, 180),
withLink: true, withImage: true, out var bytesA);
using var sourceB = Source("B", 2, XColor.FromArgb(178, 34, 34),
withLink: false, withImage: true, out var bytesB);
// ----- the assembly itself -----
var document = new PdfDocument();
document.Info.Title = "Assemble";
// Created first and drawn last, once there are numbers to put on it.
var report = document.AddPage();
// AddPage(PdfPage) takes a page belonging to another document and copies it in. The
// annotation setting decides what happens to anything interactive on the way: a shallow
// copy keeps the annotation and its destination if the destination is coming too, a deep
// copy drags in what it points at, and DoNotCopy leaves it behind.
foreach (var page in sourceA.Pages)
_ = document.AddPage(page);
foreach (var page in sourceB.Pages)
_ = document.AddPage(page);
var bytesMerged = Bytes(document);
// Both source documents drew the same photograph, and each loaded it separately, so the
// merged document carries the image twice over. Nothing about the pages changes; one of
// the two XObjects simply stops being referenced.
document.ConsolidateImages();
var bytesConsolidated = Bytes(document);
// Dropping what no page actually draws with. A document this library wrote gives each page
// its own resource dictionary, so there is usually nothing here to find - the saving turns
// up on pages imported from a producer that names every font in the document on every page.
document.PruneUnusedResources();
var bytesPruned = Bytes(document);
// A duplicate of the first imported page, placed at the end. Within one document, so no
// import is involved and the copy shares what it can with the original.
_ = document.DuplicatePage(1, document.PageCount);
// And a reorder. The pages that follow this report are their own evidence: B2 has moved
// from the end of the run to the front of it.
document.MovePage(5, 1);
var annotationsOnFirstImported = document.Pages[2].Annotations.Count;
// ----- splitting, which is importing read backwards -----
// The document being assembled cannot be split from directly: its pages belong to a
// document that is open to be written, and AddPage refuses to hand them to somebody else.
// Saving it and reopening in Import mode is the whole of the technique - and is the same
// step the two source documents went through on their way in.
long splitTotal = 0;
var splitCount = 0;
using (var buffer = new MemoryStream())
{
document.Save(buffer, false);
buffer.Position = 0;
using var assembled = PdfReader.Open(buffer, PdfDocumentOpenMode.Import);
foreach (var page in assembled.Pages)
{
using var single = new PdfDocument();
_ = single.AddPage(page);
splitTotal += Bytes(single);
splitCount++;
}
}
// ----- the report page, now that everything has a number -----
using (var gfx = XGraphics.FromPdfPage(report))
{
var prose = new XTextFormatter(gfx);
gfx.DrawString("Assembling documents", heading, XBrushes.Black, new XPoint(50, 60));
prose.DrawString(
"Two documents were built in memory, saved, reopened in Import mode and merged "
+ "into this one. Import mode is what permits taking pages out of a document; "
+ "Modify permits changing them and refuses to let them be extracted. Picking the "
+ "wrong one is the usual reason an assembly API appears to do nothing.",
body, XBrushes.Black, new XRect(50, 80, 495, 60));
gfx.DrawString("What was done", label, XBrushes.Black, new XPoint(50, 155));
(string Step, string Detail)[] steps =
{
("Document A", $"3 pages, a photograph on each, and a link from A1 to A3. {bytesA:N0} bytes."),
("Document B", $"2 pages, the same photograph on each. {bytesB:N0} bytes."),
("AddPage x 5", $"Every page of both, copied in. {bytesMerged:N0} bytes."),
("ConsolidateImages", $"{bytesMerged - bytesConsolidated:N0} bytes saved - the photograph was embedded twice."),
("PruneUnusedResources", $"{bytesConsolidated - bytesPruned:N0} bytes saved."),
("DuplicatePage(1, 6)", "A copy of the first imported page, placed at the end."),
("MovePage(5, 1)", "B2 moved from the end of the run to the front of it."),
("Split", $"{splitCount} single-page documents, {splitTotal:N0} bytes between them.")
};
double y = 175;
foreach (var step in steps)
{
gfx.DrawString(step.Step, mono, XBrushes.Black, new XPoint(50, y));
gfx.DrawString(step.Detail, body, XBrushes.DimGray, new XPoint(190, y));
y += 16;
}
gfx.DrawString("What to look for", label, XBrushes.Black, new XPoint(50, y + 20));
prose.DrawString(
"The pages after this one read B2, A1, A2, A3, B1, A1 - the order the moves above "
+ "left them in, not the order they were added. The first A1 still carries its "
+ $"link to A3 ({annotationsOnFirstImported} annotation(s) survived the import); "
+ "the copy at the end came from DuplicatePage rather than from another import.",
body, XBrushes.Black, new XRect(50, y + 32, 495, 60));
gfx.DrawString("Why the two savings differ so much", label, XBrushes.Black, new XPoint(50, y + 105));
prose.DrawString(
"ConsolidateImages finds XObjects with identical bytes and points every reference "
+ "at one of them, which pays whenever two merged documents embedded the same logo "
+ "or photograph. PruneUnusedResources drops what a page names and does not draw "
+ "with, and a document this library wrote gives each page its own resources - so "
+ "there is little to find here. It pays on a page imported from a producer that "
+ "names every font in the document on every page of it, which is why splitting "
+ "such a document can otherwise give every single-page file the weight of the "
+ "whole.",
body, XBrushes.Black, new XRect(50, y + 117, 495, 100));
prose.DrawString(
$"Splitting bears that out from the other side: {splitCount} one-page documents "
+ $"come to {splitTotal:N0} bytes against the {bytesPruned:N0} of the document they "
+ "came from, because each of them carries its own copy of every font and image its "
+ "page draws with.",
body, XBrushes.Black, new XRect(50, y + 225, 495, 60));
}