مقاله فنی

رندر پیش‌رونده (Progressive Rendering) و قابل‌لغو فایل‌های PDF در دلفی (PDFium)

بیشتر صفحات PDF در عرض چند میلی‌ثانیه شطرنجی (rasterise) می‌شوند و شما هرگز به آن فکر نمی‌کنید. سپس کاربری یک نقشه مهندسی A1، یا صفحه‌ای مملو از ده‌ها هزار ضربه برداری (vector strokes)، یا پوستری شلوغ با گروه‌های شفافیت (transparency groups) و ماسک‌های نرم باز می‌کند، و فراخوانی تکی که آن را نقاشی می‌کند دو یا سه ثانیه طول می‌کشد. اگر آن فراخوانی روی ترد رابط کاربری (UI thread) اجرا شود، پنجره دیگر بازنشانی (repaint) نمی‌شود، نوار عنوان خاکستری شده، و سیستم‌عامل پیشنهاد بستن اجباری (kill) برنامه را می‌دهد. کار در حال انجام کاملاً قانونی است. صفحه واقعاً به این مدت زمان نیاز دارد. نقص کار اینجاست که رندر یک فراخوانی مسدودکننده و غیرقابل‌تقسیم است که هیچ راهی برای تنفس و هیچ راهی برای توقف ندارد

این مقاله دقیقاً در مورد یکی از این دو مشکل است: لغو کردن رندر طولانی یک صفحه تکی بدون متوقف کردن (freezing) رابط کاربری. کاربر روی صفحه بعدی کلیک کرده، زوم کرده، یا سند را بسته است، و رندری که در حال پرواز (in flight) است اکنون یک کار بیهوده است که باید در اولین فرصت خاتمه یابد، نه اینکه تا انتها اجرا شود. روان کردن اسکرول و زوم با کش کردنِ (caching) آنچه قبلاً شطرنجی شده، دغدغه جداگانه‌ای با طراحی خاص خود است که در مقاله همراه آن در انتهای متن پوشش داده شده است. در اینجا تنها سوال این است که چگونه می‌توان کاری کرد که یک رندر پیش‌رونده، به یک درخواست لغو به سرعت و به تمیزی پاسخ دهد

رندر پیش‌رونده‌ای که PDFium از پیش ارائه می‌دهد

PDFium مشکلِ متوقف شدن (freezing) را پیش‌بینی کرده است. در کنار تابع یک‌باره‌ی FPDF_RenderPageBitmap، یک نسخه پیش‌رونده ارائه می‌دهد که صفحه را به تکه‌های کوچکتر کاری تقسیم می‌کند. شما یک بار FPDF_RenderPageBitmap_Start را فراخوانی می‌کنید تا رندر را برای یک بیت‌مپ مقصد تنظیم کنید، سپس FPDF_RenderPage_Continue را مکرراً فراخوانی می‌نمایید. هر Continue یک برشِ مشخص را شطرنجی کرده و یک وضعیت (status) برمی‌گرداند. مقدار FPDF_RENDER_TOBECONTINUED به معنای وجود کار بیشتر، FPDF_RENDER_DONE به معنای اتمام صفحه، و FPDF_RENDER_FAILED به این معناست که رندر با خطا متوقف شده است. زمانی که حلقه پایان می‌یابد، شما FPDF_RenderPage_Close را فرامی‌خوانید تا وضعیت پیش‌روندهِ اختصاصی صفحه (per-page progressive state) را آزاد کنید. از آنجا که بین برش‌ها، کنترل به کد شما بازمی‌گردد، می‌توانید پیام‌ها را پمپ کنید، نشانگر پیشرفت (progress indicator) را به‌روزرسانی نمایید، یا بررسی کنید که آیا هنوز به این کار نیاز هست یا خیر

