مقال تقني

العرض التدريجي القابل للإلغاء لملفات PDF في Delphi (PDFium)

تتم عملية تنقيط (rasterise) معظم صفحات PDF في بضع أجزاء من الثانية ولا تفكر فيها أبداً. ثم يفتح المستخدم رسماً هندسياً بحجم A1، أو صفحة مليئة بعشرات الآلاف من الخطوط المتجهة (vector strokes)، أو ملصقاً مزدحماً بمجموعات الشفافية والأقنعة الناعمة (soft masks)، وتستغرق المكالمة الوحيدة التي ترسمها ثانيتين أو ثلاث ثوانٍ. إذا تم تشغيل تلك المكالمة على سلسلة واجهة المستخدم (UI thread)، تتوقف النافذة عن إعادة الرسم، ويتحول شريط العنوان إلى اللون الرمادي، ويعرض نظام التشغيل إنهاء التطبيق. العمل شرعي. فالصفحة تحتاج حقاً إلى كل هذا الوقت. الخلل هو أن عملية العرض عبارة عن مكالمة واحدة غير قابلة للتجزئة ومحظورة ولا توجد طريقة لالتقاط الأنفاس أو التوقف

يتناول هذا المقال بالضبط إحدى هاتين المشكلتين: إلغاء عرض طويل لصفحة واحدة دون تجميد واجهة المستخدم. نقر المستخدم على الصفحة التالية، أو قام بالتكبير، أو أغلق المستند، وأصبح العرض الجاري الآن عملاً ضائعاً يجب أن ينتهي في أقرب فرصة بدلاً من الاستمرار حتى النهاية. يُعد تسهيل التمرير والتكبير/التصغير عن طريق التخزين المؤقت لما تم تنقيطه بالفعل أمراً منفصلاً بتصميمه الخاص، والذي يتم تناوله في المقال المرافق المرتبط في النهاية. السؤال الوحيد هنا هو كيفية جعل عرض تدريجي واحد يستجيب لطلب الإلغاء بسرعة وبشكل نظيف

واجهة برمجة تطبيقات العرض التدريجي التي توفرها PDFium بالفعل

توقعت PDFium مشكلة التجميد. إلى جانب الدالة FPDF_RenderPageBitmap التي تعمل بضربة واحدة، فإنها تعرض متغيراً تدريجياً يقسم الصفحة إلى أجزاء من العمل. يمكنك استدعاء FPDF_RenderPageBitmap_Start مرة واحدة لإعداد العرض مقابل صورة نقطية الوجهة، ثم استدعاء FPDF_RenderPage_Continue بشكل متكرر. تقوم كل دالة Continue بتنقيط شريحة محددة وإرجاع حالة. تعني FPDF_RENDER_TOBECONTINUED أن هناك المزيد للقيام به، وتعني FPDF_RENDER_DONE أن الصفحة قد انتهت، وتعني FPDF_RENDER_FAILED أنها توقفت بسبب خطأ. عندما تنتهي الحلقة، تستدعي FPDF_RenderPage_Close لتحرير الحالة التدريجية لكل صفحة. نظراً لعودة التحكم إلى التعليمات البرمجية الخاصة بك بين الشرائح، يمكنك ضخ الرسائل، أو تحديث مؤشر التقدم، أو التحقق مما إذا كان العمل لا يزال مطلوباً

الآلية التي توفرها PDFium لتحديد متى يجب التوقف هي بنية استدعاء (callback struct) تسمى IFSDK_PAUSE. تقوم بتمريرها إلى Start وإلى كل Continue. بعد كل جزء، تستدعي PDFium مؤشر الدالة NeedToPauseNow الخاص بها، وإذا أرجع قيمة غير صفرية، فإن دالة Continue الحالية تتوقف مبكراً وتعيد التحكم مع FPDF_RENDER_TOBECONTINUED. تحمل البنية أيضاً حقل version، والذي يجب تعيينه إلى 1، ومؤشر user حر التنسيق لا تلمسه PDFium أبداً ويمر دون تغيير. هذا المؤشر غير الملموس هو المحور الأساسي للتصميم الذي يتبع

إعادة توظيف الإيقاف المؤقت كإلغاء

القصد الأصلي من NeedToPauseNow هو تقسيم الوقت (time-slicing). يمكنك إرجاع قيمة غير صفرية عندما تنفد ميزانية الإطار الخاص بك، وإرجاع صفر لمواصلة العرض، وستتوقف PDFium مؤقتاً حتى تتمكن من القيام بشيء آخر قبل استئناف العرض نفسه. يعيد مكون PDFium استخدام نفس الإشارة لفعل مختلف. فبدلاً من الإجابة على "هل يجب أن أتوقف مؤقتاً وأدعك تستأنف"، يجيب الاستدعاء على "هل تم إلغاء هذا العمل". يتطابق الاثنان مع بعضهما البعض بشكل نظيف بسبب ما تفعله الحلقة عندما ترى العلامة. يتوقع التوقف المؤقت الحقيقي استدعاء Continue لاحقاً؛ أما الإلغاء فلا يتوقع ذلك. بمجرد أن تلاحظ حلقة الاستدعاء أنه تم إلغاء الرمز (token)، فإنها تغلق سياق العرض ولا تستدعي Continue مرة أخرى أبداً، وبالتالي فإن نفس العائد غير الصفري الذي تقرأه PDFium كـ "أوقف هذا الجزء" يصبح، في الواقع، "توقف نهائياً"

