פתח קובץ PDF שנוצר על ידי Microsoft Word או Excel, דפדף בו, ושום דבר לא נראה יוצא דופן. טען אותו לתוכנית Delphi, קרא את מספר העמודים בחזרה, והמספר נכון. לאחר מכן שמור אותו מחדש עם הצפנה מופעלת והפעולה נכשלת עם EListError, או שהפלט נפתח עם אזהרת הפניה צולבת פגומה. הקובץ מעולם לא היה מושחת. זהו קובץ עם הפניה היברידית (hybrid-reference), והמבנה המאפשר למציג בן חמש עשרה שנה לפתוח אותו הוא בדיוק המבנה שמכשיל טוען שמפסיק לקרוא מוקדם מדי.
זוהי אחת הדרכים הנפוצות ביותר שבהן תהליך עיבוד PDF שעבר כל בדיקה פנימית פוגש קובץ שאינו יכול לעבד ולשמור מחדש בצורה תקינה. כל הקלטים נוצרו באופן פנימי, ולכן הם מעולם לא היו היברידיים. הקובץ ההיברידי הראשון מגיע ביום שבו לקוח מעביר חשבונית שיוצאה מגיליון אלקטרוני.
מה ש-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, שה-trailer שלה מצביע על ה-/XRefStm שבו נמצאים נתוני השחזור בפועל.
מדוע ספירת עמודים נכונה אינה מוכיחה דבר
מכיוון שהקטלוג ועץ העמודים נגישים מהטבלה הקלאסית בכוונה, טוען שקורא רק את הטבלה הזו מוצא את /Root, עובר על עץ העמודים ומדווח על מספר העמודים הנכון. כל מה שקורא ישן צריך נמצא שם, ולכן הקובץ נראה תקין. האובייקטים שהלכו לאיבוד הם אלו שנארזו בזרמי אובייקטים: מילוני שדות AcroForm, רכיבי מבנה של tagged-PDF, והזנב הארוך של מילונים קטנים שמעולם לא היו צריכים להיות גלויים למציג מיושן.
אינך מבחין בפער עד שמשהו נוגע באובייקטים האלה, ושמירה מחדש מלאה נוגעת בכולם. מעבר על המסמך כדי להצפין אותו מחדש או לשכתב אותו היא בדיוק הפעולה שמבקשת כל מספר אובייקט בתורו, וזו הסיבה שהסימפטום צץ בזמן השמירה ולא בזמן הטעינה, רחוק מהגורם לו.
המלכודת היא גלאי שרואה xref ועוצר
הדרך הזולה להחליט כיצד קובץ מאונדקס היא לעקוב אחר startxref ולבדוק את הבתים הראשונים שהוא מצביע עליהם. מילת המפתח xref פירושה טבלה קלאסית; אובייקט זרם פירושו זרם הפניה צולבת. בדיקה זו נכונה עבור כל קובץ שמתחייב לשיטה אחת. היא שגויה עבור קובץ היברידי, שה-startxref שלו מכוון למקטע קלאסי למטרה היחידה של סיפוק קוראים ישנים, בעוד ה-/XRefStm ב-trailer של אותו מקטע הוא המקום שבו רוב המסמך מאונדקס בפועל. גלאי שמחזיר "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;
כאשר גלאי היציאה המוקדמת נמצא במקומו, הטעינה נראית תקינה והשמירה מחדש היא המקום שבו האובייקטים החסרים מכריזים על עצמם. התיקון אינו לקרוא עוד בתים בהתחלה; הוא לזהות את ה-trailer ההיברידי ולעקוב אחר /XRefStm לפני שמחליטים שהקובץ הושלם.
סדר המיזוג אינו נתון למשא ומתן
ברגע ששני האינדקסים נקראו, ניתן לשלב אותם בכיוון אחד בלבד. יש למזג תחילה את זרם ההפניה הצולבת, כאשר הרשומות הקלאסיות ימלאו את המרווחים סביבו. הסיבה היא ההטעיה הקטנה בלב הפורמט. קובץ היברידי מסמן את האובייקטים הדחוסים שלו כחופשיים בטבלה הקלאסית כדי שקוראים ישנים יתעלמו מהם. טוען המכבד מדיניות של "הראשון שמופיע מנצח" וקורא תחילה את הטבלה הקלאסית ירשום את מספרי האובייקטים הללו כחופשיים, ואז יפטר מרשומות הזרם שממקמות אותם בפועל, מכיוון שהמשבצות כבר תפוסות. הפוך את הסדר והרשומות מסוג 2 מהזרם, שכל אחת מהן היא מספר זרם אובייקטים פלוס אינדקס, זוכות במשבצות שהן אמורות להחזיק בהן, והרשומות הקלאסיות מתמקמות סביבן.
אותה משמעת מגינה מפני גרסה ישנה יותר המחזירה לחיים אובייקט שנמחק. עדכונים תוספתיים משתרשרים לאחור דרך /Prev, וכניסה חופשית מסוג 0 היא סימן שמקטע עדכני יותר ביטל מספר אובייקט. אסור לאפשר למקטע מאוחר יותר אך ישן יותר בשרשרת לדרוס את אותו סימן עם מיקום מיושן. התייחס לראשון-שנראה כמוסמך עבור סימונים חופשיים והאובייקט שנמחק יישאר מחוק; treat it carelessly and a file's own history reanimates content the latest revision removed.
מה המשמעות של זה ב-HotPDF
המנוע פותר קובצי הפניה היברידית עבורך, והוא עושה זאת בכל נתיב שצריך לנתח את נתוני ההפניה הצולבת. טען מסמך עם LoadFromFile או LoadFromStream, בצע את השינויים שלך וקרא ל-SaveLoadedDocument; או הרץ פעולה חד-פעמית כגון 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]);
הפרט ששווה לזכור נמצא לפני ה-API. קבצים המגיעים מ-Word, Excel, PowerPoint ומרשימה ארוכה של תהליכי "שמור כ-PDF" הם היברידיים כבשגרה, כך שטוען שאתה מריץ רק מול פלט המחולל שלך עשוי שלא לפגוש אחד כזה בבדיקות. הזן את בדיקות התוכנה שלך במסמכים שיוצאו מאפליקציות Office אמיתיות, ולא רק בקבצים שהקוד שלך הפיק.
בדיקת קובץ שאתה חושד בו
שתי בדיקות פותרות את השאלה במהירות. פתח את הקובץ בתצוגת hex וקרא את הבתים שאחרי ה-startxref האחרון; קובץ היברידי מציג מקטע קלאסי קצר שמילון ה-trailer שלו מכיל /XRefStm. או השווה את ספירת האובייקטים שניתוח מלא מדווח עליה מול מספר האובייקט הגבוה ביותר ש-/Size מצהיר עליו ב-trailer. פער גדול פירושו שאובייקטים מסתתרים בזרמים שהטוען לא פתח, וזהו אותו גרעון שהופך לכשל בזמן השמירה מאוחר יותר.
צד הכותב בסיפור זה, כיצד מיוצרים מלכתחילה זרמי אובייקטים והפניות צולבות דחוסות, מכוסה ב-מאמר שלנו על זרמי אובייקטים ועדכונים תוספתיים. כאשר הקובץ ההיברידי המדובר הוא גם גדול מאוד, טכניקות הטעינה ב-מדריך ה-Direct File API לזרימות עבודה של קובצי PDF גדולים מאפשרות לך לבדוק אותו מבלי לקרוא את כולו לזיכרון. שניהם משתלבים באופן טבעי עם השחזור המתואר כאן, אשר נשלח כחלק מ-HotPDF Component עבור Delphi ו-C++Builder לצד ממשקי ה-API לטעינה, עריכה, הצפנה וחתימה המכוסים במקומות אחרים בבלוג זה.