مکانیسمی که PDFium برای تصمیم‌گیری در مورد زمانِ واگذاری (yield) ارائه می‌دهد، یک ساختارِ تابع بازگشتی (callback struct) به نام IFSDK_PAUSE است. شما آن را به Start و به هر Continue پاس می‌دهید. پس از هر تکه، PDFium اشاره‌گر تابع NeedToPauseNow خود را فراخوانی می‌کند، و اگر مقدار غیرصفری برگرداند، Continue فعلی زودهنگام متوقف شده و کنترل را با FPDF_RENDER_TOBECONTINUED بازمی‌گرداند. این ساختار همچنین دارای یک فیلد version است که باید روی 1 تنظیم شود، و یک اشاره‌گر user با فرم آزاد (free-form) که PDFium هرگز آن را لمس نکرده و بدون تغییر عبور می‌دهد. همان اشاره‌گرِ دست‌نخورده، تمام لولای طراحی‌ای است که در ادامه می‌آید

تغییر کاربریِ مکث (pause) به عنوان لغو (cancel)

قصد اولیه از NeedToPauseNow تکه‌تکه کردن زمان (time-slicing) است. زمانی که بودجه فریم شما تمام شده مقدار غیرصفر برمی‌گردانید، برای ادامه رندر مقدار صفر برمی‌گردانید، و PDFium مکث می‌کند تا بتوانید قبل از ازسرگیریِ همان رندر کار دیگری انجام دهید. کامپوننت PDFium از همان سیگنال برای فعل متفاوتی استفاده می‌کند. تابع بازگشتی به جای پاسخ دادن به این سوال که "آیا باید مکث کنم و به شما اجازه ازسرگیری بدهم؟"، پاسخ می‌دهد "آیا این کار لغو شده است؟". این دو مفهوم به خوبی بر روی یکدیگر نگاشت (map) می‌شوند، به دلیل کاری که حلقه زمانی که پرچم (flag) را می‌بیند انجام می‌دهد. یک مکثِ واقعی، منتظرِ یک Continue در آینده است؛ اما لغو این‌گونه نیست. زمانی که حلقه فراخوان‌دهنده مشاهده کند توکن لغو شده است، کانتکست (context) رندر را می‌بندد و هرگز دوباره Continue را فراخوانی نمی‌کند، بنابراین همان مقدار برگشتیِ غیرصفری که PDFium آن را به عنوان "توقف این تکه" می‌خواند، در عمل تبدیل به "توقف برای همیشه" می‌شود

لغو از طریق یک اینترفیس، IPdfCancellationToken، بیان می‌شود که ویژگی IsCancelled آن از حالت false به true تغییر می‌کند زمانی که بخش دیگری از برنامه درخواست توقف رندر را داشته باشد. پل بین این اینترفیس پاسکال (Pascal) و تابع بازگشتیِ C در PDFium یک اشاره‌گرِ تکی است. ارجاعِ اینترفیسِ توکن در IFSDK_PAUSE.user نوشته می‌شود، و یک تابع بازگشتی استاتیک cdecl آن را خوانده و کوئری می‌گیرد. این یک مشکل کلاسیک در اجازه دادن به کتابخانه C برای فراخوانیِ توابع پاسکال است: تابع بازگشتی باید یک تابع ساده با قرارداد فراخوانیِ C (C calling convention) باشد، نه یک متد، زیرا PDFium یک اشاره‌گر تابعِ خام (bare function pointer) را ذخیره و فراخوانی می‌کند که چیزی از اشیاء پاسکال یا Self نمی‌داند

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium این را می‌خواند؛ فیلد .user توکن را نگه می‌دارد
    Token: IPdfCancellationToken; // ارجاع قوی (strong ref) توکن را زنده نگه می‌دارد
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // غیرصفر: PDFium این تکه را متوقف می‌کند
end;

این تابع بازگشتی، توکن را با تبدیل (casting) مجددِ pThis^.user به نوع اینترفیس بازیابی کرده و IsCancelled را می‌خواند. هیچ چیز در آن حافظه‌ای تخصیص نمی‌دهد، قفل (lock) نمی‌کند یا مسدود نمی‌سازد، که این موضوع اهمیت دارد زیرا PDFium پس از هر تکه، آن را در ترد رندر فراخوانی می‌کند و هر کاری که اینجا انجام شود به هزینه خودِ رندر افزوده می‌شود. محافظت در برابر ساختار nil یا فیلد user با مقدار nil به این معنی است که نصبِ همین تابع حتی روی رندری که هرگز توکن واقعی به آن داده نشده، ایمن است