يتم التعبير عن الإلغاء من خلال واجهة (interface)، IPdfCancellationToken، والتي تنقلب خاصية IsCancelled الخاصة بها من خطأ إلى صواب عندما يطلب جزء آخر من البرنامج إيقاف العرض. الجسر بين واجهة Pascal واستدعاء C الخاص بـ PDFium هو مؤشر واحد. يتم كتابة مرجع واجهة الرمز في IFSDK_PAUSE.user، ويقوم استدعاء cdecl ثابت بقراءته مرة أخرى والاستعلام عنه. هذه هي المشكلة الكلاسيكية للسماح لمكتبة C بالاستدعاء مرة أخرى في Pascal: يجب أن يكون الاستدعاء دالة عادية مع اصطلاح استدعاء C، وليس طريقة (method)، لأن PDFium تقوم بتخزين واستدعاء مؤشر دالة مجردة لا يعرف شيئاً عن كائنات Pascal أو 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;

يقوم الاستدعاء باستعادة الرمز عن طريق تحويل (casting) pThis^.user مرة أخرى إلى نوع الواجهة وقراءة IsCancelled. لا شيء فيه يقوم بالتخصيص، أو القفل، أو الحظر، وهو أمر مهم لأن PDFium تستدعيه على سلسلة العرض بعد كل جزء، وأي عمل يتم هنا يضاف إلى تكلفة العرض نفسه. تعني الحماية ضد بنية فارغة (nil struct) أو حقل user فارغ أن نفس الدالة آمنة للتثبيت حتى في عملية عرض لم يتم إعطاؤها رمزاً حقيقياً أبداً

إبقاء الرمز حياً عبر الحلقة

تحويل مؤشر واجهة من خلال Pointer خام والعودة هو المكان الذي تولد فيه أخطاء مدة الحياة. واجهة IInterface في Delphi تُحسب بمرجع، ولا يتحرك العدد إلا عندما يتمكن المترجم من رؤية تعيين متغير مكتوب كواجهة. سيؤدي تخزين الرمز فقط كمؤشر مجرد داخل IFSDK_PAUSE.user إلى إخفائه عن عداد المراجع تماماً. إذا خرج المرجع الآخر الوحيد لهذا الرمز عن النطاق بينما لا تزال حلقة Continue قيد التشغيل، فسيتم تحرير الكائن أسفل الاستدعاء، وسيقوم الجزء التالي بإلغاء مرجعية مؤشر متدلٍ (dangling pointer)

لهذا السبب، فإن الواصف عبارة عن سجل (record) يحمل شيئين، وليس شيئاً واحداً. حقل Pause هو البنية التي تقرأها PDFium. حقل Token هو مرجع واجهة حقيقي يحسبه المترجم، وهو موجود لسبب وحيد هو تثبيت الرمز في الذاكرة طوال فترة بقاء السجل. السجل عبارة عن متغير محلي على مكدس (stack) روتين العرض، لذا يظل صالحاً طوال مدة الحلقة بالكامل ويتم تفكيكه فقط عند خروج الروتين. يشير المؤشر المجرد في user والمرجع المحسوب في Token إلى نفس الكائن؛ أحدهما هو ما يمكن لـ PDFium قراءته، والآخر هو ما يمنع هذا الكائن من الجمع (collection)

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);

إغلاق سياق العرض بغض النظر عن كيفية انتهاء الحلقة

تقوم كل مكالمة لـ FPDF_RenderPageBitmap_Start بتخصيص حالة تدريجية تربطها PDFium بالصفحة، ويتم تحرير هذه الحالة فقط بواسطة FPDF_RenderPage_Close. هناك ثلاث طرق للخروج من حلقة المحرك. تنتهي الصفحة وتكون الحالة الأخيرة هي FPDF_RENDER_DONE. أو يتعثر الرمز وتخرج الحلقة مبكراً للإبلاغ عن الإلغاء. أو يفشل شيء ما وتكون الحالة FPDF_RENDER_FAILED. يجب أن تستدعي جميعها الثلاثة الدالة Close، ومسار الإلغاء هو الأسهل في ارتكاب الأخطاء، لأن الشكل الطبيعي لـ "رؤية الإلغاء، والخروج" يميل إلى تخطي التنظيف في طريقه إلى المخرج. ترك Close دون الوصول إليها يؤدي إلى تسرب الحالة الخاصة بكل صفحة، والعارض الذي يتيح للمستخدم إلغاء العرض تلو الآخر سيؤدي إلى تراكم هذا التسرب في كل صفحة يتم إجهاضها

