مقال تقني

خلل حلقة for في FPC: النقر على إلغاء لا يوقف الطباعة

النقر على إلغاء أثناء مهمة طباعة في عارض مكوّن PDFium لم يفعل شيئًا أحيانًا: استمرت الحلقة في رسم كل صفحة ونسخة متبقية، ووصلت المهمة إلى الطابعة رغم ذلك. كان السبب حلقة for في Free Pascal، التي تثبّت حدها الأعلى مرة واحدة عند الدخول إلى الحلقة، بحيث لا يغيّر تصفير المتغير خلف CopyCount أو ToPage في منتصف الحلقة شيئًا يعمل بالفعل. خلل حدود حلقة for هو خامس مزلق ظهر من تدقيق توافق Delphi/FPC نفسه الخاص بـPDFiumPas الذي أنتج أربعة مزالق أخرى عبر المترجمات، وخلافًا لتلك الأربعة، يعيش هذا كليًا داخل روتين طباعة عارض — احتاج الأمر مختبِرًا يمسك إلغاء طوال مهمة طويلة ليلاحظ فعليًا أن الطابعة لم تتوقف أبدًا

لماذا تستمر مهمة الطباعة بعد النقر على إلغاء؟

استمرت مهمة الطباعة لأن فحص الإلغاء أعاد ضبط المتغيرات التي تغذّي حدود الحلقة فقط، لا الحلقات نفسها، التي كان Free Pascal قد قفلها بالفعل عند بدء كل حلقة. يبني معالج SpeedButtonPrintClick خلف زر الطباعة في عرضي PDFViewer وMultiPageViewer التوضيحيين كل مهمة من ثلاث حلقات متداخلة: حلقة خارجية على مجموعات نسخ مُجمَّعة (collated)، وحلقة وسطى على الصفحات، وحلقة داخلية على نسخ غير مُجمَّعة من الصفحة نفسها. يقرر PrintDialog.Collate أي عداد، CollateCopyCount أو CopyCount، يحمل فعليًا عدد النسخ المطلوب، بينما يبقى الآخر عند واحد. يجب أن يصل الإلغاء عبر المستويات الثلاثة كلها في آن واحد، وحاول الإصدار الأول من هذه الشيفرة فعل ذلك بإعادة ضبط متغيرات الحد نفسها في اللحظة التي أصبح فيها Cancel صحيحًا

for CollateCopy:= 1 to CollateCopyCount do
  for Page:= FromPage to ToPage do
    for Copy:= 1 to CopyCount do
    begin
      // ... render the page and send it to the printer ...
      Application.ProcessMessages;
      if Cancel then
      begin
        CollateCopyCount:= 0;
        ToPage:= 0;
        CopyCount:= 0;
      end;
    end;
Printer.EndDoc;  // runs whether or not Cancel fired

يُقرأ القصد بوضوح كافٍ: إذا انخفضت العدادات التي تحدد عدد التجميعات، والصفحات، والنسخ المتبقية جميعها إلى صفر، يجب أن تنفد الحلقات من العمل وتسقط من تلقاء نفسها. ثم كانت Printer.EndDoc تعمل دون شرط بعد الحلقة بصرف النظر عن كيفية انتهائها، بحيث حتى مهمة اعتقد المستخدم أنه أوقفها كانت لا تزال تُرسَل إلى المُجمِّع (spooler) مع رسم كل صفحة قبل ملاحظة النقرة

ما الذي يقفله Free Pascal عندما تبدأ حلقة for

