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

Числа PDF без локали для Delphi с запятой-десятичной

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;
AddPageMatrix в PDFlibPas на comma-десктопе до починки писала 0,5 0 0 0,25 1E-5 12,75 cm, что парсер PDF читает как число 0 плюс неизвестные токены, cm остаётся с неверными операндами, а страница молча трансформируется, тогда как PLDoubleToStrConst пишет валидные десятичные с точкой
Запятая — не числовой символ в синтаксисе PDF, поэтому повреждение читается как валидные токены с неверным смыслом — а экспоненты ffGeneral вроде 1E-5 были невалидны на любой локали, не только запятой

Два сорта чисел, два семейства хелперов

Починка в 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. Последнее правило существует потому, что округление крошечного масштабного коэффициента в ноль превращает валидную матрицу в сингулярную — баг похуже исправляемого

PDFlibPas делит форматирование чисел надвое: PLFloatToStr и PLStrToFloat остаются привязаны к локали для пользовательского текста, а PLDoubleToStrConst и PLTryStrToFloatInvariant форматируют всё, что становится синтаксисом PDF, с точкой, без экспоненты и с фиксированной точностью под задачу — шесть, четыре или три знака
Инвариантный форматтер написан руками намеренно: он срезает хвостовые нули, никогда не пишет экспоненту и хранит значащие цифры крошечных значений, ведь округление масштабного коэффициента в ноль сделало бы валидную матрицу сингулярной
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 числами больше не принимаются

SetStructElemBBox в PDFlibPas и его братья передают числа строками через AddTagAttribute, а писатель /A парсит эти строки обратно, поэтому v3.539.32 двинул оба конца вместе: PLDoubleToStrConst пишет с точкой, а PLTryStrToFloatLenient сперва читает точку с локальным fallback, так что comma-локальное 1,25 по-прежнему читается как 1.25
Починка одного писателя выродила бы каждый атрибут структурного элемента в PDF-имя, поэтому round trip двигает оба конца сразу или никак
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