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

Объединение нескольких PDF-файлов в один документ с помощью компонента PDFium

Компонент PDFium предоставляет объединение PDF-файлов через один метод: ImportPages. Шаблон всегда одинаков: создать пустой целевой документ, открыть каждый исходный файл, вызвать ImportPages для копирования страниц, закрыть источник и повторить. Когда цикл завершается, SaveAs записывает результат на диск. Здесь нет специального режима объединения, нет конфигурации для переключения. Сложность кроется в крайних случаях, и есть несколько таких, которые кусаются без предупреждения

Основной цикл

Вам понадобятся только два экземпляра TPdf. Один хранит целевой документ, созданный пустым с помощью CreateDocument. Другой по очереди открывает каждый исходный файл. Ниже приведена процедура, которая принимает список путей к файлам и записывает объединенный результат в один путь:

procedure MergeFiles(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  InsertAt, I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    InsertAt := 1;  // ImportPages uses 1-based destination position

    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;

      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open: %s', [FileList[I]]);

      PdfDest.ImportPages(
        PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount),  // full document range
        InsertAt);

      Inc(InsertAt, PdfSrc.PageCount);
      PdfSrc.Active := False;
    end;

    PdfDest.SaveAs(OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

В этом коде есть две вещи, которые легко упустить из виду при первом чтении. Первая — это то, как PDFium сообщает об ошибках загрузки. Active := True никогда не вызывает исключение: если файл отсутствует, поврежден или защищен паролем, PDFium перехватывает ошибку внутренне и оставляет Active равным False. Без явной проверки на 10-й строке плохой файл просто тихо выпал бы из объединения без каких-либо индикаций в выводе. Окончательный PDF имел бы меньше страниц, чем ожидалось, и вы бы не знали, какой файл был виноват

Вторая — это счетчик InsertAt. Третий аргумент ImportPages — это 1-индексная позиция в месте назначения, куда попадает первая импортированная страница. Значение 1 помещает первый исходный документ в начало иначе пустого файла. После каждого источника счетчик увеличивается на PdfSrc.PageCount, поэтому следующая партия страниц добавляется после последней. Если забыть увеличить его, каждый последующий источник будет перезаписывать страницы в позиции 1, в результате чего вы получите последний документ в списке и больше ничего

Выборочные диапазоны страниц

Вам не обязательно брать каждую страницу из источника. Строка диапазона, передаваемая в качестве второго аргумента, следует простому формату с запятыми и дефисами: "1-3" берет страницы с 1 по 3, "2,4,6" выбирает три конкретные страницы, а "1-" означает страницу с 1 до конца документа. Диапазоны могут быть объединены в одной строке, поэтому "1-3,5,7-" пропускает страницы 4 и 6. Здесь важна одна тонкость: числа всегда относятся к страницам в исходном документе, начиная с 1, независимо от того, где эти страницы окажутся в целевом документе. Если вам нужны страницы с 40 по 50 из 200-страничного каталога, строка диапазона будет "40-50", а не позиция относительно того, что уже есть в месте назначения

// Extract cover plus a three-page executive summary from a long report
PdfSrc.FileName := 'annual-report.pdf';
PdfSrc.Active   := True;
if PdfSrc.Active then
begin
  // Page 1 is the cover; pages 3-5 are the summary
  PdfDest.ImportPages(PdfSrc, '1,3-5', InsertAt);
  Inc(InsertAt, 4);  // 1 cover + 3 summary pages = 4 pages added
  PdfSrc.Active := False;
end;

При вычислении приращения для InsertAt считайте страницы, которые вы фактически импортировали, а не количество страниц в источнике. Если вы передаете '1,3-5', вы импортировали 4 страницы, поэтому увеличивайте на 4. Увеличение на PdfSrc.PageCount оставит пробел из пустых целевых позиций и поместит следующий исходный документ дальше в файле, чем планировалось

Что сохраняет ImportPages и что нет

Страницы, скопированные с помощью ImportPages, сохраняют свое видимое содержимое в неизменном виде. Текст, векторная графика, растровые изображения, встроенные шрифты и формы XObjects передаются как часть потоков содержимого страницы. Аннотации на уровне страницы, включая комментарии, выделения и штрихи от пера, тоже переносятся, потому что они хранятся внутри словаря страницы, а не на уровне документа

Метаданные на уровне документа — это другая история. Строки заголовка, автора, темы и ключевых слов из словаря Info источника остаются позади. Целевой документ начинается с пустыми метаданными после CreateDocument, поэтому, если в объединенном результате необходимо заполнить эти поля, вы должны назначить их PdfDest напрямую перед вызовом SaveAs. Свойства Title, Author, Subject, Keywords и Creator в TPdf принимают обычные строки и записывают их в словарь Info при сохранении

Интерактивные поля форм устроены сложнее. Определения полей AcroForm находятся в словаре на уровне документа, а не внутри отдельных потоков страниц. Когда ImportPages копирует страницу, содержащую поля форм, их внешний вид переносится, потому что он отрисован в потоке содержимого страницы, но виджеты полей, делающие их интерактивными, являются частью структуры AcroForm и не следуют за страницей. При типичном объединении текстовое поле из исходного документа будет отображать то значение, которое оно имело на момент импорта, но оно не будет редактируемым в объединенном файле. Если вам нужно, чтобы поля оставались заполняемыми, выполните их сведение (flatten) в каждом исходном документе перед импортом: это впечатает текущие значения в поток содержимого и удалит интерактивный слой, давая вам чистый визуальный результат без сломанных виджетов на выходе

Зашифрованные исходные файлы

Защищенные паролем исходные документы открываются так же, как и незашифрованные, только сперва нужно установить одно дополнительное свойство. Назначьте пароль в PdfSrc.Password перед переключением Active := True, и PDFium использует его во время открытия:

PdfSrc.Password := 'user-password';
PdfSrc.FileName := 'protected.pdf';
PdfSrc.Active   := True;
if not PdfSrc.Active then
  raise Exception.Create('Wrong password or file cannot be opened');

PdfDest.ImportPages(PdfSrc, '1-' + IntToStr(PdfSrc.PageCount), InsertAt);
Inc(InsertAt, PdfSrc.PageCount);
PdfSrc.Active := False;

Неверный пароль вызывает тот же тихий результат Active = False, что и отсутствующий файл, поэтому явная проверка здесь столь же необходима. Шифрование не переносится в место назначения: страницы, импортированные из защищенного источника, попадают в место назначения как незащищенный контент. Если для объединенного результата также требуется шифрование, настройте его на PdfDest перед вызовом SaveAs

Сохранение результата

SaveAs в TPdf принимает либо путь к файлу, либо TStream. Для большинства объединений перегрузка с файлом — это то, что вам нужно:

PdfDest.SaveAs('merged-output.pdf');

Необязательный второй аргумент — это TSaveOption, который контролирует режим сохранения. По умолчанию используется saNone, который записывает инкрементное обновление, если документ был загружен из файла, или полную перезапись, если он был создан заново. Поскольку целевой документ, созданный с помощью CreateDocument, всегда свежий, результат будет компактным файлом с одной ревизией. Третий аргумент, TPdfVersion, позволяет закрепить заголовок версии PDF, если у вас есть последующие потребители, которым требуется конкретная версия; если оставить его как pvUnknown, PDFium сможет выбрать на основе содержимого

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