Техническа статия

PDF числа срещу JSON: NaN, Infinity и null в Delphi

PDF Library for Delphi (PDFlibPas) излъчва валиден JSON за всяко PDF число от v3.539.31 насам. GetObjectJSON пренаписва токените, които 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?

Защото двата grammar-а се разминават по четири дребни подробности, а PDF парсер, зачитащ изходния текст, ще пренесе тези подробности направо в изхода. ISO 32000-1 §7.3.3 позволява число да започва с плюс, да пропусне цялата част (.5), да свърши на гола точка (4.) и да носи водещи нули (007.5). RFC 8259 §6 не позволява нито едно от тях: незадължителен минус, цяла част, която е или 0, или започва с 1 до 9, и поне една цифра след всяка десетична точка. Производителите са свободни да записват PDF формите, а доста генератори и ръчно редактирани файлове го правят

Течът дойде от нарочна feature за прецизност. От v3.539.19 TPDFNumeric.Output връща точния текст, който tokenizer-ът е парснал за реални числа, — това пази калибрирана цветна стойност точна при запис, както е описано в запазването на парсната PDF десетична прецизност. Tokenizer-ът вече закърпва .5 в 0.5 и 4. в 4.0 при входа, а целите числа се преформатират от стойността си, така че +3 се връща като 3. Дословно оцелява останалото: подписана водеща точка (-.25), изричен плюс при реално число (+1.5) и водещи нули (007.5). Старият object writer долепяше Output направо след "value":, а TJSONParser.ParseNumber в собствения четец на библиотеката спира на всеки от тях с „Invalid JSON number", така че експортът успяваше, а пре-импортът се проваляше с PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON пренаписва PDF число токените, които RFC 8259 отхвърля, цифра по цифра: -.25 става -0.25, +1.5 губи плюса, 007.5 хвърля водещите си нули, а дробни цифри като 1.250000 оцеляват, защото форматиране от съхранения Double би добавило двоичен шум
Старият writer долепяше точния парснат текст, собственият четец на библиотеката спираше с 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 преписва токена с друга ортография, вместо да го преизчисли от Double. Функцията в PDFlibObjectJSON чете незадължителен знак, събира цифрите преди и след една-единствена десетична точка и после прилага само редакциите, които JSON иска: маха плюс, отрязва водещи нули, запазвайки една, доставя 0, когато цялата част е празна, маха гола крайна точка и връща минуса. Токен, съдържащ друг символ, или въобще без цифри, се връща към PLJSONNumber(Value, 10), който записва null, когато стойността не е крайна

PDFlibPas PDFNumberTextToJSON чете знака, събира цифрите около една-единствена десетична точка и прилага само редакциите, които JSON иска, докато друг символ или празна цифрова редица се връщат към PLJSONNumber, който записва null за NaN и Infinity вместо число
Преписването с друга ортография бие преизчисляването: tokenizer-ът вече е закърпил .5 и 4. при входа, така че writer-ът пази всяка оцеляла цифра и round trip пресъздава точно същата стойност
  • -.25 става -0.25, а +.5 става 0.5
  • +1.5 става 1.5
  • 007.5 става 7.5, докато 0.75 си остава какъвто е
  • 4. става 4, ако такъв токен някога стигне до writer-а
  • 2.22221 и 1.250000 пазят всяка дробна цифра, включително крайните нули

Форматирането от съхранения Double би било по-кратко и грешно, по същата причина, поради която съществува поправката за прецизност: подразбиращата се изходна прецизност е четири знака, а дори преобразуване с пълна прецизност може да добави двоичен шум към десетичен literal. Пазенето на цифрите значи, че SetObjectJSON и ImportObjectJSON, които подават всеки JSON число текст на PDF tokenizer-а, пресъздават точно същата стойност. Гаранцията покрива стойността, не байтовете: след пре-импорт -.25 се съхранява и записва като -0.25. И двете ортографии са равни по §7.3.3, но diff на байтово ниво ще отбележи промяната, така че не третирайте цикъл експорт-импорт като no-op върху документ, чийто байтове са покрити от подпис

Какво става с число, което JSON не може да представи?

