مقال تقني

تجميع صفحات المسح الضوئي المزدوج في Delphi: دمج PDF بالتشبيك

CollateDocumentsEx في مكتبة PDFlibPas لـ Delphi يدمج عدة مستندات مفتوحة في مستند واحد متشابك الصفحات. تضيف الدالة GroupSize صفحة من كل مصدر في كل جولة، وتقبل قائمة نطاقات صفحات لكل مصدر، وتعامل النطاق التنازلي مثل 3-1 على أنه عكس لذلك المصدر. استدعاء واحد يحوّل كومة أمامية وكومة خلفية معكوسة إلى ترتيب قراءة صحيح

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

الماسح الضوئي ينتج كومتين، إحداهما معكوسة

التجميع ليس دمجًا. الدمج يضم نطاقات صفحات متتالية؛ أما التجميع فيشبّكها، ونمط التشبيك خاصية للجهاز الفعلي الذي أنتج المدخلات. أخطئ في النمط، ولن يكون الملف خاطئًا قليلًا بل غير قابل للقراءة إطلاقًا: كل صفحة ثانية تنتمي إلى ورقة مختلفة. ثلاثة متغيرات تصف تقريبًا كل حالة واقعية: عدد المصادر في الدورة، وعدد الصفحات المأخوذة من كل مصدر في كل جولة، وهل يحتاج أي مصدر إلى قراءته بترتيب معكوس. تغطي CollateDocuments أول متغيرين بمصفوفة بسيطة من مقابض المستندات وعدد صحيح GroupSize. أما CollateDocumentsEx فتضيف المتغير الثالث بقبولها قائمة نطاقات صفحات مفصولة بفواصل منقوطة، مقطع لكل مصدر، حيث يعني المقطع الفارغ كل صفحات ذلك المصدر ويعكسه النطاق التنازلي. تُلحق كلتا الدالتين النتيجة بنهاية المستند المحدد حاليًا وتعيدان 1 عند النجاح و0 عند أي رفض

لماذا يضاعف التجميع الساذج حجم الملف؟

لأن خريطة الاستيراد التي تربط أرقام الكائنات المصدر بأرقام الكائنات الهدف تُعاد بناؤها في كل استدعاء نسخ، وأي شيء يمكن الوصول إليه من أكثر من جزء يُستورد مرة واحدة لكل جزء. داخليًا في PDFlibPas، تُعيد TPDFDocument.CopyPagesFromDoc ضبط NewIndObjList في بداية كل استدعاء. تلك القائمة هي الذاكرة الوحيدة التي يملكها الناسخ عمّا نقله بالفعل. استدعِها مرة واحدة بنطاق من عشر صفحات، فيُضمَّن خط مشترك بين العشر مرة واحدة. استدعِها عشر مرات بصفحة واحدة في كل مرة، ويُضمَّن نفس الخط عشر مرات. هذا الأمر أخطر بكثير في المستندات الممسوحة ضوئيًا منه في المستندات النصية، لأن الصفحة الممسوحة هي كائن صورة XObject كبير واحد، والكائنات المشتركة هي التي تحمل الوزن الحقيقي: ملف تعريف ICC مضمّن، سلسلة /DecodeParms مشتركة، كائن نموذج XObject لختم أو علامة مائية مطبَّق على كل ورقة، خط طبقة نص التعرف الضوئي على الحروف. الطريقة الواضحة لكتابة تجميع دوري هي حلقة على الجولات، وتلك الحلقة هي بالضبط الحالة المرضية

// Do not do this. Each CopyPageRanges call rebuilds the import map,
// so anything the two sources share internally is imported once per
// round instead of once per source.
var
  RoundIndex: Integer;
begin
  for RoundIndex := 1 to 12 do
  begin
    PDF.CopyPageRanges(Fronts, IntToStr(RoundIndex));
    PDF.CopyPageRanges(Backs, IntToStr(13 - RoundIndex));
  end;
end;

