Technical Article

Производительность извлечения страниц HotPDF в Delphi

Две минуты на копирование трех страниц из 40-страничного PDF — это не проблема настройки производительности. Это сигнал о том, что используется неправильный путь API. Когда я впервые увидел это время в примере копирования страниц HotPDF Component, моим первым инстинктом было посмотреть сначала на структуру документа, а затем на код. Оказалось, что этот порядок имеет значение

Что на самом деле работало медленно

Рассматриваемый PDF-файл представлял собой 40-страничный справочный документ с нетривиальным деревом страниц: несколько промежуточных узлов /Pages вместо единого плоского массива. Исходный код примера вызывал LoadFromFile, затем создавал новый документ с помощью BeginDoc, проходил в цикле по выбранным номерам страниц, и на каждой итерации снова загружал исходный документ с диска, чтобы извлечь страницу. Это стоимость полного парсинга, умноженная на количество нужных вам страниц. Файл размером 12 МБ обращался к диску шесть раз для извлечения трех страниц, потому что никто не подумал о том, должен ли файл оставаться открытым между итерациями

Второй фактор был невидим в коде: LoadFromFile в HotPDF разрешает всю таблицу перекрестных ссылок и распаковывает каждый поток объектов при загрузке. Это правильное поведение для документа, который вы собираетесь изменять, но это больше работы, чем вам нужно, если вам нужно только количество страниц и их подмножество. Для доступа к структуре только для чтения DAOpenFileReadOnly позволяет избежать десериализации полного дерева объектов, что имеет значение для сжатых файлов с большими ресурсами изображений

Ни одно из этих явлений не является ошибкой библиотеки. В обоих случаях вызывающий код выбирает API, предназначенный для одной задачи, и использует его для другой

Использование InsertPagesFromDocument для извлечения страниц

Правильный путь для копирования диапазона страниц из одного документа HotPDF в другой — это функция InsertPagesFromDocument, вызываемая после LoadFromFile для исходного файла. Вы загружаете источник один раз, загружаете или создаете адресат один раз, перемещаете страницы и сохраняете. Источник остается в памяти во время всех вставок страниц:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // Load source once: full parse happens here and only here
    Source.LoadFromFile(SourceFile);

    // Build a minimal destination document
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Copy the requested range; '1-3' inserts pages 1 through 3
    // starting at position 1 in the destination
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

Параметр PageRange принимает тот же формат, что и пример командной строки: разделенный запятыми список номеров страниц или диапазонов, таких как '1-3' или '1,5,7-9'. Нумерация страниц начинается с 1. InsertPagesFromDocument копирует потоки содержимого, словари ресурсов и геометрию страниц, не затрагивая метаданные, закладки или вложенные файлы, если на них нет ссылок с копируемых страниц. Для извлечения трех страниц из 40-страничного документа это небольшой рабочий набор

Время выполнения на том же файле размером 12 МБ, который ранее обрабатывался две минуты: менее 1,5 секунд при использовании этого шаблона. Большая часть этого времени — это один вызов LoadFromFile. Структура документа становится неважной после того, как таблица объектов разрешена в первый раз

Когда LoadFromFile — это слишком: Direct File API

Если вам нужно только посчитать страницы, проверить информацию о документе или скопировать файл, не трогая его содержимое, Direct File API полностью избегает полного парсинга. DAOpenFileReadOnly отображает таблицу перекрестных ссылок без распаковки потоков объектов, поэтому подсчет страниц имеет сложность O(размер xref), а не O(размер файла):

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile is a byte-preserving copy, no re-serialization
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Оговорка: DAOpenFileReadOnly принимает параметр пароля, но возвращается к полному парсингу для зашифрованных входных файлов, поскольку для расшифровки требуется дерево объектов, чтобы разрешить словарь шифрования. Если исходные файлы зашифрованы, сначала расшифруйте их с помощью DecryptFile, чтобы получить незашифрованную копию, а затем откройте ее с помощью Direct File API. Функция DecryptFile на уровне файла использует прямой путь перезаписи AES-256 для стандартного шифрования и работает быстрее, чем LoadFromFile с последующим SaveLoadedDocument для больших файлов, поскольку она не создает полную модель объектов в памяти

Память во время пакетной обработки

Пакетные задания, которые обрабатывают десятки файлов в цикле, имеют паттерн, который выглядит правильным, но накапливает память: создание THotPDF внутри цикла, вызов LoadFromFile, выполнение работы, вызов Free. Структурно это нормально. Проблема возникает, когда внутренняя работа распределяет временные объекты, перехватывает исключения и оставляет эти временные объекты живыми на путях с ошибками. Диспетчер памяти Delphi не выполняет сжатие, поэтому сотня утечек на путях ошибок при пакетном выполнении может поднять потребление памяти до такой степени, что замедлит выделение памяти для всего остального

Исправление не является чем-то экзотическим. Каждый THotPDF и каждый промежуточный TStream или TBitmap, участвующий в работе с PDF, должен находиться в блоке try/finally, где Free является последним оператором. Установите локальные указатели в nil перед try, чтобы ветка finally могла безопасно использовать if Assigned(x) then x.Free в случае сбоя инициализации на полпути. Это стандартная дисциплина владения в Delphi, и это вся история для данного класса проблем

Еще одна вещь, которую нужно проверить в контексте пакетной обработки: AddImage регистрирует изображения во внутреннем списке, который сохраняется в течение всего времени жизни экземпляра THotPDF. Если вы повторно используете один экземпляр для множества документов, многократно вызывая LoadFromFile, регистрации изображений из более ранних документов остаются в списке. Либо создавайте новый экземпляр для каждого документа, либо вызывайте путь очистки списка изображений между документами

Измерение перед внесением изменений

Прежде чем обращаться к любому из этих паттернов, измерьте. Класс TStopwatch в Delphi из модуля System.Diagnostics оборачивает QueryPerformanceCounter и достаточно точен для профилирования файлового ввода-вывода. Оберните только LoadFromFile и посмотрите, сколько времени он занимает. Если это 90% от общего времени, исправлением будет Direct File API или уменьшение количества парсингов одного и того же файла. Если это меньше 20%, узкое место находится в другом месте, и вы гоняетесь не за тем, чем нужно

Двухминутное извлечение, с которого начался этот пост, оказалось полностью результатом паттерна многократной загрузки. Структура документа ничего не меняла; плоское дерево страниц работало бы так же. Переход к одному LoadFromFile, за которым следует один вызов InsertPagesFromDocument, привел к времени 1,3 секунды на том же оборудовании без каких-либо других изменений

API управления страницами, показанный здесь, является частью HotPDF Component для Delphi и C++Builder