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

Потоковая передача огромных PDF-файлов по запросу с помощью PDFium в Delphi

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

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

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

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

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

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

Адаптер, который связывает 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, потому что 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 ГиБ — это подлинное ограничение этого пути загрузки, и честным шагом будет громко заявить об этом, а не маскировать арифметикой, которая случайно скомпилировалась. Когда архив действительно пересекает черту, обещанные в начале средства защиты находятся за пределами этого API: разделите скан на файлы-тома, каждый из которых остается ниже потолка, или оставьте документ на диске и обслуживайте его через дизайн прямого доступа, построенный на 64-битных смещениях, а не через FPDF_FILEACCESS

Сбои не должны пересекать границу

Чтение может завершиться сбоем. Поток может быть поддерживаемым сетью объектом, тайм-аут которого истек, дескриптором большого двоичного объекта (blob), который был закрыт под вами, или файлом, который был усечен после открытия документа. Контракт 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 Component для Delphi и C++Builder наряду с API для рендеринга, извлечения текста и аннотаций, описанными в других местах этого блога