Technical Article

Splitting PDF Documents with PDFium Component in Delphi

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

Diagram of the PDFium Component split loop in Delphi: CreateDocument, ImportPages from the read-only source, a checked SaveAs, and the Active reset before each new iteration
Whatever decides the groups, the loop stays identical: import the pages, save with the result checked, then reset Active so the next CreateDocument starts from a clean state

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 /Dest entry or as a GoTo action in /A (ISO 32000-1 §12.3.3 and §12.6.4.2). PageNumber is filled from /Dest; for an action-based item it stays 0 and the page is in ActionPageNumber instead
  • 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
Diagram mapping top-level PDF bookmarks to computed page ranges and output files when splitting with the PDFium Component in Delphi, including an out-of-range bookmark that is skipped
A section runs from each top-level bookmark's page to one page before the next bookmark, and entries pointing past the end are skipped rather than producing empty files

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