مقال تقني

إعادة استخدام مثيل THotPDF عبر المستندات في Delphi

يقرأ الخطأ Please load the document before using BeginDoc (يرجى تحميل المستند قبل استخدام BeginDoc)، ويظهر دائماً تقريباً في المرة الثانية. يُكتب المستند الأول بشكل جيد. ثم يُطلب من مثيل THotPDF نفسه أن يبدأ مستنداً ثانياً، فيثير BeginDoc خطأً، وتشير الرسالة إلى تحميل مستند، وهو عكس ما يحاول الكود القيام به. عدم التطابق بين الأعراض والرسالة هو ما يجعل هذا الخطأ عالقاً. الموضوع الحقيقي هو دورة حياة المكون، وبمجرد فهم ذلك يتوقف الخطأ عن كونه غامضاً

THotPDF document lifecycle showing Create, BeginDoc, EndDoc, and Free per output file
يعين مثيل THotPDF واحد إلى مستند واحد: Create، و BeginDoc، والرسم، و EndDoc، و Free.

مثيل THotPDF عبارة عن مستند واحد، وليس مصنع مستندات

النموذج العقلي المغري هو أن THotPDF هو كائن خدمة تقوم بتشغيله مرة واحدة وتغذي المستندات إليه، بالطريقة التي قد تبقي بها اتصال قاعدة البيانات مفتوحاً وتقوم بتشغيل استعلام تلو الآخر من خلاله. الأمر ليس كذلك. يصمم المثيل (instance) مستنداً واحداً قيد الإنشاء، وتحمل آلة الحالة (state machine) الداخلية الخاصة به افتراض أنها تمشي المسار مرة واحدة: من فارغ، عبر مستند مفتوح، إلى ملف محفوظ. يفتح BeginDoc هذا المسار ويحدد المثيل على أنه يحتوي على مستند قيد التقدم. يسلسل EndDoc كل شيء إلى FileName ويغلقه. استدعاء BeginDoc مرة أخرى على نفس المثيل المنتهي يطلب منه إعادة الدخول في حالة لم يتركها بشكل نظيف، والحارس (guard) الذي ينطلق هو الحارس الذي تصادف أن تذكر رسالته التحميل، لأنه داخلياً، يتم التحقق من شروط "جاهز للبدء" و "يحتوي على مستند محمل" معاً

إذن الرسالة مضللة، لكن الحارس يقوم بعمله. إنه يرفض السماح لك ببدء مستند جديد فوق مكون لا يزال يعتقد أنه في منتصف المستند. الإصلاح ليس في هزيمة الحارس. بل في التوقف عن إعادة استخدام مثيل مستهلك

دورة الحياة، بالترتيب الذي يجب أن تحدث به

يتبع كل مستند يكتبه HotPDF من الصفر نفس النبضات الأربع، والترتيب غير قابل للتفاوض. يخصص Create المكون. يفتح BeginDoc المستند ويثبت الخيارات الهيكلية، لذلك يجب تعيين أي شيء يؤثر على الملف بأكمله (حجم الصفحة، والضغط، والتشفير، واسم ملف الإخراج) بين Create و BeginDoc. ثم ترسم. ثم يكتب EndDoc البايتات على القرص. يحرر Free المثيل. لا تحتوي استدعاءات الرسم الموضوعة قبل BeginDoc على صفحة لتهبط عليها؛ خصائص المستند بأكمله المخصصة بعده يتم تجاهلها دون شكوى

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // opens the document
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // writes invoice.pdf, closes it out
  finally
    Pdf.Free;                            // one instance, one document
  end;
end;

اقرأ ذلك كوحدة عمل. Create واحد، و BeginDoc واحد، و EndDoc واحد، و Free واحد، وملف واحد على القرص. في اللحظة التي تريد فيها ملفاً ثانياً، فأنت تبدأ وحدة عمل جديدة، مما يعني مثيلاً جديداً

ما يجب أن تعنيه "إعادة الاستخدام": مثيل جديد لكل ملف

