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)
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, когда значение нефинитно
-.25становится-0.25, а+.5—0.5+1.5становится1.5007.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
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