GetDocumentJSON вече записва null за всяко число, което е NaN или безкрайно, защото RFC 8259 §6 няма синтаксис за нито едното. Безкрайността е по-лесна за произведене, отколкото звучи: PDF tokenizer-ът натрупва цифри с повторно умножение в Double, който се изчерпва около 1.8 × 10308, така че целочислен literal малко над 300 цифри тихо става +Inf. Честни файлове никога не съдържат такъв literal; фузирани и враждебни — да, затова те принадлежат в същия тестов корпус като случаите в закаляването на Pascal PDF парсер срещу зловредни файлове. Старият document writer форматираше не-цели с Str(D:0:6), а за +Inf това записва текста +Inf, който никакъв JSON потребител няма да парсне

null-ът е нарочено губещ. Консуматорите на изхода на GetDocumentJSON трябва да приемат null навсякъде, където може да се появи число, и да го четат като „стойност имаше, но не може да бъде представена", а не като липсващ ключ. Оригиналният literal не е възстановим от document JSON-а, така че pipeline, на който му пука, трябва да логва обекта и да третира файла като подозрителен, вместо да замества с подразбираща се стойност

Защо един-единствен NaN можеше да прекъсне SVG или JSON експорт?

Защото PLDoubleToStr, invariant числовият форматор зад content stream-ите, SVG, XML, CSV и повечето JSON в библиотеката, мащабираше входа си и викаше Round, а Round(NaN) вдига EInvalidOp на цели като Win32, където Delphi оставя x87 изключението за невалидна операция немаскирано. Изключението светваше след като writer-ът вече беше излъчил част от изхода си, така че едно дегенерирано измерение, 0/0 в метрика или NaN, подаден от извикващ, оставяше отрязан файл след себе си. PLDoubleToStr вече връща 0 за NaN, а integer клонът му clamp-ва до ±9.2e18 като дробния, така че Infinity също излиза като краен literal

Нулата е правилният отговор за content stream, където число-слот трябва да държи число, и грешният за отчет, където 0 е правдоподобно измерение. JSON writer-и, които трябва да запазят разликата, ползват PLJSONNumber(Value, Decimals) от PDFlibExtra, който записва null за NaN или Infinity и invariant цифри иначе. PLJSONNumber вече стои зад GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON и отчетите за barcode, deskew, structured text и PDF/VCR; deskew отчетът преди записваше 0 за некраен ъгъл и вече записва null

PDFlibPas спира NaN и Infinity по три начина: AddPageMatrix, ScalePage и RedactRegion отхвърлят некрайни аргументи отпред, PLDoubleToStr записва 0 за content-stream слотове, а PLJSONNumber записва null в отчети, където нулата би се прочела като правдоподобно измерение, след като Round(NaN) вдигаше EInvalidOp по средата на експорт
Нулата е правилният отговор за content stream и грешният за отчет, затова report writer-ите подават всеки 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): Double overload-ът следва потребителския локал
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Къде потребителският локал още се промъква в JSON?

През всеки форматор, който пита регионалните настройки, а пълен одит на machine-readable изхода намери точно един останал: maxAcceptedMeanError в GetSimilarImageDeduplicationReportJSON, записван с PLFloatToStr, тънка обвивка около FloatToStr. На десктоп, чийто десетичен разделител е запетая, отчетът съдържаше "maxAcceptedMeanError":1,5, което JSON парсер чете като стойността 1, следвана от заблуден токен. Полето докладва най-лошата приета пикселна грешка от перцептивната дедупликация на изображения и вече минава през PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Останал капан е PLStringBuilder: на Delphi той е обикновен alias на System.SysUtils.TStringBuilder, чийто Append(Double) overload форматира през потребителския локал, докато FPC build-овете ползват клас на библиотеката, така че тест на 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', '');
    // Dry run с прагове 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-цифрен integer, и всеки отчет, пуснат при локал със запетая — всеки валидиран със строг парсер, а не с око. Object JSON, document JSON и аналитичните отчети в PDF Library for Delphi споделят едни и същи правила за числа през Delphi, C++Builder и Free Pascal; пълният списък с feature-и е на продуктовата страница на PDF Library for Delphi