מאמר טכני

אימות קבצי PDF דחוסים: זרמי Object ו-XRef

אתם כותבים מאמת קטן. הוא פותח PDF, מחפש עד הסוף, מוצא startxref, קורא את ההסטה (offset), ומצפה לנחות על מילת המפתח xref עם טבלת הצלבה (cross-reference) ברוחב קבוע מתחתיה. מתוך טבלה זו הוא אוסף הסטות של אובייקטים, ואז סורק לאחור כדי למצוא את מילת המפתח trailer כדי ללמוד את /Root ואת /Size. הוא עובד בצורה מושלמת על כל קובץ שיצרתם כדי לבדוק אותו. ואז מגיע קובץ שהופק על ידי גרסה נוכחית של Word, או על ידי ספרייה המכוונת ל-PDF 1.5, והמאמת מכריז עליו כשבור. אין מילת מפתח xref היכן שההסטה מצביעה, אין מילון trailer בשום מקום, וטבלת האובייקטים שהמאמת בנה כמעט ריקה. הקובץ חוקי. המאמת קורא אותו מבעד לעדשה בת חמש עשרה שנים

זוהי הסיבה הנפוצה ביותר לכך שמיבדק PDF ברמת הבתים (byte-level) שנכתב מול הפריסה הקלאסית נכשל על מסמכים מודרניים. המבנה שעליו הוא מסתמך, טבלת ההצלבה בטקסט פשוט ומילת המפתח trailer, הפך לאופציונלי ב-PDF 1.5 ונעדר לעתים קרובות. שתי תכונות החליפו אותו: זרם ההצלבה (cross-reference stream) וזרם האובייקט הדחוס. שניהם מתוארים ב-ISO 32000-1, ומאמת שאינו מודע אליהם רואה קובץ בריא כערמה של אובייקטים חסרים

מה PDF 1.5 שינתה בזנב הקובץ

ISO 32000-1 §7.5.8 מגדיר את זרם ההצלבה, ו-§7.5.7 מגדיר את זרם האובייקט מסוג /ObjStm. יחד הם מאפשרים לכותב להשמיט את שני המבנים שמנתח (parser) קלאסי מתבסס עליהם. קובץ PDF 1.5 עשוי להסתיים ללא טבלת xref כלל. במקומה, האובייקט אליו מצביע startxref הוא אובייקט זרם רגיל שהמילון שלו נושא /Type /XRef, וזרם זה מחזיק את נתוני ההצלבה בצורה בינארית קומפקטית. גם אין מילת מפתח trailer, מכיוון שהקדימון (trailer) הוא כעת המילון של הזרם עצמו. המפתחות שמנתח קלאסי צד, /Root, /Size ו-/ID, חיים בתוך אותו מילון

השינוי השני מזיז את האובייקטים עצמם. במקום לכתוב כל אובייקט עקיף בהסטת הבתים שלו, כותב יכול לארוז אובייקטים קטנים רבים, מילוני הדפים, מילוני ההערות, עץ המבנה, לתוך זרם אובייקט יחיד ולדחוס את כל המכל עם Flate. לאובייקטים הבודדים כבר אין הסטת בתים בקובץ. יש להם מיקום בתוך blob דחוס. מאמת הסורק את הבתים הגולמיים עבור 1 0 obj לעולם לא ימצא אותם, מכיוון שטקסט זה קיים רק לאחר הניפוח (inflation). מבחינת מנתח קלאסי, חצי מהמסמך פשוט נעלם

מפתחות ה-trailer הם בטקסט פשוט, אפילו בקובץ דחוס

החלק המרגיע הוא שקריאת הקדימון של זרם הצלבה אינה דורשת ניפוח של דבר. אובייקט זרם נכתב כמילון שאחריו באה מילת המפתח stream ואז הבתים הדחוסים. המילון הוא טקסט פשוט. לכן כאשר startxref מצביע על זרם הצלבה, הבתים שמיד אחרי מספר האובייקט נראים כמו מילון רגיל, ו-/Root, /Size ו-/ID יושבים שם באופן ברור, לפני שמילת המפתח stream ונתוני ה-Flate מתחילים

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

זרמי אובייקט: כותרת, ואז Flate blob