اثنتا عشرة جولة، ومصدران، أربع وعشرون خريطة استيراد. لا شيء يحذّرك. ترتيب الصفحات صحيح، وكل صفحة تُعرض بشكل سليم، والعرض الوحيد للمشكلة هو ملف أكبر عدة مرات من مجموع مدخلاته. في مهمة دفعية من 300 صفحة، لا يكون المضاعِف خطأ تقريب، بل هو الفارق بين أرشيف يناسب ميزانية الاحتفاظ بالبيانات وآخر لا يناسبها

استورد مرة واحدة، ثم أعد ترتيب شجرة الصفحات

الحل هو فصل الاهتمامين اللذين دمجتهما الحلقة الساذجة معًا. النسخ يقرر أي الكائنات موجودة في الهدف؛ والترتيب يقرر أين تقع الصفحات في شجرة الصفحات. تنسخ CollateDocumentsEx كل مصدر مرة واحدة بالضبط، في استدعاء واحد لـ CopyPagesFromDoc بنطاق ذلك المصدر الكامل، بحيث يحصل كل مصدر على خريطة استيراد واحدة وتُكتب الموارد المشتركة مرة واحدة. لا يحدث التشبيك إلا بعد وصول كل مصدر، ويتم بالكامل عبر TPDFPageTree.MovePage

نقل الصفحات مجاني بالمعنى المهم هنا. تُعرّف الفقرة §7.7.3 من ISO 32000-1 شجرة الصفحات بأنها بنية متوازنة من قواميس عقد تحمل مصفوفات /Kids تخزن مراجع غير مباشرة، مع حمل /Count لإجمالي الأوراق عند كل عقدة. نقل صفحة يعني إزالة مرجع غير مباشر واحد من مصفوفة /Kids، وإدراجه في أخرى، وتعديل كلتا قيمتي /Count، وإعادة توجيه /Parent الخاص بالصفحة. لا يُمسّ أي دفق محتوى، ولا يُكرَّر أي مورد، ولا يُنشأ أي كائن. تحتفظ الصفحة برقم كائنها، وهذا هو أيضًا سبب بقاء أرقام الكائنات ثابتة بنفس الطريقة الموضحة في استبدال الصفحات مع الحفاظ على أرقام الكائنات. وهناك تفصيل إضافي يخطئ فيه نقل الصفحة الساذج ولا تخطئ فيه MovePage. تسمح الفقرة §7.7.3.4 من ISO 32000-1 بأن تُورَّث /Resources و/MediaBox و/CropBox و/Rotate من عقدة سلف بدلًا من ذكرها في الصفحة نفسها. فإذا ورثت صفحة مواردها من العقدة A ثم نُقلت تحت العقدة B، فإنها ترث بصمت شيئًا مختلفًا، أو لا شيء إطلاقًا. لذلك تحلّ MovePage القيمة الموروثة وتكتبها على قاموس الصفحة قبل النقل، بحيث تحمل الصفحة سماتها الخاصة عبر عملية النقل

ماذا تفعل مرحلة إعادة الترتيب فعليًا؟

تُشغّل فرزًا بالاختيار مقابل دلالات "الإدراج عند". يُحسب أولًا الترتيب النسبي المطلوب داخل الكتلة: تجوّل عبر المصادر بالتناوب، خذ حتى GroupSize فهرس من كل مصدر، تخطَّ مصدرًا استُنفد، وكرّر حتى توضع كل صفحة. ينتج عن ذلك تبديل على الكتلة المُلحقة. تطبيق هذا التبديل هو الجزء المزعج، لأن MovePage عملية إدراج لا تبديل، فكل نقلة تزيح كل ما بين الموضع القديم والجديد بمقدار واحد

يحتفظ التنفيذ بمصفوفة Current تمثّل الموضع الحالي لكل صفحة مُلحقة، وتمسح إلى الأمام من الموضع K بحثًا عن الصفحة التي تنتمي إلى K، وتُصدر النقلة، ثم تُزحزح مدخلات المصفوفة لتعكس ما فعلته النقلة بالشجرة. هذا يستغرق ترتيب O(n تربيع) في عمليات المصفوفة وصفرًا في نسخ الكائنات، وهذه هي المفاضلة الصحيحة لهذا الحمل: تجميع 500 صفحة هو ربع مليون عملية خلط أعداد صحيحة ولا بايت واحد من بيانات صور مكررة. لا تحتاج النطاقات التنازلية ولا الصفحات المكررة إلى معالجة خاصة في هذه المرحلة لأن PLParsePageRangeList تُستدعى مع تعطيل الفرز والسماح بالتكرار، بحيث يصل الترتيب المطلوب سليمًا بعد التحليل

