מאמר טכני

איברים עצלים בזרמי אובייקטים וכתיבה מחדש מלאה של PDF בדלפי

כשרכיב HotPDF ל-Delphi טוען קובץ PDF 1.5 עם LoadFromFile, הוא לא מפרסר את האובייקטים הארוזים בתוך מכולות /Type /ObjStm. הוא רושם איפה כל איבר דחוס יושב ומפרסר אותו רק כשמשהו מבקש אותו. האינווריאנט העצלן הזה הוא מה שמחזיק את זמן הטעינה פרופורציונלי למה שבאמת נוגעים בו, והוא גם הסיבה שכתיבה מחדש מלאה חייבת לעשות עבודה אחת נוספת לפני שיוצאים בתים: להרחיב כל איבר שעדיין לא פוענח, כי הכתיבה מחדש עומדת לזרוק את המכולות שהאיברים האלה חיים בהן

הסימפטום שהוביל להערה הזו קל לתיאור ולא נעים לניפוי באגים. טענו קובץ שהפונטים, מרחבי הצבע ועץ המבנה שלו יושבים בזרמי אובייקטים, העבירו אותו דרך זוג היצירה BeginDoc ו-EndDoc, והפלט נפתח בלי תלונה. מספר העמודים נכון, הטקסט נראה בעמודים שבדקתם באופן נקודתי. ואז עמית פותח את עמוד 40 וגוף הטקסט מוצג בפונט מוחלף, או שפקודת Extract Text מחזירה זבל במקום שבו היה תחליף ActualText. שום דבר לא קרס. הכותב פשוט סריאל אובייקט שמעולם לא נטען, ואובייקט שלא נטען מסתריאל לכלום

מה LoadFromFile באמת שומר עבור אובייקט דחוס?

לכל רשומת cross-reference מסוג 2, LoadFromFile שומר רשומה קטנה ב-FCompactObjects: מספר האובייקט, האינדקס של הזרם המכיל בטבלת המכולות, המיקום של האיבר בתוך אותו זרם, ומצביע ParsedObject שמתחיל כ-nil. המכולה עצמה מאותרת, מפוענחת אם המסמך מוצפן, ומנופחת, אבל גופי האיברים נשארים כבתים. ISO 32000-1 §7.5.7 מגדיר את פריסת המכולה שמאפשרת את זה: כותרת של זוגות מספר אובייקט והיסט, ואחריה גופי האיברים המשורשרים אחרי /First, כך שאפשר לחלץ כל איבר בודד בלי לגעת בשכנים שלו

EnsureCompressedObjectLoaded הוא הנתיב היחיד שהופך רשומה לאובייקט. הוא מוצא את הרשומה לפי מספר אובייקט, ואם ParsedObject כבר מושם הוא מחזיר את האובייקט השמור מה-cache וסופר פגיעת cache. אחרת הוא טוען מחדש את המכולה אם היא פונתה, מחשב את טווח הבתים של האיבר מטבלת ההיסטים, מוסר למפרסר תצוגה בלי העתקה של אותו מקטע, ומאחסן את התוצאה חזרה ברשומה. מכאן ואילך האובייקט עקיף, נושא את מספר האובייקט האמיתי שלו, ורשום באינדקס האובייקטים של המסמך כמו כל אובייקט שנפרסר מגוף הקובץ. ה-catalog, מילון ה-info, שורש עץ העמודים ואובייקטי העמודים עוברים בנתיב הזה בזמן הטעינה כי הניווט צריך אותם. פונטים, מרחבי צבע, מילוני ExtGState ואיברי מבנה לא, והם נשארים כרשומות עד שרינדור עמוד או כתיבה מחדש נוגעים בהם

איך רכיב HotPDF ל-Delphi מאחסן איבר דחוס לפני שהוא נפרסר: רשומת FCompactObjects מחזיקה את מספר האובייקט, אינדקס המכולה, אינדקס האיבר ומצביע ParsedObject שערכו nil, בעוד EnsureCompressedObjectLoaded הופכת רשומה לאובייקט רשום דרך פגיעות cache, טעינה מחדש של מכולה, חיתוך לפי טבלת היסטים ופרסור בלי העתקה
LoadFromFile משאירה את גופי האיברים של /ObjStm כבתים ומפרסרת אותם רק כשקורא מבקש, כך שזמן הטעינה עוקב אחרי מה שאתם נוגעים בו — ה-catalog ועץ העמודים מגיעים מוקדם בעוד פונטים, מרחבי צבע ואיברי מבנה נשארים כרשומות