זרם אובייקט הוא מכל. המילון שלו נושא /Type /ObjStm, רשומת /N הנותנת את מספר האובייקטים הארוזים בפנים, ורשומת /First הנותנת את הסטת הבתים, בתוך הנתונים המנופחים, שבה מתחיל הגוף של האובייקט הראשון. המטען (payload) הדחוס, לאחר שנופח, מתחיל עם כותרת קטנה של צמדי מספרים שלמים מסוג /N. כל צמד הוא מספר אובייקט וההסטה של גוף אותו אובייקט יחסית ל-/First. אחרי הכותרת באים גופי האובייקטים עצמם, כשהם משורשרים (concatenated)

הרחבת זרם היא מכנית ברגע שהבתים מנופחים. אתם קוראים את המילון כדי לקבל את /N ואת /First, מנפחים את הזרם עם מפענח (decoder) Flate, עוברים על /N הזוגות המובילים כדי ללמוד איזה מספר אובייקט חי באיזו הסטה, ואז מרימים כל גוף החוצה כאילו היה אובייקט עקיף רגיל. התלות האמיתית היחידה היא מפענח ה-Flate, וכבר יש לכם אחד: Delphi מספקת את System.ZLib, ו-Free Pascal מספקת את יחידת zstream, כאשר שתיהן עוטפות את zlib ומנפחות זרם Flate גולמי ללא כל קוד צד-שלישי. שגרה (routine) שמצרפת כל אובייקט שחולץ לטבלת האובייקטים של המאמת גורמת לשאר המאמת, החלק שעובר על /Root ובודק את עץ הדפים, להתנהג בדיוק כפי שהיה מתנהג על קובץ קלאסי

מה שאתם לא חייבים ליישם

קל להעריך בהגזמה את כמות העבודה. קריאת מפתחות ה-trailer מקובץ דחוס אינה דורשת פענוח של הרשומות הבינאריות בזרם ההצלבה. זרם ההצלבה לפי §7.5.8 משתמש בשלושה סוגי רשומות, ורשומה מסוג 2, זו שאומרת אובייקט זה חי בתוך זרם אובייקט N באינדקס i, היא מה שהייתם מפענחים כדי לבנות מפת הסטות מלאה. אתם צריכים את המפה הזו כדי לפתור (resolve) אובייקטים שרירותיים לפי מספר. אינכם צריכים אותה כדי לקרוא את /Root, /Size ואת /ID, שנמצאים במילון שהוא טקסט פשוט, ואינכם צריכים אותה כדי להרחיב זרמי אובייקט, מכיוון שכל /ObjStm מכריז על התוכן שלו דרך /N ו-/First

אתם גם לא צריכים לטפל בפונקציות חזאיות (predictor) של PNG ו-TIFF שזרם הצלבה עשוי להחיל דרך ה-/DecodeParms שלו רק כדי להשיג את מפתחות הקדימון. חזאים מסננים את שורות ההצלבה הבינאריות כדי לגרום להן להידחס טוב יותר; אין להם שום קשר למילון שמקדים את הזרם. לכן, השדרוג המינימלי שהופך מאמת קלאסי למודע ל-PDF מודרני הוא קטן: כאשר startxref נוחת על זרם במקום על מילת המפתח xref, נתחו את מילון הזרם עבור מפתחות ה-trailer, והרחיבו כל אובייקט /ObjStm שאתם נתקלים בו כדי שהתוכן שלו ייכנס לטבלת האובייקטים. פענוח רשומות מסוג 2 וחזאים היא משימה נפרדת וגדולה יותר שאתם יכולים לדחות עד שתזדקקו באמת לפתרון (resolution) אובייקטים אקראיים

מדוע בדיקת תאימות חייבת להרחיב זרמים קודם

זה מפסיק להיות אקדמי ברגע שאתם מריצים בדיקת פרופיל. מאמת PDF/A או PDF/X בודק אובייקטים ספציפיים: קטלוג המסמך עבור מערך /OutputIntents, זרם ה-/Metadata עבור מנת XMP עם המזהה הנכון, כל מתאר גופן (font descriptor) עבור קובץ גופן מוטמע, הקדימון עבור /ID. בקובץ דחוס, רוב האובייקטים הללו נמצאים בתוך זרמי אובייקט. מאמת שלא הרחיב את זרמי האובייקט אינו יכול לראות את מפתחות הקטלוג, אינו יכול למצוא את המטא-נתונים, ואינו יכול למנות את הגופנים. הוא ידווח על מסמך תואם לחלוטין ככזה שחסר לו את כוונת הפלט (output intent) שלו, חסר לו את ה-XMP שלו, וחסר לו חצי מהמבנה שלו, מכיוון שהראיות שהוא צריך עדיין יושבות ב-Flate blob שהוא מעולם לא ניפח

