مقاله فنی

رندر پیش‌رونده قابل لغو PDF در Delphi با PDFium

بیشتر صفحه‌های PDF در چند میلی‌ثانیه رستری می‌شوند و معمولاً حتی به آن فکر هم نمی‌کنید. بعد کاربر یک نقشه مهندسی A1، صفحه‌ای پر از ده‌ها هزار خط برداری، یا پوستری مملو از گروه‌های شفافیت و soft mask باز می‌کند و همان فراخوانی واحدی که باید آن را نقاشی کند دو یا سه ثانیه زمان می‌برد. اگر این فراخوانی روی رشته رابط کاربری اجرا شود، پنجره دیگر بازنقاشی نمی‌شود، نوار عنوان خاکستری می‌شود و سیستم عامل پیشنهاد می‌دهد برنامه را ببندید. خود کار واقعی است و صفحه واقعاً همین قدر زمان لازم دارد. ایراد در این است که رندر به شکل یک فراخوانی یکپارچه و مسدودکننده انجام می‌شود که نه فرصتی برای نفس کشیدن دارد و نه راهی برای توقف

این مقاله دقیقاً درباره یکی از همین دو مسئله است: لغو یک رندر طولانی برای یک صفحه بدون منجمد کردن رابط کاربری. کاربر روی صفحه بعدی کلیک کرده، زوم را عوض کرده یا سند را بسته و رندری که در حال اجراست حالا کاری هدررفته است که باید در نخستین فرصت متوقف شود، نه اینکه تا پایان اجرا شود. نرم کردن پیمایش و زوم با کش کردن چیزی که قبلاً رستری شده موضوع جداگانه‌ای با طراحی مخصوص خودش است که در مقاله همراهِ پیوندشده در انتها پوشش داده شده. اینجا فقط یک سؤال داریم: چگونه یک رندر پیش‌رونده را وادار کنیم سریع و تمیز به درخواست لغو پاسخ دهد

API رندر پیش‌رونده‌ای که PDFium از قبل ارائه می‌کند

PDFium نیمه مربوط به منجمد شدن را از قبل پیش‌بینی کرده بود. در کنار FPDF_RenderPageBitmap که یک‌باره کل کار را انجام می‌دهد، یک گونه پیش‌رونده هم ارائه می‌کند که صفحه را به قطعه‌های کاری تقسیم می‌کند. یک بار FPDF_RenderPageBitmap_Start را برای آماده‌سازی رندر روی بیت‌مپ مقصد صدا می‌زنید و بعد بارها FPDF_RenderPage_Continue را فراخوانی می‌کنید. هر Continue یک برش محدود را رستری می‌کند و یک وضعیت برمی‌گرداند. FPDF_RENDER_TOBECONTINUED یعنی هنوز کار باقی مانده، FPDF_RENDER_DONE یعنی صفحه تمام شده و FPDF_RENDER_FAILED یعنی کار با خطا متوقف شده. وقتی حلقه تمام شد FPDF_RenderPage_Close را صدا می‌زنید تا وضعیت پیش‌رونده مخصوص همان صفحه آزاد شود. چون بین برش‌ها کنترل به کد شما برمی‌گردد، می‌توانید پیام‌ها را pump کنید، نوار پیشرفت را به‌روز کنید یا بررسی کنید آیا این کار هنوز لازم است یا نه

PDFium برای تصمیم‌گیری درباره زمان واگذاری کنترل یک ساختار callback به نام IFSDK_PAUSE می‌دهد. آن را به Start و هر فراخوانی Continue می‌دهید. PDFium بعد از هر قطعه، تابع NeedToPauseNow را صدا می‌زند و اگر خروجی آن غیرصفر باشد، همان Continue زودتر متوقف می‌شود و با FPDF_RENDER_TOBECONTINUED کنترل را پس می‌دهد. این ساختار یک فیلد version هم دارد که باید روی 1 تنظیم شود و یک اشاره‌گر آزاد به نام user که PDFium هرگز به آن دست نمی‌زند و فقط همان‌طور عبورش می‌دهد. همین اشاره‌گر دست‌نخورده محور تمام طراحی‌ای است که در ادامه می‌آید

استفاده دوباره از pause به عنوان cancel

