מאמר טכני

למה שמירה של PDF בלי שינוי יכולה להשחית Info ו-XMP

‏PDF Library for Delphi בגרסאות v3.539.18 ו-v3.539.20 מתקנת שתי דרכים שבהן שמירה של PDF שלא משנה כלום עדיין יכולה להשחית מטא-נתונים של המסמך: כשהשדות /CreationDate ו-/ModDate הפנו לאותו אובייקט מחרוזת, עדכון ה-ModDate האוטומטי כתב מחדש את שניהם, וכשאובייקט ה-XMP נוצר לפני שנקרא זרם ה-/Metadata המקורי, חבילת ברירת מחדל החליפה את המקורית. התיקונים מחליפים הפניות במילון במקום לשנות אובייקטים משותפים, ומצלמים את החבילה הקיימת לפני אתחול XMP עצלן

התרחיש הזה הוא הפעולה הכי משעממת שספריית PDF מבצעת: לטעון קובץ, לשמור אותו תחת שם חדש, לא לגעת בכלום בדרך. העמודים הוצגו אותו דבר לפני ואחרי. ה-hashes של זרמי התוכן התאימו. הקובץ עבר כל בדיקה שהייתה לנו, והוא עדיין היה שגוי בשני מקומות שאף מציג לא היה מראה לכם. שני הפגמים ישבו בנתיב read-modify-write שכל עריכה אמיתית עוברת דרכו, כך שכל שמירה שהיא הספיקה כדי להפעיל אותם, ושניהם נמצאו רק כשמפענח שני ובלתי תלוי השווה את הסמנטיקה הלא-חזותית של שני הקבצים

למה שמירה של PDF משנה את ה-CreationDate שלו?

כי מילון המידע של המסמך רשאי להפנות משני מפתחות לאובייקט מחרוזת עקיף אחד, והספרייה עדכנה את האובייקט ולא את המפתח. ISO 32000-1 §7.3.10 מתיר לכל ערך במילון להיות הפניה עקיפה, ושום דבר ב-§14.3.3 טבלה 317 לא אומר שהערך תחת /CreationDate חייב להיות אובייקט אחר מהערך תחת /ModDate. מפיק שכתב את אותו חותם זמן פעמיים בזמן היצירה יכול, באופן חוקי לחלוטין, להפנות את שני המפתחות ל-2728 0 R בודד, וזה בדיוק מה שמסמך עיצוב ב-CJK בתוך הקורפוס המקומי שלנו עשה

הטריגר הוא תאריך השינוי האוטומטי. אם UserModDate לא נקבע, SaveToFile קוראת ל-SetInfo('ModDate', ...) עם הזמן הנוכחי לפני הכתיבה, וזה מגיע ל-SetRawInfo. ה-SetRawInfo הישן איתר את האובייקט תחת המפתח, ואם מצא TPDFString, קרא לו SetTo. זו כתיבה במקום לתוך האובייקט שהמפתח מפנה אליו כרגע, וכשהאובייקט הזה משותף, /CreationDate מדווח כעת גם את זמן השמירה. המסמך עדיין נפתח, מודפס ומוצג פיקסל-בפיקסל כמו קודם, כך שמערך בדיקות רגרסיה חזותיות עובר בלי למצמץ

שינוי של מחרוזת Info משותפת ב-PDFlibPas: /CreationDate ו-/ModDate מפנים באופן חוקי לאובייקט מחרוזת אחד 2728 0 R, ה-SetRawInfo הישן קרא ל-SetTo על כל מה שהמפתח הפנה אליו וכתב מחדש את שני התאריכים עם זמן השמירה, וה-SetRawInfo החדש מוסיף מחרוזת טרייה תחת המפתח תוך שמירה על מצב המחרוזת ההקסדצימלית
עדכון של ערך במילון מחליף כעת את ההפניה של אותו ערך במקום לשנות את האובייקט המשותף, כך שכתיבת ModDate אוטומטית אחת כבר לא יכולה לשנות את CreationDate, והאובייקט שהוחלף נשמר עבור הפניות אחרות
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate, 8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

