מאמר טכני

סדר עמודים ב-PDF: כיצד עץ העמודים שולט ברצף העמודים

אובייקט מספר 1 אינו עמוד 1. העובדה הבודדת הזו מכשילה יותר קוד עיבוד PDF מכל היבט אחר של הפורמט, והבנת הסיבה דורשת להסתכל מעבר למה שקורא (viewer) מראה לך ולתוך גרף האובייקטים שהקורא קורא בפועל

קובץ PDF הוא אוסף של אובייקטים עקיפים ממוספרים. עמודים נמנים על אותם אובייקטים, אך רצף התצוגה שלהם אינו קשור למקום שבו הם יושבים בקובץ או למספרים שהם נושאים. סדר התצוגה נקבע כולו על ידי עץ ה-/Pages, מבנה מקושר שמושרש בקטלוג המסמך. אם תתעלם מהעץ ותסרוק אובייקטים באופן מספרי, תרכיב עמודים בסדר הלא נכון עבור חלק ניכר מהקבצים בעולם האמיתי

עץ העמודים: מה שקובע בפועל את הסדר

כל PDF מתחיל בקטלוג מסמך (ISO 32000-2 §7.7.2). הקטלוג מחזיק רשומת /Pages המצביעה לצומת השורש של עץ העמודים. צומת שורש זה הוא מילון עם /Type /Pages, מערך /Kids של הפניות עקיפות, ו-/Count הנותן את סך כל עמודי העלים מתחתיו. סדר התצוגה הוא חציית עומק-תחילה משמאל-לימין של העץ הזה, נקודה

קובץ מינימלי בן שלושה עמודים הופך זאת לקונקרטי:

%PDF-1.7

1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

2 0 obj
<< /Type /Pages /Kids [20 0 R  4 0 R  9 0 R] /Count 3 >>
endobj

% אובייקט 4 מאוחסן שלישי בקובץ אך הוא עמוד 2 בסדר התצוגה
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% אובייקט 9 מאוחסן רביעי אך הוא עמוד 3
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 10 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% אובייקט 20 מאוחסן אחרון אך הוא עמוד 1; Kids[0] קובע, לא מספר האובייקט
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 21 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

מערך ה-/Kids קורא [20 0 R 4 0 R 9 0 R], לכן אובייקט 20 הוא עמוד 1, אובייקט 4 הוא עמוד 2, ואובייקט 9 הוא עמוד 3. מספור האובייקטים אינו רלוונטי. כל קוד שמעביר אובייקטים בלולאה בסדר מספרי ואוסף את אלה עם /Type /Page ייצר את הרצף השגוי בקובץ זה

מדוע מחוללים (generators) מייצרים פריסות לא רציפות? מספר סיבות. ספרייה המקצה מראש מספרי אובייקטים לכל העמודים לפני כתיבת תוכנם תמספר אותם בסדר יצירה, ואז תכתוב בתים בפועל בכל סדר שמתאים למסריט (serializer). כלי מיזוג שתופר מסמכים יחד ממספר מחדש אובייקטים מכל מסמך מקור כדי למנוע התנגשויות; אובייקטי העמודים שמוספרו מחדש מסיימים מפוזרים ברחבי טבלת האובייקטים המשולבת בעוד מערך ה-/Kids של השורש החדש מחזיק את סדר התצוגה הנכון. עדכונים מצטברים מצרפים אובייקטים חדשים בסוף הקובץ עם מספרים טריים, כך שעמוד שנוסף כמהדורה חי ליד קצה זרם הבתים גם אם הוא שייך למיקום 1 בסדר התצוגה

עצים שטוחים ותתי-עצים מקוננים

המפרט מאפשר שתי צורות לעץ העמודים. מחוללים פשוטים מייצרים מבנה שטוח: צומת /Pages שורש אחד שמערך ה-/Kids שלו לא מכיל דבר מלבד אובייקטי עלה של /Page. קל לחצות את זה: עומק רמה אחת, מעבר אחד

מסמכים גדולים משתמשים בשגרה בעץ מאוזן במקום זאת. מערך ה-/Kids של צומת ה-/Pages השורש מכיל צומתי /Pages ביניים, שכל אחד מהם בתורו מחזיק מערך /Kids משלו. ה-/Count על כל צומת ביניים מדווח על המספר הכולל של עמודי עלים בתת-העץ שלו, כך שקורא יכול לדלג על תתי-עצים שלמים בעת קפיצה לעמוד לפי אינדקס מבלי לנתח כל אובייקט. מסמך בן 1000 עמודים המובנה כעץ מאוזן עם 10 עמודים לצומת עלה יכול לאתר את עמוד 750 על ידי חיפוש בינארי דרך שלוש או ארבע בדיקות מילון במקום לסרוק 750 רשומות /Kids

ההשלכה על קוד העיבוד: אינך יכול להניח שהרמה הראשונה של /Kids מכילה אובייקטי /Page. יש לבדוק כל ילד. אם ה-/Type שלו הוא /Pages, בצע רקורסיה לתוכו. אם ה-/Type שלו הוא /Page, הוא עלה. עצירה ברמה הראשונה מפילה בשקט תתי-עצים שלמים על כל מסמך שבו המחולל בחר לקנן

מאפייני עמוד בירושה (Inherited page attributes)

