מאמר טכני

שמירה על דיוק עשרוני של PDF מפוענח בדלפי

‏PDFlibPas, ספריית losLab PDF Developer Library, שומרת את הטקסט העשרוני המדויק שפוענח עבור כל מספר ממשי במסמך וכותבת את הטקסט הזה חזרה כלשונו בכל פעם שהערך מעולם לא שונה. מאז v3.539.19 ההגדרה SetPrecision חלה רק על מספרים שהספרייה יוצרת או עורכת, כך שטעינה ושמירה רגילות כבר לא מעגלות /Gamma של CalRGB מ-2.22221 ל-2.2222 ומזיזות את הצבעים של עמוד שאף אחד לא נגע בו. השינוי קטן בקוד וגדול במה שהוא אומר על מפענחים: הערך שאתם מפענחים והליטרל שאתם פולטים הם שני דברים שונים, ומעבר הלוך-חזור דרך Double אינו טרנספורמציה זהותית

למה שמירה שלא שינתה כלום הזיזה את צבעי העמוד?

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

תמונת הכותרת מצוירת דרך מרחב צבע CalRGB, ש-ISO 32000-1 §8.6.5.3 מגדיר על ידי /WhitePoint, מערך /Gamma אופציונלי בן שלושה איברים ומערך /Matrix אופציונלי בן תשעה. המערכים האלה הם אובייקטים מספריים רגילים במילון מרחב הצבע. TPDFNumeric אחסן כל אחד מהם כ-Double ותו לא, ו-TPDFNumeric.Output עיצב את ה-Double הזה דרך PDFPrecNum, שברירת המחדל שלו היא ארבעה מקומות עשרוניים. כך /Gamma עבר מ-2.22221 ל-2.2222, איבר במטריצה עבר מ-0.71519 ל-0.7152, והמצייר הפיק בנאמנות צבעים קצת שונים מכיול קצת שונה. בתי התמונה היו חפים מפשע; המספרים סביבם לא. החלק הלא נוח הוא כמה שזה היה בלתי נראה. השוואה של בתי זרמים מפוענחים לא יכולה לראות את זה, כי המספרים חיים במילון ולא בזרם. השוואה של מטעני קבצים מצורפים לא יכולה לראות את זה. אפילו השוואת המהדורות המתוארת במאמר על רמות שינוי עושה fingerprint לגוף אובייקט מנורמל, כך ששתי המהדורות מקבלות אותו hash וההשוואה מדווחת שהן זהות. רק הרינדור תפס את זה, ולכן קו הבסיס של הקורפוס מרנדר כל עמוד במקום לסמוך על בדיקות מבניות בלבד

היכן PDFlibPas איבדה דיוק של CalRGB בשמירה בלי שינוי בדלפי: ה-/Gamma המפוענח 2.22221 ואיבר מטריצה 0.71519 חיים ב-TPDFNumeric כ-Double, Output מעצב אותם דרך PLDoubleToStr עם PDFPrecNum בארבעה מקומות עשרוניים, כל בדיקה מבנית מדווחת שהמסמך לא השתנה, ורק השוואת הרינדור מראה שכל 35 תמונות הכותרת השתנו
בתי התמונה היו חפים מפשע: TPDFNumeric עיצב מחדש את מספרי הכיול סביבם דרך PDFPrecNum, כך ש-hashes של זרמים והשוואת ה-fingerprint דיווחו שניהם על מהדורות זהות בעוד המצייר הפיק צבעים קצת שונים בכל עמוד

הערך שפוענח אינו הליטרל שצריך לכתוב

מספר ממשי ב-PDF הוא מחרוזת עשרונית, ו-ISO 32000-1 §7.3.3 מפורש שהיא רק מחרוזת עשרונית: בלי סימון בסיס, בלי צורת חזקה. נספח C מפרט אחר כך את הדיוק שמימוש אמור לכבד, כחמש ספרות עשרוניות מובהקות בחלק השברי. דיוק פלט של ארבע כבר נמצא מתחת לזה, וזה מחמיר ליד אפס: PLDoubleToStr מכפיל את הערך, מעגל למספר שלם ופולט 0 כשהתוצאה אפס, כך שאיבר מטריצה של -0.000012345 לא מאבד ספרה, הוא נעלם לגמרי