تحاول النسخة التي تنكسر أن تكون مقتصدة في التخصيص: قم ببناء المكون مرة واحدة، وقم بالتكرار عبر دفعة (batch)، واستدعِ BeginDoc و EndDoc داخل الحلقة (loop). التكرار الثاني يرمي خطأً. النسخة التي تعمل تعامل كل إخراج على أنه كائن قصير العمر خاص به، وتكلفة تخصيص إنشاء مكون تافهة بجوار عمل تخطيط (laying out) وتسلسل PDF، لذلك لا يوجد شيء لوفره من خلال اكتناز (hoarding) المثيل

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // new instance each pass
    try
      Pdf.FileName := Names[I] + '.pdf';
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 12);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Statement for ' + Names[I]);
      Pdf.EndDoc;
    finally
      Pdf.Free;
    end;
  end;
end;

إن try/finally الموجودة داخل الحلقة هي الجزء الذي يستحق الدفاع عنه في المراجعة. إذا أثار BeginDoc أو أي استدعاء للرسم خطأً في منتصف مستند واحد، فسيظل مثيل هذا التكرار متحرراً قبل بدء التكرار التالي، لذلك لا يترك السجل السيئ الواحد مكوناً مبنياً جزئياً عالقاً ويسمم بقية التشغيل. اسحب Create إلى ما فوق الحلقة من أجل "التحسين" وستعود إلى الخطأ الأصلي، وهو يرتدي الآن حلقة دفعة (batch loop)

تعديل ملف موجود يمثل نقطة دخول مختلفة

هناك قراءة ثانية لـ "إعادة الاستخدام" وهي مشروعة تماماً: أنت لا تريد مستنداً فارغاً، بل تريد فتح ملف PDF موجود بالفعل وتغييره. هذا المسار لا يمر عبر BeginDoc على الإطلاق، وهو بالضبط سبب تسمية رسالة الخطأ للتحميل. تقوم بتحميل الملف، وتحريره، وحفظه تحت أي اسم تختاره

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('contract.pdf');
    if PageCount > 0 then
    begin
      Pdf.CurrentPage.SetFont('Arial', [fsBold], 10);
      Pdf.CurrentPage.TextOut(40, 30, 0, 'REVIEWED');
      Pdf.SaveLoadedDocument('contract-reviewed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

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

مشكلة قفل الملف (file-lock) حقيقية، والحل لا يتمثل في إغلاق نوافذ العارض

غالباً ما ينتقل خطأ إعادة الاستخدام مع شكوى ثانية، ويتشابك الاثنان لأنهما يظهران في نفس سير عمل (workflow) إعادة إنشاء الملف. يفتح المستخدم ملف PDF الذي أنتجته للتو، ويتركه مفتوحاً في Acrobat أو Foxit، ثم يشغل إعادة بناء. يحاول EndDoc كتابة نفس المسار، ويرفض نظام التشغيل لأن العارض يحمل مشاركة قراءة (read share) تحظر الكتاب، وتحصل على فشل تم رفض الوصول (access-denied). هذه مشكلة حقيقية في قفل ملفات Windows (file-locking) وليست مشكلة حالة مكون (component-state)، وهي تستحق إجابة حقيقية بدلاً من حل بديل (workaround)

الحل البديل المنتشر، والذي يسرد (enumerating) النوافذ عالية المستوى (top-level windows) وينشر WM_CLOSE لأي شيء يبدو عنوانه مثل عارض PDF، هو غريزة خاطئة. إنه يصل عبر حدود العملية لإغلاق النوافذ التي لا يمتلكها برنامجك، ويخمن في العارضين من خلال نص العنوان، ويمكنه التخلص من التعليقات التوضيحية غير المحفوظة للمستخدم دون أن يطلب. تعامل مع هذا النهج بأكمله على أنه رائحة كريهة (smell). الإصلاح الموثوق به هو عدم الكتابة أبداً إلى مسار قد تحمله عملية أخرى. قم بالتسلسل (Serialize) إلى ملف مؤقت في نفس الدليل، ثم استبدله في مكانه بإعادة تسمية ذرية (atomic rename) بمجرد نجاح EndDoc. إذا كان لا يزال لدى العارض الملف القديم مفتوحاً، فإن إعادة التسمية إما أن تنجح بشكل نظيف أو تفشل بصوت عالٍ، وتُظهر رسالة واضحة بدلاً من محاربة القفل

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // Temp file in the SAME directory as the target: a rename inside one
  // NTFS volume swaps the name atomically, while a cross-volume move
  // degrades to copy-plus-delete and loses that guarantee
  TempPath := TPath.Combine(TPath.GetDirectoryName(FinalPath),
    TGUID.NewGuid.ToString + '.pdf.tmp');
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.FileName := TempPath;
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 11);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
      Pdf.EndDoc;                    // the temp file is complete on disk here
    finally
      Pdf.Free;
    end;

    // Swap into place. TFile.Move refuses to overwrite, so clear a stale
    // target first; if a viewer still holds the old file, the delete is
    // what fails, loudly, before the good bytes are touched
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // or: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // never strand a half-written temp file
    raise;
  end;