هدف اولیه NeedToPauseNow تقسیم زمانی است. وقتی بودجه زمانی فریم تمام شد مقدار غیرصفر برگردانید و وقتی می‌خواهید رندر ادامه پیدا کند صفر برگردانید تا PDFium مکث کند و بعداً همان رندر از سر گرفته شود. PDFium Component همین سیگنال را برای یک فعل دیگر دوباره استفاده می‌کند. به جای پاسخ دادن به این سؤال که «باید مکث کنم تا بعداً ادامه بدهی؟»، callback پاسخ می‌دهد «آیا این کار لغو شده است؟». این دو معنا خیلی تمیز روی هم می‌افتند چون حلقه وقتی آن پرچم را می‌بیند رفتار مشخصی دارد. مکث واقعی انتظار یک Continue دیگر را می‌کشد، اما لغو نه. به محض اینکه حلقه فراخواننده ببیند token لغو شده، context رندر را می‌بندد و دیگر هرگز Continue را صدا نمی‌زند، بنابراین همان خروجی غیرصفری که PDFium آن را «این قطعه را متوقف کن» می‌خواند، در عمل به «برای همیشه متوقف شو» تبدیل می‌شود

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

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  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; // non-zero: PDFium stops this chunk
end;

callback با cast کردن pThis^.user به نوع interface، token را بازیابی می‌کند و IsCancelled را می‌خواند. درون آن هیچ تخصیص، قفل یا انتظار مسدودکننده‌ای انجام نمی‌شود و این مهم است، چون PDFium آن را بعد از هر قطعه روی رشته رندر فراخوانی می‌کند و هر کاری که اینجا انجام دهید به هزینه خود رندر اضافه می‌شود. محافظت در برابر ساختار nil یا فیلد user تهی هم باعث می‌شود همین تابع حتی روی رندری که اصلاً token واقعی نداشته هم با خیال راحت نصب شود

زنده نگه داشتن token در طول حلقه

عبور دادن یک اشاره‌گر interface از میان یک Pointer خام و بازگرداندن دوباره آن جایی است که باگ‌های مربوط به طول عمر متولد می‌شوند. در Delphi، IInterface دارای شمارش مرجع است و این شمارش فقط وقتی تغییر می‌کند که compiler ببیند یک متغیر از نوع interface در حال مقداردهی است. اگر token را فقط به صورت یک اشاره‌گر خام در IFSDK_PAUSE.user نگه دارید، آن را کاملاً از دید شمارنده مرجع پنهان کرده‌اید. اگر تنها مرجع دیگر به آن token در حالی از scope خارج شود که حلقه Continue هنوز در حال اجراست، شیء درست زیر پای callback آزاد می‌شود و قطعه بعدی یک اشاره‌گر آویزان را dereference می‌کند

به همین دلیل descriptor یک record با دو عضو است، نه یک عضو. فیلد Pause همان ساختاری است که PDFium می‌خواند. فیلد Token یک مرجع واقعی از نوع interface است که compiler آن را می‌شمارد و تنها دلیل وجودش این است که تا وقتی record زنده است، token را هم در حافظه نگه دارد. این record یک متغیر محلی روی stack تابع رندر است، بنابراین در تمام طول حلقه معتبر می‌ماند و فقط هنگام خروج از تابع از بین می‌رود. اشاره‌گر خام در user و مرجع شمارش‌شده در Token هر دو به همان شیء اشاره می‌کنند؛ یکی چیزی است که PDFium می‌تواند بخواند و دیگری چیزی است که مانع جمع‌آوری آن شیء می‌شود

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

بستن context رندر در هر مسیری که حلقه تمام شود

هر فراخوانی FPDF_RenderPageBitmap_Start وضعیت پیش‌رونده‌ای را ایجاد می‌کند که PDFium آن را به صفحه وصل می‌کند و این وضعیت فقط با FPDF_RenderPage_Close آزاد می‌شود. حلقه محرک سه راه خروج دارد. صفحه تمام می‌شود و وضعیت نهایی FPDF_RENDER_DONE است. token عمل می‌کند و حلقه با گزارش لغو زودتر تمام می‌شود. یا خطایی رخ می‌دهد و وضعیت FPDF_RENDER_FAILED برمی‌گردد. در هر سه حالت باید Close فراخوانی شود و مسیر لغو ساده‌ترین جایی است که ممکن است اشتباه شود، چون شکل طبیعی «لغو را دیدم، بیرون می‌پرم» معمولاً cleanup را در راه خروج جا می‌اندازد. اگر Close اجرا نشود وضعیت مخصوص آن صفحه نشت می‌کند و در یک viewer که کاربر بارها رندرها را لغو می‌کند، این نشت روی هر صفحه لغوشده جمع می‌شود

شکل مقاوم، حلقه و دسته‌بندی نتیجه را داخل یک 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
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

حلقه پیش از هر Continue خود token را هم بررسی می‌کند، علاوه بر callback داخلی آن. callback قطعه فعلی را کوتاه می‌کند و بررسی حلقه مانع آغاز قطعه بعدی می‌شود. این دو با هم مدت‌زمان لازم برای اثر کردن یک لغو را تقریباً به اندازه طول یک قطعه محدود می‌کنند

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

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