يُقيّم Free Pascal القيمة النهائية لحلقة for مرة واحدة بالضبط، في لحظة بدء الحلقة، وأبدًا مرة أخرى طوال عمر تلك الحلقة. تقرأ for Page := FromPage to ToPage do قيمة ToPage مرة واحدة لحساب عدد التكرارات التي ستُشغَّل، وبعد ذلك لا تهتم الحلقة أبدًا مرة أخرى بمتغير باسم ToPage، فقط بعدد التكرار الذي التقطته بالفعل. تعيين ToPage := 0 من داخل جسم الحلقة يغيّر متغيرًا لم تعد الحلقة الجارية تستشيره، وهذا بالضبط سبب إمكانية أن يكون Cancel صحيحًا بينما استمرت الطابعة في استقبال صفحات لعدة تكرارات إضافية، أحيانًا كلها

ذلك النمط عادة معقولة تمامًا لنقلها من C أو C++، ويعكس عرض C++Builder التوضيحي لمكوّن PDFium نظيره في Pascal بقرب كافٍ لجعل المقارنة مباشرة. تعيد for (Copy = 1; Copy <= CopyCount; Copy++) الخاصة به اختبار Copy <= CopyCount مقابل القيمة الحية لـCopyCount في كل تمريرة، بحيث يُنهي تصفير العداد هناك الحلقة حقًا عند الفحص التالي. حمل عرض C++Builder التوضيحي شيفرة تصفير-العدادات المتطابقة وعملت، وهذا بالضبط ما جعل الفكرة نفسها تبدو آمنة لإعادة استخدامها في بناء Pascal الجالس مباشرة بجوارها في المستودع نفسه

لماذا لم يصطدم بناء Delphi بالخلل نفسه؟

لم يعتمد عرض PDFViewer التوضيحي لـDelphi قط على ملاحظة حلقة لحد متغيّر، لأن مسار إلغائه يتفكك عبر استثناء بدلًا من مقارنة. تستدعي حلقته الأعمق إجراء Abort الخاص بـRTL في اللحظة التي يصبح فيها Cancel صحيحًا، ما يُطلق EAbort صامتًا ينتشر مباشرة عبر الحلقات الثلاث المتداخلة إلى معالج ملفوف حول كتلة الطباعة بأكملها

Printer.BeginDoc;
try
  for CollateCopy:= 1 to CollateCopyCount do
    for Page:= FromPage to ToPage do
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Abort;  // raises EAbort, unwinds all three loops at once
      end;
  Printer.EndDoc;
except
  on E: EAbort do
    Printer.Abort;
else
  begin
    Printer.Abort;
    raise;
  end;
end;

لا يهتم الاستثناء بعدد حلقات for التي تفصل النقطة التي أُطلق فيها عن المعالج الذي يلتقطه، وهذه بالضبط الخاصية التي تحتاجها هذه المشكلة. لم تكن تلك المتانة دفاعًا متعمدًا ضد سلوك تثبيت الحدود الموصوف أعلاه — لجأ مؤلف عرض Delphi التوضيحي ببساطة إلى أداة مختلفة. لا يزال الهروب المبني على الاستثناء يستحق التسمية كنمط أمتن: فهو ينجو من إضافة مستوى تداخل رابع لاحقًا، بينما يجب تذكّر سلسلة من عبارات Break الموضوعة يدويًا وإعادة إضافتها في كل مرة يتغير فيها تداخل الحلقة

الحل: Break عند كل مستوى تداخل، مُقيَّد بعلم PrintSucceeded

يحافظ الإصلاح الذي شُحن في PDFiumPas v2.27.0 على بنية الحلقات الثلاث في عروض Lazarus وC++Builder التوضيحية لكنه يجعل الإلغاء صريحًا عند كل مستوى، ويفصل توقف الحلقة عن إرسال المهمة إلى علم لا يُقرَأ إلا بعد انتهاء الحلقة تمامًا

Printer.BeginDoc;
try
  Cancel:= False;
  for CollateCopy:= 1 to CollateCopyCount do
  begin
    for Page:= FromPage to ToPage do
    begin
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Break;
      end;
      if Cancel then
        Break;
    end;
    if Cancel then
      Break;
  end;
  PrintSucceeded:= not Cancel;
