مقال تقني

دمج PDF السريع في Delphi: إزاحة المراجع على مستوى البايت

يبدو أن ضم ملفات PDF يجب أن يكون رخيصًا. محتوى الصفحة مرسوم بالفعل، والخطوط مضمنة بالفعل، والصور مضغوطة بالفعل. نظريًا، الدمج مجرد أعمال دفترية: أعد ترقيم الكائنات حتى تتوقف مساحات الترقيم في الملفين عن التصادم، واصِل أشجار الصفحات معًا، وأصلح جدول المراجع العكسية، ثم اكتب الناتج. لكن عمليًا، معظم كود الدمج يبدد تلك الرخص. فهو يشغّل لكل كائن في كل ملف مدخل تحليلًا كاملًا إلى شجرة كائنات مرمّزة، يغيّر بضع مراجع غير مباشرة، ثم يعيد تسلسل الشجرة إلى بايتات. التحليل وإعادة التسلسل هما النصفان المكلفان، وبالنسبة إلى الغالبية الساحقة من الكائنات ينتجان تسلسل بايتات يكاد يطابق ما دخل

PDFlibPas هو محرك PDF أصلي بلغة Object Pascal لـ Delphi وC++Builder، ويُوجد مسار الدمج السريع فيه لتجاوز تلك الدورة كلما كان ذلك آمنًا على نحو يمكن إثباته. الفكرة ضيقة لكنها تؤتي ثمارها عبر مجموعات المستندات كلها: بالنسبة إلى كائن غير معدل وغير متدفق، خذ بايتات المصدر الأصلية كما هي وقم بإعادة كتابة واحدة على مستوى البايت للمراجع غير المباشرة التي تحتويها، بحيث يتحول كل N G R إلى (N+Offset) G R. لا محلل، لا شجرة كائنات، لا مولد تسلسل. يشرح هذا المقال أين تكون هذه الحيلة قانونية، وحالة آلة التحليل التي تنفذ إعادة كتابة البايتات من دون إفساد أي شيء، ولماذا احتاج دمج الإشارات المرجعية إلى آلية مختلفة تمامًا، وكيف أُعيد بناء مسار الدمج العادي من تربيعي إلى خطي في الوقت نفسه

لماذا إعادة ترقيم الكائنات هي الكلفة الحقيقية للدمج

يحمل كل ملف PDF مساحة ترقيم كائناته الخاصة. للملف A الكائن 1 والكائن 2 وما إلى ذلك، وللملف B الكائن 1 والكائن 2 وما إلى ذلك. لا يمكنك إسقاط كائنات B داخل ملف A كما هي، لأن الأرقام ستتصادم وستؤدي كل مرجعية غير مباشرة داخل B الآن إلى الكائن الخطأ. الحل هو إزاحة: إذا انتهى A عند عدد كائنات يساوي Offset، فإن الكائن N يصبح الكائن N+Offset في الناتج، ويجب إزاحة كل مرجعية N G R التي تظهر في أي موضع داخل كائنات B إلى (N+Offset) G R لتتطابق

تلك الإزاحة هي المهمة الدلالية الكاملة لدمج الجسم. إصلاحات شجرة الصفحات ودمج AcroForm تعديلات صغيرة محدودة على عدد قليل من الكائنات. أما العمل الأكبر فهو إعادة كتابة المراجع عبر آلاف الكائنات، والطريقة الساذجة لفعل ذلك هي تحليل كل كائن حتى تتمكن من العثور على المراجع بنيويًا. يأخذ MergeFileListFastMergeFileListFast موقفًا معاكسًا: فالمراجع يمكن العثور عليها في البايتات الخام أيضًا، إذا كنت حذرًا بشأن السياقات التي تكون فيها سلسلة رقم-فراغ-رقم-فراغ-R ليست

مرجعية. تجاوز التحليل، وأزح في المكان، فتنهار الكلفة لكل كائن إلى مسح خطي واحد للبايتات التي كنت ستنسخها على أي حال

متى يكون إعادة استخدام بايتات المصدر آمنًا على نحو يمكن إثباته

  • Doc2.IsChangedObject(X)لا يُسلك مسار البايتات إلا عندما تتحقق ثلاثة شروط كلها للكائن الجاري نسخه من مستند لاحق. فشل أي شرط منها يعيد الكائن إلى مسار فك الترميز ثم إعادة التسلسل الكامل، لذلك يبقى الصواب هو الفائز دائمًا على السرعة:/ParentDoc2.IsChangedObject(X)
  • . إذا كان محرك الدمج قد عدّل الكائن بالفعل في الذاكرة، مثل كائن صفحة أُعيد توجيه stream/Parentstream الخاص به، فإن الشجرة الموجودة في الذاكرة هي مصدر الحقيقة، والبايتات الأصلية قديمة. فقط الكائنات التي لم تمسها التعديلات تصلح.endstreamتحتوي بايتات المصدر على كلمة
  • stream/StructTreeRoot. جسم كائن الدفق بيانات ثنائية معتمة محاطة بـ /StructElemstream/endstream

