مقال تقني

ربط PDFlibPas عبر DLL و ActiveX و dylib: استدعاء محرك PDF واحد من أي لغة

كان تقرير الخطأ يقول: "يعمل من C# على Windows، وينهار من Python على macOS". كان الفريق قد بنى ربط macOS بنسخ ملف التصريحات الخاص بـ Windows وتبديل اسم الملف الثنائي فقط. كل الرموز حُلّت، والاستدعاء الأول أعاد بيانات تالفة، والثاني انهار. لم تكن هناك مشكلة في منطق PDF لديهم: صادرات Windows تستخدم اصطلاح Stdcall، بينما يصدّر macOS dylib الدوال نفسها بصيغة Cdecl مع شرطة سفلية بادئة، وأي ربط FFI يتجاهل أيا من التفصيلين يفسد المكدس قبل أن يُفتح أي مستند أصلا.

يلف PDFlibPas، وهو محرك PDF من losLab متاح المصدر لـ Delphi و C++Builder، نموذج الكائنات كله في واجهة مسطحة واحدة هي TPDFlib، ثم يشحن تلك الواجهة بثلاثة أشكال ثنائية: DLL على Windows يضم نحو 1,250 دالة مصدّرة، وكائن أتمتة COM/ActiveX، و dylib على macOS. دلالات PDF متطابقة في الأشكال الثلاثة كلها. ما يختلف — وما ترسمه هذه المقالة — هو ABI: اصطلاحات الاستدعاء، وترميزات السلاسل، وملكية المقابض، ومن يحق له تحرير أي مخزن مؤقت.

واجهة واحدة وثلاثة أشكال ثنائية

لكل دالة عامة في TPDFlib مقابل مسطح اسمه DL متبوعا باسم الطريقة: تتحول LoadFromFile إلى DLLoadFromFile، و Encrypt إلى DLEncrypt، و NewSignProcessFromFile إلى DLNewSignProcessFromFile. أول معامل في معظم الصادرات تقريبا هو InstanceID يعيده DLCreateLibrary، ويحل محل مرجع الكائن الذي كان سيحمله مستدعي Delphi. هذا التطابق واحدا لواحد يستحق أن تستوعبه مبكرا، لأنه يعني أن مرجع API الخاص بـ Delphi يعمل أيضا كتوثيق لكل لغة أخرى — ما تستطيع الفئة فعله يستطيع DLL فعله باسم يمكن توقعه.

ينتج بناء Windows الملفين PDFlibDLL32.dll و PDFlibDLL64.dll؛ اختر ما يطابق بتّية العملية المضيفة، لأن عملية Java أو .NET ذات 64 بت لا تستطيع تحميل مكتبة 32 بت مهما بدا التصريح صحيحا.

Windows: نسخ Stdcall وأزواج الدوال W/A

كل دالة مصدّرة تأخذ سلسلة نصية موجودة بنسختين: نسخة عريضة تأخذ PWideChar (UTF-16، وهو الملاءمة الطبيعية لـ .NET و Java و c_wchar_p في Python)، ونسخة ذات لاحقة A تأخذ PAnsiChar. النسختان متطابقتان دلاليا ولا تختلفان إلا في الترميز، ولهذا بالضبط يكون خلطهما مؤلما في التصحيح: لا يفشل شيء مباشرة، بل تحصل على mojibake في البيانات الوصفية أو "file not found" للمسارات التي تحتوي أي حرف خارج ASCII.

// Windows binding (PDFlibDLL64.dll): Stdcall, plain export names
function DLCreateLibrary: Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLCreateLibrary';
function DLReleaseLibrary(InstanceID: Integer): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLReleaseLibrary';
function DLLoadFromFile(InstanceID: Integer;
  FileName, Password: PWideChar): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLLoadFromFile';

// macOS binding: same function, Cdecl, and an underscore prefix on the export
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

اختر عرضا واحدا للحروف في كل مضيف وثبته في مولد الربط. قاعدة عملية: إذا كانت اللغة المضيفة تملك سلاسل UTF-16 أصلية، فاربط نسخ W في كل مكان ولا تلمس عائلة A مرة أخرى.

