Technical Article

Потокове завантаження великих PDF за запитом з PDFium у Delphi

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

Компонент відкриває цей шлях через адаптер потоку. Ви передаєте йому будь-кий 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 і виконує читання з потоку цього екземпляра. Це стандартний прийом (trampoline) для передачі контексту об'єкта через межу 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 ГБ є реальним обмеженням цього шляху завантаження, і правильним рішенням є відверте повідомлення про це, а не приховування проблеми за допомогою арифметики, яка просто успішно компілюється.

Збої не повинні перетинати межу інтерфейсу

Читання може завершитися збоєм. Потік може бути мережевим об'єктом, для якого виник таймаут, дескриптором blob, закритим під вами, або файлом, який було усічено вже після відкриття документа. Контракт PDFium для зворотного виклику читання вимагає повернення значення: ненульове значення при успіху, нуль при збої. Це фрейм C, і він не має механізмів для перехоплення чи поширення винятків Pascal.

Ось чому перехідна функція (trampoline) обгортає позиціонування та читання блоком 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 рендерингу, вилучення тексту та анотацій, описаними в інших статтях цього блогу.