התיקון ב-TPDFDocument.SetRawInfo קטן והעיקרון שמאחוריו כללי: עדכון של ערך במילון מחליף את ההפניה של אותו ערך, ואף פעם לא את האובייקט שהוא במקרה הפנה אליו. הקוד החדש קורא את ה-TPDFStringMode הקיים כך שמחרוזת hex נשארת hex ומחרוזת literal נשארת literal, ואז מוסיף מחרוזת טרייה מ-FStructure.NewString(Value, StringMode) תחת המפתח. שני פרטים נוספים חשובים כמו השינוי המרכזי. הענף הישן לערך שהוא זרם ניקה את הזרם עם SetTo('') לפני ההחלפה, מה שהיה מרוקן את הערך מכל מפתח אחר שעדיין הפנה לאותו זרם, ולכן הניקוי הזה נעלם. והאובייקט שהוחלף לא נמחק, כי המבנה הוא הבעלים שלו והפניות אחרות עשויות עדיין להזדקק לו

// לפני: לשנות כל אובייקט שהמפתח מפנה אליו כרגע
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// אחרי: לשמור על הייצוג, להחליף רק את ההפניה של המפתח הזה
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

הרגרסיה ב-Tests\SharedInfoSemantics.inc בונה את ה-alias בכוונה ולא נסמכת על קובץ מהקורפוס: מחרוזת hex אחת שמופנית משני מפתחות התאריך, מחרוזת ישירה אחת שמשותפת ל-/Title ול-/Subject, וזרם אחד שמשותף ל-/Author ול-/Keywords. אחרי עדכון של מפתח אחד בכל זוג, השני חייב עדיין להחזיר את הערך המקורי שלו, והמחרוזת המעודכנת חייבת להישאר hex. התיעוד הציבורי של SetInformation מציין כעת את ההבטחה במשפט אחד: עדכון של שדה Info מחליף רק את השדה הזה, גם כששדות אחרים מפנים לאותו אובייקט

למה חבילת XMP קיימת מוחלפת בערכי ברירת מחדל?

בגלל הסדר של שתי שורות. ל-TPDFDocument.GetMetadata יש מסלול מהיר: כשהשדה XMP כבר מושם, הוא מחזיר את XMP.SaveToString במקום לפענח את זרם ה-/Metadata מה-catalog. כמה אתרי קריאה אתחלו בעצלנות עם XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, שזה נקרא טבעי והוא שגוי: עד ש-GetMetadata רצה, XMP כבר מושם, כך שה"מקור" שנטען הוא חבילת ברירת המחדל המסוריאלית של אובייקט שנוצר שורה אחת קודם. החבילה המקורית, עם ה-dc:creator שלה, מרחבי השמות המותאמים וכל זיהוי תקן, אף פעם לא מגיעה לאובייקט ונכתבת מחדש בשמירה. אותו תאריך שינוי אוטומטי מספיק כדי להפעיל את זה, כי SetInfo מאתחל את XMP לפני שהוא נוגע במילון ה-Info כדי ש-xmp:ModifyDate יישאר מסונכרן עם /ModDate. שימו לב למה הפגם הזה מתחבא מאחוריו: השוואת מילון ה-Info מהבאג הראשון עוברת, כי /Author ו-/Title ב-/Info לא נגועים. רק עץ ה-XMP השתנה, ורק בדיקה שמפרסרת ומשווה את העץ הזה מבחינה בכך

סדר האתחול העצלן של XMP ב-PDFlibPas: יצירת אובייקט ה-XMP לפני הקריאה ל-GetMetadata גורמת למסלול המהיר לסריאל את חבילת ברירת המחדל ולזרוק את dc:creator, מרחבי השמות המותאמים וזיהוי התקן, בעוד צילום Source לפני TPDFlibXMP.Create טוען את זרם ה-/Metadata המקורי מה-catalog
כל שמירה הפעילה את ההחלפה כי SetInfo מאתחל את XMP כדי לשמור על xmp:ModifyDate מסונכרן עם /ModDate, ולכן כל אתחול עצלן במסמך עובר כעת דרך EnsureXMP אחד שמצלם את החבילה הקיימת לפני יצירת האובייקט
// שגוי: GetMetadata מסריאל עכשיו את האובייקט שנוצר בשורה הקודמת
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// נכון: לצלם את זרם ה-/Metadata קודם, ואז ליצור ולטעון
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

