Техническая статья

Разделение PDF-документов с помощью компонента PDFium в Delphi

PDFium Component предоставляет вам один метод для разделения PDF: ImportPages. Все остальное, независимо от того, изолируете ли вы одну страницу, разрезаете по произвольным границам или следуете собственной структуре закладок документа, — это просто разные способы решить, какие номера страниц попадут в каждый выходной файл. Механика остается прежней. Раннее понимание этого сэкономит много неверных шагов

Как работает цикл разделения

Шаблон остается тем же независимо от того, как вы разделяете исходный документ. Создайте новый экземпляр TPdf, вызовите для него CreateDocument, чтобы инициализировать пустой PDF в памяти, импортируйте нужные страницы с помощью ImportPages, сохраните результат, затем сбросьте Active в False перед следующей итерацией. Этот последний шаг люди часто упускают: CreateDocument не закрывает неявно документ, все еще находящийся в памяти, поэтому вы должны сохранить вывод и явно сбросить Active := False, прежде чем вызывать его снова; предварительный сброс сохраняет состояние чистым и четко определенным. Внешний экземпляр TPdf повторно используется на всех итерациях, что снижает нагрузку на выделение памяти при выполнении больших заданий

Вот как выглядит постраничное разделение, если отбросить все лишнее:

procedure SplitIntoPages(Source: TPdf; const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;

      // Range is a 1-based page number string; insertion point 1 = first position
      if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
        raise Exception.CreateFmt('Failed to import page %d', [I]);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;   // reset before next CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

Параметр Range в ImportPages представляет собой тот же строковый формат, который PDFium использует внутри: список номеров страниц, разделенных запятыми, или диапазоны, разделенные дефисами (нумерация начинается с 1). '3' импортирует страницу 3. '1-5' импортирует страницы с 1 по 5 по порядку. '2,5,8' импортирует эти три страницы. Третий параметр — это позиция вставки (начиная с 1) в целевом документе; передача 1 всегда помещает импортированные страницы в начало пустого файла, что нам здесь и нужно

Разделение по диапазонам страниц

Когда вызывающая сторона предоставляет список, например 1-12,13-24,25-36, вы анализируете его на пары начало/конец и запускаете тот же цикл, создавая строку диапазона из каждой пары:

procedure SplitByRanges(Source: TPdf; const RangeList: array of string;
  const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(RangeList) do
    begin
      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeList[I], 1) then
        raise Exception.Create('Invalid page range: ' + RangeList[I]);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

Здесь важна проверка до вызова ImportPages. ImportPages возвращает False, когда номер страницы в строке диапазона превышает Source.PageCount, но он не вызывает исключения и не создает частичный выходной файл, который можно обнаружить только по имени. Проверяйте возвращаемое значение SaveAs и регистрируйте сбои отдельно; диапазон, который создает пустой выходной файл, не является очевидной ошибкой, пока кто-нибудь его не откроет

Разделение по границам закладок

Третий подход использует собственную структуру документа, а не список, предоставленный извне. Каждая закладка верхнего уровня содержит номер целевой страницы; раздел, который она определяет, проходит от этой страницы до той, которая находится перед страницей следующей закладки, или до конца документа для последней записи

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  I, StartPage, EndPage: Integer;
  PdfOut: TPdf;
  RangeStr, OutFile, SafeTitle: string;
begin
  Bm := Source.Bookmarks;
  if Length(Bm) = 0 then
    Exit;

  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(Bm) do
    begin
      StartPage := Bm[I].PageNumber;
      if I < High(Bm) then
        EndPage := Bm[I + 1].PageNumber - 1
      else
        EndPage := Source.PageCount;

      if (StartPage < 1) or (EndPage < StartPage) then
        Continue;

      RangeStr := Format('%d-%d', [StartPage, EndPage]);

      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeStr, 1) then
      begin
        PdfOut.Active := False;
        Continue;   // skip a malformed section instead of writing an empty file
      end;

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

Документ без закладок не является условием ошибки, о котором стоит сообщать пользователю как об ошибке; это просто означает, что этому режиму разделения не с чем работать. Ограничение Length(Bm) = 0 обрабатывает это молча. О чем стоит сообщить, так это о том, что номер страницы закладки находится за пределами диапазона документа, что случается в неправильно сформированных файлах, где контур (outline) никогда не обновлялся после удаления страниц. Проверка границ StartPage и EndPage пропускает эти записи, а не передает мусорный диапазон в ImportPages

Именование выходных файлов и сброс Active

Безопасности имен файлов, полученных из закладок, следует уделить особое внимание. Названия закладок могут содержать символы, допустимые в строке PDF, но не в пути файловой системы. Как минимум, замените прямую косую черту, обратную косую черту и двоеточие перед созданием выходного пути. В Windows *, ?, ", <, > и | также запрещены; простой цикл по фиксированному набору охватывает их без использования регулярных выражений

Строка Active := False в конце каждой итерации заслуживает особого внимания, поскольку это единственное неочевидное требование в шаблоне. CreateDocument не закрывает неявно то, что открыто. Если Active по-прежнему True, когда CreateDocument запускается снова, документ, все еще находящийся в памяти, никогда не был должным образом закрыт или сохранен, и вы не можете полагаться на четко определенное поведение в этом состоянии, поэтому явно сохраните и сбросьте флаг перед началом следующего документа. Думайте об этом как о паре к try/finally: блок finally освобождает внешний объект; Active := False сбрасывает внутреннее состояние документа между итерациями цикла

Использование памяти в процессе масштабного задания по разделению остается неизменным при таком подходе, поскольку вы никогда не держите в памяти более одного выходного документа одновременно. Исходный документ остается открытым и доступным только для чтения на всем протяжении; ImportPages копирует данные страницы в новый документ без изменения исходного. Если источник зашифрован, откройте его с паролем до цикла, и скопированные страницы в каждом выходном файле будут расшифрованы, что обычно является правильным поведением для разделенного вывода, распространяемого разным получателям

Еще одна вещь о SaveAs: метод возвращает логическое значение (Boolean). Несуществующий выходной каталог, путь с символами, которые ОС отклоняет, или состояние переполнения диска — все это приведет к тому, что SaveAs вернет False без создания исключения. В пакетном задании, которое разделяет 200-страничный документ на 200 одностраничных файлов, тихий сбой на странице 147 легко не заметить. Проверяйте возвращаемое значение при каждом вызове и сравнивайте количество успехов с ожидаемым общим итогом после завершения цикла

Показанные здесь методы ImportPages и CreateDocument являются частью PDFium Component для Delphi и C++Builder