، وأي فحص ساذج للمراجع داخل بيانات دفق مضغوطة أو مشفرة سيعثر بسعادة على أنماط بايت تشبه المراجع ويفسدها. كائنات الدفق تبقى على المسار الأصلي الواعي بالدفق.ShiftIndRefsInSourceتحتوي بايتات المصدر على GetObject ولا ShiftIndRef. في النمط السريع تُسقط شجرة بنية tagged-PDF بدلًا من دمجها، لذلك يجب أن تمر تلك الكائنات عبر مسار فك الترميز حيث يمكن للمحرك تصفيرها عمدًا

ObjectData := '';
if not Doc2.IsChangedObject(X) then
begin
  ObjectData := FastMergeObjectSource(Reader2, X);
  if (PLPos('stream', ObjectData) > 0) or
     ((not PreserveStructTree) and (PLPos('/StructTreeRoot', ObjectData) > 0)) or
     ((not PreserveStructTree) and (PLPos('/StructElem', ObjectData) > 0)) then
    ObjectData := ''                                  // fall back to decode
  else
    ObjectData := ShiftIndRefsInSource(ObjectData, Offset);
end;

if ObjectData <> '' then
  Writer.AddObject(X + Offset, Doc2.GetGenNum(X), ObjectData)
else
begin
  Obj := Doc2.GetObject(X, TempStruct);              // full parse path
  // ... null out struct-tree objects, ShiftIndRef, Obj.Output ...
end;

يقع القرار داخل حلقة النسخ لكل كائن. عندما تنجح الشروط الثلاثة كلها، تذهب بايتات الكائن مباشرةً إلى ObjectData ثم إلى الكاتب؛ وإلا تُهمل البايتات ويُعاد بناء الكائن باستخدام

آلة إزاحة المراجع وحالاتها الحدية

إعادة كتابة المراجع غير المباشرة على مستوى البايت سهلة الخلل على نحو خادع، لأن R وعمليات متتابعات الأرقام تظهر في كل مكان داخل كائن PDF في سياقات ليست مراجع. ShiftIndRefsInSource هو محلل صغير مكتوب يدويًا يجوب البايتات مرة واحدة ولا يعيد كتابة رقم إلا عندما يتبعه، مع وجود فراغات PDF بين الرموز، رقم آخر ثم R محدِّد. وتأتي المخارج السهلة أولًا: إذا كانت الإزاحة صفرًا أو كان المصدر فارغًا، تُعاد البايتات كما هي من دون دخول المحلل أصلًا

تعتمد صحة المحلل على التعرف إلى السياقات التي يجب أن تُترك فيها سلسلة تبدو كأنها مرجع وحدها. هذه هي الحدود الأسهل أن تفوتك، وكل واحدة منها تُعالج صراحةً:

  • السلاسل الحرفية المحددة بـ ( و) تُنسخ حرفيًا، مع تتبع عمق التداخل واحترام الهروب بالشرطة المائلة الخلفية حتى لا تربك قوسًا هاربًا عدّ العمق. سلسلة مثل (see object 3 0 R for details) تحتوي على نمط مرجعي نموذجي، لكنه في الحقيقة مجرد نثر، ويجب أن يبقى بايتًا مقابل بايت
  • السلاسل السداسية المحددة بـ < و> تمر كما هي من دون تفسير. البايتات 52 داخل سلسلة سداسية هي الرمز ASCII للحرف R، وأي محلل يتعامل مع الحمولة السداسية كنص يمكنه أن يصنع مرجعًا وهميًا. تُكتشف الفتحة الأولى << التابعة لقاموس أولًا حتى لا يلتبس القاموس بسلسلة سداسية
  • كائنات الأسماء التي تبدأ بـ / تُستهلك كاملة، من الشرطة المائلة حتى الفراغ أو المحدِّد التالي. من دون ذلك، قد يُقرأ اسم مثل /R (مفتاح مورد شائع) على أنه R لمراجع
  • التعليقات التي يبدأها % تمتد حتى نهاية السطر وتُتجاوز كنص معتم
  • اختبار العدد ثم R صارم. لا تُتعرف المرجعية إلا عندما تكون N فراغات G فراغات R مع R منتهيًا بفراغ أو محدد أو نهاية الإدخال. إذا كان رقم الجيل مفقودًا، أو إذا تبعه R حرف، فإن الأرقام تُصدَّر من دون تغيير. هذا هو ما يحمي العدد الصحيح في /Length 1234 والأربعة أعداد الخاصة بـ MediaBox من أن تُزاد بصمت