زنده نگه داشتن توکن در سراسر حلقه

تبدیلِ یک اشاره‌گر اینترفیس از طریق یک Pointer خام و برگرداندنِ آن، همان جایی است که باگ‌های طول عمر (lifetime bugs) متولد می‌شوند. یک IInterface در دلفی به صورتِ شمارش ارجاع (reference counted) است، و شمارش تنها زمانی تغییر می‌کند که کامپایلر ببیند یک متغیر با نوع اینترفیس در حال تخصیص است. ذخیره توکن صرفاً به عنوان یک اشاره‌گر خام در درون IFSDK_PAUSE.user، آن را کاملاً از دید شمارنده ارجاع پنهان می‌کند. اگر تنها ارجاعِ دیگر به آن توکن، در حالی که حلقه Continue هنوز در حال اجرا بود، از میدان دید (scope) خارج می‌شد، شیء از زیرِ دست تابع بازگشتی آزاد می‌گردید و تکه بعدی یک اشاره‌گرِ معلق (dangling pointer) را فرامی‌خواند

به همین دلیل است که توصیف‌گر (descriptor) رکوردی است که دو چیز را نگه می‌دارد، نه یک چیز. فیلد Pause ساختاری است که PDFium می‌خواند. فیلد Token یک ارجاع واقعیِ از نوع اینترفیس است که کامپایلر آن را می‌شمارد، و هیچ دلیل دیگری برای وجود آن نیست جز اینکه توکن را تا زمانی که رکورد زنده است، در حافظه سنجاق (pin) کند. رکورد، یک متغیر محلی در پشته (stack) مربوط به روتینِ رندر است، بنابراین در کل طول مدت حلقه معتبر باقی می‌ماند و تنها زمانی تخریب می‌شود که روتین به پایان برسد. اشاره‌گر خام در user و ارجاعِ شمرده‌شده در Token هر دو به یک شیء اشاره دارند؛ یکی چیزی است که PDFium می‌تواند بخواند، و دیگری چیزی است که آن شیء را از جمع‌آوری شدن (collection) حفظ می‌کند

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... انتخاب EffectiveToken ...

  // ابتدا ارجاع قوی، سپس انتشار همان شیء برای PDFium از طریق .user
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

بستن کانتکست رندر فارغ از نحوه پایان حلقه

هر فراخوانی به FPDF_RenderPageBitmap_Start وضعیتی پیش‌رونده را تخصیص می‌دهد که PDFium آن را به صفحه مرتبط می‌سازد، و این وضعیت تنها توسط FPDF_RenderPage_Close آزاد می‌شود. سه راه برای خروج از این حلقه پیش‌برنده وجود دارد. صفحه تمام می‌شود و آخرین وضعیت FPDF_RENDER_DONE است. توکن لغو می‌شود و حلقه زودهنگام با گزارش لغو خارج می‌شود. چیزی با شکست مواجه می‌شود و وضعیت FPDF_RENDER_FAILED است. هر سه باید Close را فراخوانی کنند، و مسیر لغو، ساده‌ترین مسیری است که ممکن است به اشتباه طی شود، زیرا شکل طبیعی "دیدنِ لغو، شکستنِ حلقه" تمایل دارد در مسیر خروج از پاکسازی صرف‌نظر کند. رسیدن به مرحله خروج بدون فراخوانی Close منجر به نشت (leak) وضعیت اختصاصی صفحه می‌شود، و نمایشگری که به کاربر اجازه می‌دهد رندرها را پشت سر هم لغو کند، این نشت را در هر صفحهِ ناتمام انباشته می‌کند

