مقال تقني

عارض PDFium في Delphi: تكتيكات Render Cache والتكبير السلس

كانت تذكرة الدعم تقول: "يتجمد العارض لثانيتين كلما لمست منزلق التكبير". كان المستند صك ملكية ممسوحًا من 600 صفحة، والجهاز حاسوبًا محمولًا 4K، وكانت الشيفرة تفعل ما تفعله معظم العوارض الأولى: تعيد تصيير الصفحة المرئية تزامنيًا عند كل حدث change من المنزلق. لم تكن سرعة التصيير نفسها معطلة؛ فالصفحة كانت تتحول إلى raster في نحو 180 ms. المشكلة أن سحبًا واحدًا للمنزلق يطلق عشرات أحداث change، وكل حدث يضع تصييرًا كامل الجودة في الطابور، ولا يمكن إلغاء أي منها. إصلاح هذه الفئة من المشاكل لا يتعلق كثيرًا بجعل التصيير أسرع، بل بتحديد أي عمليات تصيير لا ينبغي إكمالها. يمنح PDFium Component عوارض Delphi وC++Builder وLazarus البدائيات الصحيحة: bitmaps يملكها المستدعي، وprogressive renderer مع cancellation، وfit modes، ويترك سياسة caching لك، وهذا بالضبط مكانها الصحيح.

أين تذهب المللي ثواني عند تغيير التكبير

كن محددًا بشأن التكلفة قبل تصميم cache. صفحة A4 عند 96 DPI تساوي تقريبًا 794 في 1123 pixels، أي نحو 3.5 MB كـ 32-bit bitmap. عند تكبير 200% تصبح أربعة أضعاف ذلك؛ وعند 400% على شاشة high-DPI فأنت تخصص وتملأ bitmap بحجم 50-60 MB لكل صفحة، بينما يحتفظ عارض continuous-scroll بعدة صفحات حية في الوقت نفسه. تكلفة rasterization تتوسع مع output pixels، لذلك فإن مضاعفة التكبير تقربًا تضاعف زمن التصيير أربع مرات مع الذاكرة. تنتج نتيجتان مباشرة: cache يتجاهل مستوى التكبير في المفتاح عديم الفائدة، وcache غير محدود سيستنزف عملية 32-bit في المستندات نفسها التي يضغط فيها المستخدمون التكبير بأقصى قوة، أي المسوح الكثيفة والرسومات كبيرة التنسيق.

مفتاح cache عقد مع الشاشة

لا يجوز إعادة استخدام bitmap مخزن إلا عندما يطابق كل ما أثر في pixels الخاصة به: رقم الصفحة، والتكبير الفعلي أو حجم output pixel، والدوران، وDPI الشاشة، وخيارات التصيير التي كانت مفعلة. الصفحة المصيرة مع reAnnotations ليست الصورة نفسها كصفحة بلا تعليقات، والتصيير الرمادي عبر reGrayscale أثر مختلف أيضًا. اترك أيًا من هذه العناصر خارج المفتاح وسترى الأعراض الكلاسيكية: طبقات تعليقات قديمة بعد إجراء مراجعة، أو صفحة ضبابية بعد أن يسحب المستخدم النافذة إلى شاشة أخرى.

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);   // the cache now owns this bitmap
end;

مسار hit يعود خلال microseconds. السؤال المهم هو ما يحدث للـ bitmaps التي تخسر مكانها، وهذا سؤال عن الملكية.

من يحرر bitmap: التسرب الذي يظهر بعد الظهر

الصيغة الدالية من RenderPage تعيد TBitmap يملكه المستدعي. في export لمرة واحدة يكون ذلك واضحًا؛ داخل cache يصبح أكثر تسرب شائع في عوارض PDF المكتوبة بـ Delphi. في اللحظة التي تدخل فيها bitmap إلى dictionary يحتفظ cache بالإشارة الوحيدة، ويجب على eviction استدعاء Free، فـ TDictionary عادي لن يفعل ذلك نيابة عنك. لا يظهر التسرب في اختبار عشر دقائق؛ يظهر بعد أن يمرر مساعد قانوني صكوكًا لثلاث ساعات، ولهذا تنتمي memory-pressure eviction إلى التصميم الأول لا إلى backlog. ضع حدًا للـ cache حسب estimated bytes، أي width × height × 4، وأخرج صفحات least-recently-used خارج viewport وprefetch window، وحرر ما تخرجه. overloads التي تصير إلى TBitmap يقدمه المستدعي أو مباشرة إلى HDC تتجنب الملكية للرسومات العابرة، وهي مناسبة لـ print preview، حيث لا معنى للتخزين أصلًا.

Progressive rendering وإلغاء صادق

الاستدعاءات التزامنية تحجب التنفيذ حتى تنتهي؛ ولمشكلة سحب المنزلق تريد RenderPageProgressive، الذي يأخذ IPdfCancellationToken ويعيد prsDone أو prsCancelled أو prsFailed. التفصيل السلوكي الحاسم هو أن cancellation يفحص عند حدود chunk داخل التصيير، لا لحظيًا. token يجري signaling له في منتصف chunk ينهي chunk الحالي أولًا، لذلك توقع في صفحة معقدة latency للإلغاء بعشرات المللي ثواني لا صفرًا. صمم لذلك: أرسل signal للـ token القديم فور وصول قيمة تكبير جديدة، لكن لا تفترض أن bitmap القديم يتوقف عن التغير في اللحظة التي تطلب فيها ذلك.

procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandon the previous in-flight render
  FTokenSource := TPdfCancellationTokenSource.New;  // FPdfAsync unit

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

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // superseded by a newer request: drop silently
    prsFailed:    ShowRenderFailure;
  end;
end;

عامل prsCancelled كنتيجة طبيعية ومتكررة أثناء التفاعل، لا كخطأ. queue تصيير يسجل كل cancellation كتحذير سيطمر سطر log الوحيد المهم. اقرن المسار التدريجي بplaceholder رخيص: تكبير bitmap سابق مخزن إلى التكبير الجديد يبدو طريًا 100-200 ms لكنه يبدو فوريًا، وهذا يمنح التصيير كامل الجودة وقتًا لينتهي أو يجري تجاوزه.

Zoom وFitMode: إعادة الضبط الصامتة

خاصية FitMode في العارض، مثل pfmFitPage وpfmFitWidth، تعيد حساب zoom عند كل resize. الفخ هو أن إسناد Zoom مباشرة يعيد FitMode إلى pfmNone. هذا default معقول، فالمستخدم الذي اختار 150% لا يريد أن يلغي resize للنافذة اختياره، لكنه يعض toolbars التي تنفذ أزرار التكبير كـ Zoom := Zoom * 1.25 ثم تتساءل لماذا توقف fit-to-width بهدوء. إذا كانت واجهتك تقدم الاثنين، فاحفظ آخر خيار fit للمستخدم بنفسك واستعده صراحة عندما يضغط زر fit مرة أخرى؛ لا تتوقع من component أن يتذكر mode أسقطه إسناد zoom للتو.

ميزانية ذاكرة يمكن الدفاع عنها

الأرقام تجعل السياسة قابلة للنقاش. افترض أن continuous scroll يحتفظ بالصفحة المرئية إضافة إلى صفحة prefetched في كل اتجاه، ومعها شريط thumbnails. عند 100% على شاشة 96-DPI، هذا ثلاث bitmaps بنحو 3.5 MB لكل منها، أي لا شيء تقريبًا. عند 300% على شاشة 4K، يصبح ذلك ثلاث bitmaps بنحو 30 MB لكل منها قبل أن يحتفظ cache بأي شيء تاريخي. default قابل للدفاع لعملية Delphi 32-bit هو bitmap budget بحجم 256 MB مع LRU eviction؛ أما في 64-bit فوسعها مع physical RAM لكن أبق hard cap، لأن نمط الفشل ليس موت العملية بل أن الجهاز كله يبدأ paging بينما العارض "يعمل". ينبغي تصيير thumbnails مرة واحدة بحجم pixel الصغير الخاص بها وحفظها في pool منفصل لا يجري إخراجه أبدًا: إعادة توليد thumbnail بعرض 120-pixel عبر تصغير bitmap صفحة بحجم 60 MB هي أغلى طريقة يمكن تخيلها لرسم طابع بريد.

بالنسبة إلى الصفحات الفردية الكبيرة جدًا، مثل رسومات الهندسة والخرائط، يتوقف تصيير الصفحة كاملة عند تكبير عالٍ عن كونه قابلًا للحياة مهما كانت الميزانية سخية، لأن ورقة E-size واحدة عند 400% تعني allocation بمئات megabytes. المخرج هو tiling: RenderTile يحول إلى raster المنطقة فقط عند pixel offset (Left, Top) من صفحة scaled إلى PageWidth × PageHeight، لذلك صير المنطقة المرئية فقط مع apron بمقدار tile واحدة حولها، واجعل cache key يتضمن tile offsets هذه إضافة إلى zoom. أبق أبعاد tile ثابتة حتى يسبب تغير DPI invalidation نظيفًا بدل إنتاج seams.

عمل color-filter يضاعف ضغط cache أيضًا: عمليات post-render مثل grayscale أو inversion تنتج bitmaps إضافية بالحجم الكامل، وهي تكلفة تفحصها مقالة ترشيح الألوان لضعف البصر في عوارض PDF لـ Delphi. وإذا كان العارض يبرز الكلمات أثناء text-to-speech، فإن طبقة highlight تعطل العرض عند كل كلمة منطوقة، وكيفية تفاعل ذلك مع speech rate مغطاة في إبراز TTS كلمة بكلمة.

الأسئلة الشائعة

لماذا يسرب عارض PDF في Delphi الذاكرة عند التكبير؟

السبب في الغالب أن TBitmap التي يعيدها RenderPage يجري تخزينها أو التخلص منها بلا Free. المستدعي يملك تلك bitmap؛ وcache الذي يخزنها يجب أن يحررها عند eviction وعند تدمير cache.

لماذا لا يوقف إلغاء التصيير العملية فورًا؟

RenderPageProgressive يجري polling للـ cancellation token عند حدود chunks داخلية. في الصفحات المعقدة يظل token الذي تم signaling له يكمل chunk الحالي، لذلك صمم UI ليتحمل عشرات المللي ثواني من cancellation latency.

لماذا توقف fit-to-width عن العمل بعد ضبط Zoom؟

إسناد Zoom يعيد FitMode إلى pfmNone حسب التصميم. استعد fit mode صراحة عندما يطلبه المستخدم مرة أخرى.

توثيق rendering overloads وprogressive status codes وviewer component موجود في صفحة المنتج: PDFium Component.