جوهر ذلك الاختبار الصارم يقرأ تقريبًا كما تصفه جملة المواصفة:

if (P <= N) and (Source[P] = 'R') and
   ((P = N) or PLIsPdfWhite(Source[P + 1]) or PLIsPdfDelimiter(Source[P + 1])) then
  Obj1 := PLStrToIntDef(PLCopy(Source, I, E1 - I), -1);

if Obj1 >= 0 then
begin
  AppendStr(PLIntToStr(Obj1 + Offset));   // shifted object number
  AppendBytes(E1, P - E1);                 // original whitespace + generation
  AppendBytes(P, 1);                       // the 'R'
end;

لا يُعاد كتابة سوى رقم الكائن؛ أما رقم الجيل والفراغ الأصلي الدقيق بين الرموز فيُنسخان كما هما، لذلك يكون الناتج مطابقًا بايتًا لمدخلاته ما عدا العدد الوحيد الذي كان لا بد أن يتغير. تلك الدقة هي جوهر الفكرة كلها، فهي ما يجعل إعادة استخدام بايتات المصدر مكافئة لإعادة تسلسل كاملة، لا مجرد قريبة منها. ويغطي هذا السلوكَ مجموعةٌ مركزة من اختبارات الوحدة تمارس المراجع العارية، والمراجع داخل المصفوفات، والأرقام غير المرجعية، والسلاسل الحرفية، والسلاسل السداسية، وأرقام الجيل غير الصفرية مع إزاحة مطبقة

لماذا لم تستطع الإشارات المرجعية إعادة استخدام AppendOutline

Merging multiple documents' bookmarks into one outline tree looks like a job for the existing AppendOutline helper, which already knows how to graft one document's top-level bookmarks onto another's. It is the wrong tool here, and the reason is a subtle layering mismatch. AppendOutline يعثر على آخر إشارة مرجعية عليا حالية عبر تتبع القارئ على بايتات الملف الأصلية. لكن الدمج السريع يضع تعديلاته في مخزن لكائنات جديدة عبر ChangeObject؛ والقارئ لا يرى هذه التعديلات قط. إذا ربطت ثلاثة مستندات أو أكثر، فإن كل عملية إلحاق تعيد توجيه آخر إشارة مرجعية أصلية في المستند الأول إلى أحدث مستند، فتخرج كل إشارات المستندات الوسيطة من السلسلة - ولا يبقى صحيحًا إلا /Count التراكمي، مما يجعل الخطأ سهل الإخفاء حتى يفتح شخص ما لوحة الإشارات المرجعية

يحل المسار السريع هذا بمزج قائم على البيانات على مرحلتين لا يعيد أبدًا تتبع القارئ. تمريرة أولى عبر كل المدخلات تجمع، لكل مستند، كائن جذر المخطط وأرقام الجيل، وأرقام أول وآخر إشارة مرجعية عليا، و/Count للجذر. ومن هذا الملخص يحسب الكود أرقام الكائنات العالمية لكل رابط يحتاج إلى صنعه - لكل مستند /Parent إلى الجذر المشترك، و/Prev لأول المستند السابق، و/Next لأول المستند التالي - باستخدام حسابات أرقام الكائنات الخالصة. وراء هذا قيد ترتيب كتابة: كائنات المستند الأول تُكتب قبل أن يُفتح أي مستند لاحق أصلًا، لذلك يجب أن تكون كل تعديلات المخطط الخاصة بالمستند الأول (/Count و/Last، و/Next) قابلة للتعبير عنها بحساب لا يحتاج إلى مستند لاحق في اليد. أما تعديلات كل مستند لاحق فتُطبَّق في مكانها بعد فتحه ولكن قبل كتابته، لذلك تمر عبر مسار تغيير الكائن نفسه

ثابت محاذاة الإزاحة الذي يربط كل ذلك

يعتمد كل من إزاحة المراجع وإدراج الإشارات المرجعية على ثابت حسابي واحد، وهو أضعف افتراض في التصميم كله. المرجعية التي تُدرج في مستند لاحق تُكتب على أنها عدد الكائن العالمي الهدف ناقص Offset الخاص بذلك المستند، بحيث عندما يُزاح الكائن لاحقًا بواسطة ShiftIndRef(Offset) تصل القيمة إلى الرقم العالمي المقصود. يحصل المستند الأول على Offset = 0 ويستخدم الأرقام العالمية مباشرةً. ولكي يكون هذا الطرح صحيحًا، يجب أن يتطابق تسلسل الإزاحة الجاري المستخدم أثناء الإدراج مع تسلسل الإزاحة المستخدم عندما تُكتب الكائنات في النهاية