end;

ملاحظتان صريحتان على هذا الكود. يعين كل من TFile.Move و RenameFile الكلاسيكي إلى نفس إعادة تسمية Windows، والتي تكون ذرية فقط عندما يجلس المصدر والوجهة على نفس وحدة التخزين (volume)، وهذا هو بالضبط سبب ذهاب الملف المؤقت إلى الدليل الوجهة بدلاً من TPath.GetTempPath. وزوج الحذف ثم النقل (delete-then-move) ليس في حد ذاته خطوة ذرية واحدة: هناك نافذة وجيزة لا يوجد فيها أي من الملفين. بالنسبة لتطبيق سطح المكتب الذي يعيد إنشاء تقرير، فإن هذه النافذة غير ذات صلة؛ القراء الذين يحتاجون إلى عقد أقوى على وحدة التخزين نفسها يمكنهم استدعاء ReplaceFile في Win32 أو MoveFileEx مع MOVEFILE_REPLACE_EXISTING مباشرة، مما يطوي التبديل في استدعاء واحد

بالنسبة للخادم ذي الحجم الكبير الذي يعيد إنشاء المستندات باستمرار، فإن الانضباط الأنظف هو كتابة كل إخراج تحت اسم فريد (طابع زمني أو معرف وظيفة) بحيث لا تتنافس عمليتا تشغيل أبداً على مسار واحد، والسماح لسياسة احتفاظ (retention policy) منفصلة بتنظيف الملفات القديمة. النمط هو سطر واحد من انضباط التسمية لكل طلب

// One output path per request: two concurrent jobs can never contend
// for the same name, so no rename dance and no lock to lose
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

يعمل معرف الطلب أو معرف الوظيفة تماماً مثل معرف GUID عندما يسلمه لك الإطار المحيط (framework) بالفعل، ويجعل اسم الملف قابلاً للتتبع للرجوع إلى سطر سجل (log line) مجاناً. في كلتا الحالتين، المبدأ هو نفسه: صمم بحيث يكون الملف الذي تكتبه لك وحدك في اللحظة التي تكتبه فيها. يختفي القفل ليس لأنك أجبرت نافذة على الإغلاق ولكن لأنه لا يوجد شيء آخر يلمس البايتات

شكل الإصلاح

جرد المشكلتين للرجوع إلى جذورهما وهما كلتيهما تتعلقان باحترام الحدود. يريد خطأ آلة الحالة منك احترام حد المثيل: THotPDF واحد، مستند واحد، ثم اتركه واصنع واحداً آخر. خطأ قفل الملف يريد منك احترام حد الملف: اكتب حيث لا يقرأ أي شيء آخر، ثم انقل النتيجة إلى مكانها. لا يدعو أي منهما إلى تصحيح المكتبة أو كتابة برامج نصية (scripting) لسطح المكتب. كلاهما ينتج عن معاملة كل مستند كوحدة عمل قائمة بذاتها، تم إنشاؤها حديثاً، ومكتوبة بشكل نظيف، ومحررة، وهو النمط نفسه الذي يجعل بقية المكون قابلاً للتنبؤ به

استدعاءات BeginDoc، و EndDoc، و LoadFromFile، و SaveLoadedDocument الموضحة هنا هي جزء من مكون HotPDF لـ Delphi و C++Builder