PDFlibPas, библиотека разработки PDF от losLab для Delphi, пишет каждое число, попадающее в поток содержимого, с десятичной точкой и без экспоненты, что бы ни говорили региональные настройки Windows. С v3.539.26 AddPageMatrix, ScalePage, DeskewPage, RedactRegion, вывод text-to-path и перекраска форматируют операнды через PLDoubleToStrConst, а с v3.539.33 парсеры, читающие эти числа обратно, используют PLTryStrToFloatInvariant вместо системной локали. На немецкой, французской или бразильской машине тот же код теперь даёт те же байты, что и на американской, — а иного поведения файловый формат терпеть не может
Почему comma-локаль калечит PDF без единой ошибки?
Comma-локаль калечит PDF молча, потому что запятая — не числовой символ в синтаксисе PDF, и повреждение читается как валидные токены с неверным смыслом. До починки PLFloatToStr была ничем иным, как голым вызовом FloatToStr, а FloatToStr следует FormatSettings.DecimalSeparator. При разделителе-запятой AddPageMatrix(0.5, 0.5, 0, 0) писала 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 разрешает в числе цифры, одну точку и ведущий знак, и ничего больше, поэтому парсер содержимого читает эту строку как число 0 и неизвестный токен ,5, и оператор cm получает неверные операнды. Ничего не бросается, ничего не логируется. Страница просто рисуется с уплывшей матрицей трансформации, а трассировка от съехавшего рисунка назад к настройке локали — то ещё развлечение
Второй дефект прячется за первым. FloatToStr использует формат ffGeneral, который перескакивает на экспоненциальную запись, как только величина падает ниже 1E-4, так что крошечный офсет выходил как 1E-5. Тот же §7.3.3 сообщает, что PDF экспоненциальную форму не поддерживает, то есть даже машина с US-локалью могла записать невалидный операнд при достаточно малом значении. Регрессионные тесты этого релиза прибивают обе формы отказа: они переключают разделитель на запятую, зовут API и сканируют полученное содержимое на любой токен с запятой или экспонентой
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
OldSeparator: Char;
begin
Lib := TPDFlib.Create;
try
Lib.SetPageDimensions(300, 200);
Lib.DrawBox(10, 10, 20, 20, 1);
OldSeparator := FormatSettings.DecimalSeparator;
try
FormatSettings.DecimalSeparator := ','; // имитируем десктоп de-DE
Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
// v3.539.26 и новее пишут: 0.5 0 0 0.25 0.00001 12.75 cm
// старые сборки писали: 0,5 0 0 0,25 1E-5 12,75 cm
Writeln(Lib.GetPageContentToString);
finally
FormatSettings.DecimalSeparator := OldSeparator;
end;
finally
Lib.Free;
end;
end;
Два сорта чисел, два семейства хелперов
Починка в PDFlibPas — жёсткое разделение: числа, показываемые людям, могут следовать локали, а числа, пишемые для машины, — никогда. PLFloatToStr и PLStrToFloat остаются в PDFlibExtra.pas для пользовательского текста, и их объявление теперь несёт комментарий ровно об этом. Всё, что становится синтаксисом PDF, идёт через PLDoubleToStrConst с фиксированным числом знаков, выбранным под задачу: шесть для матриц, четыре для координат и подстроек TJ, три для цветов и FDF-прямоугольников. Аудит v3.539.26 тронул больше вызовов, чем обещал исходный багрепорт:
AddPageMatrix,ScalePageиDeskewPage, которые дописываютcmперед существующим содержимым страницы- Строители элементов страницы, издающие сбросы
Tm, продвиженияTJи трансформацииcm - Матрицы размещения глифов и точки контуров в конвертере text-to-path
- Чёрный заливочный прямоугольник, дописываемый
RedactRegion, значения/Rectв FDF-экспорте и операнды перекраски
PLDoubleToStrConst — ручной форматтер, а не обёртка над FloatToStrF, и три его свойства здесь существенны. Он всегда пишет точку и срезает хвостовые нули, так что 0.5 остаётся 0.5, а не 0.500000. Он никогда не пишет экспоненту для конечного ввода. И ненулевое значение меньше запрошенной точности сохраняет значащие цифры вместо схлопывания в ноль, так что PLDoubleToStrConst(1E-9, 6) возвращает 0.000000001; в 0 превращаются только значения ниже примерно 5E-16. Последнее правило существует потому, что округление крошечного масштабного коэффициента в ноль превращает валидную матрицу в сингулярную — баг похуже исправляемого
uses
PDFlibExtra;
procedure CheckNumberHelpers;
var
V: Double;
begin
// Машинный вывод: точка, без экспоненты, хвостовые нули срезаны
Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235'); // хранит 4 значащие цифры
Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');
// Машинный ввод: мягкий отказ вместо EConvertError
Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
Assert(not PLTryStrToFloatInvariant('0,5', V)); // числа содержимого никогда не пишут запятую
end;
Почему парсящая сторона опаснее пишущей?
Парсящая сторона опаснее, потому что парсер, привязанный к локали, не выдаёт неверное число — он бросает исключение. PLStrToFloat звал StrToFloat, который поднимает EConvertError, когда текст не совпадает с системным разделителем. На comma-системе это значило, что RecolorPage умирала на первом же обычном операторе 0.5 g, так что отказывалась каждая настоящая страница, а не экзотика. RenderPageRegionToFile отвергал собственный документированный clip-формат "10.5,20.5,50.5,40.5", а атрибуты длин SVG, цвета SVG-экспорта, списки вершин аннотаций и значения solidity у output intent либо отвергались, либо молча заменялись дефолтами. Библиотека, идеально работающая на машине разработчика и падающая на первом клиенте в Мюнхене, — это ровно тот сорт кода, который, как случаи из статьи о Delphi-коде, работающем случайно, выглядит правильным только там, где его тестировали
v3.539.33 расклассифицировал каждый вызов StrToFloat и TryStrToFloat по происхождению ввода. Операнды потока содержимого, атрибуты SVG, цветовые строки художника и списки clip и вершин через запятую имеют фиксированный синтаксис с точкой, поэтому теперь идут через PLTryStrToFloatInvariant, который тримит текст, парсит его с PLInvariantFormatSettings и возвращает False на пустом, кривом или нефинитном вводе вместо исключения. Списку через запятую не остаётся простора для компромисса: запятая не может быть одновременно разделителем списка и десятичным знаком. Тот же проход починил и запись за границей буфера: RenderPageRegionToFile раньше складывал пятое clip-значение за четырёхэлементным буфером. Для конвейера перекраски из руководства о переводе PDF в одно цветовое пространство практический итог таков: RecolorPage и RecolorDocument больше не умирают на comma-системе. Значения правил, которые вызывающий впечатывает в CheckDocumentPolicy, — единственный парсящий случай, оставшийся на снисходительном хелпере, и причина объяснена в следующем разделе
Что будет, если починить только один конец round trip?
Починка одного конца локального round trip ломает код, который раньше работал, поэтому правка атрибутов структуры в v3.539.32 двигала писателя и читателя вместе. Обёртки SetStructElem* передают числа строками: SetStructElemBBox форматирует четыре значения в одну строку, хранит её через AddTagAttribute, а писатель /A позже парсит эту строку, решая, чем она станет — числом, массивом или именем. Оба конца жили в системной локали, так что на comma-системе round trip был самосогласованным. Баг всплывал, только если вызывающий следовал документации и передавал "0.5" в AddTagAttribute: читатель разобрать её не мог и издавал PDF-имя /0.5. У заглушки PDF/VCR была зеркальная проблема: библиотека генерировала GTS_BBox с точкой, а валидировала по локали перед сохранением
Поменять у писателя одну точку было бы хуже, чем ничего не делать: каждое значение SetStructElem* провалило бы привязанного к локали читателя и выродилось в имя. Поэтому писатели теперь используют PLDoubleToStrConst(v, 6), а читатель — новый PLTryStrToFloatLenient, который сперва пробует форму с точкой и откатывается к системной локали. Вызывающий с comma-локалью, передававший в прошлом "1,25", по-прежнему получает число 1.25. Компромисс намеренный и задокументированный: на немецкой системе "1.500" раньше становилось именем, потому что StrToFloat отвергает разделители тысяч, а теперь читается как 1.5, зато литеральные строки NAN и INF числами больше не принимаются
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
begin
FormatSettings.DecimalSeparator := ','; // вызывающий с comma-десятичной
Lib := TPDFlib.Create;
try
Lib.BeginTag('Figure', 'Sales chart', '');
Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5'); // /SpaceAfter 0.5, раньше /0.5
Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // по-прежнему /StartIndent 1.25
Lib.DrawText(20, 20, 'figure');
Lib.EndTag;
Lib.SaveToFile('tagged.pdf');
finally
Lib.Free;
end;
end;
Где останавливаются NaN и бесконечность
AddPageMatrix, ScalePage и RedactRegion теперь отвергают NaN и бесконечные аргументы на входе и возвращают 0, потому что никакое число PDF их не представит. ScalePage уже отказывал факторам нуля и ниже, но NaN проходит тест <= 0, так что NaN-масштаб раньше доехал до самого форматтера. В v3.539.26 тот форматтер всё ещё звал Round на NaN, что поднимает EInvalidOp на Win32, где блок x87 не маскирует невалидные операции; v3.539.31 научил PLDoubleToStrConst писать 0 для NaN как последнюю линию обороны, но ноль в матрице — сингулярная трансформация, так что настоящая починка — проверка на уровне API. Две границы оставлены на месте намеренно. Строки состояния метафайла пишутся и читаются с локалью внутри одного процесса и наружу не выходят, поэтому их не тронули. А тест, форматирующий 1E-5 через путь элементов страницы, обязан прочитать содержимое до перезаписи слоя, потому что переиздание операндов в точности документа легитимно превращает это значение в 0
Если ваше приложение уезжает к клиентам за пределы мира десятичной точки, самая безопасная привычка — та, что теперь использует тестовый набор PDFlibPas: прогнать PDF-производящие пути единожды с FormatSettings.DecimalSeparator, выставленным в запятую, и отсканировать вывод на запятые и экспоненты. Статья о сохранении точности распарсенных десятичных покрывает вторую половину этой истории — как числа, прочитанные из существующего файла, сохраняют свой точный текст при записи. Загрузки, полный справочник API и триальная сборка — на странице продукта PDFlibPas Delphi PDF library