מאמר טכני

דקדוק מספרי PDF מול JSON:‏ NaN,‏ Infinity ו-null בדלפי

PDF Library for Delphi‏ (PDFlibPas) פולט JSON תקף עבור כל מספר PDF מאז v3.539.31. ה-GetObjectJSON כותב מחדש token-ים ש-ISO 32000-1 מקבל אבל RFC 8259 דוחה, כמו -.25, +1.5 ו-007.5, ל--0.25, 1.5 ו-7.5 ספרה-אחר-ספרה; ה-GetDocumentJSON ודוחות הניתוח כותבים null עבור NaN ו-Infinity; וה-PLDoubleToStr כותב 0 עבור NaN במקום להעלות EInvalidOp באמצע ייצוא. לפני התיקון, הספרייה יכלה להניב JSON שהקורא שלה עצמה סירב לטעון בחזרה

למה מספר PDF תקף שובר JSON?

כי שני הדקדוקים לא מסכימים על ארבעה פרטים קטנים, ופרסר PDF שמכבד את טקסט המקור יעביר את הפרטים האלה ישר אל הפלט. ISO 32000-1 §7.3.3 מרשה למספר להתחיל בסימן פלוס, לוותר על החלק השלם (.5), להיגמר בנקודה עירומה (4.) ולשאת אפסים מובילים (007.5). RFC 8259 §6 לא מרשה אף אחד מאלה: מינוס אופציונלי, חלק שלם שהוא או 0 או מתחיל ב-1 עד 9, ולפחות ספרה אחת אחרי כל נקודה עשרונית. מפיקים חופשיים לכתוב את הצורות של PDF, והרבה מחוללים וקבצים שנערכו ביד אכן עושים זאת

הדליפה באה מתכונת דיוק מכוונת. מאז v3.539.19, ה-TPDFNumeric.Output מחזיר את הטקסט המדויק שה-tokenizer פרסר עבור מספרים ממשיים, וזה מה שמשאיר ערך צבע מכויל מדויק בשמירה, כפי שמתואר בשימור דיוק עשרוני מפורסר של PDF. ה-tokenizer כבר מטפל ב-.5 לתוך 0.5 וב-4. לתוך 4.0 בדרך פנימה, ומספרים שלמים מעוצבים מחדש מהערך שלהם, כך ש-+3 חוזר כ-3. מה ששורד מילה-במילה הוא השאר: נקודה פותחת עם סימן (-.25), פלוס מפורש על ממשי (+1.5) ואפסים מובילים (007.5). הכותב הישן של האובייקטים חיבר את ה-Output מיד אחרי "value":, וה-TJSONParser.ParseNumber בקורא של הספרייה עצמה עוצר על כל אחד מאלה עם "Invalid JSON number", כך שהייצוא הצליח והייבוא החוזר נכשל עם PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

ה-GetObjectJSON של PDFlibPas כותב מחדש את token-י המספרים של PDF ש-RFC 8259 דוחה ספרה-אחר-ספרה: -.25 הופך ל--0.25, הפלוס של +1.5 נשמט, 007.5 משיל את האפסים המובילים שלו, וספרות שברוניות כמו 1.250000 שורדות, כי עיצוב מה-Double המאוחסן היה מוסיף רעש בינארי
הכותב הישן חיבר את הטקסט המפורסר המדויק, הקורא של הספרייה עצמה עצר עם Invalid JSON number, ושגיאה 105 שברה round trip שצד הייצוא קרא לו הצלחה
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  JSON: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('legacy-drawing.pdf', '') = 0 then
      raise Exception.Create('load failed');

    // אובייקט 12 הוא מערך שנכתב כ-[-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 ומעלה: הערכים מגיעים כ: -0.25, 1.5, 7.5

    // ל-SetObjectJSON אין אפשרויות, ולכן מעבירים 0
    if Lib.SetObjectJSON(12, JSON, 0) = 0 then
      raise Exception.CreateFmt('round trip rejected, error %d',
        [Lib.LastErrorCode]);

    Lib.SaveToFile('legacy-drawing-roundtrip.pdf');
  finally
    Lib.Free;
  end;
end;

איך PDFNumberTextToJSON שומר על כל ספרה?

ה-PDFNumberTextToJSON מאיית מחדש את ה-token במקום לחשב אותו מחדש מתוך Double. הפונקציה ב-PDFlibObjectJSON קוראת סימן אופציונלי, אוספת ספרות לפני ואחרי נקודה עשרונית אחת, ואז מיישמת רק את העריכות ש-JSON דורש: היא משמיטה פלוס, משייפת אפסים מובילים בזמן שהיא שומרת אחד, מספקת 0 כשהחלק השלם ריק, משמיטה נקודה עירומה בסוף ומחזירה את המינוס. token שמכיל תו אחר כלשהו, או בכלל לא מכיל ספרות, נופל חזרה אל PLJSONNumber(Value, 10), שכותבת null כשהערך אינו סופי

