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

PDF Library for Delphi: large-PDF merge and split with direct access

Слияние или разделение двухгигабайтного PDF очевидным способом обходится сразу в две вещи: астрономическое время выполнения и адресное пространство. Очевидный способ — загрузить каждый входной файл, выполнить работу, записать результат. Ломается всё именно на загрузке. Архив сканов, переходящий с 300 на 600 DPI, удваивает линейное разрешение и примерно вчетверо увеличивается на диске, так что одна и та же сборочная задача, которая весь год спокойно обрабатывала файлы по 400 МБ, начинает захлёбываться в тот момент, когда входной файл переваливает за гигабайт, зачастую просто считая страницы и не делая больше ничего. Сама задача сложнее не стала. Открыть, посчитать, выбрать диапазоны, склеить — вот и всё её содержание. Полная загрузка дерева попросту перестала быть разумным вариантом по умолчанию при таких размерах. PDF Library for Delphi, PDF-библиотека losLab для Delphi и C++Builder, отвечает на это слоем прямого доступа Direct Access: семейством функций с префиксом DA, опирающимся на потоковый ридер, который обходит таблицу перекрёстных ссылок на месте, вместо того чтобы строить весь документ в памяти

Куда уходит память при полной загрузке

Загрузка PDF «обычным способом» означает разбор xref, разрешение каждого косвенного объекта в дерево в памяти, декодирование потоков объектов и связывание дерева страниц, шрифтов и аннотаций в объекты, которыми можно манипулировать. Для сценариев редактирования это правильный компромисс. Для слияния, разделения и инспекции — по большей части пустая трата ресурсов. Архив сканов на 30 000 страниц может содержать миллионы косвенных объектов, а задаче разделения нужно прочитать лишь несколько сотен из них: узлы страниц в запрошенном диапазоне плюс всё, на что эти узлы ссылаются

Слой прямого доступа переворачивает эту модель. DAOpenFile и DAOpenFileReadOnly разбирают трейлер и xref — несколько килобайт в хвосте файла — и возвращают дескриптор файла. Объекты подгружаются лениво, когда их запрашивает конкретный вызов. Практическое следствие в том, что открытие многогигабайтного файла занимает примерно столько же времени, сколько открытие маленького, а память отражает то, к чему вы обратились, а не то, что содержит файл

Сравнение в PDF Library for Delphi: загрузка гигабайтного PDF в полное объектное дерево в памяти против открытия с прямым доступом, где разбор останавливается на trailer и xref, а дескриптор обслуживает ленивое пообъектное чтение
Полная загрузка декодирует каждый косвенный объект до старта слияния, поэтому ОЗУ и время открытия растут вместе с архивом. Путь прямого доступа возвращает рабочий дескриптор, прочитав килобайты, и позволяет каждому вызову подтянуть ровно нужные ему объекты

Зондирование огромного файла без его загрузки

Паттерн ниже взят из собственного бенчмарка библиотеки для больших файлов: открыть только для чтения, задать вопросы, закрыть. Дерево документа при этом вообще ни разу не создаётся

var
  Lib: TPDFlib;
  Handle, Pages: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Handle := Lib.DAOpenFileReadOnly('archive-2025.pdf', '');
    if Handle = 0 then
      raise Exception.Create('Direct access open failed');
    Pages := Lib.DAGetPageCount(Handle);
    Writeln('pages : ', Pages);
    Writeln('title : ', Lib.DAGetInformation(Handle, 'Title'));
    Lib.DACloseFile(Handle);
  finally
    Lib.Free;
  end;
end;

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

PageRef — это дескриптор объекта, а не номер страницы

Самая распространённая ошибка при работе с API DA — передать номер страницы там, где функция ожидает PageRef. Почти каждый постраничный вызов DA принимает дескриптор-ссылку на объект страницы, а не номер страницы: DAExtractPageText, DARenderPageToFile, DARotatePage и DACapturePage — все они ожидают ref. Получить его можно, преобразовав человекочитаемый номер через DAFindPage:

PDF Library for Delphi: поток перевода номера страницы в PageRef, где DAFindPage питает постраничные вызовы прямого доступа, против сырой целочисленной ссылки, попадающей на произвольный объект и дающей молча текст не той страницы
Каждый вызов прямого доступа к странице потребляет PageRef, произведённый DAFindPage, а не номер, видимый человеку. Пропуск этого преобразования заставляет целое число выдавать себя за идентификатор объекта, и текст не с той страницы может уйти незамеченным
PageRef := Lib.DAFindPage(Handle, 250);          // номер страницы -> дескриптор объекта
if PageRef <> 0 then
begin
  Text := Lib.DAExtractPageText(Handle, PageRef, 0);
  Lib.DARenderPageToFile(Handle, PageRef, 5, 150, 'page250.png');
end;

Передача сырого числа 250 вместо этого не вызывает ошибки. Она адресует тот объект, который случайно оказался за этим значением дескриптора, что в удачный день заметно ломается, а в неудачный — извлекает текст с не той страницы прямо в документ, который увидит клиент. Если вы оборачиваете слой DA собственным сервисным кодом, сделайте так, чтобы преобразование нельзя было пропустить: принимайте номера страниц на границе, немедленно вызывайте DAFindPage и передавайте дальше только refs

Слияние сотен файлов через именованный список

Для двух файлов достаточно MergeFiles(First, Second, Output). Пакетная сборка масштабируется лучше через списки файлов: зарегистрируйте входные файлы под именем списка, затем слейте список за один проход

PDF Library for Delphi: рабочий процесс именованного списка файлов — выписки January, February и March регистрируются под одним именем списка и сливаются за один проход, причём варианты Fast, default и strict разменивают сохранение дерева структуры на скорость
Сотни зарегистрированных входов сворачиваются в один проход MergeFileList, чей результат проверяется за миллисекунды другим read-only зондом. Выбор варианта — решение на уровне конвейера, поскольку Fast отбрасывает дерево структуры Tagged PDF
Lib.AddToFileList('Statements', 'jan.pdf');
Lib.AddToFileList('Statements', 'feb.pdf');
Lib.AddToFileList('Statements', 'mar.pdf');
Lib.MergeFileList('Statements', 'q1-statements.pdf');

// Дёшево проверить результат: снова прямой доступ
Handle := Lib.DAOpenFileReadOnly('q1-statements.pdf', '');
Writeln('merged pages: ', Lib.DAGetPageCount(Handle));
Lib.DACloseFile(Handle);

У семейства функций слияния три варианта, и разница не только в скорости. MergeFileListFast пропускает сохранение дерева структуры; MergeFileListStrict включает строгий режим; версия без суффикса — сбалансированный вариант по умолчанию. Отсюда следует практическое правило: если хотя бы один входной файл — Tagged PDF, чья структура доступности должна сохраниться (очевидный случай — всё, что создано для PDF/UA), берите вариант по умолчанию или Strict, потому что Fast молча отбрасывает дерево структуры. Для обычных архивов сканов без тегирования Fast — это бесплатная производительность. Решайте на уровне конвейера, а не по настроению разработчика, и записывайте использованный вариант в журнал задания

Разделение без загрузки: извлечение диапазонов

Разделение следует той же философии отказа от загрузки. ExtractFilePages(InputFileName, Password, OutputFileName, RangeList) вытягивает диапазон страниц прямо из файла в файл, со списком диапазонов вроде '1-500', '501-1000' или выборок через запятую, и исходный файл ни разу не превращается в дерево документа. Когда документ уже загружен по другим причинам, ExtractPageRanges создаёт новый документ в памяти из текущего, а CopyPageRanges переносит диапазоны из другого загруженного документа по ID. Для постатейного разделения консолидированных потоков печати именно вариант «файл в файл» не даёт входному файлу в 4 ГБ хоть когда-нибудь раздуться в оперативной памяти

Файлы, которые лгут о своей геометрии

Конвейеры для больших файлов встречают повреждённые файлы с частотой, которую конвейеры для маленьких файлов никогда не видят, — просто потому, что входные данные проходят через больше систем. Два вида сбоев заслуживают явной обработки

