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

Грамматика чисел PDF против JSON: NaN, Infinity и null

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)

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

PDFNumberTextToJSON в PDFlibPas читает знак, собирает цифры вокруг единственной десятичной точки и применяет только правки, которых требует JSON, тогда как любой другой символ или пустой набор цифр откатывается к PLJSONNumber, пишущей null для NaN и Infinity вместо числа
Переписывание бьёт пересчёт: токенизатор уже залатал .5 и 4. на входе, поэтому писатель хранит каждую уцелевшую цифру, и round trip воссоздаёт ровно то же значение
  • -.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 нет синтаксиса ни для того, ни для другого. Infinity проще получить, чем кажется: 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-значным целым и любой отчёт под comma-локалью, каждый проваленный строгим парсером, а не на глаз. Object JSON, document JSON и аналитические отчёты в PDF Library for Delphi делят одни правила чисел на Delphi, C++Builder и Free Pascal; полный список фич — на странице продукта PDF Library for Delphi