התיקון עושה שני דברים. TPDFDocument.EnsureXMP מצלם כעת Source := GetMetadata לפני TPDFlibXMP.Create, וכל אתחול עצלן במסמך הוחלף בקריאה אליו: SetInfo, SetXMPInformation, GetXMPInformation, ה-setters של מצבי PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR ו-PDF/UA, ונתיב תיקון המטא-נתונים. נקודות כניסה ציבוריות כמו SetXMPProperty כבר עברו דרך EnsureXMP, ו-GetXMPProperty קורא דרך GetDocumentMetadata, כך שכל פני השטח חולקים סדר אתחול אחד. עותק נכון אחד של רצף בן שלוש שורות שווה יותר מעשרה עותקים שבמקרה מסכימים היום

שתי מלכודות קטנות יותר באותו נתיב

סריאליזר ה-XMP ב-Windows משתמש בכותב ה-XML של הפלטפורמה, שפולט הצהרת XML שחבילת ה-XMP אסור שתישא. הקוד הישן הסיר אותה על ידי מחיקת תווים עד שהגיע ל-<?xpacket. ISO 16684-1 §7.3.2 הופך את עטיפת ה-xpacket לאופציונלית, ומפיק שכותב אלמנט <x:xmpmeta> חשוף נמצא בתוך התקן, כך שעל חבילה כזו הלולאה מחקה את המסמך התקין כולו. הסריאליזר מאתר כעת את ה-?> הסוגר של ההצהרה ומסיר רק אותו. Tests\XMPRetentionSemantics.inc מריץ את בדיקת השימור פעמיים, פעם עם העטיפה ופעם בלעדיה, ומאמת שסמן של מרחב שמות מותאם והמחבר המקורי שורדים את SetInfo, GetMetadata, SaveToString וטעינה מחדש. המלכודת השנייה הייתה סימן preprocessor: הסנכרון מ-Info ל-XMP בתוך SetInfo היה מוגן ב-NOVCL, שנקבע בבניות של Free Pascal, אבל ה-backend של XMP נשלט על ידי מערכת ההפעלה ולא על ידי הפריימוורק, כי PDFlibXMP.pas מגדיר NO_XMP רק כש-OS_WINDOWS חסר. לבנייה של Lazarus ב-Windows היה לכן אובייקט XMP עובד ו-SetInfo שדילג על עדכונו בשקט. השומר הוא כעת NO_XMP, כך שאפליקציית Free Pascal ב-Windows מקבלת את אותו סנכרון כמו דלפי

איך שומרים על ה-ModDate המקורי בשמירת pass-through?

קבעו KeepModDate ב-TPDFlibSaveOptions ושמרו דרך SaveToFileOptions. האפשרות קובעת UserModDate למשך הקריאה, ו-SaveToFile מדלגת אז על חותם הזמן האוטומטי, שהוא גם הצעד שמאתחל בעצלנות את אובייקט ה-XMP. מסמך שלא נגעתם במטא-נתונים שלו, ושלבו לא הופעל שום מצב תאימות, שומר גם על מילון ה-Info וגם על זרם ה-/Metadata שלו כפי שנטענו. קריאה ל-SetInformation(8, ...) עושה את אותו אפקט לצמיתות, כי קביעה של תאריך השינוי בעצמכם מסמנת אותו כמבוקר על ידי המשתמש

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // בלי /ModDate אוטומטי, בלי אתחול XMP עצלן
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

תהיו כנים לגבי מה זה נותן לכם. KeepModDate הוא הבחירה הנכונה לצעד pass-through שהפלט שלו אמור לתאר את אותה מהדורה כמו הקלט שלו, והוא הבחירה השגויה לכל דבר שבאמת עורך תוכן, כי §14.3.3 מצפה ש-/ModDate ישקף את השינוי האחרון. הוא גם לא מתקן בדיעבד ספרייה שמשנה אובייקטים משותפים; הוא רק נמנע מהכתיבה האחת שחשפה את הפגם. שני התיקונים שלמעלה הם מה שהופך שמירה רגילה לבטוחה, והאפשרות היא מה שהופך no-op מכוון לכן