شکلِ مقاوم، حلقه و دسته‌بندیِ نتیجه را درون یک try و FPDF_RenderPage_Close را در finally معادلِ آن قرار می‌دهد. بیت‌مپ مقصد در همان بلوک از بین می‌رود. لغو می‌تواند حلقه را از طریق یک Exit زودهنگام ترک کند و بخش finally همچنان اجرا می‌شود، بنابراین دقیقاً یک جا وجود دارد که وضعیت پیش‌رونده را آزاد می‌کند و نمی‌توان آن را دور زد

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // آزادسازی وضعیت پیش‌رونده‌ای که Start تخصیص داده بود؛ الزامی در هر مسیر.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

حلقه قبل از هر Continue توکن را بررسی می‌کند و در کنار آن به تابع بازگشتی در درون خود تکیه دارد. تابع بازگشتی، تکه فعلی را کوتاه می‌کند؛ بررسی درون حلقه از شروع تکه بعدی جلوگیری می‌نماید. آن‌ها با هم، زمانی را که طول می‌کشد تا لغو عمل کند، تقریباً به مدت زمانِ یک تکه محدود می‌کنند

سه خروجی، و آنچه بیت‌مپ پس از لغو نگه‌داری می‌کند

نقطه ورودِ عمومی TPdf.RenderPageProgressive است، و یک TPdfProgressiveStatus برمی‌گرداند که یکی از مقادیر prsDone، prsCancelled، یا prsFailed می‌باشد. این مقادیر بازتاب‌دهنده ثابت‌های FPDF_RENDER_* از PDFium در اصطلاحِ پاسکال هستند اما حالتِ لغو را به عنوان یک نتیجه درجه اول در نظر می‌گیرند نه به عنوان یک خطا

نکته‌ای که افراد را غافلگیر می‌کند این است که بیت‌مپ مقصد پس از prsCancelled حاوی چیست. خالی نیست. PDFium به صورت پیش‌رونده تکه پس از تکه در همان بیت‌مپ رندر می‌کند، بنابراین زمانی که یک لغو، حلقه را متوقف می‌سازد، بیت‌مپ حاویِ هر آنچه تا آن لحظه نقاشی شده است می‌باشد، که یک تصویرِ جزئی است: برخی نوارها انجام شده، و بقیه همچنان رنگ پرکننده (fill colour) را نشان می‌دهند. اینکه آیا این نتیجه جزئی مفید است یا خیر به فراخوان‌دهنده بستگی دارد. نمایشگری که می‌خواهد بیت‌مپ را دور بیندازد زیرا کاربر به جای دیگری رفته است می‌تواند به سادگی آن را نادیده بگیرد. نمایشگری که می‌خواهد یک پیش‌نمایش کم‌هزینه نشان دهد می‌تواند آن را حفظ کند. کاری که نباید انجام دهید این است که فرض کنید prsCancelled دلالت بر یک بیت‌مپ خالی یا تعریف‌نشده دارد؛ بلکه دلالت بر یک عکس‌برداری (snapshot) واقعی از یک رندر ناتمام دارد

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // توکن بدون لغو شروع می‌شود؛ ویژگی Token.IsCancelled را از جای دیگر تغییر دهید
    // (مثل یک عملکرد UI، یک رویداد ناوبری) تا رندرِ در حال پرواز سقط شود.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // کاملاً رندر شده
      prsCancelled: ;                            // بیت‌مپ جزئی، معمولاً دور انداخته می‌شود
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

توکن nil و مسیرِ بدون‌انشعاب (branch-free) تابع بازگشتی

لغو کردن، اختیاری (opt-in) است. فراخوان‌دهنده‌ای که تنها رندر پیش‌رونده را برای بهره‌گیری از مزیتِ پمپاژ پیام (message-pumping) می‌خواهد، و هیچ قصدی برای سقط کردن آن ندارد، باید بتواند nil را برای توکن پاس دهد. روش ساده‌لوحانه برای پشتیبانی از این کار، پراکنده کردنِ بررسی‌های "اگر توکنی داده شده بود" در سراسر تابع بازگشتی و حلقه است، که به معنای یک انشعاب (branch) روی هر تکه، و تابع بازگشتی‌ای است که هم باید یک توکن واقعی و هم عدم وجود آن را مدیریت کند

