For a 40-page user manual whose first top-level bookmark points at page 5, a splitter that cuts "from this bookmark to the next one" writes chapter files that add up to 36 pages. The cover, the copyright page and a two-page table of contents sit in front of every outline entry, so they never land in any file. An outline only marks where sections begin. It does not cover what comes before the first entry, and nothing in the format requires the entries to be listed in page order, so a reliable splitter collects the bookmark pages as cut points, sorts them and adds an explicit front-matter range
One loop behind every split mode
PDFium Component splits a document with the same four steps on a second TPdf, whatever decides the boundaries: CreateDocument starts an empty PDF in memory, ImportPages copies a range of pages from the source into it, SaveAs writes the file, and Active := False closes the output again. Page-by-page splitting, fixed-size chunks and bookmark sections differ only in the range string handed to ImportPages
The reset at the end of each pass is not optional. CreateDocument refuses to run on a component that is still active and raises an EPdfError whose message begins "Cannot perform this operation on an active", so the second iteration of a loop that forgets Active := False stops with an exception after exactly one file has been written. Putting the reset in a finally block keeps a failed import or save from leaving the output open for the next pass. One TPdf instance serves the whole run, and only one output document is ever held in memory
Opening the source deserves its own check. Assigning Active := True never raises: when the file is missing, damaged or needs a password that was not supplied, TPdf catches the load error and leaves Active at False. ImportPages also tries to activate the source on its own, which means an unopened source shows up only as a False return on the first page and gives no hint about the cause. Opening it explicitly and testing Active turns that into an error that names the file before any output exists:
procedure SplitIntoPages(const SourceFile, OutputDir: string);
var
Source, PdfOut: TPdf;
I: Integer;
OutFile: string;
begin
Source := TPdf.Create(nil);
PdfOut := TPdf.Create(nil);
try
Source.FileName := SourceFile;
Source.Active := True; // a load failure leaves Active = False
if not Source.Active then
raise Exception.Create('Cannot open ' + SourceFile);
for I := 1 to Source.PageCount do
begin
PdfOut.CreateDocument; // raises while PdfOut is still active
try
// Range is 1-based; position 1 is the start of the empty document
if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
raise Exception.CreateFmt('Cannot import page %d', [I]);
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('page_%.4d.pdf', [I]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False; // close the output before the next pass
end;
end;
finally
PdfOut.Free;
Source.Free;
end;
end;
The third argument of ImportPages is the 1-based position in the destination where the first imported page lands. In an empty document 1 is the only position there is. The range syntax itself (single pages, closed spans like '5-10', comma lists) and the position mistakes that reverse a merged bundle are covered in merging multiple PDF files into one document, which is the same API pointed the other way
Fixed-size chunks and caller-supplied ranges
Cutting a document every N pages is a counting problem, and the only case that needs care is the tail. A 230-page scan split into 25-page parts gives nine full parts and a tenth of five pages, so the end of each span is clamped to Source.PageCount:
procedure SplitEvery(Source: TPdf; ChunkSize: Integer; const OutputDir: string);
var
PdfOut: TPdf;
FirstPage, LastPage, Part: Integer;
Range, OutFile: string;
begin
if ChunkSize < 1 then
raise Exception.Create('ChunkSize must be at least 1');
PdfOut := TPdf.Create(nil);
try
Part := 0;
FirstPage := 1;
while FirstPage <= Source.PageCount do
begin
LastPage := FirstPage + ChunkSize - 1;
if LastPage > Source.PageCount then
LastPage := Source.PageCount; // the last part is usually shorter
if FirstPage = LastPage then
Range := IntToStr(FirstPage) // a one-page tail as a plain number
else
Range := Format('%d-%d', [FirstPage, LastPage]);
Inc(Part);
PdfOut.CreateDocument;
try
if not PdfOut.ImportPages(Source, Range, 1) then
raise Exception.Create('Cannot import pages ' + Range);
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('part_%.3d.pdf', [Part]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False;
end;
FirstPage := LastPage + 1;
end;
finally
PdfOut.Free;
end;
end;
Ranges typed by a user are a different matter. ImportPages reports a bad range through its return value: a page number past Source.PageCount, a zero, a reversed span or text that does not parse makes the call return False, and none of the pages in that string are copied. Inside a loop, that failure arrives on the fourth entry after three section files already exist on disk. Checking every entry against Source.PageCount before the first CreateDocument costs a few lines of parsing and means a typo in entry four writes nothing at all, which is the behavior a person retrying the job expects
When the selection already lives in an integer array, for example pages picked in a thumbnail view, ImportPagesByIndex(Source, PageIndices, InsertAt) skips the string step. Both the indices and InsertAt are zero-based there, so the same selection that reads '1,3-5' as a range becomes [0, 2, 3, 4]
Why do bookmark sections come out short or missing?
Bookmark sections go wrong when the code trusts the outline to be tidy. TPdf.Bookmarks returns the top-level entries in the order the author arranged them, each as a TBookmark record with a Title and a 1-based PageNumber. Four things in real files break the naive "this entry to the next" rule:
- An outline item may carry its target as a
/Destentry or as a GoTo action in/A(ISO 32000-1 §12.3.3 and §12.6.4.2).PageNumberis filled from/Dest; for an action-based item it stays0and the page is inActionPageNumberinstead - Outline order is reading order as the author saw it. An "Index" entry placed second, or an appendix listed before the chapters, makes "next bookmark minus one" smaller than the current start, and that section disappears
- Two top-level entries can point at the same page, typically a part title and its first chapter
- An entry can point past the last page when pages were deleted with a tool that left the outline alone
The version below treats the outline as a source of cut points. It resolves each entry's page, drops entries that point nowhere or past the end, keeps the first title on a shared page, sorts by page number and adds a "Front matter" section when the first cut is after page 1. Front matter gets its own file here instead of being glued onto chapter 1, because the person who receives chapter 1 expects it to open on the page the bookmark named, not on a table of contents
procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
Bm: TBookmarks;
Starts: array of Integer;
Titles: array of string;
I, J, K, N, P, EndPage: Integer;
Known: Boolean;
PdfOut: TPdf;
OutFile: string;
begin
Bm := Source.Bookmarks; // top-level entries only
if Length(Bm) = 0 then
Exit; // nothing to cut on
SetLength(Starts, Length(Bm) + 1); // +1 leaves room for front matter
SetLength(Titles, Length(Bm) + 1);
N := 0;
for I := 0 to High(Bm) do
begin
P := Bm[I].PageNumber; // resolved from /Dest
if P < 1 then
P := Bm[I].ActionPageNumber; // resolved from a GoTo action in /A
if (P < 1) or (P > Source.PageCount) then
Continue; // unresolved, or past the last page
Known := False;
for J := 0 to N - 1 do
if Starts[J] = P then
Known := True; // the first title on a page wins
if Known then
Continue;
J := N; // insertion sort by page number
while (J > 0) and (Starts[J - 1] > P) do
begin
Starts[J] := Starts[J - 1];
Titles[J] := Titles[J - 1];
Dec(J);
end;
Starts[J] := P;
Titles[J] := Bm[I].Title;
Inc(N);
end;
if N = 0 then
Exit;
if Starts[0] > 1 then // cover, TOC and anything before them
begin
for J := N downto 1 do
begin
Starts[J] := Starts[J - 1];
Titles[J] := Titles[J - 1];
end;
Starts[0] := 1;
Titles[0] := 'Front matter';
Inc(N);
end;
PdfOut := TPdf.Create(nil);
try
for K := 0 to N - 1 do
begin
if K < N - 1 then
EndPage := Starts[K + 1] - 1
else
EndPage := Source.PageCount;
PdfOut.CreateDocument;
try
if not PdfOut.ImportPages(Source,
Format('%d-%d', [Starts[K], EndPage]), 1) then
raise Exception.CreateFmt('Cannot import pages %d-%d',
[Starts[K], EndPage]);
// Declare SafeFileName (shown further down) before this routine
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('%.2d_%s.pdf', [K + 1, SafeFileName(Titles[K])]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False;
end;
end;
finally
PdfOut.Free;
end;
end;
Because the starts are strictly increasing after the sort, every computed span is valid and the guard on ImportPages should never fire on a readable source. A document without bookmarks simply produces no files in this mode; the caller decides whether that falls back to chunking. To cut one level deeper, for example at chapters inside parts, BookmarkChildren[Bm[I]] returns the children of a top-level entry in the same TBookmarks form, and the same cut-point logic applies to the combined list
What each output file carries
Each output file carries the pages themselves and everything their page dictionaries reference: content streams, fonts, images and the annotation list of each page. Form widgets come along as annotations, but the new file has no AcroForm, so they are no longer working fields. The source is only read. ImportPages copies objects into the new document and leaves the open source untouched, so the same Source instance feeds every iteration
The outline stays behind. Bookmarks live in the document catalog under /Outlines (ISO 32000-1 §7.7.2), outside any page, so a file built from imported pages starts with no bookmarks at all. For chapter files that is often fine. When each part needs its own navigation, the outline has to be built for the new page numbering, which is the subject of editing a PDF outline and remapping its pages. Internal links behave the same way: a "see chapter 7" link on page 12 points at a page that went into a different file, and in this file it has nothing to land on
Encrypted sources need the password before the loop starts. Assign Source.Password while the component is inactive, then set Active := True; with the wrong password the component stays inactive, which the check in SplitIntoPages turns into an error. The output written by SaveAs is a new document without an encryption dictionary. That suits split files meant for different recipients, and when each part has to be protected again, SaveAsEncrypted(FileName, Options) takes a TPdfEncryptOptions record with its own user and owner passwords
File names, failed saves and reruns
Bookmark titles are PDF text strings and can contain anything, including characters Windows rejects in file names. The rules come from the file system, not from PDF: < > : " / \ | ? * and control characters are invalid, trailing spaces and dots are stripped by the Win32 file APIs, and names such as CON, PRN or COM1 are reserved devices. The numbered prefix in SplitByBookmarks already takes care of the device names, keeps the files in reading order in a directory listing, and stops two chapters both titled "Summary" from overwriting each other. The rest is a small function that you write yourself:
// Reader-side helper, not part of PDFium Component
function SafeFileName(const Title: string): string;
const
Forbidden = '<>:"/\|?*';
var
I: Integer;
begin
Result := Title;
if Length(Result) > 80 then
SetLength(Result, 80); // keep full paths well under MAX_PATH
for I := 1 to Length(Result) do
if (Ord(Result[I]) < 32) or (Pos(Result[I], Forbidden) > 0) then
Result[I] := '_';
while (Result <> '') and
((Result[Length(Result)] = '.') or (Result[Length(Result)] = ' ')) do
SetLength(Result, Length(Result) - 1);
if Result = '' then
Result := 'section';
end;
SaveAs with a file name writes to a temporary file in the target folder and swaps it into place only after the save succeeded. The target is therefore either the previous file or a complete new one, never half written, so rerunning a split over an existing output folder is harmless. Failures come back two ways. SaveAs returns False when the document cannot be serialized, and the temporary file is removed. Problems in the file system, such as an output folder that does not exist, a path the OS rejects or a target locked by a viewer, raise an exception from the file layer
How a batch reacts to those failures is a choice, and for splitting it differs from merging. A merged bundle with a missing part is a wrong document, so stopping is correct there. Split outputs are independent of each other: if section_147.pdf cannot be written because a colleague has it open, the other sections are still good. For split jobs, catch per-file exceptions inside the loop, record the file name and the message, keep going, and compare the number of files written with the number of ranges planned when the loop ends. The examples above raise on the first failure only to stay short. A log that says 199 of 200 files were written and names the one that was locked takes one rerun to fix, while a run that aborted at file 147 leaves someone working out which 53 files are missing
The split loop, the bookmark records and the encrypted save used here all come with PDFium Component, the PDFium-based PDF library for Delphi and C++Builder