Technical Article

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

Сканиран архив може да достигне до няколко гигабайта в един PDF файл. Четец, който отваря такъв файл, обикновено иска да покаже една страница, може би съдържанието или страница, на която потребителят е скочил от маркер. Четенето на целия файл в паметта за изобразяване на две страници е разточително по всяко направление: изразходва адресно пространство, спира потребителя зад дълго първоначално четене и при 32-битов Delphi процес може да се срине напълно, преди да се появи и една страница. PDFium е изграден с тази мисъл. Той може да зарежда документ чрез обратно извикване, което изисква конкретните диапазони от байтове, от които се нуждае, тогава когато се нуждае от тях, и никога не изисква целия файл наведнъж.

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

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

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

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

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

Адаптерът, който свързва TStream на Delphi към 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 GiB и защо се нуждае от предпазител

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

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

Вместо това адаптерът отхвърля входа. Конструкторът сравнява размера на потока с High(FPDF_DWORD) и предизвиква EPdfError в момента, в който потокът е твърде голям, за да бъде описан. Явна, незабавна грешка посочва реалния проблем в момента на конструиране. Тихото съкращаване го скрива зад подвеждащ симптом, който бихте преследвали много по-късно. Ограничението от 4 GiB е реално ограничение на този път на зареждане и честното нещо е да го изведете силно на повърхността, вместо да го замазвате с аритметика, която просто се компилира.

Сривовете не трябва да преминават границата

Четенето може да се срине. Потокът може да е мрежово базиран обект, чието време изтича, 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 за рендиране, извличане на текст и анотации, разгледани на други места в този блог.