افتح ملف PDF أنتجه Microsoft Word أو Excel، وتصفحه، ولا يبدو أي شيء غير عادي. قم بتحميله في برنامج Delphi، واقرأ عدد الصفحات مرة أخرى، وستجد الرقم صحيحًا. ثم أعد حفظه مع تشغيل التشفير وتفشل المهمة بخطأ EListError، أو يفتح الإخراج مع تحذير بوجود مرجع تبادلي (cross-reference) تالف. الملف لم يكن تالفًا أبدًا. إنه ملف مرجعي هجين (hybrid-reference)، والبنية ذاتها التي تسمح لعارض عمره خمسة عشر عامًا بفتحه هي البنية التي تهزم أداة التحميل التي تتوقف عن القراءة مبكرًا جدًا
هذه واحدة من أكثر الطرق شيوعًا التي يقابل بها مسار عمل PDF الذي اجتاز كل اختبار داخلي ملفًا لا يمكنه إرجاعه ذهابًا وإيابًا (round-trip). كانت المدخلات كلها مولدة داخليًا، لذا لم تكن هجينة أبدًا. يصل أول ملف هجين في اليوم الذي يعيد فيه العميل توجيه فاتورة مُصدَّرة من جدول بيانات
ما يكتبه Word و Excel في الواقع
يصف ISO 32000-1 تخطيط المرجع الهجين في §7.5.8.4. يكتب التطبيق الذي يريد ميزات PDF 1.5 مثل تدفقات الكائنات (object streams)، مع الاستمرار في السماح لقارئ PDF 1.4 بفتح الملف، معلومات المرجع التبادلي مرتين. هناك جدول المرجع التبادلي الكلاسيكي، وهو صفوف ASCII ذات العرض الثابت التي تنهي كل ملف PDF حتى الإصدار 1.4، وهناك تدفق مرجع تبادلي يفهرس الباقي. يحمل ذيل (trailer) القسم الكلاسيكي إدخال /XRefStm وتكون قيمته هي إزاحة البايت (byte offset) لذلك التدفق
تقسيم العمل متعمد. يمكن معالجة الكائنات التي يجب على القارئ القديم الوصول إليها، بما في ذلك الكتالوج وشجرة الصفحات، من الجدول الكلاسيكي. يتم تمييز الكائنات التي تم طيها في تدفقات كائنات مضغوطة على أنها خالية في الجدول الكلاسيكي، مع إدخال من النوع f، بحيث يتخطاها القارئ 1.4 مباشرة ولا يتعثر أبدًا في بنية لا يمكنه تحليلها. تعيش مواقعها الحقيقية فقط في تدفق المرجع التبادلي. بصمة مثل هذا الملف هي ذيله: قسم كلاسيكي قصير، غالبًا ما لا يكون أكثر من xref يليه رأس قسم فرعي 0 0، يشير ذيله إلى /XRefStm حيث توجد بيانات الاسترداد الفعلية
لماذا لا يثبت عدد الصفحات الصحيح أي شيء
نظرًا لأن الكتالوج وشجرة الصفحات يمكن الوصول إليهما من الجدول الكلاسيكي عن قصد، فإن أداة التحميل التي تقرأ هذا الجدول فقط تجد /Root، وتسير في شجرة الصفحات، وتبلغ عن العدد الصحيح للصفحات. كل ما يحتاجه القارئ القديم موجود، لذا يبدو الملف سليمًا. الكائنات التي فُقدت هي تلك المعبأة في تدفقات الكائنات: قواميس حقول AcroForm، وعناصر بنية tagged-PDF، والذيل الطويل للقواميس الصغيرة التي لم تكن مضطرة أبدًا لأن تكون مرئية لعارض قديم (legacy viewer)
أنت لا تلاحظ الفجوة حتى يلمس شيء ما تلك الكائنات، وإعادة الحفظ الكاملة تلمسها جميعًا. السير في المستند لإعادة تشفيره أو إعادة كتابته هو بالتحديد العملية التي تطلب كل رقم كائن بدوره، وهذا هو السبب في ظهور الأعراض في وقت الحفظ بدلاً من وقت التحميل، بعيدًا عن سببها
الفخ هو كاشف يرى xref ويتوقف
الطريقة الرخيصة لتقرير كيفية فهرسة ملف هي اتباع startxref وفحص البايتات الأولى التي يشير إليها. تعني الكلمة الأساسية xref جدولاً كلاسيكيًا؛ كائن التدفق يعني تدفقًا مرجعيًا تبادليًا. هذا الاختبار صحيح لأي ملف يلتزم بمخطط واحد. إنه خاطئ بالنسبة لملف هجين، حيث يشير startxref الخاص به إلى قسم كلاسيكي لغرض وحيد هو إرضاء القراء القدامى، في حين أن /XRefStm في ذيل هذا القسم هو المكان الذي يتم فيه فهرسة معظم المستند فعليًا. الكاشف الذي يعيد "classic" عند أول xref يقابله لا يقرأ أبدًا /XRefStm، وكل كائن يعيش فقط في التدفق يصبح غير مرئي
var
Pdf: THotPDF;
PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf'); // count is correct
// inspect or edit the loaded document here
Pdf.SaveLoadedDocument('Invoice_secured.pdf'); // walks every object
finally
Pdf.Free;
end;
end;
مع وجود كاشف الخروج المبكر في مكانه، يبدو التحميل جيدًا وإعادة الحفظ هي المكان الذي تعلن فيه الكائنات الغائبة عن نفسها. الإصلاح ليس قراءة المزيد من البايتات في البداية؛ بل التعرف على الذيل الهجين واتباع /XRefStm قبل أن تقرر انتهاء الملف
ترتيب الدمج غير قابل للتفاوض
بمجرد قراءة كلا الفهرسين، يمكن دمجهما في اتجاه واحد فقط. يجب دمج تدفق المرجع التبادلي أولاً، مع ملء الإدخالات الكلاسيكية حوله. والسبب هو الخداع الصغير في قلب التنسيق. يقوم الملف الهجين بتمييز كائناته المضغوطة على أنها خالية في الجدول الكلاسيكي حتى يتجاهلها القراء القدامى. أداة التحميل التي تحترم سياسة "ما يُرى أولاً يفوز" (first-seen-wins) وتقرأ الجدول الكلاسيكي أولاً ستسجل أرقام الكائنات هذه على أنها خالية، ثم تتجاهل إدخالات التدفق التي تحدد موقعها فعليًا، لأن الفتحات مأخوذة بالفعل. اعكس الترتيب وإدخالات النوع 2 من التدفق، كل منها رقم تدفق كائن بالإضافة إلى فهرس، تفوز بالفتحات التي من المفترض أن تمتلكها، وتستقر الإدخالات الكلاسيكية حولها
يحمي نفس الانضباط من قيام مراجعة أقدم بإحياء كائن محذوف. تتسلسل التحديثات المتزايدة (Incremental updates) للخلف عبر /Prev، وإدخال خالٍ من النوع 0 هو حارس (sentinel) بأن قسمًا أحدث قد سحب رقم كائن. لا يجب السماح لقسم أقدم لاحق في السلسلة بالكتابة فوق هذا الحارس بموقع قديم. تعامل مع "ما يُرى أولاً" باعتباره موثوقًا للعلامات الخالية (free markers) وسيظل الكائن المحذوف محذوفًا؛ تعامل معه بلا مبالاة وتاريخ الملف نفسه سيعيد تنشيط المحتوى الذي أزالته أحدث مراجعة
ما يعنيه هذا في HotPDF
يقوم المحرك بحل ملفات المراجع الهجينة نيابة عنك، ويفعل ذلك في كل مسار يجب أن يحلل بيانات المرجع التبادلي. قم بتحميل مستند باستخدام LoadFromFile أو LoadFromStream، وقم بإجراء تغييراتك، واستدعِ SaveLoadedDocument؛ أو قم بتشغيل عملية ذات طلقة واحدة (one-shot operation) مثل EncryptFile تقرأ مُدخلًا وتكتب مُخرجًا. في كلتا الحالتين، يقرأ الاسترداد /XRefStm، ويدمج قسم التدفق قبل الإدخالات الكلاسيكية، ويحل الكائنات التي تعيش في تدفقات قبل أن تعددها عملية الكتابة. مسار تشفير AES-256 هو المكان الذي ظهرت فيه المشكلة لأول مرة، لأن تشفير مستند يعيد كتابة كل كائن وبالتالي يتطلب أن يكون كل كائن قد تم تحديد موقعه بالفعل
// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
'owner-secret', '', aes256, [prPrint, prFillAnnotations]);
التفاصيل التي تستحق أخذها في الاعتبار تقع في المراحل السابقة (upstream) لواجهة برمجة التطبيقات (API). الملفات التي تصل من Word و Excel و PowerPoint وقائمة طويلة من مسارات عمل "حفظ بتنسيق PDF" تكون هجينة بشكل روتيني، لذا فإن أداة التحميل التي تمارسها فقط مقابل مخرجات المولد الخاص بك قد لا تقابل واحدة أبدًا في الاختبار. قم بزرع تركيباتك (fixtures) بمستندات مصدرة من تطبيقات Office حقيقية، وليس فقط بملفات أنتجها كودك الخاص
التحقق من ملف تشتبه فيه
عمليتا تفتيش تحسمان السؤال بسرعة. افتح الملف في عرض سداسي عشري (hex view) واقرأ البايتات بعد startxref النهائي؛ يعرض الملف الهجين قسمًا كلاسيكيًا قصيرًا يحتوي قاموس ذيله على /XRefStm. أو قارن عدد الكائنات الذي يُبلغ عنه التحليل الكامل مع أعلى رقم كائن يصرح به /Size في الذيل. تعني الفجوة الكبيرة أن الكائنات تختبئ في تدفقات لم تفتحها أداة التحميل، وهو نفس النقص الذي يتحول إلى فشل وقت الحفظ لاحقًا
يجعل ذيل تصدير Excel النموذجي الفحص الأول ملموسًا. كل شيء بعد الكلمة الأساسية xref النهائية هو ASCII عادي، لذا فإن البصمة مقروءة مباشرة من عرض سداسي عشري (الإزاحات توضيحية، تمت إضافة التعليقات التوضيحية)
xref
0 0 % empty classic subsection: no rows at all
trailer
<< /Size 216 % one past the highest object number in use
/Root 1 0 R
/Info 15 0 R
/ID [<5C9A...> <5C9A...>]
/XRefStm 87325 % byte offset of the cross-reference stream
>>
startxref
88710 % points at the classic section above
%%EOF
القسم الفرعي 0 0 هو الدليل: يوجد جدول كلاسيكي يحتوي على صفر من الإدخالات فقط لحمل الذيل، ويوجد الذيل بشكل أساسي لقول /XRefStm 87325. الكاشف الذي يتوقف عند الكلمة الأساسية xref قد رأى، في هذه المرحلة، فهرسًا لا شيء. عندما تفضل كتابة نص برمجي للفحص بدلاً من مشاهدته بعينيك، فإن العلامة توجد دائمًا في غضون الكيلوبايتات القليلة الأخيرة من الملف، لذلك تكفي القراءة الخلفية المحدودة (bounded backward read)
// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
FS: TFileStream;
Tail: AnsiString;
Len, P: Integer;
begin
Result := -1;
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Len := 2048; // the trailer lives in the tail
if FS.Size < Len then
Len := Integer(FS.Size);
FS.Position := FS.Size - Len; // bounded backward read: 2 KB max
SetLength(Tail, Len);
FS.ReadBuffer(Tail[1], Len);
finally
FS.Free;
end;
P := Pos(AnsiString('/XRefStm'), Tail);
if P = 0 then
Exit; // no hybrid marker in the tail
Inc(P, Length('/XRefStm'));
while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
Inc(P); // skip whitespace after the key
Result := 0;
while (P <= Len) and (Tail[P] in ['0'..'9']) do
begin
Result := Result * 10 + Ord(Tail[P]) - Ord('0');
Inc(P);
end;
end;
// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
Writeln('hybrid-reference file: resave will need the /XRefStm section');
تعامل مع الفحص كفرز أولي (triage)، وليس كمحلل (parser): يخبرك بالملفات التي تستحق الانتباه في دفعة قبل تشغيل مهمة إعادة الحفظ، ولا شيء أكثر. ما يجب أن تفعله أداة التحميل بعد ذلك بالإزاحة التي تجدها، متبعة سلسلة الأقسام، ودمج إدخالات التدفق قبل تلك الكلاسيكية، واحترام حراس الإدخال الخالي (free-entry sentinels)، تم شرحه خطوة بخطوة في مقالنا المصاحب حول التعامل مع ملفات PDF ذات المراجع الهجينة من تطبيقات Office
تتم تغطية جانب الكاتب من هذه القصة، وكيفية إنتاج تدفقات الكائنات والمراجع التبادلية المضغوطة في المقام الأول، في مقالنا حول تدفقات الكائنات والتحديثات المتزايدة. عندما يكون الملف الهجين المعني كبيرًا جدًا أيضًا، فإن تقنيات التحميل في إرشادات Direct File API لمسارات عمل PDF الكبيرة تتيح لك فحصه دون قراءة كل شيء في الذاكرة. كلاهما يقترن بشكل طبيعي مع الاسترداد الموصوف هنا، والذي يأتي كجزء من مكون HotPDF لـ Delphi و C++Builder جنبًا إلى جنب مع واجهات برمجة تطبيقات التحميل والتحرير والتشفير والتوقيع المغطاة في مكان آخر على هذه المدونة