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

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

Цикл слияния, передающий 1 в качестве позиции вставки при каждом вызове, выдаёт файл с правильным числом страниц и документами в обратном порядке. Последний файл списка оказывается на странице 1, потому что позиция 1 значит «вставить перед текущей первой страницей», и каждый новый источник отодвигает предыдущие дальше. Ничего не потеряно — именно почему ошибка переживает быструю проверку числа страниц и всплывает, когда кто-то реально читает сборку

Постраничный цикл с ImportPages

PDFium Component собирает объединённый PDF в Delphi из двух экземпляров TPdf: целевого, созданного пустым через CreateDocument, и исходного, который по очереди открывает каждый входной файл. TPdf.ImportPages(Pdf, Range, PageNumber) копирует страницы, названные в Range, из Pdf в целевой и возвращает True при успехе. PageNumber отсчитывается с единицы и по умолчанию 1. Он называет позицию в целевом, куда ляжет первая импортированная страница, поэтому 1 — приставить спереди, а PageCount + 1 — дописать в конец

Чистейший способ держать файлы в порядке списка — вообще перестать вести курсор и спросить целевой, где его конец. Бегущий счётчик InsertAt тоже сработает, но его надо двигать на число реально импортированных страниц, а это число легко испортить, как только появляются диапазоны. PdfDest.PageCount + 1 прав всегда:

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;            // не бросает при неудаче
      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);

      // позиция с единицы: PageCount + 1 дописывает после последней страницы
      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 можно менять только при неактивном
    end;

    if not PdfDest.SaveAs(OutputPath) then
      raise Exception.Create('Cannot write ' + OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

Две проверки в том цикле несут реальный вес. Установка Active := True никогда не бросает: если файл отсутствует, повреждён или требует пароль, TPdf перехватывает ошибку загрузки и оставляет Active в False. Компонент остаётся в согласованном закрытом состоянии, и форма не падает на плохом файле. Цикл слияния, пропустивший проверку, однако, просто выдаёт более короткий вывод. Вторая проверка — булев результат ImportPages, возвращающий False, когда строка диапазона не разобралась или позиция ушла за PageCount + 1. Заметьте также, что FileName и Password можно присваивать только при неактивном компоненте — почему источник закрывается внизу каждого прохода

Цикл слияния Delphi с PDFium Component: каждый файл-источник открывается, копируется через ImportPages на позицию InsertAt, а документ назначения записывается один раз через SaveAs
ImportPages приземляет каждый источник на позицию InsertAt, а отсутствующий или повреждённый файл проваливается молча, если не проверять Active

Строки диапазонов: что принимает парсер

Аргумент диапазона — список через запятую из отдельных страниц и замкнутых промежутков, нумерованных с 1 в исходном документе. '1-3' берёт первые три страницы, '2,4,6' — три конкретные, а '1,3-5' — обложку плюс трёхстраничное резюме. Числа всегда относятся к источнику; куда эти страницы лягут в целевом, решает только позиционный аргумент

Открытые промежутки — где спотыкаются. Строка вроде '7-' выглядит как «страница 7 до конца», но парсер диапазонов PDFium требует оба конца промежутка и отвергает всю строку. То же с перевёрнутым промежутком ('5-3'), номером страницы 0 или любой страницей за числом страниц источника. Один плохой элемент валит весь вызов: ImportPages возвращает False, и ни одна страница из той строки не копируется. Поэтому цикл выше собирает '1-' + IntToStr(PdfSrc.PageCount), а не пишет '1-'

Когда индексы страниц уже лежат в массиве, ImportPagesByIndex(Source, PageIndices, InsertAt) пропускает строковый шаг. Только следите за сменой конвенции: там и индексы, и InsertAt отсчитываются с нуля, так что дописывание — это InsertAt = PdfDest.PageCount, а выборка «обложка плюс резюме» становится [0, 2, 3, 4]. Пустой массив импортирует все страницы

Есть ли слияние списка файлов одним вызовом?

Да. TPdf.MergeFiles(Sources, Options, Report) принимает массив записей TPdfBatchMergeSource, каждый из которых строится через TPdfBatchMergeSource.FromFile(FileName, PageRange, Password), открывает их по одному и заполняет TPdfBatchMergeReport тем, что произошло. Пустой PageRange значит все страницы. Синтаксис диапазонов — тот же формат «запятые и промежутки», а кривой диапазон становится ошибкой по файлу в отчёте, а не тихим пропуском

Дефолты из TPdfBatchMergeOptions.Default стоит знать до всякой правки. CreateDestination = True, поэтому неактивный целевой создаётся сам. InsertAt = -1 — значит дописать; любое другое значение — индекс с нуля, в ногу с ImportPagesByIndex, а не с ImportPages. ErrorPolicy = bmepStop, а RollbackOnCancelOrFailure = True, поэтому один нечитаемый файл заканчивает прогон и убирает каждую страницу, которую прогон добавил. CopyViewerPreferences копирует предпочтения просмотрщика первого источника, импортировавшегося успешно

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;        // идти дальше сквозь нечитаемые файлы

  Dest := TPdf.Create(nil);               // неактивный: MergeFiles создаст сам
  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;

Какую политику ошибок брать, зависит от того, что значит отсутствующий файл для вывода. Для пакета договора или прогона счетов сборка без одной из частей — неправильный документ: держите дефолтный bmepStop с откатом. Переключайтесь на bmepSkip только для подборок по остаточному принципу — например, еженедельного дайджеста отчётов — и тогда трактуйте bmrsCompletedWithErrors в Report.Status как то, на что обязан посмотреть человек. Что бы вы ни взяли, читайте Report.Issues: каждая запись несёт индекс источника, имя файла и сообщение — это отвечает на вопрос «кто виноват», который рукописный цикл отвечает собственными исключениями

Для долгих прогонов важны ещё две опции. OnProgress и CancellationToken позволяют интерфейсу показывать текущий файл и остановить прогон, а отменённый прогон откатывается как провалившийся. ResourcePolicy по умолчанию bmrpSourceScoped — импортирует выбранные страницы каждого источника одним нативным вызовом, так что шрифты и картинки, общие для тех страниц, копируются однажды. bmrpChunked импортирует партиями по PageChunkSize страниц, что даёт более тонкие точки отмены ценой повторного копирования общих ресурсов на каждую партию

Что несёт импортированная страница и что остаётся в источнике

Импортированная страница приносит всё достижимое из своего словаря страницы: поток содержимого, шрифты, изображения и form XObjects в её ресурсах, и свои аннотации, включая комментарии, выделения и чернила. Всё, что живёт на уровне документа в источнике, не приходит, потому что ISO 32000-1 держит это вне дерева страниц

  • Словарь информации о документе (ISO 32000-1 §14.3.3) остаётся позади, поэтому в объединённом файле нет ни Title, ни Author, ни Keywords источников. Свойства Title и Author у TPdf только для чтения — удобно для протоколирования того, что каждый источник объявил, прежде чем вы его закроете
  • Закладки (§12.3.3) при дефолтном OutlinePolicy = bmopDiscard отбрасываются; bmopReject превращает источник с закладками в ошибку по файлу. Чтобы сохранить закладки, блок FPdfMerge добавляет MergeFilesWithNavigation, который перестраивает закладки, переименовывает конфликтующие именованные назначения с префиксом Imported_ по умолчанию и перемаркирует ссылки между страницами; как переписываются те назначения — в статье редактирование закладок и перемаркировка страниц
  • Шифрование (§7.6) принадлежит файлу-источнику. Защищённый источник открывается со своим Password, но целевой, созданный CreateDocument, не зашифрован — как и страницы, скопированные в него. Если объединённый файл обязан быть защищён, пишите его через SaveAsEncrypted вместо SaveAs
ImportPages в PDFium переносит содержимое страниц, шрифты и аннотации в объединённый PDF, тогда как метаданные Info, дерево полей AcroForm и шифрование остаются в каждом файле-источнике
ImportPages переносит всё, что хранилось со страницей, тогда как метаданные уровня документа и дерево полей AcroForm остаются позади

Почему поля форм перестают работать после слияния?

Поля форм разрезаны между двумя уровнями PDF. Виджет-аннотация, рисующая текстовое поле, сидит на странице, а само поле с именем и значением перечислено в массиве /Fields словаря интерактивной формы каталога (ISO 32000-1 §12.7.2). Нативный импорт страницы копирует виджет вместе с прочими аннотациями страницы, но никто не добавляет его в дерево полей целевого. Бокс всё ещё рисует последний вид. Зато просмотрщик, строящий модель форм из /AcroForm, не имеет поля, к которому прикрепить виджет, — значение не редактируется, и любая формная операция, идущая по дереву полей, его пропускает

TPdfBatchMergeOptions.FormPolicy даёт три ответа для простого слияния. bmfpPreserveNative — дефолт — оставляет нативный результат импорта, описанный выше. bmfpFlatten генерирует виды и сводит выбранные страницы каждого формоносного источника в памяти перед импортом, так что значения становятся обычным содержимым страницы; файл источника на диске не меняется. bmfpReject превращает любой источник с формой AcroForm или XFA в ошибку по файлу

Когда поля обязаны остаться заполняемыми, используйте MergeFilesWithForms из блока FPdfFormMerge. Он импортирует содержимое страниц, перестраивает виджеты из дерева полей каждого источника и прививает те поля к форме целевого. Имена полей — трудная часть. По §12.7.3.2 два поля с одинаковым полностью квалифицированным именем — одно поле и делят одно значение, поэтому поле Total в счёте и поле Total в накладной перезаписали бы друг друга. Дефолтный CollisionPolicy — pagcpReject, останавливающий слияние на первом же конфликте:

uses
  PDFium, FPdfCompress, FPdfFormMerge;

procedure MergeFillableForms(Dest: TPdf;
  const Sources: TPdfBatchMergeSources; Log: TStrings);
var
  Options: TPdfFormMergeOptions;
  Report: TPdfFormMergeReport;
begin
  Options := TPdfFormMergeOptions.Default;
  // Конфликтующие имена получают RenamePrefix ('Imported_', если не поменять)
  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 кладёт поля каждого источника под собственный префикс (Imported плюс номер источника), а pagcpMergeCompatible соединяет одноимённые поля, только когда их типы и опции совпадают. MergeFilesWithForms работает с источниками и целевыми AcroForm без полей подписи. XFA-формы и подписанные файлы он отвергает сразу, поскольку переписывание подписанного файла инвалидирует подписанный диапазон байтов из ISO 32000-1 §12.8.1. Открытый целевой также обязан быть незашифрованным: слияние в защищённый паролем файл проваливается, так что добавляйте защиту после — через SaveAsEncrypted. Страница, выбранная дважды в одном источнике, как в '1,1', требует явного RepeatedPagePolicy

Целевой документ подменяется только после того, как подготовленный результат переоткрыт и проверен. Имена полей, значения и типы сравниваются страница за страницей, и провалившееся слияние форм оставляет Dest как был. Глубже про обработку конфликтов — в статье прививка AcroForm между документами

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

SaveAs имеет перегрузку с файлом и перегрузку с TStream, обе берут необязательный TSaveOption (дефолт saNone) и TPdfVersion (дефолт pvUnknown, оставляющий заголовок библиотеке). Обе возвращают Boolean, и в пакетном задании тот результат заслуживает той же проверки, что Active. Закрепляйте версию чем-то вроде pv17 только когда нижестоящая система настаивает на конкретном заголовке

Обратная работа — нарезка одного файла на несколько — использует те же строки диапазонов и те же позиции с единицы; статья о нарезке проводит через неё

Импортированные здесь ImportPages, MergeFiles и формосознающее слияние поставляются с PDFium Component, приносящим эти операции над страницами в приложения Delphi и C++Builder