איך מאמתים ששמירה לא שינתה דבר מלבד ה-ModDate?

לא בפיקסלים ולא ב-hashes של זרמים, כי שני הפגמים משאירים כל עמוד וכל זרם תוכן זהים בית-בבית. הבדיקה שתפסה אותם היא תמונת מצב סמנטית לא-חזותית שנלקחת על ידי מפענח בלתי תלוי, כזה שלא חולק קוד עם הספרייה הנבדקת, מקובץ המקור ומהקובץ השמור, ואחריה השוואה מבנית. תמונת המצב מכסה את מילון ה-Info בלי /ModDate, את עץ ה-outline כשכל סימנייה מפוענחת למספר עמוד ולא למספר אובייקט, יעדים בעלי שם וקישורים שמפוענחים לאותו אופן, ערכי שדות טופס, בתי קבצים מצורפים כ-hashes, ואת חבילת ה-XMP כמפורסרת כעץ ולא כטקסט להשוואה. מספרי אובייקטים לא נכללים בזה בכוונה, כי כתיבה מלאה מחדש ממספרת הכול מחדש והשוואה שמבוססת עליהם תדווח רעש

אימות סמנטי לא-חזותי לשמירות של PDFlibPas: מפענח בלתי תלוי בלי קוד משותף מצלם את מילון ה-Info בלי /ModDate, עמודי outline ויעדים, ערכי טופס, hashes של קבצים מצורפים ועץ ה-XMP, ואז משווה בין המקור לקובץ השמור תוך השמטת /ModDate, xmp:ModifyDate ו-xmp:MetadataDate כשינויים צפויים
פיקסלים ו-hashes של זרמים נשארים זהים בית-בבית דרך שני הפגמים, ולכן ההשוואה עובדת על סמנטיקה מפוענחת ולא על מספרי אובייקטים, ומטא-נתונים ששרד מדווח בכנות כנשמר, ולא כתקף סכימה או כתואם PDF/UA ו-PDF/A

ההשמטות חשובות כמו ההכללות. /ModDate, xmp:ModifyDate ו-xmp:MetadataDate צפויים להשתנות ומושמטים לפני ההשוואה; קובץ שהמקור שלו לא נשא XMP בכלל לא נענש על כך שקיבל חבילה. ומה שהבדיקה לא טוענת מפורש באותה מידה: שימור של חבילה קיימת לא אומר דבר על האם החבילה תקפה סכימטית או האם המסמך עומד ב-PDF/UA או בכל חלק של PDF/A. אלו שאלות נפרדות עם כלים נפרדים, וערבוב של "המטא-נתונים שרדו" עם "המטא-נתונים תואמים" הוא הדרך שבה הבאג הראשון התחבא כל כך הרבה זמן. בצד הספרייה שתי הרגרסיות רצות כעת בכל מעבר ממוקד על Delphi Win32 ו-Win64 ו-Free Pascal Win32 ו-Win64, וההשוואה הסמנטית היא תנאי מעבר בבנצ'מרק של קורפוס המסמכים האמיתיים

אם אתם עובדים ברמה שמתחת לתיקונים האלה, המכניקה של איך שמירה כותבת אובייקטים מחדש מכוסה בעדכונים מצטברים ושמירה בשיטת append-only, שהוא מצב השמירה היחיד שבו אובייקט משותף פשוט נשאר במקומו, וברמות שינוי והשוואת מהדורות, שהוא המקום האחר שבו תאריך מיושן או כתוב מחדש מטעה קורא. הצד של התיקון לאותו זוג Info ו-XMP, שבו שני החצאים מיושרים זה לזה ולא רק נשמרים, נמצא בהמרה ל-PDF/A ותיקון מטא-נתונים

‏PDF Library for Delphi היא ספריית PDF ילידית ב-Pascal עבור Delphi, C++Builder ו-Lazarus, ונתיב ה-read-modify-write המתואר כאן הוא אותו נתיב שכל עריכה בתהליך שלכם עוברת דרכו, כך שההבטחות שלמעלה חלות בין אם אתם שומרים פעם אחת או אלף פעמים ביום — ראו את עמוד המוצר של PDF Library for Delphi עבור המהדרים והפלטפורמות הנתמכים