Технічна стаття

Потокове завантаження величезних 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 рендерингу, вилучення тексту та анотацій, розглянутими в інших розділах цього блогу