עץ העמודים נושא גם מנגנון שיתוף משאבים. מאפייני עמוד מסוימים: /MediaBox, /CropBox, /Resources, ו-/Rotate ניתנים להורשה (ISO 32000-2 §7.7.3.4). אם מילון /Page משמיט אחד מהם, קורא הולך במעלה שרשרת ה-/Parent עד שהוא מוצא את המאפיין או מגיע לשורש. הצבת מילון גופנים משותף בצומת /Pages השורש במקום להעתיק אותו לכל עמוד עלה יכולה להפחית את גודל הקובץ במידה ניכרת עבור מסמכים המשתמשים באותם גופנים לכל אורכם

כלל ההורשה יוצר דקות (subtlety) עבור קוד שקורא מאפייני עמוד. קריאת /MediaBox ישירות מאובייקט /Page והתייחסות למפתח חסר כשגיאה היא שגויה; ייתכן שהמפתח פשוט מורש. קוד הפותר נכון גיאומטריית עמוד חייב לעקוב אחר שרשרת ההורה. הוא גם דורש שומר מעגל (cycle guard): קובץ פגום יכול להיות בעל הפניית /Parent המצביעה חזרה לצומת שכבר בוקר, מה שיגרום ללולאה אינסופית ללא בדיקת אובייקט-שבוקר

טבלת ההצלבה וזרמי הצלבה (xref)

בדיקת אובייקטים עקיפים עוברת דרך טבלת ההצלבה (או יורשתה, זרם ההצלבה שהוצג ב-PDF 1.5). ה-xref ממפה כל מספר אובייקט להיסט בתים בתוך הקובץ. קורא תואם (conforming reader) משתמש ב-xref כדי לקפוץ ישירות לכל אובייקט; הוא אינו סורק את הקובץ ברצף. עיצוב גישה אקראית זה הוא מה שהופך קפיצה מהירה לעמודים לאפשרית: הצופה קורא את הקטלוג, פותר את ההפניה ל-/Pages דרך ה-xref, קורא את צומת /Pages השורש, פותר רשומת /Kids, וכן הלאה, ונוגע רק באובייקטים שהוא צריך

עדכונים מצטברים מוסיפים קטע xref חדש בסוף הקובץ עם trailer שמשתרשר בחזרה לקודם. אובייקט שעודכן במהדורה מקבל רשומה חדשה בקטע ה-xref המצורף; הבתים המקוריים נשארים במקומם אך מוחלפים. כך מסמכי PDF חתומים דיגיטלית נשארים ניתנים לאימות גם לאחר הוספת הערות או מהדורות מילוי טפסים: טווח הבתים החתומים לעולם אינו מקבל נגיעה, והתוכן החדש חי בקטע המצורף. גם עץ העמודים יכול להתעדכן, כך שתוספות או מחיקות של עמודים במהדורה מייצרות שורש /Pages חדש עם מערך /Kids מתוקן, בעוד אובייקט השורש הישן עדיין תופס את מיקומו המקורי בקובץ

מה משתבש ללא חציית עץ

מצב הכשל עבור גישות סריקת-אובייקטים הוא שקט. מסמך הפלט נראה סביר: יש לו המספר הנכון של עמודים וכל עמוד מכיל תוכן שניתן לזהות. הסדר פשוט שגוי, ושגוי בדרך שתלויה במחולל, במספר המהדורות, ובשאלה האם עמודים כלשהם מוזגו ממקורות חיצוניים. קורפוס בדיקה של קבצים שהופקו על ידי כלי יחיד עשוי לעבור לחלוטין; קבצים מכלי שונה או מתהליך מיזוג ייכשלו. חוסר העקביות הזה הוא הסיבה שתיקונים היוריסטיים (heuristic fixes) לעולם אינם מחזיקים מעמד

קבצי עדכון-מצטבר מועדים לכך במיוחד מכיוון שעמודים שנוספו או סודרו מחדש במהדורות מאוחרות יותר נושאים מספרי אובייקטים גבוהים בעוד שסדר התצוגה נשלט על ידי מערך ה-/Kids המעודכן. סריקה המעבדת אובייקטים בסדר מספרי תציב עמודים הממוספרים-מאוחר אלה בסוף, ללא קשר למקום אליו העץ אומר שהם שייכים

התיקון אינו מסובך. התחל בקטלוג, פתור את הפניית ה-/Pages, עבור על מערך ה-/Kids באופן רקורסיבי, ופלוט עלים בסדר שבו אתה נתקל בהם. זהו סדר התצוגה בהגדרה, ללא קשר למספרי אובייקטים, היסטי בתים או מבנה קובץ. רוב ספריות ה-PDF הבשלות חושפות ספירת עמודים ומאחזר עמודים ממונדקס (indexed page accessor) שכבר עושים זאת נכון; הסיכון הוא בקוד שעוקף את מודל העמודים של הספרייה ונוגע בשכבת האובייקטים ישירות

חריגה מבנית אחת שכדאי לטפל בה במפורש: הערך /Count על צומת ביניים /Pages יכול להיות שגוי בקבצים פגומים (malformed files). אמון ב-/Count לבדיקת גבולות ואז עצירה לפני חצייה מלאה תשמיט בשקט עמודים כאשר הספירה מוצהרת בחסר. שימוש ב-/Count רק כרמז ביצועים להקצאת קיבולת מראש או לחיפוש בינארי, וגזירת הספירה בפועל מהחצייה, היא התבנית הבטוחה יותר עבור מסמכים חשובים

המאמר הבא