النطاقات المعكوسة والدمج المزدوج بنداء واحد

عند التعبير عن العكس كنطاق، تنهار حالة التمرير المزدوج على الماسح الضوئي المسطح إلى استدعاء واحد. تريد الأوجه الأمامية ترتيبها الطبيعي، وتريد الخلفية 12-1، ويعني المقطع الأول الفارغ قبل الفاصلة المنقوطة أن المصدر الأول يساهم بكل صفحاته

var
  PDF: TPDFlib;
  Target, Fronts, Backs: Integer;
begin
  PDF := TPDFlib.Create;
  try
    Target := PDF.NewDocument;
    if PDF.LoadFromFile('fronts.pdf', '') <> 1 then
      Exit;
    Fronts := PDF.SelectedDocument;
    if PDF.LoadFromFile('backs.pdf', '') <> 1 then
      Exit;
    Backs := PDF.SelectedDocument;
    PDF.SelectDocument(Target);
    // fronts 1..12 in order, backs scanned in reverse: F1 B12 F2 B11 ...
    if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 1 then
      PDF.SaveToFile('duplex.pdf');
  finally
    PDF.Free;
  end;
end;

يستحق سلوكان في ذلك المقتطف الذكر الصريح. تُلحق الصفحات المجمّعة بنهاية المستند المحدد، فمستند أُنشئ بـ NewDocument يساهم بصفحته الفارغة الأولية قبلها، وعليك حذفها إن لم ترغب بها. وقد تكون المصادر غير متساوية: مع GroupSize بقيمة 2 على مصدر من ثلاث صفحات وآخر من خمس، تخرج الجولات كـ A1 A2 B1 B2، ثم A3 B3 B4 عندما يكاد A ينفد، ثم B5 وحدها، لأن المصدر المستنفد يُتخطَّى ببساطة بدلًا من حشوه

التراجع، وحقول النماذج، وما لا يُنقل معه

يُتحقق من كل وسيط قبل أن يُمسّ الهدف. مقبض مستند مفقود، أو المستند المحدد مدرَج كمصدر لنفسه، أو GroupSize أقل من واحد، أو عدد مقاطع لا يطابق عدد المصادر، أو نطاق يسمّي صفحة لا يملكها المصدر: كل هذه تُعيد 0 مع بقاء الهدف دون تغيير. الفشل أثناء النسخ هو الحالة الأصعب، ويُعالَج عبر DeletePages العامة بدلًا من PageTree.DeletePages الخام. السبب محدد: يعمل النسخ مع تفعيل MergeFormData، فتكون حقول نماذج المصدر قد أُلحقت بمصفوفة /AcroForm /Fields في الهدف قبل فشل مصدر لاحق. حذف الصفحات على مستوى شجرة الصفحات سيُجرّد صفحات الودجت ويترك تلك المراجع الحقلية معلّقة؛ أما المسار العام فيلغي ربط مراجع الحقل والمخطط التفصيلي وسلسلة المقالات جنبًا إلى جنب مع الصفحات

if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 0 then
  // Nothing was appended and the target is byte-identical to before.
  // 412 is the copy failure; 0 means the arguments were rejected
  // during validation, before any page was touched.
  Log(Format('collate rejected, LastErrorCode=%d', [PDF.LastErrorCode]));

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

تشحن PDFlibPas دوال التجميع مع بقية سطح تجميع الصفحات لديها، فيجتمع سير عمل الماسح الضوئي، والاستخراج القائم على النطاقات، ومسارات الملفات الكبيرة خلف مكوّن واحد في Delphi وC++Builder. المرجع الكامل للواجهة البرمجية ونسخة تجريبية متاحان على صفحة منتج مكتبة losLab لـ PDF بلغة Delphi