ظهرت المشكلة في أداة لنسخ الصفحات مبنية فوق HotPDF Component: عند طلب الصفحة 1 من مستند مكون من ثلاث صفحات، كانت النتيجة دائمًا الصفحة 2. فحص منطق الفهرسة لم يكشف أي خلل. كان الاستدعاء يستخدم فهرسًا منطقيًا يبدأ من 0، وكانت الحسابات صحيحة، وحدود القيم سليمة. ومع ذلك كانت الصفحة الخاطئة تُعاد في كل مرة.
لم يكن الخلل في كود النسخ نفسه، بل في الطريقة التي كان HotPDF يبني بها مصفوفة الصفحات الداخلية عند تحميل الملف.

ترتيبان، ومصدر واحد للالتباس
ملف PDF هو مجموعة من الكائنات غير المباشرة، لكل منها رقم كائن. بنية الملف لا تفرض على هذه الأرقام أن تعكس ترتيب القراءة. قد يحمل الكائن 1 الصفحة 2، وقد يحمل الكائن 20 الصفحة 1. ما يحدد ترتيب القراءة فعليًا هو شجرة الصفحات: تسلسل هرمي من قواميس /Pages التي تسرد مصفوفات /Kids مراجع الصفحات بالترتيب الذي ينبغي أن يعرضها به العارض (ISO 32000-1 §7.7.3).
كان المستند الذي أطلق الخلل يملك بنية شجرة الصفحات التالية:
{ Pages tree root, object 16 }
16 0 obj
<<
/Type /Pages
/Count 3
/Kids [20 0 R { logical page 1 }
1 0 R { logical page 2 }
4 0 R] { logical page 3 }
>>
endobj
صادف أن الملف يسرد الكائن 1 والكائن 4 قبل الكائن 20 في تيار البايتات. أي محلل يمر على الكائنات غير المباشرة وفق ترتيبها في الملف ويضعها في PageArr كلما وجد قاموسًا من نوع الصفحات سينتهي به الأمر إلى وضع الكائن 1 في الفهرس 0، والكائن 4 في الفهرس 1، والكائن 20 في الفهرس 2. عندها تصبح الصفحة المنطقية 1 في PageArr[2]. وعند طلب الفهرس 0 تُجلب الصفحة المنطقية 2 بدلًا منها.
كان هذا بالضبط ما كان يفعله مسارا التحليل الداخليان في HotPDF. فالمسار التقليدي، المستخدم مع ملفات PDF 1.3/1.4، والمسار الحديث، المستخدم مع مستندات تدفقات الكائنات (PDF 1.5+)، كانا يبنيان PageArr عبر المرور على الكائنات غير المباشرة بالترتيب المادي للملف بدلًا من اتباع سلسلة /Kids.
تأكيد الفرضية
قبل لمس أي إصلاح، كان يجب إثبات عدم التطابق بدل افتراضه. أداة سطر الأوامر qpdf تجعل ذلك مباشرًا:
{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }
qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }
أظهر استخراج كل صفحة على حدة والتحقق من أحجام الملفات أن الخريطة كانت صحيحة: ما ينتجه PageArr[0] كان المحتوى الخاص بالصفحة المنطقية 2، وPageArr[2] كان يحمل الصفحة المنطقية 1. هذا الانزياح الدائري كان الدليل القاطع. وهذا يفسر أيضًا لماذا ظهرت المشكلة عبر عدة مستندات مصدر مختلفة: أي PDF تكون فيه كائنات الصفحات ذات أرقام أقل من صفحة منطقية أسبق كان سيكشف الخلل.
هناك سبب مباشر لوصول ملفات PDF إلى هذه الحالة. فالحفظ التزايدي يضيف الكائنات المحدثة بأرقام كائنات جديدة، ويترك المواقع القديمة في جدول المراجع العكسية بلا هدف. والمحررات التي تضيف صفحة غلاف تُدرجها برقم كائن مرتفع بغض النظر عن موضعها في مصفوفة Kids. وبعض المولدات تكتب الصفحات ببساطة بالترتيب الأنسب لتدفق المحتوى لا لتسلسل الصفحات المنطقي. ولا يفرض تنسيق PDF غير ذلك.
الإصلاح: اتبع مصفوفة Kids
النهج الصحيح هو بناء PageArr عبر تتبع سلسلة /Kids من جذر الكتالوج، لا عبر مسح الكائنات غير المباشرة. وبعد أن ينهي مسارا التحليل مرورهما الأولي، تُستخدم خطوة معالجة لاحقة لحل الترتيب المنطقي:
procedure THotPDF.ReorderPageArrByPagesTree;
var
PagesObj : THPDFDictionaryObject;
KidsArray : THPDFArrayObject;
NewPageArr: array of THPDFDictArrItem;
I, J, PageIndex, KidsIndex: Integer;
RefObj : THPDFLink;
PageObjNum: Integer;
Found : Boolean;
begin
{ Locate root /Pages dictionary via FRootIndex }
PagesObj := FindPagesRootFromCatalog;
if PagesObj = nil then Exit;
KidsIndex := PagesObj.FindValue('Kids');
if KidsIndex < 0 then Exit;
KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));
SetLength(NewPageArr, KidsArray.Items.Count);
PageIndex := 0;
for I := 0 to KidsArray.Items.Count - 1 do
begin
RefObj := THPDFLink(KidsArray.GetIndexedItem(I));
PageObjNum := RefObj.Value.ObjectNumber;
Found := False;
for J := 0 to Length(PageArr) - 1 do
begin
if PageArr[J].PageLink.ObjectNumber = PageObjNum then
begin
NewPageArr[PageIndex] := PageArr[J];
Inc(PageIndex);
Found := True;
Break;
end;
end;
{ Non-page Kids (intermediate /Pages nodes) produce no match; skip }
end;
if PageIndex > 0 then
begin
SetLength(PageArr, PageIndex);
for I := 0 to PageIndex - 1 do
PageArr[I] := NewPageArr[I];
end;
end;
تُوضع هذه الاستدعاءات في نهاية كل مسار تحليل، بعد فهرسة جميع الكائنات ولكن قبل خدمة أي عملية على الصفحات:
{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;
{ Modern path (object streams) }
if TryParseModernPDF then
begin
Result := ModernPageCount;
ReorderPageArrByPagesTree;
Exit;
end;
خطوة إعادة الترتيب ذات تعقيد O(n * m)، حيث n هو عدد عناصر Kids وm هو الطول الحالي لـ PageArr، لكن في أي مستند ذي شجرة صفحات مسطحة، حيث تكون جميع الأوراق على العمق 1 وهذا يغطي الغالبية الساحقة من ملفات PDF الواقعية، يكون العددان متساويين تقريبًا وتكون الكلفة ضئيلة. أما أشجار الصفحات العميقة فتتطلب اجتيازًا تراجعيًا بدلًا من النهج أحادي المستوى الموضح هنا؛ والتنفيذ الإنتاجي يعالج هذه الحالة على حدة.
استخدام CopyPageFromDocument بعد الإصلاح
مع وجود ReorderPageArrByPagesTree، تعمل فهارس الصفحات المنطقية كما هو متوقع. وتتعامل الدالة الأعلى مستوى CopyPageFromDocument مع فهرس منطقي يبدأ من 0 وتنسخ الصفحة الصحيحة إلى مستند الوجهة:
var
Source, Dest: THotPDF;
begin
Source := THotPDF.Create(nil);
Dest := THotPDF.Create(nil);
try
Source.LoadFromFile('source.pdf');
Dest.FileName := 'extracted.pdf';
Dest.BeginDoc;
{ Copy logical page 0 (first page the user sees) }
Dest.CopyPageFromDocument(Source, 0, 0);
Dest.EndDoc;
finally
Source.Free;
Dest.Free;
end;
end;
CopyPageFromDocument تستعلم داخليًا عن ترتيب شجرة الصفحات بدلًا من الاعتماد على فهرس PageArr الخام، لذلك تتصرف بشكل صحيح حتى مع المستندات التي يختلف فيها الترتيب المادي عن الترتيب المنطقي. وفي العمليات الدفعية، تقبل InsertPagesFromDocument مصفوفة من الفهارس المنطقية وتنسخها في مرور واحد.
ما الذي يكشفه هذا عن تحليل PDF
مواصفات PDF صريحة: ترتيب الصفحات المنطقي تحدده مصفوفة /Kids في شجرة الصفحات، لا أرقام الكائنات ولا إزاحات البايتات (ISO 32000-1 §7.7.3.2). أي محلل يستخدم ترتيبًا مختلفًا كاختصار سيعطي نتائج صحيحة في معظم المستندات التي يراها، لأن أغلب المولدات تكتب الصفحات بالترتيب الطبيعي وتخصص أرقام كائنات متسلسلة. يظل الخلل مخفيًا حتى يحمّل أحدهم ملف PDF عُدّل تدريجيًا، أو أُعيد تنظيمه بأداة أخرى، أو أنشئ ببرنامج اختار تخطيطًا مختلفًا.
الاختبار على ملفات PDF التي ينشئها المطور نفسه فقط يفوّت هذه الفئة من المشكلات تمامًا. لذلك يحتاج إصلاح أي انحدار في ترتيب الصفحات إلى مجموعة من المستندات من مصادر متنوعة: عمليات حفظ تزايدية، ومستندات ممسوحة أُدرجت فيها صفحات غلاف، وملفات PDF أنشأتها أدوات تسطيح أو تحسين لمخطط الكائنات بطريقة مختلفة. ويجب أن يبقى المستند الذي كشف الخلل الأصلي ضمن حزمة اختبارات الانحدار بشكل دائم.
تغطي صفحة HotPDF Component واجهة البرمجة الكاملة لعمليات الصفحات، بما في ذلك CopyPageFromDocument وInsertPagesFromDocument وMovePage.