פתחו PDF שהופק על ידי Microsoft Word או Excel, דפדפו בו, ושום דבר לא נראה חריג. טענו אותו לתוך תוכנית Delphi, קראו בחזרה את ספירת העמודים, והמספר נכון. לאחר מכן שמרו אותו מחדש עם הצפנה מופעלת והמשימה נכשלת עם EListError, או שהפלט נפתח לאזהרה על הפניה צולבת פגומה. הקובץ מעולם לא היה מושחת. הוא קובץ בעל הפניה היברידית, והמבנה עצמו שמאפשר לצופה (viewer) בן חמש עשרה לפתוח אותו הוא המבנה שמביס טוען שעוצר את הקריאה מוקדם מדי
זוהי אחת הדרכים הנפוצות ביותר שבהן צינור נתונים (pipeline) של PDF שעבר כל בדיקה פנימית פוגש קובץ שהוא אינו יכול לבצע עבורו הלוך-ושוב. כל הקלטים נוצרו פנימית (in-house), ולכן הם מעולם לא היו היברידיים. הקובץ ההיברידי הראשון מגיע ביום שלקוח מעביר חשבונית שיוצאה מגיליון אלקטרוני
מה Word ו-Excel כותבים למעשה
תקן ISO 32000-1 מתאר את פריסת ההפניה ההיברידית בסעיף §7.5.8.4. יישום שרוצה תכונות של PDF 1.5 כגון זרמי אובייקטים, תוך מתן אפשרות לקורא של 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 משמעותה טבלה קלאסית; אובייקט זרם (stream object) משמעותו זרם הפניות צולבות. הבדיקה הזו נכונה לכל קובץ שמתחייב לשיטה אחת. היא שגויה עבור קובץ היברידי, שה-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 לפני שמחליטים שהקובץ סיים
סדר המיזוג אינו נתון למשא ומתן
לאחר ששני האינדקסים נקראו, ניתן לשלב אותם בכיוון אחד בלבד. יש למזג את זרם ההפניות הצולבות תחילה, כשהכניסות הקלאסיות ממולאות סביבו. הסיבה היא ההטעיה הקטנה בלב הפורמט. קובץ היברידי מסמן את האובייקטים הדחוסים שלו כפנויים בטבלה הקלאסית כדי שקוראים ישנים יתעלמו מהם. טוען המכבד מדיניות של 'הראשון-שנראה-מנצח' (first-seen-wins) וקורא את הטבלה הקלאסית ראשונה יתעד את מספרי האובייקטים הללו כפנויים, ואז ישליך את כניסות הזרם שמאתרות אותם למעשה, כי החריצים (slots) כבר תפוסים. הפכו את הסדר והכניסות מסוג 2 מהזרם, כל אחת מספר זרם-אובייקטים פלוס אינדקס, יזכו בחריצים שהם נועדו להחזיק, והכניסות הקלאסיות מתמקמות סביבן
אותה משמעת מגנה מפני עדכון ישן יותר המקים לתחייה אובייקט שנמחק. עדכונים מצטברים משתלשלים לאחור דרך /Prev, וכניסה פנויה מסוג 0 היא שומר (sentinel) המציין שמקטע עדכני יותר הוציא לפנסיה מספר אובייקט. אסור לאפשר למקטע מאוחר וישן יותר בשרשרת לדרוס את השומר הזה עם מיקום לא עדכני. התייחסו ל'ראשון-שנראה' כסמכותי עבור סמנים פנויים והאובייקט שנמחק יישאר מחוק; התייחסו לכך בחוסר זהירות וההיסטוריה של הקובץ עצמו תפיח חיים בתוכן שהגרסה העדכנית ביותר הסירה
מה זה אומר ב-HotPDF
המנוע פותר קבצים עם הפניה היברידית עבורכם, והוא עושה זאת בכל נתיב שצריך לנתח את נתוני ההפניה הצולבת. טענו מסמך עם LoadFromFile או LoadFromStream, בצעו את השינויים שלכם, וקראו ל-SaveLoadedDocument; או הפעילו פעולה חד-פעמית (one-shot) כגון 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. פער גדול אומר שאובייקטים מתחבאים בזרמים שהטוען לא פתח, וזהו אותו חסר שהופך לכישלון זמן-שמירה מאוחר יותר
הזנב של ייצוא Excel טיפוסי הופך את הבדיקה הראשונה למוחשית. כל מה שאחרי מילת המפתח xref הסופית הוא ASCII פשוט, ולכן החתימה קריאה ישירות מתצוגת hex (היסטים להמחשה, נוספו הערות)
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 הוא הסימן (tell): טבלה קלאסית עם אפס כניסות קיימת רק כדי לשאת את ה-trailer, וה-trailer קיים בעיקר כדי לומר /XRefStm 87325. גלאי שעוצר במילת המפתח xref ראה, בנקודה זו, אינדקס של כלום. כאשר תעדיפו לכתוב סקריפט לבדיקה מאשר לבדוק בעין, הסמן תמיד יושב בתוך שני הקילובייטים האחרונים של הקובץ, ולכן קריאה לאחור חסומה מספיקה
// 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): היא אומרת לכם אילו קבצים באצווה ראויים לתשומת לב לפני שהרצת השמירה מחדש מתבצעת, ושום דבר מעבר לכך. מה שטוען חייב לעשות עם ההיסט שהוא מוצא, מעקב אחרי שרשרת המקטעים, מיזוג כניסות הזרם לפני הכניסות הקלאסיות, כיבוד שומרי הכניסות הפנויות, מתואר צעד אחר צעד במאמר המלווה שלנו על טיפול בקבצי PDF עם הפניה היברידית מיישומי Office
צד הכותב של הסיפור הזה, כיצד זרמי אובייקטים והפניות צולבות דחוסות מופקים מלכתחילה, מכוסה במאמר שלנו על זרמי אובייקטים ועדכונים מצטברים. כאשר הקובץ ההיברידי המדובר הוא גם גדול מאוד, טכניקות הטעינה בהדרכת ה-Direct File API עבור תהליכי עבודה של מסמכי PDF גדולים מאפשרות לכם לבדוק אותו מבלי לקרוא את כולו לזיכרון. שניהם משתלבים בטבעיות עם השחזור המתואר כאן, שמגיע כחלק מה-HotPDF Component עבור Delphi ו-C++Builder יחד עם ממשקי ה-API של טעינה, עריכה, הצפנה וחתימה המכוסים במקומות אחרים בבלוג זה