אפשר לראות את זה מבחוץ. GetLoadedObjectStreamCacheInfo מדווחת כמה מכולות קיימות, כמה איברים תועדו, וכמה מהם פוענחו עד כה:

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

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

למה כתיבה מחדש מלאה מפילה פונטים שעדכון מצטבר שומר?

כתיבה מחדש מלאה זורקת את מכולות ה-/ObjStm וה-/XRef של קובץ המקור ומסריאלית מחדש את גרף האובייקטים מאפס, כך שלכל איבר שה-ParsedObject שלו עדיין nil אין ייצוג שנשאר בפלט. לעדכון מצטבר אף פעם אין את הבעיה הזו, כי הוא מצרף אובייקטים חדשים אחרי הבתים המקוריים ומשאיר את המכולות הישנות במקומן כדי שמקטע ה-cross-reference הקודם יפנה אליהן. ההבדל אינו באופן שבו שני המצבים מתייחסים לפונטים. הוא בכך אם המכולות המקוריות שורדות כדי שהמציג הבא יקרא אותן

התיקון יושב ב-SaveToStream, הסריאליזר ש-EndDoc מפעילה בין אם קבעתם FileName ובין אם OutputStream. לפני שהיא שולחת לאחד מענפי הכותבים היא עוברת על FCompactObjects וקוראת ל-EnsureCompressedObjectLoaded על כל רשומה. אם איבר לא יכול להיטען, השמירה מעלה חריגה במקום להמשיך, כי כתיבה מחדש שמפילה בשקט מילון פונט גרועה מזו שנעצרת. ההרחבה חייבת לשבת ברמה הזו, מעל הענפים הקלאסי, ה-packed וה-linearized, ומעל גיזום הזרמים המבניים שנטענו מחדש בנתיב ה-linearized. גרסה קודמת הרחיבה איברים רק בתוך SaveLoadedDocument, מה שכיסה את אוצר המילים של מסמך טעון והחמיץ לגמרי את אוצר המילים של היצירה. LoadFromFile ואחריו BeginDoc, עריכות עמודים ו-EndDoc הגיעו ישר לכותב כשכל איבר שלא נגעו בו עדיין לא מפוענח

איפה יושבת הרחבת הכתיבה מחדש המלאה ב-HotPDF: SaveToStream עוברת על כל רשומת FCompactObjects דרך EnsureCompressedObjectLoaded לפני שהיא שולחת לכותב הקלאסי, ל-packed או ל-linearized, כך שגם אוצר המילים של SaveLoadedDocument וגם זה של LoadFromFile ואחריו BeginDoc ו-EndDoc מסריאלים אובייקטים מפוענחים במלואם במקום רשומות nil
עדכון מצטבר מצרף אחרי הבתים המקוריים ומשאיר את המכולות הישנות קריאות, אבל כתיבה מחדש מלאה זורקת אותן — מעבר הרחבה אחד מעל כל ענפי הכותבים הוא מה שמונע מפונט או איבר מבנה שלא נטענו להסתריאל לכלום
// שני אוצרות המילים של הכתיבה מחדש מרחיבים כעת איברים דחוסים לפני שכל כותב רץ.
// נתיב המסמך הטעון:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// נתיב היצירה על קובץ טעון:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // SaveToStream מממשת כל רשומת FCompactObjects קודם

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

למה בדיקות פיקסלים על שלושה עמודים מפספסות את מקרה ActualText

איברי מבנה הם המקום שבו הבאג הזה מתחבא הכי הרבה זמן. רשומת ActualText על רצף תוכן מסומן, שמוגדרת ב-ISO 32000-1 §14.9.4, מחליפה את ה-glyphs לצורכי חילוץ ונגישות אבל לא משפיעה על הרינדור. אם איבר המבנה יושב בזרם אובייקטים והכתיבה מחדש מאבדת אותו, העמוד עדיין מצויר נכון, העמודים הראשון, האמצעי והאחרון נותנים השוואת פיקסלים זהה למקור, והרגרסיה מופיעה רק כשמישהו מריץ חילוץ טקסט או קורא מסך. בדיקת כתיבה מחדש שמרנדרת עמודים בלבד אינה בדיקת כתיבה מחדש ל-PDF מתויג. עשו diff גם לטקסט המחולץ וגם לעץ המבנה

איך סיסמת משתמש ריקה משנה את הטעינה?

