مقاله فنی

جریان‌دهی PDFهای بسیار بزرگ به صورت درخواستی با PDFium در Delphi

یک آرشیو اسکن‌شده می‌تواند در یک فایل PDF به چندین گیگابایت برسد. نمایشگری که چنین فایلی را باز می‌کند معمولاً فقط می‌خواهد یک صفحه، شاید فهرست مطالب، یا شاید صفحه‌ای را که کاربر از روی یک بوک‌مارک به آن پریده است نشان دهد. خواندن کل فایل در حافظه برای رندر کردن دو صفحه از هر جهت اتلاف است: فضای آدرس را می‌سوزاند، کاربر را پشت یک خواندن اولیه طولانی معطل می‌کند، و در یک فرایند 32 بیتی Delphi حتی ممکن است پیش از دیده شدن نخستین صفحه کاملاً شکست بخورد. PDFium دقیقاً با همین سناریو ساخته شده است. این موتور می‌تواند سند را از طریق یک callback بارگذاری کند که هر زمان لازم شد بازه‌های بایتی مورد نیازش را درخواست می‌کند و هرگز کل فایل را یک‌باره مطالبه نمی‌کند. البته یک مرز باید از همان ابتدا روشن باشد: این کانال جریان‌دهی طول فایل را با یک مقدار 32 بیتی توصیف می‌کند، بنابراین یک فایل منفرد را فقط تا 4 GiB پوشش می‌دهد که در عمل تقریباً همه آرشیوهای اسکن‌شده را شامل می‌شود. فایلی که از این مرز عبور کند در قلمرو این مقاله نیست؛ بهتر است در زمان اسکن به چند جلد شکسته شود یا از راهبردی با دسترسی مستقیم باز شود، و محافظی که این سقف را اعمال می‌کند صادقانه در ادامه بخش مخصوص خود را دارد

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

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

رابط C در PDFium یک سند را از شیئی بارگذاری می‌کند که فراخواننده آن را از طریق ساختار FPDF_FILEACCESS توصیف کرده است. این ساختار سه بخش مهم برای بحث ما دارد: یک فیلد طول، یک callback خواندن، و یک پارامتر کاربرِ کدر. نقطه ورود مصرف‌کننده آن FPDF_LoadCustomDocument است. وقتی PDFium این ساختار را در اختیار گرفت trailer را تحلیل می‌کند، جدول cross-reference را پیدا می‌کند، و از آن لحظه به بعد فقط چیزی را می‌خواند که هر عملیات مشخص نیاز دارد. باز کردن سند به انتهای فایل و چند شیء کاتالوگ سر می‌زند. رندر کردن صفحه 400 فقط جریان‌های محتوا و منابع همان صفحه را می‌خواند و هیچ چیز دیگری را لمس نمی‌کند

این همان تفاوت بارگذاری بافرشده و بارگذاری جریانی است. بارگذاری بافرشده فایل را از ابتدا تا انتها می‌خواند پیش از آن‌که PDFium حتی بایت صفر را ببیند. بارگذاری جریانی رابطه را وارونه می‌کند: PDFium خواندن را هدایت می‌کند و بایت‌هایی که هرگز لمس نمی‌شوند هرگز هم خوانده نمی‌شوند. برای فایلی چند گیگابایتی که قرار است هر بار فقط یک صفحه از آن دیده شود، این شکاف همان فاصله میان یک بارگذاری غیرقابل استفاده و یک بارگذاری فوری است

دیاگرام معماری مقایسهٔ بارگذاری بافرشده که PDF چندگیگابایتی را پیش از تجزیه به حافظه کپی می‌کند، با جریان‌سازی که PDFium بازه‌های بایتی را از طریق FPDF_FILEACCESS از TStream در Delphi می‌خواهد
باز کردن فقط هزینه trailer و کاتالوگ را دارد؛ رندر صفحه 400 فقط بایت‌های صفحه 400 را و هیچ چیز دیگری را از طریق callback می‌کشد

آداپتور جریان

آداپتوری که یک TStream در Delphi را به FPDF_FILEACCESS پل می‌زند TPdfStreamAdapter است. سازنده آن جریان و یک پرچم مالکیت را می‌گیرد، طول جریان را یک‌بار ثبت می‌کند، رکورد FPDF_FILEACCESS را پر می‌کند، و callback خواندن را متصل می‌سازد. وقتی PDFium بعداً با یک offset و یک size فراخوانی برمی‌گرداند، آداپتور جریان را به همان 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 یک عدد unsigned long ۳۲ بیتی است. جریانی را
  // رد کن که به‌طور خاموش بیش از ۴ گیبی‌بایت truncate می‌شود
  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 یک تابع static است

