مقاله فنی

نمایشگر Delphi با PDFium: کش رندر و تکنیک‌های بزرگنمایی روان

در یک نمایشگر PDF ساده‌لوح دکمهٔ بزرگ‌نمایی را نگه دارید و نمودار CPU را تماشا کنید. یک بار فشردن یک کنترل بزرگ‌نمایی با تکرار خودکار، در هر ثانیه ده‌ها گام بزرگ‌نمایی شلیک می‌کند، و اگر هر گام یک رندر دوبارهٔ تمام‌کیفیت از صفحهٔ دیدنی را راه بیندازد، رندرها سریع‌تر از آنکه تمام شوند روی هم تلنبار می‌شوند. صفحه به‌تنهایی خوب شطرنجی می‌شود، شاید 180 ms برای یک اسکن A4، اما حالا شما ده‌ها رندر 180 میلی‌ثانیه‌ای را روی کاری اجرا می‌کنید که کاربر پیشاپیش از آن گذشته است. نمایشگر قفل می‌کند، یک هسته روی 100% میخکوب می‌شود، و تا وقتی صفحه‌نمایش خودش را برساند کاربر روی سطح بزرگ‌نمایی‌ای ایستاده که چهار رندر پیش‌تر بوده است. درمان، یک شطرنجی‌ساز سریع‌تر نیست. درمان، حافظهٔ نهانی است که صفحه‌های آماده را بی‌درنگ برمی‌گرداند و حلقهٔ رندری که حاضر است کار را همان لحظه که کهنه می‌شود رها کند

PDFium Component قطعه‌های هر دو را در اختیارتان می‌گذارد و از سیاست‌گذاری کنار می‌کشد. bitmapهایی می‌گیرید که مالکشان فراخوان است، یک رندرکنندهٔ تدریجی که توکن لغو می‌پذیرد، حالت‌های جاسازی که بزرگ‌نمایی را هنگام تغییر اندازه بازمحاسبه می‌کنند، و یک فراخوانی کاشی‌کاری برای صفحه‌هایی که بزرگ‌تر از آن‌اند که یکجا شطرنجی شوند. آنچه عمداً فراهم نمی‌کند خودِ حافظهٔ نهان است، چون سیاست درست بیرون‌راندن به viewport شما، سقف حافظهٔ سکوی شما و شیوهٔ پیمایش کاربرانتان بستگی دارد. آن تصمیم را باید شما درست بگیرید، و پیامد اشتباه گرفتنش دقیقاً همان قفل‌شدن و همان نشت است

میلی‌ثانیه‌ها و مگابایت‌ها کجا می‌روند

پیش از طراحی هر چیزی، روی هزینه عدد بگذارید. یک صفحهٔ A4 در 96 DPI تقریباً 794 در 1123 پیکسل است، حدود 3.5 MB به‌شکل یک bitmap سی‌ودو بیتی. تا 200% بزرگ‌نمایی کنید و این مقدار چهار برابر می‌شود. در 400% روی یک نمایشگر با DPI بالا، شما یک bitmap تک‌صفحه‌ای 50 تا 60 MB تخصیص می‌دهید و پر می‌کنید، و یک نمایشگر با پیمایش پیوسته چند صفحه را همزمان زنده نگه می‌دارد. هزینهٔ شطرنجی‌سازی پابه‌پای پیکسل‌های خروجی می‌رود، پس هر دو برابر شدن بزرگ‌نمایی تقریباً هم زمان رندر و هم حافظه را با هم چهار برابر می‌کند

دو پیامد مستقیماً از همین حساب بیرون می‌آید. حافظهٔ نهانی که کلیدش سطح بزرگ‌نمایی را نادیده می‌گیرد بی‌ارزش است، چون همان حرکتی که باید شتاب بگیرد، یعنی بزرگ‌نمایی، هر بار یک bitmap تازه تولید می‌کند. و حافظهٔ نهان بی‌کران، فضای آدرس یک فرایند سی‌ودو بیتی را دقیقاً روی همان سندهایی تمام می‌کند که مردم بیشترین بزرگ‌نمایی را رویشان انجام می‌دهند: اسکن‌های متراکم سند مالکیت، نقشه‌های مهندسی، نقشه‌های قطع‌بزرگ. کلید حافظهٔ نهان باید درست ساخته شود و سقفش محکم بسته شود، و هیچ‌کدام اختیاری نیست

چه چیزی در کلید حافظهٔ نهان جا دارد

