object streams של PDF 1.5 אורזים הרבה אובייקטים עקיפים קטנים למכולה אחת דחוסת Flate, ו-losLab PDF Library פולט אותם בשמירה מלאה דרך הדגל PackObjectStreams. הרווח אמיתי: מאות מילוני עמוד, גופן והערה שכל אחד מהם עולה עשרות בייטים לא דחוסים מתכווצים לכמה בלובים דחוסים. המחיר הוא שכל אובייקט ארוז כעת זקוק ל-cross-reference stream שיתאר אותו
שם המחצית השנייה קורסים כותבים. בניית מכולת /ObjStm היא אריתמטיקה; ללמד את מנגנון ה-cross-reference להצביע לתוכה זהו עיצוב מחדש. כותב שמייצר מכולה תקינה לגמרי ואז מתאר את חבריה בקיזוזי type-1 רגילים ייצר קובץ ש-Acrobat יפתח בדיוק מספיק זמן כדי להכריז שהוא פגום. שני התכונות הן תכונה אחת, והמאמר הזה מכסה את צד הכתיבה של שתיהן, כפי שמוגדר ב-ISO 32000-1 §7.5.7 ו-§7.5.8
מה מכולת ObjStm בעצם מכילה
object stream הוא stream שהבייטים המפוענחים שלו הם שני אזורים מחוברים, ו-ISO 32000-1 §7.5.7 נותן למילון בדיוק שלושה מפתחות שחשובים לבנייה. /Type /ObjStm מזהה אותו, /N נותן את מספר החברים, ו-/First נותן את אורך הבייטים של אזור הכותרת — באופן שקול, את ה-offset שבו מתחיל הגוף. הכותרת היא זוגות מופרדים ברווחים של מספר אובייקט ו-offset; הגוף הוא החברים מסודרים אחד אחרי השני, כאשר כל offset נמדד מתחילת הגוף ולא מתחילת המטען המפוענח. קריאה של מכולה מפוענחת במלואה הופכת זאת לברור: למטה, /First הוא 14 כי שלוש שורות הכותרת תופסות ארבעה עשר בייטים, ואובייקט 7 יושב 55 בייטים לתוך הגוף כי אובייקט 4 מתסדר ל-54 תווים בתוספת מפריד
// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
// /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]
שני כללי חברות מוחלטים ושניהם מגיעים ישירות מ-§7.5.7. אובייקט stream לעולם לא יכול להיות חבר, כי stream נושא בייטים גולמיים שהיו צריכים להיות מקוננים בתוך stream אחר. וחבר חייב להיות ערך אובייקט שלם, לעולם לא הפניה עקיפה חשופה — אובייקט דחוס שהוא פשוט 5 0 R יוצר עקיפות שהקורא לא יכול לפתור בלי לדעת מראש לאן היא מצביעה. losLab PDF Library מסנן שני המקרים בזמן איסוף המועמדים, יחד עם מילון ההצפנה ואובייקט 0, ואז אורז את מה ששרד בקבוצות של 200 למכולה. התקרה הזו היא החלטת גישה אקראית (random-access) ולא מגבלת מפרט: קורא שרוצה חבר אחד חייב לנפח את כל המכולה, כך שמכולות גדולות מדי הופכות חיפושים קטנים ליקרים
מדוע חברי ObjStm חייבים להשתמש ברשומות cross-reference מסוג type-2?
כי לאובייקט ארוז אין byte offset לרשום. ISO 32000-1 §7.5.8 עונה על כך בשלושה סוגי רשומות ב-cross-reference stream בינארי: type 0 לאובייקטים חופשיים, type 1 לאובייקטים רגילים בשימוש המאוחסנים ב-byte offset, ו-type 2 לאובייקטים דחוסים, ששני שדות הנתונים שלהם מחזיקים את מספר אובייקט המכולה ואת אינדקס החבר בתוכה. אין דרך לבטא אובייקט ארוז בטבלת xref הקלאסית בטקסט רגיל, וזו בדיוק הסיבה ש-PDF 1.5 הציג את שתי התכונות יחד
הסדר שנובע מכך מכשיל כמעט כל מימוש ראשון, כולל שלנו. אובייקטים רגילים מקבלים רשומות type-1. מכולות ה-/ObjStm עצמן מקבלות רשומות type-1, כי מכולה היא אובייקט stream עקיף רגיל לחלוטין הכתוב ב-offset אמיתי. רק החברים מקבלים רשומות type-2. וה-cross-reference stream עצמו הוא אובייקט עקיף בקובץ, כך שהוא זקוק לרשומת type-1 משלו שמצביעה על ה-offset שבו הוא עצמו נכתב — אותו offset ש-startxref רושם. גרסה מוקדמת של הכותב שלנו החריגה מספרי אובייקט מכולה מלולאת הכתיבה במקום להחריג חברים, והתוצאה הייתה קובץ עם cross-reference stream וללא object streams כלל: קוהרנטי מבנית, ריק סמנטית, נדחה במורד הזרם. הערך /Size מסתיר off-by-one מקביל, מכיוון שהוא מספר האובייקט הגבוה ביותר פלוס אחד וה-cross-reference stream מוקצה כמספר האובייקט הגבוה ביותר, כך שגם אותו צריך לספור
קביעת גודל מערך /W: מדוע ארבעה בייטים לא מספיקים
מערך /W מצהיר על רוחב הבייטים של כל אחד משלושת השדות, ו-losLab PDF Library כותב אותו כ-/W [1 Field2 Field3] כאשר שדה 1 קבוע בבייט אחד לקוד הסוג ושדה 3 קבוע בשני בייטים, מה שמכסה גם מספרי generation עד 65535 וגם אינדקסי חברים. שדה 2 הוא זה שלא יכול להיות קבוע, כי הוא נושא שתי כמויות לא קשורות: ברשומת type-1 הוא byte offset המוגבל רק על ידי גודל הקובץ, ואילו ברשומת type-2 הוא מספר אובייקט מכולה וברשומת type-0 הוא האובייקט החופשי הבא בשרשרת. שדה 2 קבוע בארבעה בייטים עובד היטב עד שהקובץ חוצה 4 GB, ובאותו רגע כל offset מעבר לגבול נחתך בשקט וכל הטבלה הופכת לזבל. לכן הכותב סורק את הטבלה המורכבת בחיפוש אחר הערך הגדול ביותר שכל חריץ שדה-2 אי פעם יחזיק, כולל ה-offset של ה-cross-reference stream עצמו, ומרחיב את השדה עד שמונה בייטים
// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
Field2Value := XRefTable[X].ObjStrNum // type-2: container number
else
Field2Value := XRefTable[X].ObjPos; // type-1 offset / type-0 next-free
if Field2Value > MaxField2Value then
MaxField2Value := Field2Value;
end;
Field2 := 4;
while (Field2 < 8) and
(MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
Inc(Field2);
Field3 := 2; // generation numbers and member indices both fit
ברגע שהרוחבים ידועים, גודל המטען ידוע במדויק, כך שהכותב מקצה מראש את כל ה-buffer וממלא אותו לפי אינדקס; הוספת רשומות בייט-בייט ל-AnsiString הופכת את בניית הטבלה לריבועית (quadratic), מה שאף אחד לא שם לב אליו בחשבונית של עשרה עמודים וכולם שמים לב אליו במסמך עם מאתיים אלף אובייקטים. שני פרטים נוספים שומרים על שביעות רצון קוראים קפדניים. /Index מצהיר אילו טווחי מספרי אובייקט הטבלה מכסה, ובכתיבה מחדש מלאה זה פשוט [0 N] ללא פערים. וכל חריץ שהכותב לא פלט בפועל חייב ברירת מחדל של חופשי ולא בשימוש: אובייקט 0 מוביל את שרשרת החופשיים, כל חריץ חופשי מקושר לבא, וחריץ שהחזיק פעם אובייקט שנמחק שומר על מספר ה-generation שלו מוגדל באחד. ההערה הנלווית על בטיחות זיכרון בעת ניתוח קבצי PDF לא מהימנים מציגה את אותה טיעון גבולות מצד הקריאה
מדוע ה-cross-reference stream לעולם לא חייב להיות מוצפן?
כי הקורא חייב לנתח אותו לפני שהוא יכול לדעת איך לפענח כל דבר. ה-cross-reference stream הוא זה שמספר לקורא איפה מילון ה-/Encrypt נמצא; אם הבייטים שלו עצמם היו מוצפנים, הקורא היה זקוק למפתח הקובץ כדי למצוא את האובייקט שמתאר את מפתח הקובץ. losLab PDF Library אוכף זאת בפרדיקט יחיד: ShouldCryptStreamData מחזיר False בכל פעם שמילון ה-stream נושא /Type /XRef, כך שהפטור מחזיק מעמד לא משנה איזה נתיב מגיע ל-serializer
מכולת ה-/ObjStm מקבלת יחס הפוך, וחוסר הסימטריה הוא מכוון. מכולה מוצפנת בשלמותה, עם מפתח לפי מספר האובייקט שלה, בדיוק כמו כל stream אחר. חבריה אינם מוצפנים בנפרד — הם ארוזים בצורת plaintext מפוענחת שלהם, והמעבר היחיד על המכולה המורכבת מכסה אותם, כולל מחרוזות. הצפנה כפולה של החברים מייצרת קובץ שמפוענח ל-ciphertext, ומכיוון שהשכבה החיצונית מצליחה, הכשל צף כשגיאת ניתוח עמוק בגרף האובייקטים ולא כשגיאת אימות. אובייקט אחד אז נשאר מחוץ לסכימה לגמרי: במסמך מוצפן ה-Catalog נשמר כאובייקט type-1 ישיר ולעולם לא נארז, כי אריזתו הייתה מכריחה את הטוען לנפח ולפענח object stream כדי להגיע לשורש המסמך, לפני שהקשר הפענוח שהשורש עוזר לבסס נבנה במלואו
הפעלת האריזה מ-Delphi
המתג הציבורי הוא PackObjectStreams, חשוף כשדה על TPDFlibSaveOptions, כ-setter עצמאי SetPackObjectStreams, וכ-property על אובייקט המסמך. ברירת המחדל שלו מופעלת והוא מוגבל אוטומטית לפי גרסה: הכותב אורז רק כשהמסמך כבר PDF 1.5 ומעלה, והוא קורא לשומר הגרסה המינימלית הפנימי כך שמסמך ארוז מקודם ל-1.5 במקום להיות מסומן שגוי. אחרי השמירה, GetLastSaveUsedObjectStreams מדווח אם השער בפועל נפתח, וזה ה-assertion שרוצים בבדיקת רגרסיה במקום השוואת גודל בייטים
var
Doc: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('report.pdf', '') <= 0 then
Exit;
Doc.SetInformation(0, '1.5'); // packing is gated on PDF 1.5+
FillChar(Options, SizeOf(Options), 0);
Options.CompressContent := True;
Options.GarbageCollect := True; // drop orphans before packing
Options.PackObjectStreams := True;
if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
if Doc.GetLastSaveUsedObjectStreams = 1 then
Writeln('Saved with ObjStm containers and an xref stream');
finally
Doc.Free;
end;
end;
הסדר חשוב בין אריזה לאיסוף גרוסה. ניתוח נגישות חייב לרוץ קודם, כי חבר ששורד לתוך מכולה גורר את המכולה איתו — אם אובייקט חי ארוז, מספר המכולה שלו נגיש בהגדרה, וניקוי המכולה משאיר את החבר תקוע ללא דרך לאתר אותו. הרצת האספן קודם גם אומרת שאובייקטים מתים לעולם לא נכנסים למכולה כלל, ומשם מגיע רווח הגודל המצטבר. אריזה משלימה את מכווני הגודל האחרים ולא מחליפה אותם; הסקירה של אופטימיזציית גודל קובץ PDF ו-font subsetting מכסה את המכוונים שפועלים על מטען ה-stream, בעוד object streams פועלים על המבנה
גבולות שכדאי לדעת לפני ההפעלה
שמירות מצטברות לעולם לא אורזות. עדכון מצטבר מוסיף אובייקטים חדשים וחלק cross-reference חדש תוך השארת גרסאות קודמות שלמות פיזית, כך שאריזה מחדש של אובייקטים קיימים למכולות חדשות הייתה מייתמת את רשומות ה-type-1 שהגרסה הקודמת עדיין מפנה אליהן; losLab PDF Library משבית אריזה בכל פעם ש-append mode פעיל, והמאמר על עדכונים מצטברים וזרימת append-mode מכסה את הנתיב הזה במלואו. מסמכים מתחת ל-PDF 1.5 שומרים על טבלת cross-reference בטקסט רגיל ללא תנאי: צרכן 1.4 לא יודע מה /ObjStm אומר, וקידום מסמך בשקט כי הכותב העדיף קובץ קטן יותר יהיה העסקה הלא נכונה לעשות בשם הקורא. מפתח אופציונלי אחד שאנחנו מכוונים לא לפלוט הוא /Extends, ש-ISO 32000-1 §7.5.7 מגדיר כדי שמכולה תוכל לציין קודמת וקוראים יוכלו להתייחס לשרשרת מכולות כקבוצה לוגית. הוא באמת אופציונלי, כל מכולה שאנחנו כותבים היא עצמאית וניתנת לפענוח באופן עצמאי, ודילוג עליו מסיר קטגוריה שלמה של באגי מעגלים והפניות תלויות מהכותב — אם כי קוראים כמובן עדיין חייבים לכבד /Extends כשהם נתקלים בו בקבצים מיצרנים אחרים
אריזת object-stream ופלט cross-reference stream מגיעים כחלק מ-losLab PDF Library עבור Delphi ו-C++Builder, לצד אספן הגרוסה ואופטימייזר זרם התוכן שאיתם הם משתלבים; דף המוצר נושא את הפניית אפשרויות השמירה המלאה