callback خواندنی که PDFium در m_GetBlock نگه می‌دارد یک اشاره‌گر معمولی به تابع C با قرارداد فراخوانی cdecl است. یک متد Delphi را نمی‌توان مستقیم به آن داد، چون متد یک آرگومان پنهان Self دارد که فراخواننده C نه آن را می‌شناسد و نه هرگز ارسالش می‌کند. بنابراین آداپتور callback را به صورت یک class function با برچسب cdecl; static تعریف می‌کند تا به تابعی مستقل با چیدمان فریم C مورد انتظار PDFium و بدون Self ضمنی کامپایل شود

این مسئله قرارداد فراخوانی را حل می‌کند، اما یک سؤال دوم ایجاد می‌شود: وقتی Selfای در کار نیست، callback چگونه به همان جریان مشخصی می‌رسد که باید از آن بخواند؟ پاسخ همان پارامتر کاربرِ کدر است. وقتی آداپتور رکورد را می‌سازد، اشاره‌گر نمونه خودش را در m_Param می‌گذارد. PDFium همان اشاره‌گر را به عنوان نخستین آرگومان هر callback برمی‌گرداند. تابع static آن را دوباره به TPdfStreamAdapter تبدیل می‌کند و عملیات خواندن را روی جریان همان نمونه انجام می‌دهد. این همان trampoline استاندارد برای عبور دادن زمینه یک شیء از مرز Cای است که اصولاً مفهومی از شیء ندارد

دیاگرام ترامپولین cdecl که درخواست‌های بلوکی PDFium را از مرز C به نمونهٔ TPdfStreamAdapter در Delphi می‌برد و استثناها را به مقدار بازگشتی صفر می‌فشارد
یک callback استاتیک cdecl هیچ Self ضمنی پنهان نمی‌کند، پس m_Param نمونه adapter را به هر فراخوانی پس می‌دهد و هر exception دلفی به یک بازگشت صفر فرومی‌افتد
// عیناً از خودِ مؤلفه: trampoline از نوع 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;  // شکست را با مقدار بازگشتی گزارش بده، هرگز با raise کردن
  end;
end;

سقف 4 GiB و این‌که چرا به محافظ نیاز دارد

اینجاست که مرز گفته‌شده در آغاز مقاله معنا پیدا می‌کند. فیلد طول m_FileLen در FPDF_FILEACCESS یک مقدار بدون علامت 32 بیتی است. بیشترین طول قابل نمایش آن یک بایت کمتر از 4 GiB است. یک TStream اندازه خود را به صورت Int64 گزارش می‌کند، پس یک جریان می‌تواند خیلی بیش از چیزی را که آن فیلد تحمل می‌کند توصیف کند. از همان لحظه‌ای که اندازه جریان از آن سقف بیشتر شود، دیگر راه صادقانه‌ای برای گفتن طول فایل به PDFium وجود ندارد

واکنش اشتباه این است که اندازه را همان‌طور انتساب دهید و بگذارید مقدار wrap شود. اگر طول 5 GiB را در یک فیلد 32 بیتی truncate کنید، یک عدد کوچک و در ظاهر معقول تولید می‌شود و بعد PDFium فایل را این‌طور تحلیل می‌کند که انگار حدود یک گیگابایت بعد به پایان می‌رسد. trailer و جدول cross-reference در انتهای واقعی فایل هستند، یعنی خیلی فراتر از طول truncate شده، و در نتیجه تحلیل به شکلی شکست می‌خورد که هیچ ربطی به علت واقعی ندارد. شما مشغول اشکال‌زدایی یک خطای cross-reference روی فایلی خواهید شد که کاملاً معتبر است، بی‌آن‌که کوچک‌ترین نشانه‌ای داشته باشید که دو لایه بالاتر یک عدد wrap شده است