يضع الشكل القوي الحلقة وتصنيف النتيجة داخل 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 بالإضافة إلى الاعتماد على الاستدعاء بداخله. يختصر الاستدعاء الجزء الحالي؛ ويوقف فحص الحلقة الجزء التالي من البدء. معاً يحددان المدة التي يستغرقها الإلغاء ليصبح ساري المفعول إلى مدة جزء واحد تقريباً

ثلاث نتائج، وما تحتويه الصورة النقطية بعد الإلغاء

نقطة الدخول العامة هي TPdf.RenderPageProgressive، وهي ترجع حالة TPdfProgressiveStatus التي تكون إما prsDone، أو prsCancelled، أو prsFailed. تعكس القيم ثوابت FPDF_RENDER_* الخاصة بـ PDFium في لغة Pascal ولكنها تدمج حالة الإلغاء كنتيجة من الدرجة الأولى بدلاً من كونها خطأ

النقطة التي تلفت انتباه الناس هي ما تحتويه الصورة النقطية الوجهة بعد prsCancelled. إنها ليست فارغة. تقوم PDFium بالعرض بشكل تدريجي في نفس الصورة النقطية جزءاً تلو الآخر، لذلك عندما يوقف الإلغاء الحلقة، تحتفظ الصورة النقطية بكل ما تم رسمه حتى تلك اللحظة، وهي صورة جزئية: تم إنجاز بعض النطاقات (bands)، والباقي لا يزال يظهر لون التعبئة. يعتمد ما إذا كانت هذه النتيجة الجزئية مفيدة أم لا على المتصل. العارض الذي على وشك التخلص من الصورة النقطية لأن المستخدم انتقل إلى مكان آخر يمكنه ببساطة تجاهلها. والعارض الذي يريد إظهار معاينة منخفضة التكلفة يمكنه الاحتفاظ بها. ما يجب ألا تفعله هو افتراض أن 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;

الرمز الفارغ (nil) ومسار الاستدعاء الخالي من التفرع

الإلغاء هو خيار (opt-in). يجب أن يكون المتصل الذي يريد فقط عرضاً تدريجياً لغرض ضخ الرسائل، دون نية للإجهاض، قادراً على تمرير nil للرمز. الطريقة الساذجة لدعم ذلك هي نثر فحوصات "إذا تم توفير رمز" خلال الاستدعاء والحلقة، مما يعني وجود تفرع في كل جزء واستدعاء يجب أن يتعامل مع كل من الرمز الحقيقي وغيابه

يتجنب التنفيذ ذلك عن طريق استبدال كائن مفرد (singleton) عندما لا يمرر المتصل شيئاً. يتم تبديل رمز nil بـ PdfNoCancellationToken، وهي واجهة تكون فيها قيمة IsCancelled دائماً خطأ. من تلك النقطة فصاعداً، يكون للاستدعاء والحلقة رمز للاستعلام عنه في كل حالة، لذلك لا يحتاج أي منهما إلى فحص القيمة الفارغة (nil check) ولا يحتاج أي منهما إلى مسار خاص. يجيب الرمز الذي لا يلغي أبداً دائماً بخطأ، ويعود الاستدعاء دائماً بصفر، ويتم العرض حتى النهاية تماماً كما لو كان غير قابل للإلغاء. يتم تصميم السلوك الاختياري كرمز لا ينطلق أبداً بدلاً من غياب الرمز، مما يحافظ على اتساق المسار الساخن (hot path)

// 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 التي تدعم استدعاء قناة واحدة بالضبط لتمرير الحالة إلى هذا الاستدعاء، وهو مؤشر المستخدم غير الشفاف (opaque). ضع مرجع واجهة Pascal المحسوب خلف ذلك المؤشر، واحتفظ بمرجع حقيقي ثانٍ حياً بجوار البنية حتى لا يتم جمع الكائن في منتصف المكالمة، واقرأ الواجهة مرة أخرى داخل دالة cdecl ثابتة. قم بلف حلقة المحرك بالكامل في كتلة try وحرر السياق الأصلي في كتلة finally. ينتقل نفس القالب إلى أي عملية عرض تدريجية أو مدفوعة بالاستدعاء في PDFium حيث يجب أن تظل تعليمات Pascal البرمجية مسيطرة على فترة الصلاحية بينما يحمل C مؤشراً

الإلغاء هو نصف واحد فقط من العارض المستجيب. النصف الآخر هو عدم إعادة عرض الصفحات التي رسمتها بالفعل، والحفاظ على سلاسة التكبير والتصغير والتمرير من خلال عرض صور نقطية مخزنة مؤقتاً، وهو ما تمت تغطيته في مقالنا حول التخزين المؤقت للعرض وأداء التكبير والتصغير. لمعرفة كيف يتناسب العرض القابل للإلغاء مع عارض كامل إلى جانب التنقل، والتحديد، والبحث، راجع بناء عارض PDF غني بالميزات باستخدام مكون PDFium. يتم شحن العرض التدريجي الموصوف هنا كجزء من مكون PDFium لكل من Delphi وLazarus إلى جانب واجهات برمجة تطبيقات التحميل، والعرض، والنماذج المغطاة في مكان آخر في هذه المدونة