Технічна стаття

Числа 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?

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

Протікання прийшло з навмисної фічі точності. З v3.539.19 TPDFNumeric.Output повертає точний текст, який токенізатор розібрав для дійсних чисел, — саме те, що тримає відкаліброване колірне значення точним при збереженні, як описано в збереженні розібраної десяткової точності PDF. Токенізатор уже латав .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)

PDFlibPas GetObjectJSON переписує відкинуті RFC 8259 числові токени PDF цифра-за-цифрою: -.25 стає -0.25, +1.5 втрачає плюс, 007.5 скидає початкові нулі, а дробові цифри на кшталт 1.250000 виживають, бо формантування зі збереженого Double додало б бінарний шум
Старий писар приклеював точний розібраний текст, власний читач бібліотеки зупинявся з Invalid JSON number, а помилка 105 ламала зворотний шлях, який експортний бік називав успіхом
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 замість числа
Переписування б'є перерахування: токенізатор уже залатав .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 було б коротшим і неправильним, з тієї ж причини, з якої існує виправлення точності: усталена точність виводу — чотири десяткові, і навіть конвертація повної точності може додати бінарний шум до десяткового літерала. Тримати цифри означає, що SetObjectJSON і ImportObjectJSON, які віддають текст кожного JSON-числа PDF-токенізаторові, відтворюють рівно те саме значення. Гарантія покриває значення, а не байти: після повторного імпорту -.25 зберігається і записується як -0.25. Обидва написи рівні за §7.3.3, але байтовий diff позначить зміну, тож не вважайте цикл експорт-імпорт no-op на документі, чиї байти покриті підписом

Що станеться з числом, яке JSON не може зобразити?

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

null тут — навмисно з втратами. Споживачі виводу GetDocumentJSON мусять приймати null всюди, де може з'явитися число, і читати його як «значення було, але його неможливо зобразити», а не як відсутній ключ. Початковий літерал не відновити з JSON документа, тож конвеєр, якому не байдуже, мусить логувати об'єкт і вважати файл підозрілим, а не підставляти усталене

Чому один NaN міг обірвати SVG- чи JSON-експорт?

Бо PLDoubleToStr, інваріантний формантер чисел, що стоїть за потоками вмісту, SVG, XML, CSV і більшістю JSON у бібліотеці, масштабував свій ввід і викликав Round, а Round(NaN) підіймає EInvalidOp на цілях на кшталт Win32, де Delphi лишає x87-виняток некоректної операції незамаскованим. Виняток стріляв після того, як писар уже видав частину свого виводу, тож одне вироджене вимірювання, 0/0 у метриці чи NaN, переданий викликачем, лишало після себе обрізаний файл. PLDoubleToStr тепер повертає 0 для NaN, а його цілочисельна гілка обрізається до ±9.2e18, як і дробова, тож Infinity теж виходить скінченним літералом

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

PDFlibPas зупиняє NaN і Infinity трьома шляхами: AddPageMatrix, ScalePage і RedactRegion відхиляють нескінченні аргументи на вході, PLDoubleToStr пише 0 у слоти потоку вмісту, а 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): перевантаження Double слідує локалі користувача
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Де локаль користувача все ще просочується в JSON?

Через будь-який формантер, що заглядає в регіональні налаштування, і повний аудит машиночитаного виводу знайшов рівно один лишок: maxAcceptedMeanError у GetSimilarImageDeduplicationReportJSON, який писався через PLFloatToStr, тонку обгортку над FloatToStr. На робочому столі, чий десятковий розділювач — кома, звіт містив "maxAcceptedMeanError":1,5, що JSON-парсер читає як значення 1 і блукаючий токен після нього. Поле звітує про найгіршу прийняту піксельну помилку з перцептуальної дедуплікації зображень, і тепер воно проходить через PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Решта пастка — PLStringBuilder: на Delphi це простий аліас System.SysUtils.TStringBuilder, чий перевантажений Append(Double) формантує через локаль користувача, тоді як 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
    // Візьміть фікстуру, що справді містить майже-дублікати зображень,
    // інакше середня помилка 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-виводу потрібно три фікстури, щоб лишатися чесним: сторінка з -.25, +1.5 і 007.5, об'єкт із 400-цифровим цілим і будь-який звіт під комовою локаллю, кожна перевірена строгим парсером, а не на око. Об'єктний JSON, JSON документа й аналітичні звіти в PDF Library for Delphi діляться тими самими правилами чисел через Delphi, C++Builder і Free Pascal; повний перелік можливостей — на сторінці продукту PDF Library for Delphi