يحدث ذلك، بسبب خاصية في طريقة عمل دمج الصفحات والنماذج: AddPages، وAddFields، وAddFieldFonts لا تعدّل إلا الكائنات الموجودة أصلًا في المستند الأول - فهي لا تضيف أية كائنات جديدة. لذلك لا يتغير عدد كائنات المستند الأول خلال مرحلة دمج الصفحات، وتبقى إزاحة كل مستند لاحق (مجموع أعداد الكائنات في كل المستندات السابقة) ثابتة من الإدراج إلى الكتابة. اكسر ذلك - أضف مرحلة تنشئ كائنًا جديدًا في منتصف الدمج - وستصبح كل مرجعية صفحة وإشارة مرجعية لاحقة غير متوافقة بعدد الكائنات التي أضفتها. الثابت هادئ، لكنه عنصر حاسم في الحمل

ثلاث نقاط دخول فوق محرك واحد

المسار السريع ليس تفرعًا عن كود الدمج. ففي السطر نفسه من العمل، جرى تفكيك المحرك على مستوى البايت إلى روتين داخلي واحد MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode)، وأصبحت الـ APIs العامة أغلفة رفيعة تختار علمين:

  • MergeFileListFast يستدعي المحرك من دون الحفاظ على شجرة البنية، وهو المسار الأخف، حيث تُسقط شجرة tagged-PDF حتى ينطبق المسار البايت على أكبر عدد من الكائنات
  • MergeFileList يستدعيه مع الحفاظ عليها، بحيث تبقى شجرة البنية ويظل الناتج tagged PDF صالحًا للاستخدام. وهذا المسار العادي يرث أيضًا دمج الإشارات المرجعية والنماذج عبر مستندات متعددة
  • MergeFileListStrict يفعّل النمط الصارم: تتوقف التمريرة الوصفية الأولى عند أول مدخل لا يبلغ عن دمج نظيف، لذلك لا يُدرج إلا المستندات المجموعة قبل الملف السيئ، بدلًا من تخطي الملف السيئ والمتابعة

كما أن دمج المسارات معًا أتاح أيضًا إعادة بناء الدمج العادي من حلقة زوجية O(N²) - دمج الملف الأول مع الثاني، ثم دمج الناتج مع الثالث، وهكذا، مع إعادة تحليل المجمّع المتنامي في كل خطوة - إلى مرور خطي واحد يفتح كل مدخل مرة واحدة. أما نقطتا الدخول القديمتان طويلتا الأمد لملفين ولتدفقين، MergeFiles وMergeStreams، فبقيتا كما هما ومتاحتين لمن يريد فعلًا دمجًا زوجيًا

ملاحظة صادقة واحدة عن سلوك شجرة البنية، لأنه أوقع مجموعة الاختبارات. إن "drop" في المسار السريع ليس حذفًا تامًا: فهو يزيل مرجع الكتالوج في المستند الأول إلى /StructTreeRoot، لكن كائن شجرة البنية نفسه لا يزال يُكتب بوصفه يتيمًا. لذلك تظل بايتات مخرجات المسار السريع تحتوي على السلسلة /StructTreeRoot، ولا يمكنك التمييز بين المخرج السريع والعادي عبر البحث عن تلك السلسلة - فالفرق الحقيقي هو ما إذا كان الكتالوج لا يزال يصل إلى شجرة البنية، وهو ما يحدد ما إذا كان الملف لا يزال tagged PDF قابلاً للتنقل

متى تختار أي مسار

The byte path is a throughput optimization for assembling many documents where you do not need the tagged-PDF structure tree preserved — report bundling, statement runs, batch concatenation. Measured over repeated merges of medium-to-large input sets, the byte reuse trimmed roughly four to thirteen percent off wall-clock time depending on object mix, with no new failures on small or malformed inputs, because any object the scanner cannot prove safe falls back to the full parse. If you do need the structure tree intact for accessibility, use the ordinary tagged-PDF merge path, which preserves it; and if you are working with very large single files rather than many inputs, the byte-copy techniques described in the companion piece on large PDF merge and split with direct file accessPDFlibPas Delphi PDF Library

، الذي يحمل توثيقه المرجع الكامل لواجهة قائمة الملفات وخيارات الدمج الموصوفة هنا.PDFlibPas Delphi PDF Library, whose documentation carries the full reference for the file-list API and the merge options described here