چیزی که معمولاً آدم‌ها را غافلگیر می‌کند این است که بعد از prsCancelled بیت‌مپ مقصد چه چیزی در خود دارد. خالی نیست. PDFium هر قطعه را در همان بیت‌مپ واحد رندر می‌کند، بنابراین وقتی لغو حلقه را متوقف می‌کند، بیت‌مپ هر چیزی را که تا آن لحظه کشیده شده نگه می‌دارد؛ یعنی یک تصویر ناقص که بعضی نوارهای آن کامل شده و بقیه هنوز رنگ پس‌زمینه را نشان می‌دهند. اینکه این نتیجه ناقص به درد می‌خورد یا نه به caller بستگی دارد. یک viewer که قرار است به دلیل رفتن کاربر به جای دیگر همین بیت‌مپ را دور بیندازد می‌تواند نادیده‌اش بگیرد. viewer دیگری که می‌خواهد یک پیش‌نمایش کم‌هزینه نشان دهد می‌تواند آن را نگه دارد. چیزی که نباید فرض کنید این است که prsCancelled یعنی بیت‌مپ خالی یا تعریف‌نشده است؛ این مقدار یعنی تصویری صادقانه از یک رندر ناتمام

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

token تهی و مسیر callback بدون انشعاب

لغو یک قابلیت اختیاری است. اگر caller فقط رندر پیش‌رونده را برای امکان pump کردن پیام‌ها بخواهد و اصلاً قصد متوقف کردن کار را نداشته باشد، باید بتواند برای token مقدار nil بدهد. روش ساده‌لوحانه برای پشتیبانی از این حالت آن است که در callback و حلقه مدام بررسی کنید «آیا token داده شده است یا نه»، که یعنی روی هر قطعه یک branch اضافه خواهید داشت و callback هم باید هم حالت token واقعی و هم نبود آن را پشتیبانی کند

پیاده‌سازی از این پراکندگی با جایگزین کردن یک singleton اجتناب می‌کند. وقتی caller چیزی نمی‌دهد، nil با PdfNoCancellationToken عوض می‌شود؛ interfaceای که IsCancelled آن همیشه false است. از آن لحظه به بعد، callback و حلقه در هر حالت tokenی برای پرسیدن دارند، بنابراین نه به nil check نیاز دارند و نه به مسیر ویژه. tokenی که هرگز لغو نمی‌شود فقط همیشه false برمی‌گرداند، callback هم همیشه صفر برمی‌گرداند و رندر دقیقاً مثل حالت غیرقابل‌لغو تا انتها اجرا می‌شود. این رفتار اختیاری به صورت «tokenی که هرگز فعال نمی‌شود» مدل شده، نه به صورت «نبود token»، و همین مسیر داغ اجرا را یکنواخت نگه می‌دارد

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

شکلی که از این طراحی بیرون می‌آید کوچک است و ارزش دارد دوباره تکرار شود، چون همین بخش قابل استفاده مجدد است. وقتی یک کتابخانه C از callback پشتیبانی می‌کند، فقط یک کانال برای عبور دادن state به آن callback دارید: همان user pointer مات و ناشفاف. پشت آن یک مرجع interface پاسکالیِ شمارش‌شده قرار دهید، کنار ساختار یک مرجع واقعی دوم را زنده نگه دارید تا شیء وسط کار جمع‌آوری نشود و داخل یک تابع ایستای cdecl آن interface را دوباره بخوانید. کل حلقه محرک را داخل try بپیچید و context بومی را در finally آزاد کنید. همین الگو را می‌توان در هر عملیات پیش‌رونده یا callbackمحور PDFium به کار برد که در آن کد پاسکال باید در حالی که C فقط یک اشاره‌گر در دست دارد، کنترل طول عمر را حفظ کند

لغو فقط نیمی از یک viewer پاسخ‌گو است. نیمه دیگر این است که صفحه‌هایی را که قبلاً کشیده‌اید دوباره رندر نکنید و با ارائه بیت‌مپ‌های کش‌شده زوم و پیمایش را روان نگه دارید؛ این بخش در مقاله ما درباره کش رندر و کارایی زوم توضیح داده شده است. برای اینکه ببینید این رندر قابل لغو چگونه در کنار ناوبری، انتخاب متن و جست‌وجو در یک viewer کامل قرار می‌گیرد، ساخت یک نمایشگر PDF پرامکانات با PDFium Component در Delphi را ببینید. رندر پیش‌رونده‌ای که اینجا توضیح داده شد به‌عنوان بخشی از PDFium Component برای Delphi و Lazarus عرضه می‌شود، در کنار APIهای بارگذاری، رندر و فرم که در بخش‌های دیگر این وبلاگ پوشش داده شده‌اند