آداپتور به جای این کار ورودی را رد می‌کند. سازنده اندازه جریان را با High(FPDF_DWORD) مقایسه می‌کند و همان لحظه که جریان برای توصیف شدن بیش از حد بزرگ باشد EPdfError را بالا می‌برد. یک خطای صریح و فوری، مشکل واقعی را دقیقاً در نقطه ساختن شیء نام می‌برد. truncate بی‌سروصدا آن را پشت نشانه‌ای گمراه‌کننده پنهان می‌کند که خیلی دیرتر باید دنبالش بگردید. محدودیت 4 GiB یک قید واقعی در این مسیر بارگذاری است و کار درست این است که آن را با صدای بلند آشکار کنید، نه این‌که با حسابی که فقط اتفاقی کامپایل می‌شود رویش سرپوش بگذارید. وقتی آرشیوی واقعاً از این مرز عبور می‌کند، راه‌حل‌هایی که در ابتدای متن وعده داده شد بیرون از این API قرار دارند: یا اسکن را به فایل‌های چندجلدی بشکنید که هر کدام زیر سقف بمانند، یا سند را روی دیسک نگه دارید و از طرحی با دسترسی مستقیم بر پایه offsetهای 64 بیتی استفاده کنید، نه از FPDF_FILEACCESS

دیاگرام تصمیم محافظ 4 گیبیابتی FPDF_FILEACCESS؛ TStream عظیم در Delphi بلافاصله EPdfError می‌دهد به‌جای آنکه بی‌سروصدا فیلد طول اعلام‌شده را بپیچد
یک EPdfError فوری از حسابی که فقط کامپایل می‌شود بهتر است: یک m_FileLen دوران‌شده دیباگ را به دنبال ارجاع متقابلی خیالی می‌فرستد

شکست نباید از مرز callback عبور کند

خواندن ممکن است شکست بخورد. جریان می‌تواند شیئی متکی بر شبکه باشد که timeout می‌دهد، یک handle مربوط به blob باشد که از زیر دست شما بسته شده است، یا فایلی باشد که بعد از باز شدن سند truncate شده است. قرارداد PDFium برای callback خواندن فقط یک مقدار بازگشتی است: غیرصفر برای موفقیت و صفر برای شکست. این یک فریم C است و هیچ سازوکاری برای گرفتن یا انتشار استثنای Pascal ندارد

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

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

متدی از مؤلفه که مسیر جریانی را هدایت می‌کند LoadCustomDocument است. این متد به شکل یک متد متمایز اعلام شده، نه یک overload دیگر برای LoadDocument، تا ارسال یک 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 تاکنون فقط trailer و کاتالوگ را خوانده است.
    // رندر یک صفحه فقط بایت‌های همان صفحه را از طریق callback می‌کشد
    // ... صفحات را اینجا رندر یا بازرسی کن ...
  finally
    Pdf.Free;  // سند را می‌بندد، که آداپتور و جریان را آزاد می‌کند
  end;
end;

همین فراخوانی برای TMemoryStream، یک blob stream از dataset پایگاه داده، یا هر فرزند سفارشی دیگری از TStream هم کار می‌کند. بارگذاری درخواستی وقتی ارزش خود را نشان می‌دهد که فایل بزرگ باشد و فقط بخشی از آن قرار است خوانده شود: یک نمایشگر آرشیو، یک تولیدکننده thumbnail که فقط چند صفحه را نمونه می‌گیرد، یا یک نمایه‌ساز جست‌وجو که هر بار یک صفحه را می‌کشد. وقتی فایل کوچک است یا به هر حال قرار است همه آن را بخوانید، بارگذاری بافرشده ساده‌تر است و ماشین جریانی هیچ سودی برایتان ندارد. عامل تعیین‌کننده نسبت بایت‌هایی است که واقعاً لمس می‌کنید به بایت‌هایی که فایل در خود دارد

وقتی صفحه‌ها به صورت درخواستی جریان پیدا کردند، نگرانی بعدی این است که در زمان zoom و scroll کاربر، صفحه‌های رندرشده همچنان responsive بمانند؛ چیزی که در یادداشت ما درباره render cache و کارایی zoom پوشش داده شده است. وقتی سند جریانی از آن نوعی است که نمایشگر باید نشانش بدهد اما نباید به کاربر اجازه export یا تغییر بدهد، تکنیک‌های مطرح‌شده در راهنمای پیش‌نمایش امن PDF به‌طور طبیعی با این مسیر بارگذاری جفت می‌شوند. هر دو بر همان بارگذاری جریانی توصیف‌شده در اینجا بنا شده‌اند که به عنوان بخشی از PDFium Component برای Delphi و C++Builder ارائه می‌شود، در کنار APIهای رندر، استخراج متن و annotation که در بخش‌های دیگر این وبلاگ پوشش داده شده‌اند