ה-PDFNumberTextToJSON של PDFlibPas קורא את הסימן, אוסף ספרות סביב נקודה עשרונית אחת ומיישם רק את העריכות ש-JSON דורש, בזמן שכל תו אחר או ריצת ספרות ריקה נופלים חזרה אל ה-PLJSONNumber, שכותב null עבור NaN ו-Infinity במקום מספר
איות מחדש מנצח חישוב מחדש: ה-tokenizer כבר תיקן את .5 ואת 4. בדרך פנימה, ולכן הכותב שומר על כל ספרה ששרדה וה-round trip משחזר בדיוק את אותו ערך
  • ה--.25 הופך ל--0.25, וה-+.5 הופך ל-0.5
  • ה-+1.5 הופך ל-1.5
  • ה-007.5 הופך ל-7.5, בזמן שה-0.75 נשאר כמו שהוא
  • ה-4. הופך ל-4 אם token כזה מגיע פעם אל הכותב
  • ה-2.22221 וה-1.250000 שומרים על כל ספרה שברונית, אפסים עוקבים כלולים

עיצוב מה-Double המאוחסן היה קצר יותר ושגוי, מאותה סיבה שבגינה קיים תיקון הדיוק: דיוק הפלט המוגדר כברירת מחדל הוא ארבע ספרות עשרוניות, ואפילו המרה בדיוק מלא יכולה להוסיף רעש בינארי ל-literal עשרוני. שמירת הספרות אומרת שה-SetObjectJSON וה-ImportObjectJSON, שמוסרים כל טקסט מספר JSON אל ה-tokenizer של ה-PDF, משחזרים בדיוק את אותו ערך. הערובה מכסה את הערך, לא את הבייטים: אחרי ייבוא חוזר, ה--.25 מאוחסן ונשמר כ--0.25. שתי האיותים שווים תחת §7.3.3, אבל diff ברמת בייטים יסמן את השינוי, ולכן אל תתייחסו אל מחזור ייצוא וייבוא כאל no-op על מסמך שבייטים שלו מכוסים בחתימה

מה קורה למספר ש-JSON לא יכול לייצג?

ה-GetDocumentJSON כותב עכשיו null עבור כל מספר שהוא NaN או אינסופי, כי ל-RFC 8259 §6 אין תחביר עבור אף אחד מהם. אינסוף קל יותר לייצור ממה שזה נשמע: ה-tokenizer של ה-PDF צובר ספרות בכפילה חוזרת אל תוך Double, שמגיע לקצה סביב 1.8 × 10308, כך ש-literal של מספר שלם באורך חודש מעל 300 ספרות הופך בשקט ל-+Inf. קבצים כנים לעולם לא מכילים literal כזה; קבצים מ-fuzz ועוינים כן, ולכן הם שייכים לאותו קורפוס בדיקות כמו המקרים בחיזוק פרסר PDF בפסקל נגד קבצים זדוניים. הכותב הישן של המסמכים עיצב לא-שלמים עם Str(D:0:6), ועבור +Inf זה כותב את הטקסט +Inf, שאף צרכן JSON לא יפרסר

ה-null הוא מאבד-מידע בכוונה. צרכנים של פלט ה-GetDocumentJSON חייבים לקבל null בכל מקום שבו מספר יכול להופיע, וראוי שיקראו אותו כ-"היה ערך אבל אין אפשרות לייצג אותו", לא כמפתח חסר. את ה-literal המקורי אי אפשר לשחזר מה-document JSON, ולכן pipeline שאכפת לו ירשום את האובייקט ל-log ויתייחס אל הקובץ כחשוד במקום להחליף בברירת מחדל

למה NaN אחד יכול היה לקטוע ייצוא SVG או JSON?

כי ה-PLDoubleToStr, מעצב המספרים האינווריאנטי שעומד מאחורי זרמי תוכן, SVG, XML, CSV ורוב ה-JSON בספרייה, הכפיל את הקלט שלו וקרא ל-Round, וה-Round(NaN) מעלה EInvalidOp על יעדים כמו Win32, שבהם דלפי משאירה את חריגת הפעולה-הלא-תקפה של ה-x87 לא ממוסכת. החריגה נורתה אחרי שהכותב כבר פלט חלק מהפלט שלו, ולכן מדידה מנוונת אחת, 0/0 במדד או NaN שקורא העביר, השאירה מאחור קובץ קטום. ה-PLDoubleToStr מחזיר עכשיו 0 עבור NaN, והענף השלם שלו נצמד ל-±9.2e18 כמו הענף השברוני, כך שגם Infinity יוצא כ-literal סופי

