מאמר טכני

טיפול במסמכי PDF עם הפניות היברידיות מיישומי Office ב-Delphi

ייצאו מסמך מ-Microsoft Word או Excel באמצעות 'שמור כ-PDF' והקובץ על הדיסק הוא, ברוב המקרים, קובץ עם הפניות היברידיות (hybrid-reference file). הוא נושא את מידע ההפניות הצולבות (cross-reference) שלו פעמיים: פעם אחת כטבלה הקלאסית ברוחב-קבוע שסיימה כל קובץ PDF עד לגרסה 1.4, ופעם אחת כזרם הפניות צולבות (cross-reference stream) דחוס שרוב המסמך תלוי בו בפועל. מפתח יחיד ב-trailer, ששמו /XRefStm, מחבר את שתי התצוגות יחד, והאם כלי רואה את המסמך כולו תלוי בשאלה האם הוא עוקב אחר מפתח זה

מאמר זה בוחן קבצים היברידיים מצד הצרכן: איך נראים הבתים (bytes) בסוף הקובץ, כיצד שתי התצוגות מתרחקות זו מזו תחת עריכה, וכיצד צינור נתונים (pipeline) ב-Delphi יכול לזהות ולנתב קלטים היברידיים. כיצד טוען (loader) ממזג את התצוגות, ומדוע הסדר אינו פתוח למשא ומתן, הוא נושא של מאמר ה-HotPDF שלנו בנושא טעינת קבצי הפניות היברידיות; מאמר זה עוסק בזיהוי הפריסה (layout) מלכתחילה

מדוע ייצואים מ-Office כותבים את האינדקס פעמיים

PDF 1.5 הציג שתי תכונות ששינו את צורת הקובץ: זרמי הפניות צולבות (cross-reference streams), המאחסנים את אינדקס האובייקטים כנתונים בינאריים דחוסים במקום טבלת טקסט רגיל (plaintext), וזרמי אובייקטים (object streams), הדוחסים הרבה אובייקטים קטנים לתוך קונטיינר (container) אחד דחוס ב-Flate. כותב המשתמש בהם מייצר קבצים קטנים יותר, אך קורא PDF 1.4 אינו יכול לפתוח את התוצאה, מכיוון שהמבנים שהוא מסתמך עליהם, מילת המפתח xref ומילון ה-trailer, אינם קיימים עוד

ISO 32000-1 §7.5.8.4 מגדיר את הפשרה. קובץ עם הפניות היברידיות כותב את שניהם: טבלת הפניות צולבות קלאסית הפונה לאובייקטים שקורא ישן חייב להגיע אליהם, הקטלוג (catalog) ועץ העמודים (page tree) ביניהם, וזרם הפניות צולבות שמאנדקס את כל השאר. אובייקטים שקופלו לתוך זרמי אובייקטים מסומנים כפנויים (free) בטבלה הקלאסית, כך שקורא 1.4 מדלג עליהם ללא תלונה; מיקומם האמיתי קיים רק בזרם. ה-trailer הקלאסי אז נושא מפתח /XRefStm המחזיק את היסט הבתים (byte offset) של אותו זרם. מציג ישן לעולם לא קורא את המפתח ומרנדר את הקובץ מתצוגת הטבלה. מציג מודרני עוקב אחריו ורואה את המסמך המלא. Word ו-Excel פלטו בדיוק את הפריסה הזו במשך שנים, וזו הסיבה שקבצים היברידיים אינם מקרה קצה אקזוטי אלא חלק גדול ממה שצינורות נתונים עסקיים מקבלים

איך נראה הזנב של קובץ היברידי

הכי קל להבין את הפריסה מהבתים. הנה הזנב (tail) של קובץ היברידי קטן, היסטים מקוצרים; בייצוא אמיתי של Office ערך ה-/XRefStm הוא בדרך כלל היסט גדול קרוב לסוף הקובץ. סדר הקריאה הוא הליכה מהזנב-קודם המתוארת בסקירת מבנה קובץ PDF שלנו: מצאו את %%EOF, קראו את startxref, קפצו לטבלה

% ... body objects, including object streams and, at byte 116,
% the cross-reference stream (a stream object with /Type /XRef) ...

xref                    % classic section: what startxref points at
0 4
0000000000 65535 f      % slot 0: head of the free list, always present
0000000017 00000 n      % object 1: the catalog, visible to any reader
0000000000 65535 f      % object 2: marked free -- lives in an object stream
0000000000 65535 f      % object 3: same; only the stream view locates it
trailer
<<
  /Size 4
  /Root 1 0 R
  /XRefStm 116          % byte offset of the cross-reference stream
>>
startxref
7164                    % byte offset of the 'xref' keyword above
%%EOF

