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

Поточно предаване на огромни PDF файлове при поискване с PDFium в Delphi

Сканиран архив може да достигне до няколко гигабайта в един PDF файл. Програма за преглед, която отваря такъв файл, обикновено иска да покаже една страница, може би съдържанието, може би страница, до която потребителят е скочил от отметка. Четенето на целия файл в паметта, за да се рендират две страници, е разточително по всяка ос: то изгаря адресно пространство, спира (stalls) потребителя зад дълго първоначално четене и при 32-битов процес на Delphi може да се провали напълно, преди да се появи дори една страница. PDFium е изграден с мисъл за това. Той може да зареди документ чрез callback, който иска специфичните диапазони от байтове, от които се нуждае, когато се нуждае от тях, и никога не изисква целия файл наведнъж. Една граница принадлежи отпред (belongs up front): този стрийминг канал описва файла с 32-битова дължина, така че обслужва един файл до 4 GiB, което покрива почти всеки сканиран архив на практика. Файл отвъд тази линия не е територия на тази статия; той иска да бъде разделен на томове (volumes) по време на сканиране или вместо това отворен чрез стратегия за директен достъп, а предпазителят, който налага тавана, честно казано, получава свой собствен раздел по-долу

Компонентът излага този път чрез стрийм адаптер (stream adapter). Подавате му всеки TStream и PDFium изтегля блокове от този поток при поискване. Файлът може да стои на диск, в blob поле на база данни или зад всеки друг наследник на TStream и нищо от това не се копира в паметта предварително

Как PDFium иска байтове

C API-то на PDFium зарежда документ от предоставен от извикващия (caller-supplied) обект, описан от структурата FPDF_FILEACCESS. Структурата има три части, които имат значение тук: поле за дължина, callback за четене и непрозрачен потребителски параметър. Входната точка, която го консумира, е FPDF_LoadCustomDocument. След като PDFium държи тази структура, той анализира трейлъра (trailer), локализира таблицата с кръстосани препратки (cross-reference table) и оттам нататък чете само това, което дадена операция изисква. Отварянето на документа докосва опашката (tail) на файла и шепа обекти от каталога. Рендирането на страница 400 прочита потоците от съдържание и ресурсите за тази страница и нищо друго

Това е разликата между буферирано зареждане и зареждане с поточно предаване (streaming load). Буферираното зареждане прочита файла от край до край, преди PDFium да види байт нула. Зареждането с поточно предаване обръща връзката: PDFium задвижва четенията и байтовете, които никога не се докосват, никога не се прочитат. За мулти-гигабайтов файл, преглеждан страница по страница, това е разликата между неизползваемо зареждане и мигновено такова

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

Адаптерът, който свързва Delphi TStream към FPDF_FILEACCESS, е TPdfStreamAdapter. Неговият конструктор взема потока и флаг за собственост, заснема дължината на потока веднъж, попълва записа FPDF_FILEACCESS и свързва (wires) callback-а за четене. Когато по-късно PDFium се обади обратно с отместване (offset) и размер, адаптерът търси в потока до това отместване и копира точно този диапазон в буфера, предоставен от PDFium

// Дословно от компонента: мостът от поток към FPDF_FILEACCESS
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 е 32-битов unsigned long. Откажете поток,
  // който тихо би отрязал (truncate) след 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 и ще се обади обратно във всеки един момент, докато документът е отворен, а не само по време на първоначалното зареждане

Защо callback-ът е статична функция

Callback-ът за четене, който PDFium съхранява в m_GetBlock, е обикновен указател към C функция с конвенцията за извикване cdecl. Метод в Delphi не може да се използва директно, защото методът носи скрит аргумент Self, за който C извикващият не знае нищо и никога няма да го предостави. Следователно адаптерът декларира callback-а като class function, маркирана с cdecl; static, която се компилира до самостоятелна функция със C рамка (C frame layout), която PDFium очаква, и без имплицитен Self

Това решава конвенцията за извикване, но повдига втори въпрос: без Self, как callback-ът достига до специфичния поток, от който трябва да чете? Отговорът е непрозрачният потребителски параметър. Когато адаптерът изгражда записа, той съхранява свой собствен указател към инстанция в m_Param. PDFium връща същия указател обратно като първи аргумент на всеки callback. Статичната функция го прехвърля (casts) обратно към TPdfStreamAdapter и изпраща (dispatches) четенето към потока на тази инстанция. Това е стандартният батут (trampoline) за предаване на контекст на обект през C граница, която няма понятие за обекти

// Дословно от компонента: cdecl батутът обратно към инстанцията
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);   // възстановете инстанцията от 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;  // докладвайте неуспех чрез върната стойност, никога чрез хвърляне на грешка
  end;
end;

Таванът от 4 GiB и защо се нуждае от предпазител

Оттук идва границата, посочена в началото. Полето за дължина m_FileLen във FPDF_FILEACCESS е 32-битова unsigned стойност. Нейната най-голяма възможна дължина е с един байт по-малко от 4 GiB. Един TStream отчита размера си като Int64, така че един поток може да опише много повече байтове, отколкото полето може да побере. В момента, в който размерът на потока надхвърли този таван, няма честен начин да се каже на PDFium колко дълъг е файлът