پیاده‌سازیِ ما، با جایگزین کردن یک سینگلتون در زمانی که فراخوان‌دهنده هیچ‌چیز پاس نمی‌دهد، از این مسئله اجتناب می‌کند. یک توکن nil با PdfNoCancellationToken مبادله می‌شود، اینترفیسی که ویژگی IsCancelled آن همیشه false است. از آن نقطه به بعد، تابع بازگشتی و حلقه، در هر حالتی توکنی برای کوئری گرفتن دارند، بنابراین هیچ‌کدام نیازی به بررسیِ nil ندارند و هیچ‌کدام نیازی به یک مسیر ویژه نخواهند داشت. این توکنِ "هرگز-لغو-نکن" به سادگی همیشه پاسخ false می‌دهد، تابع بازگشتی همیشه صفر برمی‌گرداند، و رندر دقیقاً همان‌گونه که در حالتِ غیرقابل‌لغو انجام می‌شد، تا انتها اجرا می‌شود. رفتارِ اختیاری به عنوان توکنی که هرگز عمل نمی‌کند مدل‌سازی شده است تا اینکه عدم وجود توکن باشد، که این امر مسیرِ داغ (hot path) را یکپارچه نگه می‌دارد

// مقدار nil -> سینگلتونِ هرگز-لغو-نکن، بنابراین مسیر تابع بازگشتی یکسان است
// فرقی نمی‌کند که فراخوان‌دهنده گزینه لغو را انتخاب کرده باشد یا خیر.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

شکلی که پدیدار می‌شود کوچک است و ارزش تکرار دارد، زیرا این همان بخشِ قابلِ استفاده مجدد است. یک کتابخانه C که از یک تابع بازگشتی پشتیبانی می‌کند، دقیقاً یک کانال برای عبور دادنِ وضعیت (state) به آن تابع در اختیار شما می‌گذارد که همان اشاره‌گرِ مبهمِ user است. یک ارجاعِ شمرده‌شده‌ی اینترفیسِ پاسکال را پشت آن اشاره‌گر قرار دهید، یک ارجاع واقعیِ دوم را در کنار ساختار (struct) زنده نگه دارید تا شیء در میانه فراخوانی جمع‌آوری (collected) نشود، و اینترفیس را درون یک تابع استاتیک cdecl مجدداً بخوانید. کل حلقه پیش‌برنده را درون یک try بپیچید و کانتکستِ محلی (native context) را در finally آزاد نمایید. همین الگو قابل انتقال به هر عملیات پیش‌رونده یا مبتنی‌بر-تابع-بازگشتی در PDFium است که در آن، در حالی که C یک اشاره‌گر نگه می‌دارد، کدِ پاسکال باید کنترلِ طول عمر (lifetime) را در دست داشته باشد

لغو کردن، تنها نیمی از کار یک نمایشگرِ واکنش‌گرا (responsive) است. نیمه دیگر آن عدم رندرِ مجددِ صفحاتی است که قبلاً کشیده‌اید، و همچنین روان نگه داشتن زوم و اسکرول با ارائه بیت‌مپ‌های کش شده است، که در مقاله ما درباره کش کردن رندر و عملکرد زوم پوشش داده شده است. برای مشاهده اینکه چگونه رندرِ قابلِ لغو در کنار ناوبری، انتخاب، و جستجو در یک نمایشگر کامل جای می‌گیرد، به مقاله ساخت یک نمایشگر PDF پرامکانات با کامپوننت PDFium VCL در دلفی مراجعه کنید. رندر پیش‌رونده‌ای که در اینجا توصیف شد به عنوان بخشی از کامپوننت PDFium برای دلفی (Delphi) و لازاروس (Lazarus) در کنار APIهای بارگذاری، رندر، و فرم که در جاهای دیگر این وبلاگ پوشش داده شده‌اند، عرضه می‌شود