אפס הוא התשובה הנכונה עבור זרם תוכן, שבו slot של מספר חייב להחזיק מספר, והתשובה הלא נכונה עבור דוח, שבו 0 היא מדידה סבירה. כותבי JSON שחייבים לשמור על ההבחנה משתמשים ב-PLJSONNumber(Value, Decimals) מתוך PDFlibExtra, שכותבת null עבור NaN או Infinity וספרות אינווריאנטיות אחרת. ה-PLJSONNumber תומך עכשיו ב-GetSimilarImageDeduplicationReportJSON, ב-GetAnnotationHitsJSON ובדוחות ה-barcode, ה-deskew, הטקסט המובנה וה-PDF/VCR; דוח ה-deskew כתב בעבר 0 עבור זווית לא סופית וכותב עכשיו null

PDFlibPas עוצר את NaN ואת Infinity בשלוש דרכים: ה-AddPageMatrix, ה-ScalePage וה-RedactRegion דוחים ארגומנטים לא סופיים כבר מלפנים, ה-PLDoubleToStr כותב 0 עבור slot-ים של זרם תוכן, וה-PLJSONNumber כותב null בדוחות, שבהם אפס היה נקרא כמדידה סבירה, אחרי ש-Round(NaN) היה מעלה EInvalidOp באמצע ייצוא
אפס הוא התשובה הנכונה עבור זרם תוכן והתשובה הלא נכונה עבור דוח, ולכן כותבי הדוחות מוסרים כל Double אל ה-PLJSONNumber ונותנים ל-null לומר שהיה ערך אך אין לייצגו
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // מעצבים כל Double לטקסט קודם; PLJSONNumber כותב null
    // עבור NaN או Infinity ותמיד משתמש בנקודה עשרונית
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // לעולם לא B.Append(Angle): ה-overload של Double הולך אחר ה-locale של המשתמש
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

איפה ה-locale של המשתמש עדיין מתגנב ל-JSON?

דרך כל מעצב שמתייעץ בהגדרות אזור, וביקורת מלאה של הפלט הקריא-למכונה מצאה בדיוק אחד שנותר: ה-maxAcceptedMeanError ב-GetSimilarImageDeduplicationReportJSON, שנכתב עם PLFloatToStr, wrapper דק על ה-FloatToStr. על שולחן עבודה שמפריד העשרונות שלו פסיק, הדוח הכיל "maxAcceptedMeanError":1,5, שפרסר JSON קורא כערך 1 ואחריו token תועה. השדה מדווח על שגיאת הפיקסל הגרועה שהתקבלה מדה-דופליקציה תפיסתית של תמונות, והוא עובר עכשיו דרך PLJSONNumber(Stats.MaxAcceptedMeanError, 6). מלכודת שנותרה היא ה-PLStringBuilder: בדלפי הוא alias פשוט של System.SysUtils.TStringBuilder, שה-Append(Double) overload שלו מעצב דרך ה-locale של המשתמש, בזמן ש-build-ים של FPC משתמשים במחלקת ספרייה במקום, ולכן בדיקה על Free Pascal או על מכונת en-US לעולם לא תתפוס את זה

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // משחזר שולחן עבודה גרמני או צרפתי בתוך ריצת הבדיקה
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // משתמשים ב-fixture שבאמת מכיל תמונות כמעט-שכפוליות,
    // אחרת שגיאת הממוצע היא 0 והבאג נשאר חבוי
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // ריצת יובש עם ספי 2, 2, 4: המסמך אינו משתנה
    Lib.GetSimilarImageDeduplicationReportJSON(2, 2, 4, Report);
    Parsed := TJSONObject.ParseJSONValue(Report);
    if Parsed = nil then
      raise Exception.Create('report is not valid JSON on a comma locale');
    Parsed.Free;
  finally
    Lib.Free;
  end;
end;

סוויטת רגרסיה עבור פלט JSON צריכה שלושה fixture-ים כדי להישאר ישרה: עמוד שנושא -.25, +1.5 ו-007.5, אובייקט שמחזיק מספר שלם בן 400 ספרות, וכל דוח שרץ תחת locale של פסיק — כל אחד מאומת עם פרסר קפדני ולא במבט עין. object JSON, document JSON ודוחות הניתוח ב-PDF Library for Delphi חולקים את אותם כללי מספרים בין דלפי, C++Builder ו-Free Pascal; רשימת התכונות המלאה נמצאת בעמוד המוצר של PDF Library for Delphi