macOS: الأسماء نفسها و ABI مختلف

يصدّر dylib مجموعة دوال DL نفسها، لكن مع تغييرين منهجيين: اصطلاح الاستدعاء هو Cdecl، وكل اسم تصدير يحمل شرطة سفلية بادئة — _DLCreateLibrary و _DLLoadFromFile وما شابه. كلا التغييرين آلي، وهذا يجعلهما مرشحين مثاليين لربط مولّد ومرشحين سيئين جدا لنسخ ملف Windows وتعديله يدويا. إذا كانت طبقة الربط لديك تدعم ذلك، فاحتفظ بقائمة دوال قانونية واحدة وأنتج التصريحات لكل منصة؛ نمط الفشل عند عدم فعل ذلك هو قصة تلف المكدس التي افتتحت بها هذه المقالة، ولا يظهر إلا على المنصة التي تختبرها أقل.

مضيفو COM و ActiveX: Safecall وحمولات Olevariant

بالنسبة إلى VB.NET و C# و VBScript ومضيفي الأتمتة القديمة، يلف بناء OCX الواجهة نفسها في كائن أتمتة IDispatch هو IPDFlibrary، مع إعلان كل طريقة بصيغة Safecall. اختيار هذا الاصطلاح مهم لمعالجة الأخطاء: Safecall يحوّل الإخفاقات الداخلية إلى قيم HRESULT في COM، فيرى مستدعي C# استثناء قابلا للالتقاط بدلا من رمز خطأ صامت — عكس DLL المسطح، حيث يجب عليك فحص قيم الإرجاع بنفسك.

القاعدة الثانية الخاصة بـ COM تخص البيانات الثنائية. لا توجد معاملات مؤشرات في واجهة الأتمتة؛ أي شيء ثنائي — بايتات صور داخلة، أو بايتات PDF خارجة — يعبر الحد كـ Olevariant عبر طرائق مثل AddImageFromVariant و AppendToVariant. تحويل مصفوفة بايتات إلى variant في .NET سطر واحد، لكن إذا حاولت تمرير مؤشر خام لأن "العملية نفسها على أي حال"، فطبقة dispatch سترفضه أو تشوهه. أخيرا، تذكر أن تسجيل COM مرتبط بالبتّية: OCX مسجل عبر regsvr32 ذي 32 بت لا تراه عملية مضيفة ذات 64 بت، ويظهر ذلك في موقع العميل كرسالة "class not registered" غير المفيدة الشهيرة.

انضباط المقابض: النسخ تملك المستندات

الواجهة المسطحة اقتصاد مقابض. يعيد DLCreateLibrary نسخة؛ ويعيد التحميل معرف مستند داخل تلك النسخة؛ وتعيد عمليات التوقيع وقوائم السلاسل وملفات Direct Access مقابض صحيحة أخرى ضمن النسخة نفسها. دورة الحياة القانونية تبدو هكذا من أي مضيف FFI، مكتوبة هنا بـ Pascal لتسهيل القراءة:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // one instance per worker thread
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // returns a DocumentID, 0 on failure
    if Doc <> 0 then
    begin
      DLEncrypt(Inst, 'owner-secret', 'user-secret', 3,
        DLEncodePermissions(Inst, 1, 0, 0, 0, 0, 0, 0, 1));
      DLSaveToFile(Inst, 'out.pdf');
    end;
  finally
    DLReleaseLibrary(Inst);                      // frees every document the instance owns
  end;
end;

تترتب نتيجتان على ذلك. أولا، DLReleaseLibrary هو التنظيف الوحيد الذي تحتاجه حتما — فهو يهدم كل مقبض مستند وعملية داخل النسخة — لكن الاعتماد على ذلك في خدمة طويلة العمر ليس إلا تسريبا بطيئا بخطوات إضافية؛ حرّر المستندات التي انتهيت منها. ثانيا، النسخة هي وحدة عزل الخيوط الطبيعية: أعط كل خيط عامل InstanceID خاصا به، ولا تشارك نسخة بين الخيوط من دون قفل خارجي، تماما كما لن تشارك كائن TPDFlib.