استفادهٔ دوباره از یک bitmap نهان‌شده تنها وقتی امن است که همهٔ ورودی‌هایی که پیکسل‌هایش را شکل داده‌اند هنوز مطابقت داشته باشند. یعنی شمارهٔ صفحه، بزرگ‌نمایی مؤثر (یا هم‌ارز آن، ابعاد پیکسلی خروجی)، چرخش، DPI نمایشگر، و گزینه‌های رندری که هنگام تولیدش برقرار بوده‌اند. صفحه‌ای که با reAnnotations رندر شده تصویری متفاوت از همان صفحه بدون آن است، و یک گذر خاکستری با reGrayscale باز هم چیز دیگری است. هر یک از اینها را از کلید بیندازید و باگ‌ها قابل پیش‌بینی‌اند: پوششی از یادداشت که پس از حذف نظر توسط بازبین باقی می‌ماند، یا صفحه‌ای که همان لحظه‌ای که کاربر پنجره را از پنل لپ‌تاپ به یک نمایشگر 4K بیرونی می‌کشد و DPI زیر یک bitmap کهنه تغییر می‌کند تار می‌شود

جست‌وجوی کش رندر PDFium در نمایشگر Delphi؛ کلید کش ترکیبی از صفحه و بزرگ‌نمایی و چرخش و DPI مانیتور و گزینه‌های رندر است و اصابت بیت‌مپ را در میکروثانیه برمی‌گرداند و تخلیه هر بیت‌مپ افکنده را آزاد می‌کند
کلید حافظه نهان هر ورودی‌ای را که پیکسل‌ها را شکل می‌دهد می‌پوشاند، و اخراج، بیت‌مپ‌هایی را که کنار می‌گذارد آزاد می‌کند
function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // مالک این bitmap از اینجا به بعد حافظهٔ نهان است
end;

در صورت اصابت، این تابع در چند میکروثانیه برمی‌گردد، و همین تمام هدف کار است. پرسش سخت‌تر این است که بر سر bitmapهایی که از حافظهٔ نهان بیرون می‌افتند چه می‌آید، و این در عمل پرسشی دربارهٔ مالکیت آنهاست

چه کسی bitmap را آزاد می‌کند

شکل تابعی RenderPage یک TBitmap برمی‌گرداند که مالکش فراخوان است. در یک خروجی‌گیری یک‌باره، این مالکیت آشکار است و رعایتش آسان. درون یک حافظهٔ نهان، همین به رایج‌ترین نشت در نمایشگرهای PDF نوشته‌شده با Delphi تبدیل می‌شود، چون دیکشنری حالا تنها ارجاع به هر bitmap را نگه می‌دارد، و یک TDictionary ساده کلیدها و مقدارها را تنها در صورتی برایتان آزاد می‌کند که از نوع‌های مدیریت‌شده باشند. یک TBitmap چنین نیست. یک ورودی را بدون فراخوانی Free بیرون بیندازید و پیکسل‌ها تخصیص‌یافته می‌مانند بی‌آنکه چیزی به آنها اشاره کند

دلیل اینکه این موضوع از زیر دست در می‌رود زمان‌بندی است. یک آزمون سرپایی ده‌دقیقه‌ای هرگز آن‌قدر صفحهٔ متمایز را بزرگ‌نمایی نمی‌کند که چیزی دیده شود؛ نشت تنها بعد از آنکه کسی چند ساعت یک سند بلند را پیمایش و بزرگ‌نمایی کرد خودش را نشان می‌دهد، و در آن نقطه فرایند صدها bitmap صفحهٔ بی‌صاحب را نگه داشته و ماشین شروع به صفحه‌بندی می‌کند. برای همین بیرون‌راندن جای خودش را در نخستین نسخهٔ حافظهٔ نهان دارد، نه در نسخه‌ای بعدی. سقف حافظهٔ نهان را بر پایهٔ بایت تخمینی ببندید، که برابر است با پهنا ضرب در بلندا ضرب در چهار، صفحه‌هایی را که کمترین استفادهٔ اخیر را داشته‌اند و بیرون از viewport و پنجرهٔ پیش‌واکشی نشسته‌اند بیرون بیندازید، و هر bitmap را همان‌طور که حذفش می‌کنید آزاد کنید. برای ترسیم‌هایی که واقعاً گذرا هستند، overloadهایی که در یک TBitmap تأمین‌شده توسط فراخوان یا مستقیم روی یک HDC رندر می‌کنند به شما امکان می‌دهند کل رقص مالکیت را دور بزنید. پیش‌نمایش چاپ نمونهٔ آشکار آن است، چون هر برگ را یک‌بار رندر می‌کنید و نهان‌کردنش هیچ سودی ندارد

رندر تدریجی و لغو صادقانه