הסדר קובע. הרחבה צריכה לקרות לפני שהבדיקות רצות, לא לצידן, מכיוון שכל בדיקה מניחה שהיא יכולה להגיע לאובייקט על פי מספר. אם אתם מחווטים בדיקת פרופיל ישירות לסריקת בתים גולמית, היא יורשת את העיוורון של המנתח הקלאסי ומייצרת הפרות כוזבות דווקא על הקבצים המודרניים שסביר להניח שהם בנויים היטב (well formed), מכיוון שהם הגיעו משרשראות כלים חדשות מספיק כדי לכתוב זרמי הצלבה מלכתחילה

לתת ל-PDFium לעשות את הניתוח עבורכם

רכיב PDFium מנתח זרמי הצלבה וזרמי אובייקט כחלק מטעינת מסמך, וזוהי הדרך המעשית להימנע מיישום ידני של שלב הניפוח-והרחבה. כאשר אתם טוענים קובץ עם רכיב ה-TPdf, האובייקטים הארוזים לתוך מכלי /ObjStm כבר פתורים, ונקודות הכניסה של האימות רואות את המסמך המורחב במלואו. ValidatePdfA מחזיר רשומת TPdfAValidationResult ששדה ה-Conformance שלה הוא ערך של TPdfAConformance כמו pac1b או pacNone, ששדה ה-Issues שלה הוא סט של הבעיות הספציפיות שנמצאו, ומתודת ה-IsCompliant שלה היא true רק כאשר רמת תאימות זוהתה וסט הבעיות ריק. מכיוון שהאובייקטים הורחבו במהלך הטעינה, מערך /OutputIntents או גופן מוטמע שחיו בתוך זרם אובייקט נמצאים, ואינם מדווחים כחסרים

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

הדבר חל גם על ValidatePdfX, שמחזירה TPdfXValidationResult עם אותה צורה. העניין בניתוב דרך PDFium הוא שהביטול-דחיסה המבני (structural decompression) שתואר לעיל קורה פעם אחת, בצורה נכונה, בתוך הטוען (loader), כך שקוד האימות שלכם לעולם אינו רואה את ההבדל בין קובץ קלאסי לבין קובץ דחוס לחלוטין. שניהם מגיעים למאמת כסט פתור (resolved) של אובייקטים

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

אם הבתים כבר נמצאים בזיכרון במקום בדיסק, אותו רצף של טעינה-ואז-אימות עובד דרך העמסת היתר (overload) של LoadDocument(const Data: TBytes), אשר לוקחת את תוכן הקובץ הגולמי ומנתחת את זרמי ההצלבה והאובייקט שלו בדיוק באותו אופן שעושה נתיב הקובץ. המסר שצריך לקחת הביתה (takeaway) עבור מאמת שנכתב ידנית הוא הכלל המבני, לא ה-API: קראו את מפתחות הקדימון ממילון הזרם כטקסט פשוט, הרחיבו כל /ObjStm עם מפענח Flate לפני שאתם עוברים על המסמך, והתייחסו לפענוח רשומות ההצלבה הבינאריות כאל המשימה הגדולה יותר והאופציונלית כפי שהיא

לאחר שהמבנה הורחב, מאמת יכול להניע את שאר תהליך העבודה (workflow) עליו. עבור רתמת preflight בשורת פקודה (CLI) המדווחת על תאימות על פני תיקייה של קלטים, ראו את המדריך שלנו לבניית CLI של דוח preflight באצווה (batch). כאשר אימות הוא שער לפני פירוק מסמך גדול, הטכניקות במדריך שלנו לפיצול מסמכי PDF לקבצים מרובים משתלבות בטבעיות עם תבנית הטען-ובדוק שמוצגת כאן. שניהם נבנים על פני משטח הטעינה והאימות של רכיב PDFium עבור Delphi ו-C++Builder