Во-первых, сдвинутые заголовки. Почтовые шлюзы и спулеры печати иногда дописывают байты в начало PDF, так что маркер %PDF больше не находится по смещению 0, и каждое смещение xref в файле оказывается неверным на одну и ту же величину. Потоковый ридер обнаруживает это и раскрывает наружу (DAShiftedHeader на плоском уровне, ShiftedHeader у TSmartPDFReader), а затем компенсирует при чтении. Самодельная арифметика смещений обычно этого не делает, поэтому классический симптом звучит как «работает на каждом файле, который генерируем мы сами, но падает на файлах от клиента X»

Во-вторых, повреждённые таблицы перекрёстных ссылок. DACopyFile(InputFileName, OutputFileName, PageCount) потоково копирует весь файл в новую копию, попутно перестраивая xref, и возвращает количество страниц как побочный результат. Запуск её как этапа нормализации перед капризным потребителем вниз по цепочке превращает целый класс перемежающихся сбоев разбора в один предсказуемый шаг починки. А когда нужно сохранить собственные правки, DAAppendFile записывает их как инкрементальное обновление, дописывая новую редакцию вместо перезаписи гигабайт, так что стоимость сохранения остаётся пропорциональной изменению, а не файлу

Детали доставки: линеаризация и композиция

Две смежные возможности дополняют конвейер для больших файлов. Когда собранный результат отдаётся по HTTP для просмотра в браузере, LinearizeFile реорганизует его под потоковую передачу байтовыми диапазонами, чтобы первая страница отображалась ещё до того, как остальная часть пакета в 500 МБ закончит загружаться. Запускайте её финальным этапом, после всех слияний, потому что любое последующее изменение снова разлинеаризует файл. А когда пакетам нужна композиция, а не простая конкатенация — скажем, титульный лист, проставляемый под каждой ведомостью, или две исходные страницы, наложенные на один выходной лист, — DACapturePage превращает любую страницу в переиспользуемый шаблон, который DADrawCapturedPage помещает на страницу назначения в произвольный прямоугольник, всё так же без полной загрузки многогигабайтного источника документа

Пределы и что остаётся только для чтения

Сам формат исчерпывает запас задолго до того, как это делает Direct Access. Смещения во всём слое DA имеют тип Int64, так что реальные пределы — это доступное дисковое пространство и 10-значное поле смещения xref в классических (нестримовых) таблицах перекрёстных ссылок. Многогигабайтные архивы сканов на практике совершенно обычное дело, а память остаётся ограниченной независимо от размера файла, потому что объекты читаются только тогда, когда их запрашивает конкретный вызов

Два вопроса возникают достаточно часто, чтобы ответить на них прямо здесь. Слияние через путь по умолчанию переносит структуру документа целиком, так что закладки и ссылки выживают; именно вариант Fast меняет дерево структуры на скорость, и именно поэтому его стоит резервировать для нетегированных входных файлов. Безопасная привычка — открыть результат слияния, пройтись по его оглавлению и выборочно проверить несколько внутренних ссылок перед отправкой. Что касается редактирования: между зондированием только для чтения и полной загрузкой есть полезная золотая середина. Операции на уровне страницы работают прямо с дескриптором — среди них DARotatePage, DAMovePage и DAHidePage, а также чтение полей форм, — а DAAppendFile сохраняет эти правки как инкрементальную редакцию. Редактирование на уровне содержимого, всё, что переписывает операторы разметки внутри страницы, по-прежнему относится к слою полного документа

Похожие статьи

Если результат слияния должен остаться доступным, справочная информация о дереве структуры разобрана в статье о доступности Tagged PDF, которая точно объясняет, что именно отбросит вариант слияния Fast. Про извлечение содержимого из разделённых диапазонов — в руководстве по извлечению текста, изображений и шрифтов

Полный список функций Direct Access поставляется вместе с библиотекой; редакции и пробные версии для скачивания — на странице продукта PDF Library for Delphi