السلاسل المعادة مستعارة لا مملوكة

الدوال التي تعيد نصا، مثل DLGetPageText، تعيد PWideChar أو PAnsiChar يشير إلى مخزن مؤقت تملكه نسخة المكتبة وتعيد استخدامه. العقد هو: انسخ فورا، ولا تحرر أبدا.

var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // pointer into a library-owned buffer
  PageText := P;                 // copy now; a later call may reuse the buffer
end;

في C# يعني ذلك تحويل IntPtr إلى سلسلة مدارة قبل استدعاء المكتبة التالي؛ وفي Python ctypes يعني اقتطاع السلسلة العريضة من المؤشر مباشرة. الاحتفاظ بالمؤشر الخام عبر الاستدعاءات هو نوع الخطأ الذي ينجح في كل اختبارات الوحدة ويفشل تحت تزامن الإنتاج. تنطبق قاعدة الملكية نفسها في الاتجاه الآخر على callbacks المسجلة عبر DLSetProgressCallback: أي مؤشر تمرره المكتبة إلى callback صالح فقط طوال مدة ذلك callback، ويجب أن يبقى callback نفسه حيا — مثبّتا في المضيفات ذات garbage collection — ما دامت النسخة قد تستدعيه. delegate جُمِع في منتصف العمل هو السبب القانوني لانتهاكات وصول "عشوائية" في ربطات .NET التي عملت لأشهر.

أخيرا، ابن اختبار الدخان داخل الربط نفسه. قبل شحن مجموعة تصريحات مولّدة، مرر استدعاء واحدا عبر كل فئة: دالة بلا معاملات (DLCreateLibrary)، ودالة سلسلة داخلة بمسار غير ASCII، ودالة سلسلة خارجة، وعملية تفشل عمدا لترى كيف تظهر رموز الخطأ في مضيفك. خمس عشرة دقيقة من هذا تكشف أخطاء الاصطلاح والترميز التي كانت ستظهر لاحقا كملفات crash dump من العملاء.

أسئلة الربط التي تظهر في الدعم

أي دوال يجب أن يستخدمها ربط Python ctypes على Windows؟ حمّل DLL عبر WinDLL (Stdcall)، واربط دوال W غير ذات اللاحقة، وعرّف معاملات السلاسل كـ c_wchar_p. على macOS، انتقل إلى CDLL، واحتفظ بقائمة الدوال نفسها، وحل الأسماء من دون الشرطة السفلية — فاللودر على macOS يتعامل مع اصطلاح البادئة في معظم طبقات FFI، لكن تحقق باستدعاء واحد قبل توليد المئات.

هل أحتاج إلى تسجيل شيء لاستخدام DLL العادي؟ لا. التسجيل باستخدام regsvr32 ينطبق فقط على بناء ActiveX. ينتشر DLL بنسخ الملف، وهذا أحد أسباب تفضيله للخدمات وأحمال عمل Windows داخل الحاويات.

هل DLL آمن للخيوط؟ النمط الآمن هو نسخة واحدة لكل خيط. مقبض النسخة يحمل كل الحالة القابلة للتغيير — المستند المحدد، وخيارات التصيير، وإعدادات الاستخراج — لذلك سيخلط خيطان يشتركان في نسخة واحدة تغييرات الحالة بصمت حتى عندما تنجح استدعاءاتهما.

قراءات ذات صلة

بعد أن يصبح الربط في مكانه، تكون العمليات التي يعرّضها هي نفسها التي تغطيها مقالات Delphi بعمق — مثلا تطبيق تشفير PDF وتدقيقه، أو استخراج النصوص والصور من المستندات القائمة.

تنزيلات ثنائية لكل طبقات التكامل الثلاث تأتي مع المكتبة؛ راجع صفحة منتج PDFlibPas لمعرفة الإصدارات والترخيص.