Technical Article

Merging Multiple PDF Files into One Document with PDFium Component

A merge loop that passes 1 as the insertion position on every call produces a file with the right number of pages and the documents in reverse order. The last file in the list ends up on page 1, because position 1 means "insert before the current first page", and every new source pushes the earlier ones further back. Nothing is lost, which is exactly why the mistake survives a quick page count check and turns up when someone actually reads the bundle

The page-by-page loop with ImportPages

PDFium Component builds a merged PDF in Delphi from two TPdf instances: one destination created empty with CreateDocument, and one source that opens each input file in turn. TPdf.ImportPages(Pdf, Range, PageNumber) copies the pages named in Range from Pdf into the destination and returns True on success. PageNumber is 1-based and defaults to 1. It names the destination position where the first imported page lands, so 1 prepends and PageCount + 1 appends

The cleanest way to keep files in list order is to stop tracking a cursor at all and ask the destination where its end is. A running InsertAt counter works too, but it has to be advanced by the number of pages actually imported, and that number is easy to get wrong once ranges come into play. PdfDest.PageCount + 1 is always right:

procedure MergeFileList(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;            // does not raise on failure
      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);

      // 1-based position: PageCount + 1 appends after the last page
      if not PdfDest.ImportPages(PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount), PdfDest.PageCount + 1) then
        raise Exception.CreateFmt('Page import failed for %s', [FileList[I]]);

      PdfSrc.Active := False;             // FileName can only change while inactive
    end;

    if not PdfDest.SaveAs(OutputPath) then
      raise Exception.Create('Cannot write ' + OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

Two checks in that loop carry real weight. Setting Active := True never raises: if the file is missing, damaged, or needs a password, TPdf catches the load error and leaves Active at False. The component stays in a consistent closed state and a form does not crash on a bad file. A merge loop that skips the check, though, simply produces a shorter output. The second check is the Boolean result of ImportPages, which comes back False when the range string does not parse or the position lies past PageCount + 1. Note also that FileName and Password can only be assigned while the component is inactive, which is why the source is closed at the bottom of each pass

Delphi merge loop with the PDFium Component: each source file is opened, copied through ImportPages at the InsertAt position, and the destination document is written once with SaveAs
ImportPages lands each source at the InsertAt position, and a missing or damaged file fails silently unless Active is checked

Range strings: what the parser accepts

The range argument is a comma-separated list of single pages and closed spans, numbered from 1 in the source document. '1-3' takes the first three pages, '2,4,6' takes three specific ones, and '1,3-5' takes a cover plus a three-page summary. The numbers always refer to the source; where those pages land in the destination is decided only by the position argument

Open-ended spans are where people trip. A string such as '7-' looks like "page 7 to the end", but the PDFium range parser requires both ends of a span and rejects the whole string. The same happens with a reversed span ('5-3'), a page number of 0, or any page beyond the source's page count. One bad item fails the entire call: ImportPages returns False and no pages from that string are copied. That is why the loop above builds '1-' + IntToStr(PdfSrc.PageCount) instead of writing '1-'

When you already hold page indices in an array, ImportPagesByIndex(Source, PageIndices, InsertAt) skips the string step. Watch the convention change, though: both the indices and InsertAt are zero-based there, so appending is InsertAt = PdfDest.PageCount and the cover-plus-summary selection becomes [0, 2, 3, 4]. Passing an empty array imports every page

Is there a one-call merge for a list of files?

Yes. TPdf.MergeFiles(Sources, Options, Report) takes an array of TPdfBatchMergeSource records, each built with TPdfBatchMergeSource.FromFile(FileName, PageRange, Password), opens them one at a time, and fills a TPdfBatchMergeReport with what happened. An empty PageRange means every page. The range syntax is the same comma-and-span format, and a malformed range becomes a per-file error in the report rather than a silent skip

The defaults from TPdfBatchMergeOptions.Default are worth knowing before you change any of them. CreateDestination is True, so an inactive destination is created for you. InsertAt is -1, meaning append; any other value is a zero-based index, matching ImportPagesByIndex and not ImportPages. ErrorPolicy is bmepStop and RollbackOnCancelOrFailure is True, so one unreadable file ends the run and removes every page the run had added. CopyViewerPreferences copies the viewer preferences of the first source that imports successfully

procedure BuildBundle(const OutputPath: string; Log: TStrings);
var
  Dest: TPdf;
  Sources: TPdfBatchMergeSources;
  Options: TPdfBatchMergeOptions;
  Report: TPdfBatchMergeReport;
  I: Integer;
begin
  SetLength(Sources, 3);
  Sources[0] := TPdfBatchMergeSource.FromFile('cover.pdf');
  Sources[1] := TPdfBatchMergeSource.FromFile('annual-report.pdf', '1,3-5');
  Sources[2] := TPdfBatchMergeSource.FromFile('appendix.pdf', '', 'user-password');

  Options := TPdfBatchMergeOptions.Default;
  Options.ErrorPolicy := bmepSkip;        // keep going past unreadable files

  Dest := TPdf.Create(nil);               // inactive: MergeFiles creates it
  try
    if not Dest.MergeFiles(Sources, Options, Report) then
      raise Exception.Create('Merge failed: ' + Report.ErrorMessage);

    for I := 0 to High(Report.Issues) do
      Log.Add(Format('%s: %s',
        [Report.Issues[I].FileName, Report.Issues[I].MessageText]));
    Log.Add(Format('%d pages from %d of %d files',
      [Report.ImportedPageCount, Report.ImportedSourceCount, Report.SourceCount]));

    Dest.SaveAs(OutputPath);
  finally
    Dest.Free;
  end;
end;

Which error policy to use depends on what a missing file means for the output. For a contract packet or an invoice run, a bundle without one of its parts is a wrong document: keep the default bmepStop with rollback. Switch to bmepSkip only for best-effort collections, such as a weekly digest of reports, and then treat bmrsCompletedWithErrors in Report.Status as something a person has to look at. Whichever you pick, read Report.Issues: each entry carries the source index, the file name and the message, which answers the "which file was the culprit" question that the hand-written loop has to answer with its own exceptions

Two more options matter for long runs. OnProgress and CancellationToken let a UI show the current file and stop the run, and a cancelled run is rolled back like a failed one. ResourcePolicy defaults to bmrpSourceScoped, which imports each source's selected pages in one native call so fonts and images shared by those pages are copied once. bmrpChunked imports in batches of PageChunkSize pages, which gives finer cancellation points at the price of shared resources being copied again for each batch

What an imported page carries, and what stays in the source

An imported page brings everything reachable from its page dictionary: the content stream, the fonts, images and form XObjects in its resources, and its annotations, including comments, highlights and ink. Anything that lives at document level in the source does not come along, because ISO 32000-1 keeps it outside the page tree

  • The document information dictionary (ISO 32000-1 §14.3.3) stays behind, so the merged file has none of the sources' Title, Author or Keywords. The Title and Author properties on TPdf are read-only, which makes them handy for logging what each source declared before you close it
  • Outlines (§12.3.3) are dropped under the default OutlinePolicy of bmopDiscard; bmopReject turns an outlined source into a per-file error instead. To keep bookmarks, the FPdfMerge unit adds MergeFilesWithNavigation, which rebuilds outlines, renames clashing named destinations with an Imported_ prefix by default, and remaps links between pages; the outline editing and page remapping article covers how those destinations are rewritten
  • Encryption (§7.6) belongs to the source file. A protected source opens with its Password, but a destination made by CreateDocument is unencrypted, and so are the pages copied into it. If the merged file must be protected, write it with SaveAsEncrypted instead of SaveAs
PDFium ImportPages carries page content, fonts and annotations into a merged PDF, while Info metadata, the AcroForm field tree and encryption stay behind in each source file
ImportPages moves everything stored with the page, while document-level metadata and the AcroForm field tree stay behind

Why do form fields stop working after a merge?

Form fields are split across two levels of a PDF. The widget annotation that draws a text box sits on the page, while the field itself, with its name and value, is listed in the /Fields array of the catalog's interactive form dictionary (ISO 32000-1 §12.7.2). A native page import copies the widget along with the other page annotations, but nothing adds it to the destination's field tree. The box still draws its last appearance. A viewer that builds its form model from /AcroForm, though, has no field to attach the widget to, so the value cannot be edited and any form operation that walks the field tree skips it

TPdfBatchMergeOptions.FormPolicy gives three answers for a plain merge. bmfpPreserveNative, the default, keeps the native import result described above. bmfpFlatten generates appearances and flattens the selected pages of each form-bearing source in memory before importing them, so values become ordinary page content; the source file on disk is not modified. bmfpReject turns any source with an AcroForm or XFA form into a per-file error

When the fields must stay fillable, use MergeFilesWithForms from the FPdfFormMerge unit. It imports the page content, rebuilds the widgets from each source's field tree, and grafts those fields into the destination form. Field names are the hard part. Under §12.7.3.2 two fields with the same fully qualified name are the same field and share one value, so a Total field in an invoice and a Total field in a delivery note would overwrite each other. The default CollisionPolicy is pagcpReject, which stops the merge at the first clash:

uses
  PDFium, FPdfCompress, FPdfFormMerge;

procedure MergeFillableForms(Dest: TPdf;
  const Sources: TPdfBatchMergeSources; Log: TStrings);
var
  Options: TPdfFormMergeOptions;
  Report: TPdfFormMergeReport;
begin
  Options := TPdfFormMergeOptions.Default;
  // Clashing names get the RenamePrefix ('Imported_' unless you change it)
  Options.GraftOptions.CollisionPolicy := pagcpRename;

  if Dest.MergeFilesWithForms(Sources, Options, Report) then
    Log.Add(Format('%d fields grafted, %d renamed',
      [Report.GraftedFieldCount, Report.RenamedFieldCount]))
  else
    Log.Add('Form merge failed: ' + Report.ErrorMessage);
end;

pagcpNamespace puts each source's fields under its own prefix instead (Imported plus the source number), and pagcpMergeCompatible joins same-named fields only when their types and options agree. MergeFilesWithForms works on AcroForm sources and destinations without signature fields. It refuses XFA forms and signed files outright, since rewriting a signed file invalidates the signed byte range described in ISO 32000-1 §12.8.1. An open destination must also be unencrypted: merging into a password-protected file fails, so add protection afterwards with SaveAsEncrypted. A page selected twice in one source, as in '1,1', needs an explicit RepeatedPagePolicy

The destination document is replaced only after the staged result has been reopened and checked. Field names, values and types are compared page by page, and a failed form merge leaves Dest as it was. The AcroForm graft article goes deeper into collision handling

Saving the merged document

SaveAs has a file overload and a TStream overload, both taking an optional TSaveOption (default saNone) and a TPdfVersion (default pvUnknown, which leaves the header to the library). Both return a Boolean, and in a batch job that result deserves the same check as Active. Pin the version with something like pv17 only when a downstream system insists on a specific header

The reverse job, cutting one file into several, uses the same range strings and the same 1-based positions; the splitting article walks through it

ImportPages, MergeFiles and the form-aware merge described here ship with the PDFium Component, which brings these page operations to Delphi and C++Builder applications