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 מדווח כעת גם את זמן השמירה. המסמך עדיין נפתח, מודפס ומוצג פיקסל-בפיקסל כמו קודם, כך שמערך בדיקות רגרסיה חזותיות עובר בלי למצמץ
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 השתנה, ורק בדיקה שמפרסרת ומשווה את העץ הזה מבחינה בכך
// שגוי: 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 כמפורסרת כעץ ולא כטקסט להשוואה. מספרי אובייקטים לא נכללים בזה בכוונה, כי כתיבה מלאה מחדש ממספרת הכול מחדש והשוואה שמבוססת עליהם תדווח רעש
ההשמטות חשובות כמו ההכללות. /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 עבור המהדרים והפלטפורמות הנתמכים