مقاله فنی

گرامر عدد PDF در برابر JSON: NaN و Infinity و null در Delphi

PDF Library for Delphi (PDFlibPas) از v3.539.31 برای هر عدد PDF یک JSON معتبر تولید می‌کند. GetObjectJSON توکن‌هایی را که ISO 32000-1 قبول می‌کند اما RFC 8259 رد، مثل -.25 و +1.5 و 007.5، رقم‌به‌رقم به -0.25 و 1.5 و 7.5 بازنویسی می‌کند؛ GetDocumentJSON و گزارش‌های تحلیلی برای NaN و Infinity مقدار null می‌نویسند؛ و PLDoubleToStr برای NaN می‌نویسد 0 به‌جای آنکه وسط یک export، EInvalidOp بالا بدهد. قبل از fix، کتابخانه می‌توانست JSON ای تولید کند که خوانندهٔ خودش حاضر نبود بارگذاری‌اش کند

چرا یک عدد PDF معتبر JSON را می‌شکند؟

چون دو گرامر روی چهار جزئیات کوچک اختلاف دارند و یک parser از 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» می‌ایستد، پس export موفق می‌شد و re-import با PDFLIB_ERROR_OBJECT_JSON_INVALID (105) شکست می‌خورد

GetObjectJSON در PDFlibPas توکن‌های عدد PDF ای را که RFC 8259 رد می‌کند رقم‌به‌رقم بازنویسی می‌کند: -.25 می‌شود -0.25، +1.5 به‌علاوه‌اش را از دست می‌دهد، 007.5 صفرهای پیشوندی‌اش را می‌اندازد، و ارقام اعشاری مثل 1.250000 جان سالم به در می‌برند، چون فرمت کردن از Double ذخیره‌شده نویز باینری اضافه می‌کرد
نویسندهٔ قدیمی متن دقیق تجزیه‌شده را می‌چسباند، خوانندهٔ خود کتابخانه با پیام Invalid JSON number می‌ایستاد، و خطای 105 یک رفت‌وبرگشت را می‌شکست که سمت export آن را موفق می‌نامید
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 توکن را از نو املا می‌کند به‌جای آنکه از یک Double دوباره محاسبه‌اش کند. تابع در PDFlibObjectJSON یک علامت اختیاری می‌خواند، ارقام قبل و بعد از یک نقطهٔ اعشار تنها را جمع می‌کند و بعد فقط ویرایش‌هایی را اعمال می‌کند که JSON مطالبه می‌کند: به‌علاوه را می‌اندازد، صفرهای پیشوندی را با نگه داشتن یکی می‌تراشد، وقتی بخش صحیح خالی است 0 می‌گذارد، نقطهٔ انتهایی لخت را می‌اندازد و منها را برمی‌گرداند. توکنی که نویسهٔ دیگری داشته باشد یا اصلاً رقم نداشته باشد به PLJSONNumber(Value, 10) برمی‌گردد که وقتی مقدار متناهی نیست می‌نویسد null

PDFNumberTextToJSON در PDFlibPas علامت را می‌خواند، ارقام دور یک نقطهٔ اعشار تنها را جمع می‌کند و فقط ویرایش‌هایی را اعمال می‌کند که JSON مطالبه می‌کند، در حالی که هر نویسهٔ دیگری یا دنبالهٔ ارقام خالی به PLJSONNumber برمی‌گردد که برای NaN و Infinity به‌جای عدد می‌نویسد null
املا دوباره بهتر از محاسبهٔ دوباره است: tokenizer از قبل در مسیر ورود .5 و 4. را وصله زده، پس نویسنده هر رقم باقی‌مانده را نگه می‌دارد و رفت‌وبرگشت دقیقاً همان مقدار را دوباره می‌سازد
  • -.25 می‌شود -0.25 و +.5 می‌شود 0.5
  • +1.5 می‌شود 1.5
  • 007.5 می‌شود 7.5، در حالی که 0.75 همان‌طور که هست می‌ماند
  • 4. می‌شود 4 اگر چنین توکنی روزی به نویسنده برسد
  • 2.22221 و 1.250000 هر رقم اعشاری را نگه می‌دارند، صفرهای انتهایی هم شامل می‌شود

فرمت کردن از Double ذخیره‌شده کوتاه‌تر و غلط بود، به همان دلیلی که fix دقت وجود دارد: دقت خروجی پیش‌فرض چهار رقم اعشار است و حتی یک تبدیل با دقت کامل هم می‌تواند به یک literal اعشاری نویز باینری اضافه کند. نگه داشتن ارقام یعنی SetObjectJSON و ImportObjectJSON، که هر متن عدد JSON را به tokenizer مربوط به PDF می‌دهند، دقیقاً همان مقدار را دوباره می‌سازند. تضمین شامل مقدار است نه بایت‌ها: بعد از یک re-import، -.25 به‌شکل -0.25 ذخیره و ذخیره‌سازی می‌شود. هر دو املا زیر §7.3.3 برابرند، اما یک diff در سطح بایت تغییر را پرچم می‌زند، پس یک چرخهٔ export و import را روی سندی که بایت‌هایش زیر یک امضا پوشش داده شده no-op فرض نکن

با عددی که JSON نمی‌تواند نمایشش بدهد چه می‌شود؟

