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
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
TitleandAuthorproperties onTPdfare 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
OutlinePolicyofbmopDiscard;bmopRejectturns an outlined source into a per-file error instead. To keep bookmarks, theFPdfMergeunit addsMergeFilesWithNavigation, which rebuilds outlines, renames clashing named destinations with anImported_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 byCreateDocumentis unencrypted, and so are the pages copied into it. If the merged file must be protected, write it withSaveAsEncryptedinstead ofSaveAs
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