Грешният отговор е да присвоите размера и да го оставите да се превърти (wrap). Отрязването на 5 GiB дължина до 32-битово поле произвежда малко, правдоподобно изглеждащо число и PDFium след това ще анализира файла, вярвайки, че той свършва приблизително след един гигабайт. Трейлърът и таблицата с кръстосани препратки живеят в реалния край на файла, доста след отрязаната дължина, така че анализът се проваля по начин, който няма нищо общо с действителната причина. Бихте отстранявали грешка с кръстосана препратка във файл, който е напълно валиден, без никакъв намек, че цяло число се е превъртяло (wrapped) два слоя нагоре

Вместо това адаптерът отказва входа. Конструкторът сравнява размера на потока с High(FPDF_DWORD) и хвърля EPdfError в момента, в който потокът е твърде голям за описване. Една изрична, незабавна грешка назовава истинския проблем в точката на конструиране. Тихото отрязване го крие зад подвеждащ симптом, който бихте преследвали много по-късно. Ограничението от 4 GiB е истинско ограничение на този път на зареждане и честното нещо е да го извадите на повърхността (surface it loudly), вместо да го прикривате с аритметика, която случайно се компилира. Когато един архив наистина премине границата, средствата (remedies), обещани в началото, живеят извън това API: разделете сканирането на файлове по томове, всеки от които остава под тавана, или оставете документа на диска и го сервирайте чрез дизайн за директен достъп, изграден върху 64-битови отмествания, а не чрез FPDF_FILEACCESS

Неуспехите не трябва да пресичат границата

Четенето може да се провали. Потокът може да е обект, подкрепен от мрежата (network-backed), който изтича (times out), манипулатор на blob, който е бил затворен под вас, или файл, който е бил отрязан след отварянето на документа. Договорът на PDFium за callback-а за четене е върната стойност: ненулева за успех, нула за неуспех. Това е C рамка и тя няма механизъм да хване или разпространи Pascal изключение

Ето защо батутът увива търсенето и четенето в try/except, който поглъща изключението и връща нула. Ако на Delphi изключение беше позволено да се разпространи извън callback-а, то щеше да се развие (unwind) през cdecl рамките на стека на PDFium, които никога не са били създадени да бъдат развивани от механизма за изключения на Pascal. Резултатът е недефинирано поведение в най-добрия случай и тежък срив (hard crash) в най-лошия, дълбоко вътре в PDF анализатора (parser) без използваем стек. Връщането на нула задържа неуспеха вътре в договора. PDFium вижда неуспешно четене на блок, прекратява чисто операцията и FPDF_LoadCustomDocument съобщава, че документът не може да бъде зареден, което компонентът изважда на повърхността като EPdfError от страната на Pascal, където му е мястото

Отваряне на документ по този начин

Методът на компонента, който задвижва пътя на стрийминг, е LoadCustomDocument, деклариран като отделен метод, а не като още един LoadDocument overload (претоварване), така че подаването на TMemoryStream никога да не попада случайно на буферирания път. Той изгражда адаптера, извиква FPDF_LoadCustomDocument и поддържа адаптера жив за живота на заредения документ

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Предайте собствеността върху потока на Pdf: той освобождава FileStream, когато документът се затвори.
    Pdf.LoadCustomDocument(FileStream, True);
    // Досега PDFium е прочел само трейлъра и каталога.
    // Рендирането на страница изтегля само байтовете на тази страница чрез callback-а.
    // ... рендирайте или проверявайте страници тук ...
  finally
    Pdf.Free;  // затваря документа, което освобождава адаптера и потока
  end;
end;

Същото извикване работи за TMemoryStream, blob поток от набор от данни (dataset) в база данни или персонализиран наследник на TStream. Зареждането при поискване изкарва прехраната си (earns its keep), когато файлът е голям и само част от него ще бъде прочетена: програма за преглед на архиви, генератор на миниатюри (thumbnail generator), който взема проби (samples) от няколко страници, индекс за търсене, който изтегля една страница наведнъж. Когато файлът е малък или така или иначе ще го прочетете целия, буферираното зареждане е по-лесно и стрийминг машината не ви купува нищо. Решаващият фактор е съотношението на байтовете, до които действително ще се докоснете, към байтовете, които файлът съдържа

След като страниците се предават поточно (stream in) при поискване, следващата грижа е да се запазят рендираните страници отзивчиви, докато потребителят мащабира и превърта, което е разгледано в нашата бележка за кеширане на рендирането и производителност при мащабиране. Когато поточно предаваният документ е такъв, който програмата за преглед трябва да покаже, но не и да позволи на потребителя да експортира или променя, техниките в ръководството (walkthrough) за сигурен преглед на PDF се съчетават естествено с този път на зареждане. И двете се надграждат върху поточното зареждане, описано тук, което се доставя като част от PDFium Component за Delphi и C++Builder, заедно с API за рендиране, извличане на текст и анотации, разгледани на други места в този блог