העלאה של ברירת המחדל רק תזיז את הצוק. התיקון הוא להפסיק להעמיד פנים ש-Double הוא המספר. כשהמפרק ב-TPDFStructure.Decode מזהה real תקני, כלומר שהטוקן מכיל נקודה עשרונית ובלי סמן חזקה, הוא מאחסן את טקסט המקור בשדה החדש FOriginalText לצד הערך המומר. Output מעדיף אז את הטקסט הזה וחוזר לעיצוב רק כשאין מה להעדיף

איך PDFlibPas שומרת טקסט עשרוני מפוענח בדלפי: המפרק ב-TPDFStructure.Decode משאיר את ליטרל המקור ב-FOriginalText לכל טוקן עם נקודה עשרונית ובלי חזקה, Output כותב את הטקסט הזה כלשונו במקום לקרוא ל-PLDoubleToStr, ו-SetTo מנקה אותו כי מספר שנערך הוא מספר חדש
הערך שאתם מפענחים והליטרל שאתם פולטים הם שני דברים שונים: העדפה של הטקסט המפוענח שומרת על 2.22221 בדיוק, בעוד מספרים שנוצרו בספרייה או נערכו עדיין הולכים לפי PDFPrecNum וההגדרה אף פעם לא מגיעה לקלט שלא נגעו בו
// Lib/PDFlibStruct.pas — כל התיקון בצד הפלט
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // מספר שנערך הוא מספר חדש
  FValue:= Value;
  FChanged:= True;
End;

שני גבולות הם מכוונים. מספרים שלמים לא נשמרים, כי עיצוב של מספר שלם הוא כבר חסר אובדן. צורות חזקה כמו 6.02E23 נסבלות בקלט למען מפיקים שבורים אבל לא נשמרות בפלט, כי כתיבה שלהן חזרה הייתה מנציחה תחביר ש-§7.3.3 אוסר; הן עוברות דרך המעצב כמו כל מספר שנוצר בספרייה. המפרק גם מחיל את התיקון המינימלי הרגיל שלו לפני אחסון הטקסט, כך שליטרל עם נקודה מובילה כמו .5 נשמר כ-0.5 וליטרל עם נקודה בסוף כמו 5. נשמר כ-5.0. שניהם אותו מספר לכל קורא, והם מקובלים בהרבה יותר

מה מבטיחה SetPrecision אחרי v3.539.19?

TPDFlib.SetPrecision שולטת כעת במקומות העשרוניים של מספרים שהספרייה עצמה מפיקה: ערכים שמצוירים דרך ה-painter, מספרים שנוצרים מ-Double כגון דרך NewNumeric, וכל ערך מפוענח שנערך מאז ב-SetTo. שימו לב שטקסט שמפוענח דרך ה-object API, למשל ליטרל שמועבר ל-SetObjectFromString, עובר דרך אותו מפרק ונשמר באותו אופן. עשרוני מפוענח שמעולם לא שונה שומר על דיוק הקלט שלו בלי קשר להגדרה, ושינוי ההגדרה אחרי הטעינה לא נוגע בו בדיעבד. ערך התיעוד של SetPrecision עודכן באותו שחרור כדי לומר בדיוק את זה, כי הניסוח הישן רמז שההגדרה חלה על כל מספר בקובץ

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

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // קלט שלא נערך שורד כלשונו, כולל הערך שעיצוב
    // בארבעה מקומות עשרוניים היה מכווץ לאפס
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // עריכה זורקת את הטקסט המקורי והולכת לפי PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // הורדת הדיוק אחר כך לא מגיעה לקלט שלא נערך
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

למה מודל התוכן עדיין מנרמל מספרים?