finally
  if PrintSucceeded then
    Printer.EndDoc
  else
    Printer.Abort;
end;

تُحسَب PrintSucceeded عمدًا مرة واحدة، فورًا بعد خروج الحلقة الثلاثية التداخل، من لا شيء أكثر من not Cancel. لا شيء داخل جسم الحلقة يحصل على قرار بمفرده حول ما إذا نجحت المهمة — يمكن للحلقة أن تنتهي بطريقتين فقط، نفاد التجميعات والصفحات والنسخ، أو الاصطدام بسلسلة Break التي يُفعّلها Cancel، وتقرأ PrintSucceeded النتيجة بعد وقوعها بدلًا من تتبعها أثناء سير الحلقة. حسابها بهذه الطريقة هو ما يجعل اختيار كتلة finally بين Printer.EndDoc وPrinter.Abort جديرًا بالثقة: فهو لا يُفعَّل أبدًا قبل أن تستقر الحلقة فعليًا

تدقيق حلقات الطباعة المدفوعة بالإلغاء الخاصة بك

ثلاثة فحوصات تنتقل جيدًا إلى ما هو أبعد من روتين الطباعة هذا. لا تفترض أبدًا أن حلقة for في Pascal ستلاحظ تغيّر متغير حد بعد بدئها؛ إذا احتاجت حلقة الانتهاء مبكرًا، قل ذلك مباشرة بـBreak، عند كل مستوى تداخل يحتاج الإلغاء عبوره، لا الأعمق فقط. فضّل آلية هروب تتفكك بالبنية، مثل استثناء، بمجرد أن يكون التداخل عميقًا بما يكفي لجعل فقدان Break معقولًا — حصل زوج Abort/EAbort في عرض Delphi التوضيحي على هذا مجانًا. قيّد أي خطوة إلزام مثل EndDoc خلف علم يُحسَب بصرامة بعد الحلقة، لا داخلها أبدًا، بحيث لا يمكن أبدًا الخلط بين مهمة توقفت مبكرًا وأخرى انتهت

المراجعة نفسها التي أصلحت هذه الحلقة شدّدت أيضًا كيفية معالجة العروض التوضيحية التسعة للعارض للوحة المفاتيح بينما يكون زر إلغاء مرئيًا: تبتلع الآن كل مفتاح باستثناء Esc، بحيث لم يعد بإمكان Ctrl+P أو Ctrl+F شارد أثناء طباعة أو بحث نشط بدء عملية ثانية فوقها. إصلاح إعادة الدخول هذا خلل مختلف بآلية مختلفة، لكنه نتج عن المراجعة نفسها لما تفعله Cancel فعليًا في منتصف عملية. العادة التي تستحق الاحتفاظ بها من كلا الإصلاحين هي عادة اختبار أكثر منها عادة برمجة: مارس الإلغاء على كل مترجم يُشحن إليه روتين طباعة مشترك فعليًا، لأن فكرة مثل مسح العدادات والثقة بأن الحلقة ستلاحظ يمكن أن تنجو من الاختبار تحت C++Builder، وتصل إلى بناء Free Pascal غير مُتحقَّق منه، وتُقرأ متطابقة في المصدر، وتفشل بطريقة لا يراها أحد حتى يمسك أحدهم إلغاء وقتًا كافيًا ليشاهد المهمة تنتهي رغم ذلك. لإعداد الطباعة الذي يقف فوقه منطق الإلغاء هذا، تغطي شرح طباعة مستندات PDF عبر مكوّن PDFium VCL البقية

شُحن إصلاح إلغاء الطباعة هذا كجزء من مكوّن PDFium لـDelphi وC++Builder وFPC/Lazarus، الذي يشغّل مجموعة العروض التوضيحية نفسها عبر المترجمات الثلاثة جميعها في كل إصدار بحيث تُلتقط فجوات كهذه بمصفوفة بناء بدلًا من تذكرة دعم