Technical Article

استریم کردن PDFهای بزرگ برحسب تقاضا با PDFium در Delphi

یک آرشیو اسکن‌شده می‌تواند به چندین گیگابایت در یک PDF واحد برسد. نمایشگری که چنین فایلی را باز می‌کند معمولاً می‌خواهد یک صفحه، شاید فهرست مطالب، یا صفحه‌ای را نشان دهد که کاربر از طریق یک نشانک (Bookmark) به آن پریده است. خواندن کل فایل در حافظه برای رندر کردن دو صفحه از هر نظر اسراف است: فضای آدرس را می‌سوزاند، کاربر را پشت یک خواندن اولیه طولانی متوقف می‌کند و در یک فرآیند ۳۲ بیتی Delphi می‌تواند قبل از ظاهر شدن یک صفحه به طور کامل شکست بخورد. کتابخانه PDFium با در نظر گرفتن این موضوع ساخته شده است. این کتابخانه می‌تواند یک سند را از طریق یک فراخوانی بارگذاری کند که محدوده‌های بایت خاصی را که نیاز دارد، در زمان نیاز درخواست می‌کند، و هرگز کل فایل را یک‌باره مطالبه نمی‌کند

این کامپوننت آن مسیر را از طریق یک آداپتور جریان (Stream adapter) در دسترس قرار می‌دهد. شما هر TStream را به آن تحویل می‌دهید و PDFium بلوک‌ها را برحسب تقاضا از آن جریان دریافت می‌کند. فایل می‌تواند روی دیسک، در یک فیلد blob پایگاه داده، یا در پشت هر کلاس مشتق از TStream قرار گیرد و هیچ بخشی از آن از ابتدا در حافظه کپی نمی‌شود

چگونه PDFium بایت‌ها را درخواست می‌کند

رابط API از C در PDFium یک سند را از یک شیء ارائه‌شده توسط فراخوان‌کننده بارگذاری می‌کند که توسط ساختار FPDF_FILEACCESS توصیف می‌شود. این ساختار دارای سه بخش مهم است: یک فیلد طول، یک فراخوانی خواندن و یک پارامتر کاربر غیرشفاف (Opaque). نقطه ورودی که آن را مصرف می‌کند FPDF_LoadCustomDocument است. هنگامی که PDFium آن ساختار را در اختیار گرفت، تریلر را تجزیه می‌کند، جدول ارجاع متقابل را مکان‌یابی می‌نماید و از آن پس فقط آنچه را که یک عملیات مشخص نیاز دارد می‌خواند. باز کردن سند به انتهای فایل و چند شیء کاتالوگ دست می‌زند. رندر کردن صفحه ۴۰۰، جریان‌های محتوا و منابع آن صفحه را می‌خواند و نه چیز دیگری را

این تفاوت بین بارگذاری بافرشده و بارگذاری استریم‌شده است. یک بارگذاری بافرشده فایل را از ابتدا تا انتها قبل از اینکه 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;

سقف ۴ گیگابایتی و اینکه چرا به یک محافظ نیاز دارد

فیلد طول m_FileLen در FPDF_FILEACCESS یک مقدار بدون علامت ۳۲ بیتی است. بزرگ‌ترین طول قابل نمایش آن، یک بایت کمتر از ۴ گیگابایت است. یک TStream اندازه خود را به صورت یک Int64 گزارش می‌دهد، بنابراین یک جریان می‌تواند بایت‌های بسیار بیشتری را نسبت به ظرفیت این فیلد توصیف کند. لحظه‌ای که اندازه جریان از آن سقف فراتر می‌رود، راه درستی برای گفتن طول فایل به PDFium وجود ندارد

پاسخ اشتباه این است که اندازه را اختصاص دهیم و بگذاریم سرریز کند. کوتاه کردن طول ۵ گیگابایتی به یک فیلد ۳۲ بیتی، عددی کوچک و معقول تولید می‌کند، و سپس PDFium فایل را با این فرض تجزیه می‌کند که حدوداً در یک گیگابایتی به پایان می‌رسد. تریلر و جدول ارجاع متقابل در انتهای واقعی فایل قرار دارند، بسیار فراتر از طول کوتاه شده، بنابراین تجزیه به روشی شکست می‌خورد که هیچ ارتباطی با علت واقعی ندارد. شما مشغول دیباگ کردن خطای ارجاع متقابل در فایلی می‌شوید که کاملاً معتبر است، بدون اینکه اشاره‌ای داشته باشید که یک عدد صحیح دو لایه بالاتر سرریز کرده است

آداپتور در عوض ورودی را رد می‌کند. سازنده اندازه جریان را با High(FPDF_DWORD) مقایسه کرده و در همان لحظه که جریان برای توصیف بیش از حد بزرگ است، EPdfError را ایجاد می‌کند. یک خطای صریح و فوری، مشکل واقعی را در نقطه ساخت نام می‌برد. یک کوتاه‌سازی بی‌صدا آن را پشت یک نشانه گمراه‌کننده پنهان می‌کند که شما خیلی بعدتر به دنبال آن خواهید رفت. محدودیت ۴ گیگابایتی یک محدودیت واقعی در این مسیر بارگذاری است و کار درست این است که آن را با صدای بلند نشان دهیم تا اینکه با محاسباتی که اتفاقاً کامپایل می‌شوند، روی آن سرپوش بگذاریم

خرابی‌ها نباید از مرز زبان عبور کنند

یک عمل خواندن می‌تواند شکست بخورد. جریان ممکن است یک شیء متکی به شبکه باشد که زمان آن تمام می‌شود، یک هندل blob که در زیر کار بسته شده، یا فایلی که پس از باز شدن سند کوتاه شده است. قرارداد PDFium برای فراخوانی خواندن یک مقدار بازگشتی است: غیرصفر برای موفقیت، صفر برای شکست. این یک فریم C است و هیچ سازوکاری برای دریافت یا انتشار استثنا در Pascal ندارد

به همین دلیل است که ترامپولین عملیات seek و خواندن را در یک بلوک try/except می‌پیچد که استثنا را نادیده گرفته و صفر را برمی‌گرداند. اگر اجازه داده شود یک استثنای Delphi از فراخوانی خارج شود، از طریق فریم‌های پشته cdecl مربوط به PDFium باز می‌شود که هرگز برای باز شدن توسط مکانیسم استثنای Pascal ساخته نشده‌اند. نتیجه در بهترین حالت رفتار تعریف‌نشده و در بدترین حالت یک کرش سخت، در اعماق تجزیه‌کننده PDF بدون پشته قابل استفاده است. بازگرداندن صفر خرابی را در داخل قرارداد نگه می‌دارد. PDFium یک خواندن بلوک ناموفق را می‌بیند، عملیات را به طور تمیز متوقف می‌کند و FPDF_LoadCustomDocument گزارش می‌دهد که سند بارگذاری نشد، که کامپوننت آن را به عنوان EPdfError در سمت Pascal که به آن تعلق دارد، ظاهر می‌سازد

باز کردن سند به این روش

متد کامپوننت که مسیر استریم را هدایت می‌کند LoadCustomDocument است که به عنوان یک متد مجزا اعلام شده است تا پاس دادن یک 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 for Delphi and C++Builder alongside the rendering, text extraction, and annotation APIs covered elsewhere on this blog.