Technical Article

Потоковая загрузка огромных PDF по требованию с помощью PDFium в Delphi

Отсканированный архив может занимать несколько гигабайт в одном PDF-файле. Программа просмотра, открывающая такой файл, обычно отображает одну страницу, например оглавление или страницу, на которую пользователь перешел по закладке. Чтение всего файла в память ради отображения двух страниц нерационально по всем параметрам: расходуется адресное пространство, пользователь ждет завершения долгого чтения, а в 32-битном процессе Delphi это может привести к аварийному завершению работы программы еще до вывода первой страницы. PDFium создавался с учетом этих сценариев. Он может загружать документ через обратный вызов, запрашивающий конкретные диапазоны байтов по мере необходимости, не требуя считывания всего файла сразу

Компонент предоставляет этот механизм через адаптер потока. Вы передаете ему любой поток TStream, и PDFium запрашивает блоки данных из этого потока по мере необходимости. Файл может находиться на диске, в поле BLOB базы данных или быть представлен любым другим потомком TStream, при этом никакие данные не копируются в память заранее

Как PDFium запрашивает байты

C API библиотеки PDFium загружает документ из объекта, предоставляемого вызывающей стороной и описываемого структурой FPDF_FILEACCESS. Эта структура содержит три важные части: поле длины, обратный вызов чтения и непрозрачный пользовательский параметр. Точкой входа для этой структуры является функция FPDF_LoadCustomDocument. Получив ее, PDFium анализирует трейлер, находит таблицу перекрестных ссылок и в дальнейшем считывает только те данные, которые необходимы для текущей операции. Открытие документа затрагивает только конец файла и несколько объектов каталога. Рендеринг страницы 400 считывает потоки содержимого и ресурсы только этой страницы

Адаптер потока

Связующим звеном между Delphi TStream и структурой FPDF_FILEACCESS служит класс TPdfStreamAdapter. Его конструктор принимает поток и флаг владения, считывает длину потока, заполняет структуру FPDF_FILEACCESS и настраивает обратный вызов чтения. Когда в дальнейшем PDFium обращается по этому вызову со смещением и размером, адаптер перемещает позицию в потоке на это смещение и копирует указанный диапазон в буфер, предоставленный PDFium

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 4 GiB.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

Флаг владения определяет, кто будет освобождать поток. При передаче значения False вызывающая сторона сохраняет управление потоком и должна поддерживать его существование на протяжении всего времени жизни документа. При значении True адаптер принимает владение на себя и закрывает поток при закрытии документа. В любом случае поток должен существовать во время всех операций чтения PDFium, так как библиотека сохраняет указатель FPDF_FILEACCESS и может вызывать его в любой момент работы с открытым документом, а не только при начальной загрузке

Почему обратный вызов должен быть статической функцией

Обратный вызов чтения, который PDFium сохраняет в поле m_GetBlock, представляет собой обычный указатель на функцию C с соглашением о вызовах cdecl. Метод Delphi нельзя использовать напрямую, так как он содержит скрытый аргумент Self, о котором вызывающая сторона C ничего не знает и который она не сможет передать. Поэтому адаптер объявляет обратный вызов как метод класса class function с модификатором cdecl; static, что компилируется в автономную функцию с макетом кадра C, ожидаемым библиотекой PDFium, без неявного аргумента Self

Это решает проблему соглашения о вызовах, но порождает второй вопрос: как обратный вызов без контекста Self получает доступ к потоку для чтения данных? Ответом служит непрозрачный пользовательский параметр. При инициализации структуры адаптер записывает указатель на собственный экземпляр в поле m_Param. PDFium передает этот указатель обратно в качестве первого аргумента при каждом вызове. Статическая функция приводит его обратно к типу TPdfStreamAdapter и выполняет чтение из потока этого экземпляра. Это классический способ передачи контекста объекта через границу C, которая ничего не знает об объектах

// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // recover the instance from m_Param
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // report failure by return value, never by raising
  end;
end;

Лимит в 4 ГиБ и необходимость его контроля

Поле длины m_FileLen в структуре FPDF_FILEACCESS является 32-битным беззнаковым значением. Максимальная длина, которую оно может хранить, на один байт меньше 4 ГиБ. Однако TStream возвращает свой размер как Int64, то есть поток может хранить гораздо больше байтов, чем способно вместить это поле. Как только размер потока превышает этот лимит, корректно передать длину файла в PDFium становится невозможно

Неправильным решением было бы просто присвоить размер с усечением разрядности. Сокращение размера в 5 ГБ до 32-битного поля даст небольшое правдоподобное число, и PDFium попытается разобрать файл, считая, что его длина составляет около гигабайта. Трейлер и таблица перекрестных ссылок находятся в самом конце реального файла, далеко за пределами усеченной длины, поэтому разбор завершится ошибкой, не имеющей отношения к истинной причине проблемы. Вы будете искать ошибки структуры перекрестных ссылок в абсолютно корректном файле, даже не подозревая об усечении целого числа уровнем выше

Вместо этого адаптер отклоняет некорректные входные данные. Конструктор сравнивает размер потока с константой High(FPDF_DWORD) и вызывает исключение EPdfError, если поток слишком велик. Явная и немедленная ошибка указывает на реальную проблему непосредственно в момент создания объекта. Скрытое усечение привело бы к появлению ложных симптомов, отладка которых заняла бы много времени. Ограничение в 4 ГиБ является жестким условием данного пути загрузки, и правильнее сообщить об этом сразу, а не маскировать проблему компилируемым кодом

Ошибки не должны пересекать границу вызова

Операция чтения может завершиться ошибкой. Поток может быть сетевым объектом с истекшим таймаутом, дескриптором базы данных, закрытым во время работы, или файлом, усеченным после открытия документа. Контракт обратного вызова чтения PDFium требует возврата значения: ненулевого при успехе и нуля при ошибке. Это вызов уровня C, в котором нет механизмов для обработки или передачи исключений Pascal

По этой причине функция-трамплин оборачивает поиск позиции и чтение в блок try/except, перехватывающий исключения и возвращающий ноль. Если позволить исключению Delphi выйти за пределы обратного вызова, стек будет развернут через фреймы cdecl библиотеки PDFium, которые не рассчитаны на обработку исключений Pascal. Это может вызвать непредсказуемое поведение или аварийное завершение работы программы в глубине парсера PDF с потерей стека вызовов. Возврат нуля удерживает обработку ошибки в рамках контракта. PDFium фиксирует сбой чтения блока, корректно завершает операцию, а FPDF_LoadCustomDocument сообщает о невозможности загрузки документа, что компонент преобразует в исключение EPdfError на стороне Pascal

Открытие документа с помощью этого механизма

Метод компонента, реализующий потоковую загрузку, называется LoadCustomDocument. Он объявлен как отдельный метод, а не как перегрузка LoadDocument, чтобы передача TMemoryStream случайно не привела к использованию буферизованного пути загрузки. Он инициализирует адаптер, вызывает функцию FPDF_LoadCustomDocument и поддерживает адаптер активным на протяжении всей работы с документом

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

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

После настройки потоковой загрузки страниц следующим шагом является обеспечение отзывчивости интерфейса при масштабировании и прокрутке, что описано в нашей заметке о производительности кэша рендеринга и зума. Если документ предназначен только для просмотра без возможности экспорта или редактирования, методы из руководства по безопасному просмотру PDF отлично сочетаются с этим подходом. Оба решения базируются на потоковой загрузке, поставляемой в составе компонента PDFium для Delphi и C++Builder вместе с API для рендеринга, извлечения текста и работы с аннотациями