يتم رسم (rasterise) معظم صفحات PDF نقطياً في بضع أجزاء من الألف من الثانية ولا تفكر فيها أبداً. ثم يفتح المستخدم رسمة هندسية A1، وهي صفحة مليئة بعشرات الآلاف من الخطوط المتجهة (vector strokes)، أو ملصقاً مزدحماً بمجموعات الشفافية والأقنعة الناعمة، والاستدعاء الفردي الذي يرسمها يستغرق ثانيتين أو ثلاث ثوان. إذا تم تشغيل هذا الاستدعاء على خيط واجهة المستخدم (UI thread)، تتوقف النافذة عن إعادة الرسم، ويصبح شريط العنوان رمادياً، ويعرض نظام التشغيل إيقاف التطبيق. العمل شرعي. الصفحة تحتاج حقاً إلى هذا الوقت الطويل. العيب هو أن العرض هو استدعاء حظر واحد غير قابل للتجزئة (indivisible blocking call) بدون طريقة للتنفس ولا طريقة للتوقف
يدور هذا المقال حول نصف هاتين المشكلتين بالضبط: إلغاء عرض طويل لصفحة واحدة دون تجميد واجهة المستخدم. قام المستخدم بالنقر فوق الصفحة التالية، أو تكبيرها (zoomed)، أو إغلاق المستند، والعرض قيد التنفيذ الآن هو عمل ضائع يجب أن ينتهي في الفرصة التالية بدلاً من الاستمرار حتى الاكتمال. إن سلاسة التمرير والتكبير من خلال التخزين المؤقت لما تم رسمه بالفعل هو مصدر قلق منفصل بتصميمه الخاص، تمت تغطيته في المقال المرافق المرفق رابطه في النهاية. السؤال الوحيد هنا هو كيفية جعل عرض تدريجي واحد يستجيب لطلب الإلغاء بسرعة وبشكل نظيف
واجهة برمجة تطبيقات العرض التدريجي التي يشحنها PDFium بالفعل
توقع PDFium نصف المشكلة المتعلق بالتجميد. إلى جانب الاستدعاء من لقطة واحدة FPDF_RenderPageBitmap، فإنه يعرض متغيراً تدريجياً (progressive variant) يقسم الصفحة إلى أجزاء من العمل. تقوم باستدعاء FPDF_RenderPageBitmap_Start مرة واحدة لإعداد العرض مقابل صورة نقطية للوجهة (destination bitmap)، ثم تقوم باستدعاء FPDF_RenderPage_Continue بشكل متكرر. يرسم كل Continue شريحة محددة (bounded slice) نقطياً ويرجع حالة (status). FPDF_RENDER_TOBECONTINUED تعني أن هناك المزيد للقيام به، و FPDF_RENDER_DONE تعني أن الصفحة انتهت، و FPDF_RENDER_FAILED تعني أنها توقفت بسبب خطأ. عندما تنتهي الحلقة (loop) تستدعي FPDF_RenderPage_Close لتحرير الحالة التدريجية لكل صفحة. نظراً لأن التحكم يعود إلى التعليمات البرمجية الخاصة بك بين الشرائح، يمكنك ضخ الرسائل، أو تحديث مؤشر تقدم، أو التحقق مما إذا كان العمل لا يزال مطلوباً
الآلية التي يوفرها PDFium لتحديد موعد التوقف المؤقت (yield) هي بنية معاودة اتصال (callback struct) تسمى IFSDK_PAUSE. تسلمها إلى Start وإلى كل Continue. بعد كل جزء (chunk)، يستدعي PDFium مؤشر الدالة NeedToPauseNow الخاص به، وإذا أرجع قيمة غير صفرية، فإن Continue الحالي يتوقف مبكراً ويعيد التحكم مع FPDF_RENDER_TOBECONTINUED. تحمل البنية أيضاً حقل version، والذي يجب تعيينه على 1، ومؤشر user حر التنسيق (free-form) والذي لا يلمسه PDFium أبداً ويمرره دون مساس. هذا المؤشر الذي لم يمس هو المفصل الكامل للتصميم الذي يتبع
إعادة توظيف الإيقاف المؤقت كإلغاء
القصد الأصلي من NeedToPauseNow هو تقسيم الوقت (time-slicing). أرجع قيمة غير صفرية عند استنفاد ميزانية إطارك، أرجع صفراً لمواصلة العرض، ويتوقف PDFium مؤقتاً حتى تتمكن من القيام بشيء آخر قبل استئناف نفس العرض. يعيد مكون PDFium Component استخدام نفس الإشارة لفعل مختلف. بدلاً من الإجابة على سؤال "هل يجب أن أتوقف مؤقتاً وأدعك تستأنف"، فإن معاودة الاتصال تجيب على "هل تم إلغاء هذا العمل". يتم تعيين الاثنين على بعضهما البعض بشكل نظيف بسبب ما تفعله الحلقة عندما ترى العلامة. يتوقع التوقف المؤقت الحقيقي Continue لاحقاً؛ أما الإلغاء فلا يتوقع ذلك. بمجرد أن تلاحظ حلقة الاستدعاء (calling loop) أن الرمز (token) قد تم إلغاؤه، فإنها تغلق سياق العرض ولا تستدعي Continue أبداً مرة أخرى، لذلك فإن نفس القيمة المرجعة غير الصفرية التي يقرأها PDFium على أنها "أوقف هذا الجزء" تصبح، في الواقع، "توقف للأبد"
يتم التعبير عن الإلغاء من خلال واجهة (interface)، IPdfCancellationToken، والتي تنقلب خاصية IsCancelled الخاصة بها من خطأ إلى صواب عندما يطلب جزء آخر من البرنامج إيقاف العرض. الجسر بين واجهة Pascal هذه ومعاودة اتصال C الخاصة بـ PDFium هو مؤشر (pointer) واحد. تتم كتابة مرجع واجهة الرمز (token's interface reference) في IFSDK_PAUSE.user، وتقوم معاودة اتصال ثابتة (static) cdecl بقراءتها مرة أخرى والاستعلام عنها. هذه هي المشكلة الكلاسيكية للسماح لمكتبة C بالاتصال مرة أخرى (call back) بـ Pascal: يجب أن تكون معاودة الاتصال دالة (function) عادية مع اصطلاح استدعاء (calling convention) 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;
تستعيد معاودة الاتصال الرمز (token) عن طريق صب (casting) pThis^.user مرة أخرى إلى نوع الواجهة (interface type) وتقرأ IsCancelled. لا يوجد أي شيء فيها يقوم بالتخصيص (allocates) أو القفل (locks) أو الحظر (blocks)، وهذا أمر مهم لأن PDFium يستدعيها على خيط العرض (rendering thread) بعد كل جزء وأي عمل يتم هنا يُضاف إلى تكلفة العرض نفسه. إن الحماية (guard) ضد بنية (struct) فارغة (nil) أو حقل user فارغ تعني أن نفس الدالة آمنة للتثبيت حتى على عرض لم يُعطَ أبداً رمزاً حقيقياً
إبقاء الرمز حياً عبر الحلقة
صب مؤشر واجهة (interface pointer) من خلال Pointer خام والعودة مرة أخرى هو المكان الذي تولد فيه أخطاء العمر (lifetime bugs). إن IInterface في Delphi هو كائن يُحسب مرجعه (reference counted)، والعدد لا يتحرك إلا عندما يتمكن المترجم من رؤية تعيين متغير مكتوب كواجهة (interface-typed variable). تخزين الرمز فقط كمؤشر مجرد (bare pointer) داخل IFSDK_PAUSE.user من شأنه أن يخفيه عن عداد المراجع (reference counter) تماماً. إذا خرج المرجع الآخر الوحيد إلى هذا الرمز عن النطاق (went out of scope) بينما كانت حلقة Continue لا تزال تعمل، فسيتم تحرير (freed) الكائن أسفل معاودة الاتصال، وسيقوم الجزء التالي بإلغاء مرجع (dereference) مؤشر متدلٍ (dangling pointer)
وهذا هو السبب في أن الواصف (descriptor) عبارة عن سجل (record) يحتفظ بشيئين، وليس شيئاً واحداً. حقل Pause هو البنية (struct) التي يقرأها PDFium. حقل Token هو مرجع حقيقي مكتوب كواجهة (interface-typed reference) يحسبه المترجم، وهو موجود لسبب وحيد هو تثبيت (pin) الرمز في الذاكرة طالما كان السجل حياً. السجل عبارة عن متغير محلي (local variable) في مكدس (stack) روتين العرض (render routine)، لذلك يظل صالحاً طوال مدة الحلقة بأكملها ويتم هدمه فقط عندما يخرج الروتين (exits). المؤشر المجرد في user والمرجع المحسوب (counted reference) في Token يسميان نفس الكائن؛ أحدهما هو ما يمكن لـ PDFium قراءته، والآخر هو ما يمنع تجميع (collected) ذلك الكائن
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 يخصص (allocates) حالة تدريجية (progressive state) يربطها PDFium بالصفحة، ويتم تحرير (released) هذه الحالة فقط عن طريق FPDF_RenderPage_Close. توجد ثلاث طرق للخروج من حلقة المحرك (drive loop). تنتهي الصفحة وتكون الحالة الأخيرة هي FPDF_RENDER_DONE. يتعثر الرمز وتخرج الحلقة مبكراً وتبلغ عن الإلغاء. يفشل شيء ما وتكون الحالة هي FPDF_RENDER_FAILED. يجب أن تستدعي الثلاثة جميعاً Close، ومسار الإلغاء هو الأسهل لارتكاب خطأ، لأن الشكل الطبيعي لـ "رؤية إلغاء، خروج (break out)" يميل إلى تخطي التنظيف في طريقه إلى الخروج. إن ترك Close دون الوصول إليه يسرب (leaks) الحالة لكل صفحة، والعارض (viewer) الذي يسمح للمستخدم بإلغاء العرض بعد العرض من شأنه أن يراكم هذا التسرب في كل صفحة مجهضة
يضع الشكل القوي (robust shape) الحلقة وتصنيف النتيجة داخل try و FPDF_RenderPage_Close في finally المطابق. يتم إتلاف صورة الوجهة النقطية في نفس الكتلة. يمكن أن يترك الإلغاء الحلقة من خلال Exit مبكر ولا يزال finally قيد التشغيل، لذلك يوجد مكان واحد بالضبط يحرر (frees) الحالة التدريجية ولا يمكن تجاوزه
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;
تتحقق الحلقة من الرمز (token) قبل كل Continue بالإضافة إلى الاعتماد على معاودة الاتصال داخلها. تقوم معاودة الاتصال بتقصير الجزء الحالي؛ يوقف فحص الحلقة بدء الجزء التالي. يقومان معاً بتحديد طول الوقت الذي يستغرقه الإلغاء ليدخل حيز التنفيذ ليكون مساوياً تقريباً لمدة جزء واحد
ثلاث نتائج، وما تحمله الصورة النقطية بعد الإلغاء
نقطة الدخول العامة (public entry point) هي TPdf.RenderPageProgressive، وهي تُرجع TPdfProgressiveStatus والذي يكون أحد prsDone، prsCancelled، أو prsFailed. تعكس (mirror) القيم ثوابت FPDF_RENDER_* الخاصة بـ PDFium في اصطلاح Pascal ولكنها تطوي (fold) حالة الإلغاء كنتيجة من الدرجة الأولى بدلاً من كونها خطأ
النقطة التي تصطاد الناس هي ما تحتويه صورة الوجهة النقطية بعد prsCancelled. إنها ليست فارغة. يتم تقديم PDFium بشكل تدريجي في نفس الصورة النقطية جزءاً تلو الآخر، لذلك عندما يوقف الإلغاء الحلقة، فإن الصورة النقطية تحتفظ بأي شيء تم رسمه حتى تلك اللحظة، وهي صورة جزئية: تم الانتهاء من بعض النطاقات (bands)، ويستمر الباقي في إظهار لون التعبئة (fill colour). ما إذا كانت هذه النتيجة الجزئية مفيدة يعتمد على المتصل. يمكن لعارض (viewer) على وشك التخلص من الصورة النقطية لأن المستخدم انتقل إلى مكان آخر أن يتجاهلها ببساطة. يمكن لعارض يريد إظهار معاينة منخفضة التكلفة الاحتفاظ بها. ما لا يجب عليك فعله هو افتراض أن prsCancelled تعني صورة نقطية فارغة أو غير محددة (undefined)؛ إنها تعني لقطة (snapshot) صادقة لعرض غير مكتمل
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 token) ومسار معاودة اتصال خالي من التفرع (branch-free)
الإلغاء هو اشتراك (opt-in). يجب أن يكون المتصل (caller) الذي يريد فقط عرضاً تدريجياً للاستفادة من ضخ الرسائل (message-pumping)، دون أي نية للإجهاض، قادراً على تمرير nil للرمز. إن الطريقة الساذجة (naive way) لدعم ذلك هي نثر فحوصات "إذا تم توفير رمز" خلال معاودة الاتصال والحلقة، مما يعني فرعاً (branch) في كل جزء ومعاودة اتصال يجب أن تعالج كلاً من الرمز الحقيقي وغيابه
يتجنب التنفيذ ذلك عن طريق استبدال مفرد (singleton) عندما لا يمرر المتصل أي شيء. يتم تبديل رمز nil بـ PdfNoCancellationToken، وهي واجهة يكون فيها IsCancelled دائماً خطأ. من تلك النقطة، يكون لدى معاودة الاتصال والحلقة رمز للاستعلام عنه في كل حالة، لذلك لا يحتاج أي منهما إلى فحص (nil check) ولا يحتاج أي منهما إلى مسار خاص. الرمز الذي لا يُلغى أبداً يجيب ببساطة دائماً بـ "خطأ"، وتُرجع معاودة الاتصال الصفر دائماً، ويستمر العرض حتى يكتمل تماماً كما يفعل العرض غير القابل للإلغاء. يتم تمثيل السلوك الاختياري كرمز لا يُطلق أبداً (never fires) بدلاً من تمثيله بغياب رمز، مما يحافظ على المسار الساخن (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 التي تدعم معاودة الاتصال قناة واحدة بالضبط لتمرير الحالة (state) إلى معاودة الاتصال تلك، وهو مؤشر المستخدم (user pointer) المعتم. ضع مرجع واجهة Pascal المحسوب (counted Pascal interface reference) خلف ذلك المؤشر، واحتفظ بمرجع حقيقي ثانٍ (real reference) حياً بجوار البنية (struct) حتى لا يمكن تجميع (collected) الكائن في منتصف الاستدعاء (mid-call)، واقرأ الواجهة مرة أخرى داخل دالة cdecl ثابتة (static). غلف حلقة المحرك (drive loop) بالكامل في try وحرر السياق الأصلي (native context) في finally. يحمل نفس القالب (template) إلى أي عملية PDFium تدريجية أو مدفوعة بمعاودة الاتصال حيث يجب أن تظل تعليمات Pascal البرمجية مسيطرة على العمر الافتراضي (lifetime) بينما يمسك C بمؤشر
الإلغاء هو نصف واحد فقط من العارض المستجيب (responsive viewer). النصف الآخر هو عدم إعادة تقديم (re-rendering) الصفحات التي قمت برسمها بالفعل، والحفاظ على سلاسة التكبير والتمرير (zoom and scroll) من خلال عرض الصور النقطية المخبأة (cached bitmaps)، وهو ما تمت تغطيته في مقالنا حول التخزين المؤقت للعرض وأداء التكبير. لمعرفة كيف يتناسب العرض القابل للإلغاء مع عارض كامل إلى جانب التنقل (navigation)، والتحديد (selection)، والبحث (search)، راجع بناء عارض PDF غني بالميزات مع مكون PDFium VCL. يتم شحن العرض التدريجي الموضح هنا كجزء من PDFium Component لـ Delphi و Lazarus إلى جانب واجهات برمجة تطبيقات التحميل، والعرض، والنموذج (form APIs) التي تمت تغطيتها في مكان آخر في هذه المدونة