GetDocumentJSON حالا برای هر عددی که NaN یا بی‌نهایت باشد می‌نویسد null، چون RFC 8259 §6 برای هیچ‌کدام سینتکسی ندارد. ساخت Infinity از آنچه به نظر می‌رسد آسان‌تر است: tokenizer مربوط به PDF ارقام را با ضرب‌های مکرر در یک Double جمع می‌کند که حدود 1.8 × 10308 سقف دارد، پس یک literal صحیح کمی بیش از 300 رقم بی‌سروصدا به +Inf تبدیل می‌شود. فایل‌های درست هرگز چنین literal ای ندارند؛ فایل‌های fuzz شده و خصمانه دارند، برای همین جایشان در همان سویت تستی است که موارد سخت‌سازی parser PDF نوشته‌شده با Pascal در برابر فایل‌های مخرب را نگه می‌دارد. نویسندهٔ سند قدیمی غیرصحیح‌ها را با Str(D:0:6) فرمت می‌کرد و برای +Inf آن متن +Inf را می‌نوشت که هیچ مصرف‌کنندهٔ JSON ای آن را تجزیه نمی‌کند

null عمداً lossy است. مصرف‌کنندگان خروجی GetDocumentJSON باید null را هر جا که یک عدد می‌تواند ظاهر شود بپذیرند و باید آن را به این معنی بخوانند که «مقداری وجود داشت اما قابل نمایش نیست»، نه به معنی یک کلید غایب. literal اصلی از JSON سند قابل بازیابی نیست، پس پایپ‌لاینی که برایش اهمیت قائل است باید شیء را لاگ کند و فایل را مشکوک تلقی کند به‌جای آنکه یک پیش‌فرض جایگزین کند

چرا یک NaN تنها می‌توانست یک export از SVG یا JSON را متوقف کند؟

چون PLDoubleToStr، فرمت‌کنندهٔ عدد invariant پشت content streamها و SVG و XML و CSV و بیشتر JSON های کتابخانه، ورودی‌اش را مقیاس می‌کرد و صدایش می‌زد Round را، و Round(NaN) روی targetهایی مثل Win32 که Delphi استثنای invalid-operation مربوط به x87 را ماسک نمی‌کند EInvalidOp بالا می‌دهد. exception بعد از اینکه نویسنده بخشی از خروجی‌اش را تولید کرده بود شلیک می‌شد، پس یک اندازه‌گیری منحط، یک 0/0 در یک متریک یا یک NaN که فراخوانی می‌داد، یک فایل بریده برجای می‌گذاشت. PLDoubleToStr حالا برای NaN برمی‌گرداند 0 و شاخهٔ صحیحش مثل شاخهٔ اعشاری به ±9.2e18 محدود می‌شود، پس Infinity هم به‌شکل یک literal متناهی بیرون می‌آید

صفر برای یک content stream جواب درست است، جایی که جایگاه عددی باید عددی را نگه دارد، و جواب غلط برای یک گزارش، جایی که 0 یک اندازه‌گیری محتمل است. نویسنده‌های JSON ای که باید این تفاوت را حفظ کنند از PLJSONNumber(Value, Decimals) از PDFlibExtra استفاده می‌کنند که برای NaN یا Infinity می‌نویسد null و در غیر این صورت ارقام invariant. PLJSONNumber حالا پشتوانهٔ GetSimilarImageDeduplicationReportJSON و GetAnnotationHitsJSON و گزارش‌های barcode و deskew و structured text و PDF/VCR است؛ گزارش deskew قبلاً برای یک زاویهٔ نامتناهی می‌نوشت 0 و حالا می‌نویسد null

PDFlibPas سه جور NaN و Infinity را متوقف می‌کند: AddPageMatrix و ScalePage و RedactRegion آرگومان‌های نامتناهی را از همان ابتدا رد می‌کنند، PLDoubleToStr برای جایگاه‌های content stream می‌نویسد 0، و PLJSONNumber در گزارش‌ها می‌نویسد null، جایی که صفر به‌شکل یک اندازه‌گیری محتمل خوانده می‌شد، بعد از آنکه Round(NaN) وسط export باعث بالا آمدن EInvalidOp می‌شد
صفر برای یک content stream جواب درست و برای یک گزارش جواب غلط است، پس نویسنده‌های گزارش هر 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 قاچاق می‌شود؟

از طریق هر فرمت‌کننده‌ای که به تنظیمات منطقه‌ای نگاه می‌کند، و یک audit کامل از خروجی‌های قابل خواندن توسط ماشین دقیقاً یک مورد باقی‌مانده پیدا کرد: maxAcceptedMeanError در GetSimilarImageDeduplicationReportJSON، که با PLFloatToStr نوشته می‌شد، یک wrapper نازک دور FloatToStr. روی دسکتاپی که جداکنندهٔ اعشارش کاما است، گزارش شامل "maxAcceptedMeanError":1,5 بود که parser از JSON آن را به‌شکل مقدار 1 به‌همراه یک توکن سرگردان می‌خواند. این فیلد بدترین خطای پیکسلی قبول‌شده را از حذف تکراری‌های تصویر به‌شکل ادراکی گزارش می‌کند و حالا از PLJSONNumber(Stats.MaxAcceptedMeanError, 6) می‌گذرد. یک دام باقی‌مانده PLStringBuilder است: روی Delphi یک alias سادهٔ System.SysUtils.TStringBuilder است که overload مربوط به Append(Double) اش از طریق locale کاربر فرمت می‌کند، در حالی که بیلدهای 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 استفاده کن که واقعاً تصاویر تقریباً تکراری دارد،
    // وگرنه میانگین خطا صفر می‌شود و باگ پنهان می‌ماند
    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 کاما اجرا می‌شود، که هر کدام با یک parser سخت‌گیرانه اعتبارسنجی شوند نه با نگاه کردن. JSON شیء و JSON سند و گزارش‌های تحلیلی در PDF Library for Delphi بین Delphi و C++Builder و Free Pascal همان قواعد عددی مشترک را دارند؛ فهرست کامل قابلیت‌ها در صفحهٔ محصول PDF Library for Delphi است