סיסמת משתמש ריקה עדיין משמעה שהקובץ מוצפן, וזרמי אובייקטים בקובץ כזה הם טקסט מוצפן עד שמפתח הקובץ משוחזר. ISO 32000-1 §7.6.3.4 אלגוריתם 2 גוזר את המפתח הזה מהסיסמה, מהרשומה /O, מ-/P, ומהמזהה הראשון של המסמך, ו-HotPDF חייבת להריץ אותו מול המחרוזת הריקה לפני שמעבר סוג 2 יכול לנפח מכולה אחת. זו הסיבה ש-BeginDoc על מסמך מוצפן טעון קוראת ל-DecryptLoadedDocument עם סיסמה ריקה לפני כל דבר אחר: גרף האובייקטים חייב לעבור אימות ופענוח לפני שכתיבה מחדש יכולה להתחיל, בלי קשר לשאלה אם הקורא מתכוון להגן על הפלט. הצפנת הפלט היא החלטה נפרדת, שנגזרת מהגדרות ההגנה של הקורא, ו-BeginDoc משחזרת את ההגדרות האלה אחרי מעבר הפענוח כך שקלט מוצפן לא הופך בשקט לפלט מוצפן

מדיניות המכולות נקראת ממילון ה-/Encrypt לפני שמנסים סיסמה כלשהי. עבור /V 1 ו-2 כל זרם מוצפן במפתח הקובץ. עבור crypt filters, HotPDF פותרת את /StmF דרך /CF: מסנן Identity או /CFM של None משמעם מכולות טקסט גלוי, בעוד V2 ו-AESV2 משמעם מכולות מוצפנות. התשובה נוחתת ב-FReloadObjectStreamsEncrypted, והיא חשובה למקרה אחד ספציפי. כשהמכולות טקסט גלוי אבל המחרוזות לא, האיברים נושאים מחרוזות מוצפנות שצריך לפענח בנפרד, ולכן MaterializeMembersOfPlaintextObjectStreams מרחיבה כל איבר דחוס לפני מעבר הפענוח לכל אובייקט. היא לא עושה דבר כשהמדיניות עדיין לא ידועה ולא כשהמכולות עצמן היו מוצפנות, כי איברים של מכולה מוצפנת כבר פוענחו יחד איתה ואסור לפענח אותם פעמיים

מה קורה כשאין אפשרות לפענח מכולה?

מכולה שנכשלת בפענוח נכנסת להסגר, ולא נחשבת קטלנית. מעבר סוג 2 רושם רשומת THPDFObjStmQuarantineInfo ב-FObjStmQuarantine עם מספר האובייקט של המכולה, THPDFObjStmQuarantineReason, מחרוזת אבחון, ורשימת מספרי האובייקטים של האיברים שה-cross-reference הפנה לתוכה. osqrDecryptFailed מועלה בארבעה מצבים שונים: לא ניתן היה לפענח שום crypt filter, הפענוח של AES-256 או AES-GCM העלה חריגה, הפענוח של RC4 או AES-128 הישנים העלה חריגה, או שלא קיים מפתח קובץ שמיש בכלל. מכולות בלתי תלויות ממשיכות להיטען, כך שמסמך עם מכולה פגומה אחת עדיין נפתח ועדיין מרנדר כל עמוד שלא תלוי בה

איך הסגר הפענוח של HotPDF עובד על PDF טעון: מכולה שהפענוח שלה העלה חריגה נרשמת כ-THPDFObjStmQuarantineInfo עם סיבה osqrDecryptFailed ומספרי האובייקטים של האיברים שלה, מכולות בלתי תלויות ממשיכות להיטען, ו-BeginDoc מעלה חריגה ברשומה הראשונה שנכשלה לפני שכתיבה מחדש יכולה לדווח הצלחה
רשומות הסגר שורדות נסיגת מפרסר ו-BeginDoc בודקת אותן בשם ולא לפי דגל ההצפנה, כך שמסמך עם מכולה פגומה אחת עדיין נפתח בעוד נתיב הכתיבה מחדש נעצר במקום לכתוב אובייקטים ריקים

