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

Производительность извлечения страниц 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
    // Загрузить исходник один раз: полный парсинг происходит здесь и только здесь
    Source.LoadFromFile(SourceFile);

    // Сборка минимального документа назначения
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Копирование запрашиваемого диапазона; '1-3' вставляет страницы с 1 по 3
    // начиная с позиции 1 в адресате
    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 — это копирование с сохранением байтов, без повторной сериализации
      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% общего времени, исправлением будет API Direct File или уменьшение количества синтаксических анализов одного и того же файла. Если это меньше 20%, узкое место находится где-то в другом месте, и вы гоняетесь не за тем, что нужно

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

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