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

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

Руководство пользователя на 40 страниц, чья первая закладка верхнего уровня указывает на страницу 5: разделитель, режущий «от этой закладки до следующей», напишет файлы глав, дающие в сумме 36 страниц. Обложка, страница авторских прав и двухстраничное оглавление стоят перед всякой записью контура, поэтому не попадают ни в один файл. Контур лишь отмечает, где начинаются разделы. Он не покрывает то, что стоит перед первой записью, и ничто в формате не требует перечислять записи в порядке страниц, поэтому надёжный разделитель собирает страницы закладок как точки разреза, сортирует их и добавляет явный диапазон вводных материалов

Один цикл за каждым режимом разделения

PDFium Component разделяет документ одними и теми же четырьмя шагами на втором TPdf, чем бы ни задавались границы: CreateDocument начинает пустой PDF в памяти, ImportPages копирует диапазон страниц из источника в него, SaveAs пишет файл, а Active := False закрывает вывод снова. Постраничное разделение, куски фиксированного размера и секции по закладкам отличаются только строкой диапазона, переданной ImportPages

Сброс в конце каждого прохода не опционален. CreateDocument отказывается выполняться на всё ещё активном компоненте и поднимает EPdfError, чьё сообщение начинается с "Cannot perform this operation on an active", поэтому вторая итерация цикла, забывшего Active := False, останавливается исключением после ровно одного записанного файла. Сброс в блоке finally не даёт неудавшемуся импорту или сохранению оставить вывод открытым для следующего прохода. Один экземпляр TPdf обслуживает весь прогон, и лишь один выходной документ когда-либо держится в памяти

Диаграмма цикла разделения PDFium Component в Delphi: CreateDocument, ImportPages из источника только для чтения, проверенный SaveAs и сброс Active перед каждой новой итерацией
Чем бы ни задавались группы, цикл остаётся одинаковым: импортируйте страницы, сохраняйте с проверкой результата, затем сбрасывайте Active, чтобы следующий CreateDocument стартовал из чистого состояния

Открытие источника заслуживает собственной проверки. Присваивание Active := True никогда не бросает исключение: когда файл отсутствует, повреждён или требует не переданный пароль, TPdf перехватывает ошибку загрузки и оставляет Active равным False. ImportPages к тому же сам пытается активировать источник, поэтому неоткрытый источник проявляется лишь как возвращённое False на первой странице и не даёт никакого намёка на причину. Явное открытие с проверкой Active превращает это в ошибку, называющую файл, ещё до всякого вывода:

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;               // неудача загрузки оставляет Active = False
    if not Source.Active then
      raise Exception.Create('Cannot open ' + SourceFile);

    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;             // бросает, пока PdfOut ещё активен
      try
        // Диапазон нумеруется с единицы; позиция 1 — начало пустого документа
        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;          // закройте вывод перед следующим проходом
      end;
    end;
  finally
    PdfOut.Free;
    Source.Free;
  end;
end;

Третий аргумент ImportPages — позиция в приёмнике с нумерацией от единицы, куда попадает первая импортированная страница. В пустом документе 1 — единственная позиция, какая есть. Сам синтаксис диапазонов (отдельные страницы, закрытые промежутки вроде '5-10', списки через запятую) и ошибки позиции, разворачивающие объединённый пакет, разобраны в статье об объединении нескольких PDF-файлов в один документ — это тот же API, направленный в другую сторону

Куски фиксированного размера и диапазоны от вызывающего

Резать документ каждые N страниц — задача на счёт, и единственный случай, требующий осторожности, — хвост. Скан на 230 страниц, разрезанный на части по 25, даёт девять полных частей и десятую из пяти страниц, поэтому конец каждого промежутка прижимается к 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;    // последняя часть обычно короче
      if FirstPage = LastPage then
        Range := IntToStr(FirstPage)     // одностраничный хвост простым числом
      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;