שני פרטים בזריקה (dump) זו נושאים את המנגנון כולו. ראשית, startxref מצביע על המקטע הקלאסי, בכוונה: זו הכתובת שקורא ישן חייב לנחות עליה. ניתן להגיע לזרם ההפניות הצולבות רק דרך מפתח /XRefStm בתוך מילון ה-trailer, כך שמנתח (parser) שלעולם אינו מחפש מפתח זה לעולם לא לומד על קיום הזרם. שנית, אובייקטים 2 ו-3 הם שקרים מסוג לא מזיק. הטבלה הקלאסית מכריזה עליהם כפנויים, אך הם אובייקטים אמיתיים היושבים בתוך קונטיינר דחוס; סימון ה-free הוא מה שמונע מקורא 1.4 למעוד על כניסות שהוא אינו יכול להשתמש בהן. צרכן שסומך על התצוגה הקלאסית בלבד מסיק שרוב המסמך הזה אינו קיים

כיצד שתי התצוגות מתרחקות זו מזו

קובץ היברידי טרי מ-Word הוא עקבי פנימית: שתי התצוגות מתארות את אותו מסמך, כל אחת בתחום המוצהר שלה. הצרות מתחילות כאשר הקובץ נערך על ידי כלי שמבין רק אחת מהתצוגות. תארו לעצמכם שירות החתמה (stamping utility) המצרף עדכון אינקרמנטלי בסגנון קלאסי: אובייקטים חדשים, מקטע xref חדש, שרשרת /Prev למקטע הקודם, ו-trailer חדש. אם ה-trailer הזה משמיט את המפתח /XRefStm, תצוגת הזרם נשארת יתומה; אם הוא מעתיק את הערך הישן קדימה, תצוגת הזרם עדיין מתארת את המסמך כפי שהיה לפני העריכה. כך או כך, שני האינדקסים אינם מסכימים כעת לגבי מה שהקובץ מכיל

לקובץ המתקבל יש חתימת כשל מובחנת: אובייקטים הגלויים בתצוגה אחת חסרים או ישנים (stale) באחרת. קורא הפותר (resolves) דרך תצוגת הזרם מוצא את הגרסה שלפני העריכה של אובייקט שעודכן, או לא מוצא שום כניסה עבור אובייקט שצורף. קורא בתצוגת הטבלה רואה את העריכה אך מאבד מעקב אחר האובייקטים הדחוסים שרק הזרם יכול לאתר. בפועל זה צף כשדות טופס ששורדים במציג אחד ונעלמים באחר, הערות (annotations) שנראה שמהלך החתמה מחק, או חיפושים שנוחתים על האובייקט הלא נכון לחלוטין

מה שהופך את הקבצים הללו ליקרים לדיבוג הוא ש-Adobe Acrobat בדרך כלל פותח אותם ללא תלונה: כאשר האינדקס אינו מסכים עם הבתים, הוא בונה מחדש בשקט את נתוני ההפניה-הצולבת על ידי סריקה אחר כותרות אובייקטים, כך שמי שהפיק את הקובץ השבור לא רואה שום דבר רע. הכשל צף מאוחר יותר, כאשר הקובץ מגיע לצרכן קפדני, מאמת preflight, שירות חתימה, או עבודת קליטה בארכיון, שסומך על המבנה המוצהר ומדווח על אובייקטים חסרים או אי-התאמה של הפניות-צולבות. "זה נפתח מצוין ב-Acrobat" הוא איך שכמעט כל כרטיס תמיכה על חוסר סנכרון היברידי מתחיל

זיהוי קובץ היברידי ב-Delphi טהור

סיווג קלטים אינו דורש ספריית PDF. מפתח /XRefStm יכול להתרחש רק בתוך מילון trailer קלאסי, וה-trailer הפעיל יושב בתוך הקילובייטים האחרונים של הקובץ, מכיוון שהמפרט דורש ש-%%EOF יופיע קרוב לסוף הפיזי. קריאת חלון-זנב תחום וחיפוש בו מספיקה לטריאז' (triage):

uses
  System.SysUtils, System.Classes, System.StrUtils, System.Math;

function IsHybridReferencePdf(const FileName: string): Boolean;
const
  TailWindow = 2048;
var
  Stream: TFileStream;
  Buf: TBytes;
  Tail: string;
  Len, TrailerPos, NextPos, KeyPos, StartXrefPos: Integer;
begin
  Result := False;
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if Stream.Size < 48 then
      Exit;
    Len := Min(TailWindow, Integer(Stream.Size));
    SetLength(Buf, Len);
    Stream.Position := Stream.Size - Len;
    Stream.ReadBuffer(Buf[0], Len);
  finally
    Stream.Free;
  end;

  // Every keyword involved is 7-bit ASCII, so a byte-wise decode is safe
  Tail := TEncoding.ANSI.GetString(Buf);

  // Find the LAST 'trailer' keyword: with incremental updates,
  // the newest trailer is the one that governs the file
  TrailerPos := 0;
  NextPos := Pos('trailer', Tail);
  while NextPos > 0 do
  begin
    TrailerPos := NextPos;
    NextPos := PosEx('trailer', Tail, NextPos + 1);
  end;
  if TrailerPos = 0 then
    Exit;  // no classic trailer: a pure xref-stream file, not hybrid

  // A hybrid trailer carries /XRefStm between 'trailer' and 'startxref'
  KeyPos := PosEx('/XRefStm', Tail, TrailerPos);
  StartXrefPos := PosEx('startxref', Tail, TrailerPos);
  Result := (KeyPos > 0) and
    ((StartXrefPos = 0) or (KeyPos < StartXrefPos));