overloadهای سادهٔ RenderPage تا پایان کار صفحه مسدود می‌مانند، و این دقیقاً همان رفتاری است که وقتی کاربر هنوز کنترل بزرگ‌نمایی را حرکت می‌دهد نمی‌خواهید. برای آن سراغ RenderPageProgressive می‌روید. این تابع یک IPdfCancellationToken می‌گیرد و یکی از prsDone، prsCancelled یا prsFailed را برمی‌گرداند. جزئیات رفتاری‌ای که مردم را غافلگیر می‌کند این است که لغو آنی نیست. توکن در مرزهای تکه‌ها درون رندر خوانده می‌شود، پس توکنی که وسط یک تکه علامت بزنید تنها وقتی اثر می‌کند که آن تکه تمام شود. روی یک صفحهٔ پیچیده، تأخیر میان درخواست و توقف به ده‌ها میلی‌ثانیه می‌رسد. به‌جای آرزوی نبودن این شکاف، طراحی را حول آن بچینید: توکن پیشین را همان لحظه که مقدار بزرگ‌نمایی تازه می‌رسد لغو کنید، اما فرض نکنید رندر قدیمی همان لحظه که خواستید متوقف می‌شود

خط زمانی رندر تدریجی PDFium در Delphi؛ هر درخواست بزرگ‌نمایی تازه توکن قبلی را لغو می‌کند و لغو در مرز قطعه می‌نشیند و رندرهای بازنشده prsCancelled و تلاش آخر prsDone برمی‌گردانند
هر درخواست زوم جدید توکن رندر قبلی را لغو می‌کند، و لغو در مرز یک chunk فرود می‌آید
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // رندر در حال پرواز پیشین را رها کن
  FTokenSource := TPdfCancellationTokenSource.New;  // واحد FPdfAsync

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // درخواست تازه‌تر جایش را گرفت: بی‌صدا رها کن
    prsFailed:    ShowRenderFailure;
  end;
end;

در حین تعامل، prsCancelled نتیجهٔ عادی است نه استثنایی. بیشتر رندرهایی که یک حرکت بزرگ‌نمایی آغاز می‌کند پیش از پایان جایشان را به دیگری می‌دهند، پس لغو را روزمره بگیرید و نتیجه را بی‌صدا دور بریزید. صف رندری که هر لغو را به‌عنوان هشدار لاگ می‌کند، آن یک شکست واقعاً مهم را زیر هزاران خط نوفه دفن می‌کند. برای اینکه صفحه در حین اجرای رندر واقعی مرده به نظر نرسد، مسیر تدریجی را با یک بدل ارزان جفت کنید: bitmap نهان‌شدهٔ پیشین را به بزرگ‌نمایی تازه مقیاس بدهید و همان را بی‌درنگ نمایش دهید. یکی دو دهم ثانیه نرم به نظر می‌رسد، اما آنی خوانده می‌شود، و به رندر تمام‌کیفیت همان زمانی را می‌دهد که لازم دارد تا یا تمام شود یا با حرکت بعدی لغو شود

حالت جاسازی که بزرگ‌نمایی بی‌سروصدا خاموشش می‌کند

ویژگی FitMode یک نمایشگر، که روی pfmFitPage یا pfmFitWidth تنظیم شود، بزرگ‌نمایی را در هر تغییر اندازه بازمحاسبه می‌کند تا صفحه همراه تغییر پنجره جا بماند. نکته اینجاست که انتساب مستقیم Zoom مقدار FitMode را به pfmNone برمی‌گرداند. به‌عنوان پیش‌فرض این درست است: کاربری که عمداً 150% تایپ کرده نمی‌خواهد تغییر اندازهٔ بعدی پنجره آن را دور بریزد. اما هر کسی را که دکمهٔ بزرگ‌نمایی را به‌صورت Zoom := Zoom * 1.25 سیم‌کشی می‌کند غافلگیر می‌کند، چون بعد نمی‌فهمد چرا جاسازی به پهنا پس از نخستین کلیک از کار افتاد. اگر نوار ابزار شما هم بزرگ‌نمایی صریح و هم حالت‌های جاسازی را عرضه می‌کند، باید آخرین انتخاب جاسازی کاربر را خودتان به یاد بسپارید و هنگام فشردن دوبارهٔ دکمهٔ جاسازی دوباره نسبتش دهید. کامپوننت حالتی را که یک انتساب بزرگ‌نمایی همین حالا پاک کرده بازنمی‌گرداند، و قرار هم نیست بازگرداند

بودجهٔ حافظه‌ای که بتوانید از آن دفاع کنید

بودجه‌ای که بتوانید بنویسیدش، بودجه‌ای است که بتوانید در بازبینی کد از آن دفاع کنید، پس از یک سناریوی مشخص شروع کنید. فرض کنید پیمایش پیوسته صفحهٔ دیدنی به‌علاوهٔ یک صفحهٔ پیش‌واکشی‌شده در بالا و پایین را نگه می‌دارد، در کنار یک نوار بندانگشتی. در 100% روی نمایشگر 96 DPI، آن سه bitmap تمام‌اندازه هر کدام حدود 3.5 MB درمی‌آیند، که هیچ است. در 300% روی نمایشگر 4K، همان سه bitmap هر کدام تقریباً 30 MB می‌شوند، و این پیش از آن است که حافظهٔ نهان حتی یک صفحهٔ تاریخی را نگه داشته باشد. رشد در حرکت کاربر است، نه در سند