Диапазоны, набранные пользователем, — другое дело. ImportPages сообщает о плохом диапазоне возвращаемым значением: номер страницы за Source.PageCount, ноль, развёрнутый промежуток или текст, который не разбирается, заставляют вызов вернуть False, и ни одна страница из этой строки не копируется. Внутри цикла эта неудача приходит на четвёртой записи, когда три файла секций уже лежат на диске. Сверка каждой записи с Source.PageCount до первого CreateDocument стоит несколько строк разбора и означает, что опечатка в четвёртой записи не запишет ничего вовсе — именно такое поведение ожидает человек, повторяющий задачу

Когда выделение уже живёт в целочисленном массиве — скажем, страницы, отмеченные в виде миниатюр, — ImportPagesByIndex(Source, PageIndices, InsertAt) пропускает строковый шаг. И индексы, и InsertAt там нумеруются от нуля, поэтому то же выделение, читающееся как '1,3-5', становится [0, 2, 3, 4]

Почему секции по закладкам выходят короткими или пропадают?

Секции по закладкам идут наперекосяк, когда код доверяет контуру быть аккуратным. TPdf.Bookmarks возвращает записи верхнего уровня в порядке, который устроил автора, каждую как запись TBookmark с Title и PageNumber с нумерацией от единицы. Четыре вещи в реальных файлах ломают наивное правило «от этой записи до следующей»:

  • Элемент контура может нести цель как запись /Dest или как GoTo-действие в /A (ISO 32000-1 §12.3.3 и §12.6.4.2). PageNumber заполняется из /Dest; у элемента с действием он остаётся 0, а страница лежит в ActionPageNumber
  • Порядок контура — порядок чтения, каким его видел автор. Запись «Index», поставленная второй, или приложение, перечисленное перед главами, делает «следующая закладка минус один» меньше текущего начала, и секция исчезает
  • Две записи верхнего уровня могут указывать на одну страницу — как правило, заголовок части и её первая глава
  • Запись может указывать за последнюю страницу, когда страницы удаляли инструментом, не тронувшим контур
Диаграмма, сопоставляющая закладки PDF верхнего уровня с вычисленными диапазонами страниц и выходными файлами при разделении с PDFium Component в Delphi, включая закладку вне диапазона, которая пропускается
Секция идёт от страницы каждой закладки верхнего уровня до страницы перед следующей закладкой, а записи, указывающие за конец, пропускаются вместо производства пустых файлов

Версия ниже трактует контур как источник точек разреза. Она разрешает страницу каждой записи, выбрасывает записи, указывающие в никуда или за конец, хранит первый заголовок на общей странице, сортирует по номеру страницы и добавляет секцию «Front matter», когда первый разрез лежит после страницы 1. Вводные материалы получают здесь собственный файл вместо приклейки к главе 1, потому что получатель главы 1 ждёт, что она откроется на странице, которую назвала закладка, а не на оглавлении

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;                // только записи верхнего уровня
  if Length(Bm) = 0 then
    Exit;                                // резать не по чему
  SetLength(Starts, Length(Bm) + 1);     // +1 оставляет место для вводных материалов
  SetLength(Titles, Length(Bm) + 1);
  N := 0;
  for I := 0 to High(Bm) do
  begin
    P := Bm[I].PageNumber;               // разрешается из /Dest
    if P < 1 then
      P := Bm[I].ActionPageNumber;       // разрешается из GoTo-действия в /A
    if (P < 1) or (P > Source.PageCount) then
      Continue;                          // не разрешено или за последней страницей
    Known := False;
    for J := 0 to N - 1 do
      if Starts[J] = P then
        Known := True;                   // первый заголовок на странице побеждает
    if Known then
      Continue;
    J := N;                              // сортировка вставкой по номеру страницы
    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                  // обложка, оглавление и всё перед ними
  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]);
        // Объявите SafeFileName (показана ниже) до этой подпрограммы
        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;