end;

שלוש התוצאות מסתדרות עם שלוש הפריסות. לקובץ קלאסי-בלבד יש trailer אבל אין לו /XRefStm: מתקבל False. קובץ שמתחייב במלואו לזרמי הפניות צולבות אינו כולל כלל את מילת המפתח trailer, מפתחות ה-trailer שלו חיים במילון הזרם: גם כאן False, ובצדק, מכיוון שקובץ כזה הוא דחוס, לא היברידי. רק הפריסה המאונדקסת-כפליים מחזירה True

עבור שימוש בייצור, שתי הקשחות שוות את השורות הנוספות. נתחו את המספר השלם (integer) אחרי /XRefStm, חפשו לאותו היסט, ואשרו שאובייקט זרם עם /Type /XRef אכן יושב שם; קובץ קטום יכול לשאת את המפתח בזמן שהזרם נעלם, מה ששייך לקטגוריה שונה מזו של קובץ היברידי תקין. והתייחסו לגודל החלון כפרמטר: 2 KB מכסה פלט Office רגיל, אך מילון trailer גדול במיוחד יכול לדחוף את מילת המפתח מחוץ לטווח, והרחבת החלון עדיפה על פני הכרזת הקובץ כקלאסי בטעות

ניתוב קבצים היברידיים דרך צינור נתונים ב-Delphi

הזיהוי קונה לכם החלטת ניתוב. עבור קבצים שרק נקראים, מרונדרים או מאומתים, השתמשו בטוען (loader) שפותר את שתי התצוגות, ואז ודאו התנהגות במקום בתים. ה-PDFium Component מנתח את שרשרת ה-/XRefStm במהלך הטעינה, כך שטבלת האובייקטים שהקוד שלכם רואה היא הממוזגת, והבדיקות המתוארות במאמר שלנו על אימות אובייקטים וזרמי הפניות צולבות חלות ללא שינוי. אם קובץ היברידי שיצא מסנכרון ניזוק מספיק כדי לסרב להיטען, המנוע מדווח על כך דרך סט השגיאות שלו, FPDF_ERR_SUCCESS, FPDF_ERR_UNKNOWN, FPDF_ERR_FILE, FPDF_ERR_FORMAT, FPDF_ERR_PASSWORD, FPDF_ERR_SECURITY ו-FPDF_ERR_PAGE, כאשר FPDF_ERR_FORMAT הוא זה שנזק מבני מייצר. אל תסתמכו על האות הזה, עם זאת: PDFium הוא סלחן מטבעו ובונה מחדש את רוב הקבצים הלא עקביים בשקט, כך שטעינה מוצלחת מוכיחה שהקובץ ניתן לשחזור, לא ששתי התצוגות שלו מסכימות. בדיקת העקביות המשמעותית היא השוואה של מה שסיור אובייקטים מלא (full object walk) מוצא מול מה ש-/Size של ה-trailer מכריז

עבור קבצים שצינור הנתונים שלכם משנה, המדיניות הבטוחה ביותר היא להפסיק להם להיות היברידיים בכלל. טעינה ואחריה שמירה מלאה דרך HotPDF משכתבת את המסמך עם הפניה-צולבת יחידה ועקבית פנימית בצורה אחת: ללא /XRefStm, ללא תצוגה שנייה שיכולה לצאת מסנכרון, כאשר כל אובייקט בבעלות של בדיוק רשומת אינדקס אחת. הנורמליזציה הזו היא מה שאתם רוצים לפני קליטה לארכיון, לפני שירות RIP או חתימה קפדני במורד הזרם, ולאחר כל עריכה המוחלת על קלט היברידי. זה עובד כי הטוען מיזג את התצוגות כראוי בדרך פנימה, המנגנון שמאמר ההפניות ההיברידיות של HotPDF עובר עליו בפירוט

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

מסמכי PDF עם הפניות היברידיות אינם פגומים; הם גשר התאימות של הפורמט עצמו, ויישומי Office ימשיכו לייצר אותם כל עוד קוראי PDF 1.4 שורדים בבסיס ההתקנות. צינור נתונים שיכול לזהות את מפתח ה-/XRefStm, לאמת את המסמך הממוזג עם PDFium Component, ולייצר מחדש פלט נקי בעל אינדקס יחיד עם HotPDF Component מתייחס אליהם כפי שהם: קלטים רגילים עם שלט הכוונה אחד נוסף ב-trailer