כי TPDFContentProgram מבטיח אופרנדים מספריים קנוניים, וההבטחה הזו שווה יותר מטקסט כלשונו בתוך זרם תוכן. מודל התוכן הניתן לעריכה, אותו מודל שעוקב מצב הגרפיקה בנוי עליו, קיים כדי ש-NormalizeContentStreams, האופטימיזטור ו-Emit יפיקו פלט יציב וניתן להשוואה מכל קלט. אם אופרנד מפוענח היה נושא את הטקסט המקורי שלו לתוך המודל, רצף אופרטורים כמו 0.50000 0 0 RG היה נפלט אחרת מ-0.5 0 0 RG, וכל השוואה במורד הזרם הייתה נסחפת לפי הרגלי העיצוב של המפיק

לכן המודל מסיר את הטקסט המקורי בשתי נקודות הכניסה שלו. NormalizeContentNumbers רצה על כל אופרנד כשהמפרסר דוחף אותו ושוב בתוך SetOperand כשמפענחים מקור שסופק על ידי הקורא, והיא רקורסיבית דרך מערכים ומילונים כך שדפוסי dash, מערכי TJ ומילוני המאפיינים של תוכן מסומן מכוסים. קריאה ל-SetTo(AsDouble) על כל מספר מספיקה, כי זו בדיוק הפעולה שמנקה את הטקסט. נתוני תמונת inline גולמיים נשארים כמו שהם, כפי שהיו תמיד

למה מודל התוכן של PDFlibPas עדיין מנרמל מספרים: NormalizeContentNumbers רצה במקום שבו המפרסר דוחף כל אופרנד ושוב בתוך SetOperand, רקורסיבית דרך מערכים ומילונים כך שדפוסי dash, מערכי TJ ומילוני מאפיינים של תוכן מסומן מכוסים, ו-SetTo AsDouble מנקה את הטקסט המקורי כך ש-0.50000 ו-0.5 נפלטים זהים
אופרנדים מספריים קנוניים הם ההבטחה של מודל התוכן: נתוני תמונת inline גולמיים נשארים לבדם, ומספרים במילון שלא נגעו בהם מחוץ לזרמי תוכן שומרים על הבטחת הלשון המקורית, כך שזוג LoadFromFile ו-SaveToFile רגיל עדיין משמר אותם
// Lib/PDFlibContentModel.pas — מודל התוכן שומר על החוזה שלו
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

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

מה זה עולה, ואיפה ההבטחה נעצרת

כל TPDFNumeric נושא כעת הפניה אחת נוספת ל-AnsiString, וכל עשרוני מפוענח מחזיק את טקסט המקור שלו חי לכל חיי האובייקט. במסמך עם מיליוני מספרים ממשיים זה זיכרון אמיתי, והוא שייך לכל מדידה של מסמכים גדולים ולא לנופף ביד. ההבטחה גם מוגבלת למסמך של המספר עצמו: העתקה של אובייקטים בין מסמכים או בנייה מחדש של ערכים דרך ה-object API מייצרת מספרים חדשים, שהולכים לפי דיוק הפלט כמו כל מספר חדש אחר. שווה לדייק במה השחרור כן טוען ומה הוא לא טוען. טעינה ושמירה של מסמך שלא נגעו בו משמרת כעת את מספרי הכיול שהמצייר באמת צורך, וזו התכונה שקו הבסיס של הקורפוס בודק. הוא לא טוען לפלט זהה בית-בבית, שתלוי גם במספור אובייקטים, בדחיסת זרמים ובמזהה ה-trailer הנדון במאמר על מזהה PDF דטרמיניסטי. והוא גם לא גורם להשוואת ה-fingerprint לראות הבדלי עיגול בקבצים שנוצרו בתוכנה אחרת, כי אלה עדיין עושים hash לגוף המנורמל. הלקח מכליל הרבה מעבר ל-CalRGB: כשמפענח שומר רק את הערך המומר, כל שמירה היא עריכה, והדרך היחידה לשים לב היא להסתכל על התוצאה המרונדרת. הטיפול המספרי וסמנטיקת SetPrecision מתועדים בעמוד המוצר של losLab PDF Developer Library