حساب حافظهٔ بیت‌مپ PDFium در نمایشگر Delphi؛ هر دو برابر شدن بزرگ‌نمایی حافظهٔ صفحه را چهارسو می‌کند و پیمایش پیوسته سه صفحه را زنده نگه می‌دارد و بودجهٔ LRU سقف‌دار از کش دفاع می‌کند و RenderTile ترسیم‌های عظیم را می‌گرداند
هر دو برابر شدن زوم، حافظه بیت‌مپ را چهار برابر می‌کند، پس حافظه نهان به یک سقف سخت و به tile برای صفحات بیش‌ازحد بزرگ نیاز دارد

یک پیش‌فرض سالم برای یک فرایند سی‌ودو بیتی Delphi، بودجهٔ 256 MB برای bitmap با بیرون‌راندن LRU است. روی 64 بیتی می‌توانید متناسب با RAM فیزیکی مقیاس بدهید، اما در هر حال یک سقف سخت نگه دارید، چون شکستی که در برابرش نگهبانی می‌دهید فروپاشی فرایند شما نیست. شکست واقعی این است که کل ماشین فایل صفحه‌بندی‌اش را می‌کوبد در حالی که نمایشگر شما از نظر فنی همچنان کار می‌کند و کاربر می‌ماند که چرا همه چیز دیگر کند شد. یک سقف سخت قابل پیش‌بینی شکست می‌خورد؛ حافظهٔ نهان بی‌کران با خودش کل میزکار را می‌برد. بندانگشتی‌ها رفتار مخصوص خودشان را می‌طلبند: هر کدام را یک‌بار در اندازهٔ هدف کوچکشان رندر کنید و در استخری جداگانه نگه دارید که منطق LRU هرگز به آن دست نزند. تولید دوبارهٔ یک بندانگشتی 120 پیکسلی با کوچک‌کردن یک bitmap تمام‌صفحهٔ 60 MB، ولخرجانه‌ترین راه ممکن برای ساختن یک تمبر پستی است

بعضی صفحه‌های تکی هر بودجه‌ای را شکست می‌دهند. یک نقشهٔ مهندسی قطع E یا یک نقشهٔ بزرگ که یکجا در 400% رندر شود یک تخصیص چندصد مگابایتی است، و هیچ سیاست بیرون‌راندنی آن را پذیرفتنی نمی‌کند. پاسخ آنجا این است که رندر کل صفحه را کنار بگذارید. RenderTile تنها ناحیهٔ واقع در آفست پیکسلی (Left, Top) را درون صفحه‌ای که فرضاً به اندازهٔ PageWidth در PageHeight مقیاس گرفته شطرنجی می‌کند، پس فقط مستطیل دیدنی به‌علاوهٔ یک کاشی حاشیه پیرامونش را برای پیمایش نرم رندر می‌کنید و آفست‌های کاشی را در کنار بزرگ‌نمایی در کلید حافظهٔ نهان می‌گنجانید. ابعاد کاشی را در سراسر سند ثابت نگه دارید. کاشی ثابت یعنی تغییر DPI کل شبکه را تمیز بی‌اعتبار می‌کند، حال آنکه کاشی متغیر شما را به دنبال درزهای دیدنی میان ناحیه‌هایی می‌فرستد که با مقیاس‌های اندکی متفاوت رندر شده‌اند

دو قابلیت همسایه بی‌سروصدا به همهٔ اینها اضافه می‌کنند. گذرهای فیلتر رنگ مانند خاکستری‌سازی یا وارونه‌سازی پس از رندر اجرا می‌شوند و هر بار یک bitmap تمام‌اندازهٔ دوم تولید می‌کنند و ردپای هر صفحه را در هر نمایی که از آنها استفاده کند دو برابر می‌کنند؛ آن هزینه موضوع فیلتر رنگ برای کم‌بینایان در نمایشگرهای PDF با Delphi است. و نمایشگری که در حین تبدیل متن به گفتار واژه‌ها را برجسته می‌کند، نمای رندرشده را با هر واژهٔ گفته‌شده بی‌اعتبار می‌کند، پس برهم‌کنش میان ترسیم دوبارهٔ برجسته‌سازی و سرعت گفتار بیش از آنچه در نگاه اول به نظر می‌رسد اهمیت دارد، چنان‌که در برجسته‌سازی واژه‌به‌واژهٔ TTS شرح داده شده است

overloadهای رندر، کدهای وضعیت تدریجی و خودِ کامپوننت نمایشگر در صفحهٔ محصول PDFium Component مستند شده‌اند