רשימת ההסגר שורדת נסיגת מפרסר. אם טעינת ה-cross-reference הראשית נכשלת ו-HotPDF משחזרת את טבלת האובייקטים על ידי סריקת הקובץ, דגל ההצפנה מהניסיון הראשון עשוי לא לשרוד את השחזור הזה, אבל רשומות ההסגר שורדות. זו הסיבה ש-BeginDoc בודקת את רשימת ההסגר ולא את דגל ההצפנה: על מסמך טעון היא עוברת על FObjStmQuarantine ומעלה חריגה ברשומת ה-osqrDecryptFailed הראשונה, מציינת את המכולה ומבקשת טעינה מחדש עם סיסמה תקינה. כתיבה מחדש שהייתה ממשיכה מעבר לנקודה הזו הייתה כותבת את האיברים שהמכולה הייתה אמורה להחזיק כאובייקטים ריקים ומדווחת הצלחה. אפשר להריץ את אותה בדיקה בעצמכם, מוקדם יותר ועם המדיניות שלכם, דרך ה-accessors הציבוריים:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // סיסמת משתמש ריקה
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // בטוח לכתוב מחדש מכאן
end;

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

למה כתיבה מחדש צריכה את הטוקן המספרי המקורי?

HotPDF מאחסנת כל אובייקט מספרי כ-Single, ו-Single לא יכול לשחזר את טקסט המקור של מספר ממשי. ISO 32000-1 §7.3.3 מתיר לכותב לפלוט 0.750000, .75 או 0.75 לאותו ערך, ואף אחד מאלה לא שורד מעבר הלוך-חזור דרך 24 סיביות בינאריות ומעצב גנרי בלי להשתנות. גרוע מכך, ערך כמו 0.7 אינו ניתן לייצוג ב-Single בכלל; הוא נפרסר ל-float הקרוב ביותר, ועיצוב מחדש של ה-float הזה יכול להפיק 0.69999999 או שכן מעוגל סמוך, תלוי בלולאת הספרות. על צבע מילוי או קבוע שקיפות /CA, זה הבדל של יחידה אחת בערוץ של 8 סיביות, וזה מספיק כדי להיכשל בהשוואת פיקסלים מול המקור, ועל גבולות gradient — כדי להיראות

THPDFNumericObject.RememberSourceToken פותר את זה למקרה שלא שונה. המפרסר קורא לה עם הטוקן הגולמי מיד אחרי השמת Value; המתודה מקבלת רק טוקנים עשויים ספרות, עם לכל היותר נקודה עשרונית אחת וסימן מוביל אופציונלי, ומאחסנת את הטוקן יחד עם הערך שהוא תאם ב-FSourceValue. המאפיין SourceToken מחזיר את הטקסט המאוחסן רק כל עוד Value עדיין שווה ל-FSourceValue. שנו את המספר והטוקן מתאדה, כך שערך ששונה תמיד עובר בנתיב העיצוב הקיים ואף פעם לא פולט טקסט מיושן. SaveNumericObject בודקת קודם את SourceToken וכותבת אותו כלשונו כשהוא קיים, ואז נופלת לענפי המספר השלם, ההפניה למרחב הצבע והשבר רק עבור מספרים שנוצרו או נערכו בזיכרון

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

בדיקת נתיב כתיבה מחדש מול זרמי אובייקטים

שלוש בדיקות תופסות כל כשל שתואר למעלה, ואף אחת מהן לא דורשת Acrobat. ראשית, השוו בין IndexedObjectCount ל-MaterializedObjectCount אחרי השמירה; בכתיבה מחדש מלאה הם חייבים להיות שווים, וכל פער הוא איבר שהופל. שנית, חלצו טקסט ומנו את עץ המבנה בשני הקבצים, ולא רק רנדרו אותם, כדי ש-ActualText שאבד או איבר מבנה שאבד יופיעו כ-diff. שלישית, טענו את הפלט במופע חדש ואמתו ש-GetLoadedQuarantinedObjStmCount הוא אפס, מה שמוכיח גם שהכותב לא הפיק מכולה שהקורא לא יכול לפתוח. שילובי ה-crypt filter שמכריעים את FReloadObjectStreamsEncrypted מפורטים במאמר על מדיניות StmF, StrF ו-EFF. הצד של הכותב בסיפור הזה, איך לפלוט זרמי אובייקטים ומתי להעדיף עדכון מצטבר על כתיבה מחדש, נמצא במדריך לזרמי אובייקטים ולעדכונים מצטברים

טעינת איברים בעצלות, מעבר ההרחבה לפני הכותב, הסגר הפענוח ושימור הטוקן המקורי נשלחים כולם ברכיב HotPDF ל-Delphi עבור Delphi ו-C++Builder. עמוד המוצר מקשר לתיעוד ה-API אם אתם רוצים לאתר את GetLoadedObjectStreamCacheInfo ואת accessors ההסגר מול צינור הקליטה שלכם