لا يبدأ قارئ PDF من بداية الملف. بل يبدأ من النهاية. فآخر بضعة بايتات تحمل عنوان كل شيء آخر، والقارئ الذي لا يفهم هذا الترتيب سيسيء قراءة التنسيق من السطر الأول. لذلك فإن أفضل طريقة لتعلّم PDF على القرص هي أن تتعلمه كما يقرؤه القارئ: من الذيل أولًا، ثم القفز إلى الخلف نحو الخريطة، ثم حلّ الكائنات التي تشير إليها الخريطة.
البايتات نفسها سهلة القراءة إلى حد كبير في محرر نصوص عندما لا تكون مضغوطة. ملف بسيط من صفحة واحدة يرسم "Hello, World!" يقع تحت خمسمائة بايت، وكل عنصر بنيوي في التنسيق يظهر فيه. هذا هو الملف كاملًا، مع الأجزاء الأربعة المعلّمة:
%PDF-1.0 % Header
%âãÏÓ
1 0 obj % Body: the object sequence
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj
2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj
3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj
4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
/F0 36. Tf
(Hello, World!) Tj
ET
endstream
endobj
5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj
xref % Cross-reference table
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n
trailer % Trailer
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF
أربعة أجزاء، دائمًا بهذا الترتيب داخل الملف: رأس، وجسم من الكائنات، وجدول إسناد مرجعي، وتذييل. لكنك تقرؤها تقريبًا بالعكس. يضع ISO 32000-2 §7.5.1 البنية الأربع نفسها، وسبب الوصول من الخلف إلى الأمام عملي جدًا: القارئ الذي يقفز مباشرة إلى الكائن الذي يحتاجه أسرع بكثير من القارئ الذي يمسح كل بايت من الأعلى، وهذا الوصول العشوائي هو بالضبط ما صُمم التذييل وجدول الإسناد المرجعي لتوفيره.
الرأس يتكوّن من سطرين، والثاني هو المهم
السطر الأول هو %PDF-1.0. علامة النسبة تجعله تعليقًا من ناحية الصياغة، لكن القارئات تتعامل معه على أنه توقيع الملف وتستخرج منه رقم الإصدار. والتعامل مع الإصدارات مرن عمليًا. القارئ المصمم لـ PDF 2.0 سيفتح بكل سرور ملفًا يزعم أنه 1.0، ومعظم القارئات ستحاول فتح ملف رقمه المعلن خاطئ أو سطر الإصدار فيه مدفون قليلًا داخل الملف بدلًا من أن يكون عند البايت صفر. الرقم هنا مجرد تلميح عن الميزات التي ينبغي توقعها، وليس بوابة تمنع الفتح.
السطر الثاني هو السطر الذي يحذفه الناس بالخطأ ثم يقضون بعد الظهر كله في تتبع العطل. إنه تعليق أيضًا، لكن حمولة هذا التعليق أربعة بايتات أعلى من ASCII 127. وُضعت هذه البايتات كي يتعرّف أي برنامج ينقل الملف في "وضع النص" عليه بوصفه ملفًا ثنائيًا ويتوقف عن إعادة كتابة نهايات الأسطر. يحمل PDF تدفقات مضغوطة قد تصادف فيها بايتات تطابق carriage return أو line feed بالصدفة؛ وإذا أعاد أداة النقل كتابة هذه البايتات فلن يعود طول التدفق المسجّل في القاموس مطابقًا للبايتات على القرص، وسيتلف الملف. تعليق البايتات العالية دفاع عمره أربعون عامًا ضد FTP في وضع ASCII، ولا يزال موجودًا في كل ملف يكتبه أداة جادة لأن الفشل الذي يمنعه صامت وكامل.
الجسم يحمل الكائنات، وكل كائن مرقّم
كل ما يشكّل المستند يعيش في الجسم على هيئة متتالية مسطحة من الكائنات غير المباشرة. كل واحد منها يفتح برقمين وكلمة obj المفتاحية، ثم يحمل محتواه، ويُغلق بـ endobj. الكائن 1 في المثال أعلاه هو عقدة شجرة الصفحات: 1 0 obj، ثم قاموس، ثم endobj. الرقم الأول هو رقم الكائن، والرقم الثاني هو رقم الجيل. الجيل يكاد يكون صفرًا دائمًا في الملف المنشأ حديثًا؛ ولا يرتفع إلا عندما يُعاد استخدام رقم كائن عبر تعديلات، وهو أمر نادر إلى درجة يمكنك معها اعتبار الجيل غير الصفري علامة على أن الملف مرّ بتحديثات تراكمية. المحتوى بين الكلمات المفتاحية هو قاموس هنا، مكتوب بين << و >>، لكنه قد يكون أيضًا رقمًا أو سلسلة أو مصفوفة أو تدفقًا.
ما يجعل هذا رسمًا بيانيًا لا مجرد قائمة هو رمز المرجع 2 0 R. ومعناه "الكائن 2، الجيل 0، أينما كان موقعه في الملف". عقدة شجرة الصفحات أعلاه لا تحتوي الصفحة نفسها؛ بل تشير إلى الكائن 2، الذي يشير بدوره إلى الموارد وتدفق المحتوى بالطريقة نفسها. الجسم موضوع بأي ترتيب وجده الكاتب مناسبًا، والمراجع هي التي تخيطه إلى شجرة جذعها الفهرس. الموقع داخل الملف لا يحمل معنى. الهوية تأتي من رقم الكائن، والموقع يأتي من جدول الإسناد المرجعي.
جدول الإسناد المرجعي هو فهرس لإزاحات البايت
جدول xref هو ما يحول أرقام الكائنات إلى مواقع في الملف. وهو السبب في أن القارئ يستطيع فتح مستند من ألف صفحة وعرض الصفحة 850 من دون تحليل الصفحات الـ849 السابقة. يسجل كل إدخال الموقع الدقيق لبدء الكائن، محسوبًا بالبايت من بداية الملف:
xref
0 6 % 6 entries, starting at object 0
0000000000 65535 f % entry 0: head of the free list
0000000015 00000 n % object 1 begins at byte 15
0000000074 00000 n % object 2 begins at byte 74
0000000192 00000 n % object 3 begins at byte 192
0000000291 00000 n % object 4 begins at byte 291
0000000409 00000 n % object 5 begins at byte 409
التنسيق الثابت مقصود. كل إدخال يستهلك عشرين بايتًا بالضبط: إزاحة من عشرة أرقام، ومسافة، ورقم جيل من خمسة أرقام، ومسافة، وحرف واحد لنوع الإدخال، ونهاية سطر من بايتين. وبما أن الصفوف موحّدة، يمكن للقارئ أن يقفز مباشرة إلى إدخال الكائن n بالحساب بدلًا من المسح، فيصبح الجدول الذي يوفّر الوصول العشوائي للجسم نفسه قابلًا للوصول العشوائي. السطر 0 6 هو رأس قسم فرعي: وهو يقول إن الإدخالات التالية تصف ستة كائنات تبدأ من الرقم 0.
الكائن 0 خاص ويظهر دائمًا. نوعه f بمعنى free، ورقم جيله 65535، وهو يتصدر القائمة المرتبطة لأرقام الكائنات الحرة. في ملف لم يُحرَّر قط تكون القائمة الحرة مجرد هذا الإدخال الوحيد، وهو مجرد إجراء شكلي. لكنه يثبت وجوده أثناء التحديثات التراكمية، عندما تؤدي إزالة كائن إلى إضافة رقمه إلى تلك القائمة بحيث يمكن لتعديل لاحق إعادة استخدامه. الإدخالات الأخرى نوعها n بمعنى in-use، والرقم ذو العشر خانات هو الإزاحة التي ستنتقل إليها لتقرأ تعريف ذلك الكائن.
التذييل هو نقطة الدخول، ويقع في النهاية
التذييل هو أول ما يستهلكه القارئ فعليًا، رغم أنه مكتوب أخيرًا. يفتح المحلل الملف، ينتقل إلى النهاية، ثم يتتبع للخلف بحثًا عن %%EOF. وفوقه مباشرة يوجد startxref متبوعًا برقم واحد، وهذا الرقم هو إزاحة البايت لكلمة xref. وبذلك يقفز القارئ مباشرة إلى جدول الإسناد المرجعي من دون أن يكون قد مسح كائنًا واحدًا:
trailer
<<
/Root 5 0 R % the document catalog
/Size 6 % one more than the highest object number
>>
startxref
459 % byte offset of the xref table
%%EOF
يحمل قاموس التذييل القيمتين اللتين يحتاجهما القارئ قبل أن يفعل أي شيء آخر. /Root يشير إلى فهرس المستند، وهو الكائن 5 هنا، الذي يمثل قمة الرسم البياني للكائنات والطريق إلى شجرة الصفحات. /Size هو عدد الإدخالات التي ينبغي أن يحتويها جدول الإسناد المرجعي، وهو واحد أكثر من أعلى رقم كائن بسبب إدخال الفراغ في الخانة صفر. من %%EOF تتفرع كامل سلسلة القراءة: ابحث عن العلامة، واقرأ startxref لتحديد موقع الجدول، وحمّل الجدول لتعرف أين يعيش كل كائن، واقرأ /Root لتجد الفهرس، ثم حلّ الكائنات عند الحاجة من هناك. الرأس، رغم وجوده في الأعلى، لا يُلتفت إليه إلا متأخرًا. الخريطة في الأسفل هي ما يحتاجه القارئ أولًا.
التحديث التراكمي يضيف خريطة ثانية بدل أن يعيد الكتابة
هذا التصميم الذي يبدأ من الذيل يثبت قيمته عندما يتغير الملف. يمكن تعديل PDF من دون إعادة كتابة أي بايتات موجودة فيه بالفعل. تُلحق الكائنات الجديدة والمعدلة بنهاية الملف، يليها قسم إسناد مرجعي جديد وتذييل جديد، ويُترك الملف الأصلي كما هو في الأسفل. الجزء الجديد الوحيد من التسجيل هو إدخال /Prev في التذييل الجديد، يحمل إزاحة البايت لجدول الإسناد المرجعي السابق:
% ... original file, unchanged, ends here ...
6 0 obj % an object added by this edit
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj
xref % a second xref section, for the new object only
6 1
0000000612 00000 n
trailer
<<
/Root 5 0 R
/Size 7
/Prev 459 % byte offset of the earlier xref table
>>
startxref
680 % offset of this new xref section
%%EOF
لا يزال القارئ يبدأ من آخر %%EOF، ولا يزال يتبع startxref إلى أحدث جدول، لكنه الآن يتتبع سلسلة /Prev إلى الوراء عبر الجداول الأقدم، ويدمجها بحيث يفوز أحدث إدخال لأي رقم كائن. تصبح أقسام الإسناد المرجعي قائمة مرتبطة عبر الملف، كل واحدة منها تتجاوز ما قبلها بالنسبة إلى الكائنات التي تمسها. الكائن الذي استبدله تعديل ما يزال موجودًا ماديًا عند إزاحته القديمة؛ لكنه لم يعد قابلاً للوصول لأن إدخال xref لاحقًا يشير إلى مكان أحدث.
هذه هي الآلية التي تجعل ملفات PDF الموقعة رقميًا قابلة للتحقق. التوقيع الرقمي يغطي نطاقًا من بايتات الملف، وعندما يقتصر التحديث التراكمي على الإلحاق فقط فإن البايتات الموقعة لا تتحرك أبدًا. يظل التوقيع صالحًا بالنسبة إلى النطاق الأصلي بينما تقبع المراجعات الأحدث بعده، ولكل مراجعة xref وتذييل خاص بها. وهذا أيضًا سبب احتواء PDF على سجل قابل للاسترجاع: كل كائن تم تجاوزه لا يزال على القرص تحت قسم إسناد مرجعي أقدم، وهو ميزة لتتبع الإصدارات وعبء على من ظن أن "الحذف" يعني اختفاء البايتات.
الثمن هو التضخم. كل تعديل يضيف في النهاية؛ ولا يُستعاد شيء في مكانه، لذلك يتراكم في الملف عدد من الكائنات الميتة وسلسلة طويلة من أقسام xref. العلاج هو إعادة كتابة كاملة: تحميل المستند وحفظه من جديد، ما يعيد ترقيم الكائنات الباقية، ويحذف غير القابل للوصول، ويصدر جدول إسناد مرجعي واحدًا نظيفًا. الطريقتان تتبادلان الكلفة مباشرة. الإلحاق سريع ويحافظ على التواقيع والتاريخ؛ وإعادة الكتابة أبطأ وتفقدهما، لكنها تنتج ملفًا مدمجًا.
قراءة الأجزاء الأربعة عمليًا
معرفة البنية تكفي لتشخيص معظم مشكلات "هذا الملف لا يفتح" يدويًا. إذا رفض قارئ ملف PDF، فعادة تكون الأسباب في الطرفين لا في الوسط. التنزيل المبتور يفقد التذييل، فيختفي startxref أو %%EOF، ولا يعود للقارئ نقطة دخول؛ والقارئات المتسامحة تلجأ إلى مسح الملف كله لإعادة بناء xref، وهو بالضبط المسار البطيء الذي صُمم الجدول لتجنبه. النقل الخاطئ بوضع النص يفسد بايتات التدفق أو يجعل الإزاحات لا تطابق الواقع، فتُحمَّل الكائنات من الموضع الخطأ. وعندما لا تعود الإزاحات في الجدول تشير إلى كلمات obj حقيقية، يكون الملف مكسورًا بنيويًا حتى لو كان كل كائن على حدة سليمًا.
في الشفرة الجديدة، الدرس من هذه البنية هو أن تترك لمكتبة ما مسؤولية ضبط البايتات. يجب أن تتوافق الإزاحات في جدول الإسناد المرجعي مع المواقع الفعلية لكل كائن حتى آخر بايت، ويجب أن يشير التذييل إلى الجدول الصحيح، ويجب أن تتسلسل التحديثات التراكمية بشكل صحيح عبر /Prev. المكوّن الأصلي مثل HotPDF Component لـ Delphi و C++Builder يتولى كل ذلك عند كتابة ملف، بما في ذلك الاختيار بين إلحاق مراجعة تراكمية أو إعادة كتابة نسخة مدمجة. وإذا أردت أن ترى البنية نفسها وهي تُبنى من الصفر بدل أن تُفكك، فإن المقال المرافق حول بناء مستند PDF من الصفر يشرح إخراج الرأس والكائنات و xref والتذييل بالترتيب.