קורא PDF אינו מתחיל בתחילת הקובץ. הוא מתחיל בסוף. הבתים (bytes) האחרונים מחזיקים את הכתובת של כל השאר, ומנתח (parser) שאינו מבין את הסדר הזה יקרא את הפורמט באופן שגוי מהשורה הראשונה. לכן הדרך השימושית ביותר ללמוד PDF על הדיסק היא ללמוד אותו כפי שקורא עושה זאת: מהזנב קודם, ואז לקפוץ אחורה למפה, ואז לפתור (resolve) את האובייקטים שהמפה מצביעה עליהם
הבתים עצמם פשוטים מספיק לקריאה בעורך טקסט כאשר שום דבר אינו דחוס. מסמך מינימלי בן עמוד אחד שמצייר "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
ארבעה חלקים, תמיד בסדר הזה לאורך הקובץ: כותרת (header), גוף של אובייקטים (body), טבלת הפניות צולבות (cross-reference table), ו-trailer. הקאץ' הוא שקוראים אותם כמעט בסדר הפוך. ISO 32000-2 §7.5.1 מציג את אותה אנטומיה של ארבעה חלקים, והסיבה לגישה מהסוף להתחלה היא פרקטית גרידא: קורא שקופץ ישר לאובייקט שהוא צריך הוא הרבה יותר מהיר מכזה שסורק כל בית מההתחלה, והגישה האקראית הזו היא בדיוק הסיבה שה-trailer וטבלת ההפניות הצולבות קיימים
הכותרת היא שתי שורות, והשנייה חשובה
השורה הראשונה היא %PDF-1.0. סימן האחוזים הופך אותה להערה (comment) ככל שהתחביר נוגע בדבר, אבל קוראים מתייחסים לזה כאל חתימת הקובץ ושולפים ממנה את מספר הגרסה. הטיפול בגרסאות הוא רופף בפועל. קורא שנבנה ל-PDF 2.0 יפתח בשמחה קובץ שטוען להיות 1.0, ורוב הקוראים ינסו קובץ שהגרסה המוצהרת שלו שגויה או ששורת הגרסה שלו קבורה קצת לתוך הקובץ ולא בבית אפס. המספר הוא רמז לגבי אילו תכונות לצפות, לא שער חסימה
השורה השנייה היא זו שאנשים מוחקים בטעות ואז מבלים צהריים שלמים בדיבוג. זוהי גם הערה, אך המטען (payload) שלה הוא ארבעה בתים מעל ASCII 127. הם קיימים כדי שכל דבר המעביר את הקובץ ב"מצב טקסט" (text mode) יזהה אותו כבינארי ויפסיק לשכתב סיומי שורות (line endings). PDF נושא זרמים דחוסים שהבתים שלהם יכולים להתאים ל-carriage return או ל-line feed במקרה; אם כלי העברה משכתב אותם, אורך הזרם הרשום במילון כבר אינו תואם לבתים על הדיסק והקובץ מושחת. הערת הבתים-הגבוהים (high-byte) היא הגנה בת ארבעים שנה נגד FTP במצב ASCII, והיא עדיין נמצאת בכל קובץ שכלי רציני כותב מכיוון שהכשל שהיא מונעת הוא שקט ומוחלט
הגוף מחזיק את האובייקטים, כל אחד ממוספר
כל מה שמרכיב את המסמך חי בגוף כרצף שטוח של אובייקטים עקיפים (indirect objects). כל אחד נפתח עם שני מספרים שלמים ומילת המפתח obj, מחזיק את התוכן שלו, ונסגר עם endobj. אובייקט 1 בדוגמה למעלה הוא צומת עץ העמודים: 1 0 obj, ואז מילון, ואז endobj. המספר השלם הראשון הוא מספר האובייקקט, השני הוא מספר הדור (generation). הדור הוא כמעט תמיד אפס בקובץ שנכתב זה עתה; הוא מטפס רק כאשר מספר אובייקט בשימוש חוזר על פני עריכות, מה שנדיר מספיק כדי שתוכלו להתייחס לדור שאינו אפס כאל סימן שהקובץ עבר עדכונים אינקרמנטליים. התוכן בין מילות המפתח הוא מילון כאן, כתוב בין << ל->>, אבל הוא יכול להיות גם מספר, מחרוזת, מערך, או זרם
מה שהופך את זה לגרף ולא לרשימה הוא אסימון ההפניה (reference token) 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
הרוחב הקבוע מכוון. כל רשומה היא בדיוק עשרים בתים: היסט בן עשר ספרות, רווח, דור בן חמש ספרות, רווח, סוג של תו אחד, וסיום-שורה (end-of-line) של שני בתים. מכיוון שהשורות אחידות, קורא יכול לאנדקס ישר לרשומה של אובייקט n על ידי אריתמטיקה ולא על ידי סריקה, כך שהטבלה הנותנת גישה אקראית לגוף היא בעצמה נגישה אקראית. השורה 0 6 היא כותרת תת-סעיף: היא אומרת שהרשומות הבאות מתארות שישה אובייקטים החל ממספר 0
אובייקט 0 הוא מיוחד ותמיד קיים. הסוג שלו הוא f עבור חופשי (free), הדור שלו הוא 65535, והוא עומד בראש הרשימה המקושרת (linked list) של מספרי אובייקטים פנויים. בקובץ שמעולם לא נערך הרשימה הפנויה היא רק הרשומה האחת הזו, עניין פורמלי. היא מצדיקה את קיומה במהלך עדכונים אינקרמנטליים, כאשר מחיקת אובייקט מוסיפה את המספר שלו לרשימה זו כדי שעריכה מאוחרת יותר תוכל לדרוש אותו מחדש. הרשומות האחרות הן מסוג n עבור בשימוש (in-use), והמספר בן עשר הספרות שלהן הוא ההיסט שהייתם מחפשים אליו כדי לקרוא את הגדרת האובייקט ההוא
ה-trailer הוא נקודת הכניסה, והוא יושב בסוף
ה-trailer הוא הדבר הראשון שקורא למעשה צורך, למרות שהוא נכתב אחרון. מנתח פותח את הקובץ, מחפש לסוף, והולך אחורה בחיפוש אחר %%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
מילון ה-trailer נושא את שני הערכים שקורא צריך לפני שהוא יכול לעשות כל דבר אחר. /Root מצביע על קטלוג המסמך, אובייקט 5 כאן, שהוא בראש גרף האובייקטים והמסלול לעץ העמודים. /Size הוא ספירת הרשומות שטבלת ההפניות הצולבות צריכה להכיל, שהיא אחת יותר ממספר האובייקט הגבוה ביותר בגלל הרשומה הפנויה במשבצת אפס. מ-%%EOF נופל כל רצף הקריאה: מצאו את הסמן, קראו startxref כדי לאתר את הטבלה, טענו את הטבלה כדי ללמוד היכן כל אובייקט חי, קראו את /Root כדי למצוא את הקטלוג, ופתרו (resolve) אובייקטים לפי דרישה משם. הכותרת, שיושבת למעלה, בקושי מתייעצים בה עד לשלב מאוחר. המפה בתחתית היא מה שהקורא צריך קודם
עדכון אינקרמנטלי מוסיף מפה שנייה במקום לשכתב
העיצוב הזה מהזנב-קודם משתלם כשקובץ משתנה. ניתן לערוך PDF מבלי לשכתב אף אחד מהבתים שכבר נמצאים בדיסק. אובייקטים חדשים ומשתנים מצורפים לסוף (appended), ואחריהם מקטע הפניה צולבת רענן ו-trailer רענן, והקובץ המקורי מתחת נותר ללא מגע. פיסת ניהול החשבונות החדשה היחידה היא רשומת /Prev ב-trailer החדש, המחזיקה את היסט הבתים של טבלת ההפניות הצולבות הקודמת:
% ... 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 יוצרים רשימה מקושרת מטה לאורך הקובץ, כל אחד דורס את זה שלפניו עבור האובייקטים שהוא נוגע בהם. אובייקט שעריכה החליפה עדיין קיים פיזית בהיסט הישן שלו; פשוט כבר לא ניתן להגיע אליו, כי רשומת xref מאוחרת יותר מצביעה למקום חדש יותר
זהו המנגנון שהופך מסמכי PDF חתומים לניתנים לאימות. חתימה דיגיטלית מכסה טווח בתים של הקובץ, ומכיוון שעדכון אינקרמנטלי רק מוסיף לסוף, הבתים החתומים לעולם לא זזים. החתימה עדיין מאמתת מול הטווח המקורי בעוד מהדורות מאוחרות יותר יושבות מעבר אליו, כל אחת עם xref ו-trailer משלה. זו גם הסיבה ש-PDF יכול לשאת היסטוריה ניתנת לשחזור: כל אובייקט שהוחלף עדיין נמצא בדיסק תחת מקטע הפניה צולבת מוקדם יותר, וזוהי תכונה למעקב אחר גרסאות וחבות עבור כל מי שחשב ש"מחיקה" פירושה שהבתים נעלמו
העלות היא גדילה. כל עריכה מוסיפה לסוף; שום דבר לא משוחרר במקום, כך שקובץ שנערך פעמים רבות צובר אובייקטים מתים ושרשרת ארוכה של מקטעי xref. התרופה היא שכתוב מלא: טענו את המסמך ושמרו אותו מחדש, מה שממספר מחדש את האובייקטים ששרדו, משמיט את אלה שאינם ניתנים להגעה, ופולט טבלת הפניות צולבות אחת נקייה. שתי האסטרטגיות סוחרות זו מול זו ישירות. הוספה לסוף היא מהירה ומשמרת חתימות והיסטוריה; שכתוב הוא איטי יותר וזורק את שניהם, בתמורה לקובץ קומפקטי
קריאת ארבעת החלקים בפועל
הכרת הפריסה מספיקה כדי לדבג את רוב בעיות "קובץ זה לא נפתח" באופן ידני. אם קורא דוחה PDF, האשמים הרגילים נמצאים בשני הקצוות, לא באמצע. הורדה קטומה מאבדת את ה-trailer, כך ש-startxref או %%EOF חסרים ולקורא אין נקודת כניסה; קוראים סובלניים נופלים חזרה לסריקת כל הקובץ כדי לבנות מחדש את ה-xref, שזה בדיוק הנתיב האיטי שהטבלה נועדה להימנע ממנו. העברה שגויה של מצב טקסט משחיתה בתי זרם או שההיסטים מפסיקים להתאים למציאות, ואובייקטים נטענים מהמיקום הלא נכון. כאשר ההיסטים בטבלה כבר לא מצביעים על מילות מפתח obj אמיתיות, הקובץ שבור מבחינה מבנית גם אם כל אובייקט הוא בסדר באופן אינדיבידואלי
עבור קוד חדש, הלקח של הפריסה הוא לתת לספרייה להיות הבעלים של ניהול הבתים. ההיסטים בטבלת ההפניות הצולבות חייבים להסכים עם המיקומים בפועל של כל אובייקט עד הבית, ה-trailer חייב להצביע על הטבלה הנכונה, ועדכונים אינקרמנטליים חייבים להיות משורשרים כראוי דרך /Prev. רכיב מקורי (native) כמו HotPDF Component עבור Delphi ו-C++Builder מטפל בכל זה כאשר הוא כותב קובץ, כולל הבחירה בין הוספת מהדורה אינקרמנטלית לשכתוב קומפקטית. אם אתם רוצים לראות את אותו מבנה נבנה מכלום במקום מפורק, מאמר הלוויה על בניית מסמך PDF מאפס עובר דרך פליטת הכותרת, האובייקטים, ה-xref וה-trailer לפי הסדר