Поскольку после сортировки начала строго возрастают, каждый вычисленный промежуток валиден, и защита на ImportPages не должна срабатывать на читаемом источнике. Документ без закладок просто не производит файлов в этом режиме; решать, откатываться ли к нарезке кусками, вызывающему. Чтобы резать уровнем глубже — скажем, по главам внутри частей — BookmarkChildren[Bm[I]] возвращает потомков записи верхнего уровня в той же форме TBookmarks, и та же логика точек разреза применяется к объединённому списку

Что несёт каждый выходной файл

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

Контур остаётся позади. Закладки живут в каталоге документа под /Outlines (ISO 32000-1 §7.7.2), вне всякой страницы, поэтому файл, собранный из импортированных страниц, начинается вообще без закладок. Для файлов глав это часто нормально. Когда каждой части нужна собственная навигация, контур приходится строить под новую нумерацию страниц — тема статьи об редактировании контура PDF и перемаркировке его страниц. Внутренние ссылки ведут себя так же: ссылка «см. главу 7» на странице 12 указывает на страницу, ушедшую в другой файл, и в этом файле ей не на что приземлиться

Зашифрованным источникам нужен пароль до старта цикла. Присвойте Source.Password, пока компонент неактивен, затем ставьте Active := True; с неверным паролем компонент остаётся неактивным, что проверка в SplitIntoPages превращает в ошибку. Вывод, записанный SaveAs, — новый документ без словаря шифрования. Это устраивает разделённые файлы, предназначенные разным получателям, а когда каждую часть нужно защитить снова, SaveAsEncrypted(FileName, Options) берёт запись TPdfEncryptOptions с собственными паролями пользователя и владельца

Имена файлов, неудавшиеся сохранения и повторные прогоны

Заголовки закладок — текстовые строки PDF и могут содержать что угодно, включая символы, которые Windows отвергает в именах файлов. Правила идут от файловой системы, а не от PDF: < > : " / \ | ? * и управляющие символы неверны, замыкающие пробелы и точки срезаются файловыми API Win32, а имена вроде CON, PRN или COM1 — зарезервированные устройства. Нумерованный префикс в SplitByBookmarks уже заботится об именах устройств, держит файлы в порядке чтения в листинге каталога и не даёт двум главам с одинаковым названием «Summary» перезаписать друг друга. Остальное — маленькая функция, которую вы пишете сами:

// Хелпер на стороне читателя, не часть PDFium Component
function SafeFileName(const Title: string): string;
const
  Forbidden = '<>:"/\|?*';
var
  I: Integer;
begin
  Result := Title;
  if Length(Result) > 80 then
    SetLength(Result, 80);               // держит полные пути далеко под 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 с именем файла пишет во временный файл в целевом каталоге и подменяет его на место только после успеха сохранения. Цель поэтому — либо предыдущий файл, либо полный новый, никогда не записанный наполовину, так что повторный запуск разделения по существующему выходному каталогу безвреден. Неудачи возвращаются двумя путями. SaveAs возвращает False, когда документ нельзя сериализовать, и временный файл удаляется. Проблемы файловой системы — несуществующий выходной каталог, путь, отвергнутый ОС, или цель, заблокированная просмотрщиком, — поднимают исключение из файлового слоя

Как пакет реагирует на эти неудачи — выбор, и для разделения он отличается от объединения. Объединённый пакет с пропавшей частью — неверный документ, поэтому остановка там корректна. Выходы разделения независимы друг от друга: если section_147.pdf нельзя записать, потому что коллега держит его открытым, остальные секции всё ещё хороши. Для задач разделения перехватывайте исключения пофайлово внутри цикла, записывайте имя файла и сообщение, продолжайте, а в конце цикла сравнивайте число записанных файлов с числом запланированных диапазонов. Примеры выше бросают на первой неудаче лишь ради краткости. Журнал, говорящий «записано 199 из 200 файлов» и называющий заблокированный, чинится одним повторным запуском, тогда как прогон, оборвавшийся на файле 147, оставляет кого-то вычислять, каких 53 файлов не хватает

Цикл разделения, записи закладок и зашифрованное сохранение, использованные здесь, поставляются вместе с PDFium Component — библиотекой PDF на базе PDFium для Delphi и C++Builder