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

Локально-незалежні числа PDF для Delphi з комовою локаллю

PDFlibPas, бібліотека розробника PDF losLab для Delphi, пише кожне число, яке кладе в потік вмісту, з крапковим десятковим розділювачем і без експоненти, що б там не казали регіональні налаштування Windows. З v3.539.26 AddPageMatrix, ScalePage, DeskewPage, RedactRegion, вивід text-to-path і перефарбування формантують операнди через PLDoubleToStrConst, а з v3.539.33 парсери, які читають ті числа назад, користуються PLTryStrToFloatInvariant замість системної локалі. На німецькій, французькій чи бразильській машині той самий код тепер дає ті самі байти, що й на американській, — а іншої поведінки файловий формат терпіти не може

Чому комова десяткова локаль псує PDF без жодної помилки?

Комова десяткова локаль тихо псує 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 не підтримує експоненційну форму, а отже, навіть машина з американською локаллю могла записати некоректний операнд, якби значення було достатньо малим. Регресні тести цього релізу пришпилюють обидві форми відмови: перевертають розділювач на кому, викликають 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 AddPageMatrix на робочому столі з комовою десятковою писав 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; лише значення нижче приблизно 5E-16 стають 0. Останнє правило існує, бо округлення крихітного масштабного коефіцієнта до нуля перетворює коректну матрицю на сингулярну, а це гірший баг, ніж той, що виправляється

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, коли текст не збігається із системним розділювачем. На системі з комовою десятковою це означало, що RecolorPage обривався, щойно зустрічав звичайний оператор 0.5 g, тож падала кожна реальна сторінка, а не лише екзотичні. RenderPageRegionToFile відхиляв власний задокументований формат кліпу "10.5,20.5,50.5,40.5", а SVG-атрибути довжин, кольори SVG-експорту, списки вершин анотацій і значення щільності output intent або відкидалися, або тихо замінювалися усталеними. Бібліотека, що бездоганно працює на машині розробника і падає на першому клієнті в Мюнхені, — це рівно той сорт коду, який, як і випадки з статті про Delphi-код, що працює випадково, виглядає коректним лише через те, де його тестували

v3.539.33 класифікував кожен виклик StrToFloat і TryStrToFloat за тим, звідки береться його ввід. Операнди потоку вмісту, SVG-атрибути, рядки кольорів маляра і розділені комами списки кліпів та вершин усе мають фіксований крапковий синтаксис, тож тепер вони проходять через PLTryStrToFloatInvariant, який підстрибує текст, розбирає його з PLInvariantFormatSettings і повертає False для порожнього, деформованого чи нескінченного вводу замість підняття винятку. Розділений комами список не лишає місця для компромісу, бо кома не може бути одночасно розділювачем списку і десятковою міткою. Той самий прохід виправив і запис за межами буфера: RenderPageRegionToFile колись зберігав п'яте значення кліпу за межами свого чотириелементного буфера. Для конвеєра перефарбування, описаного в посібнику з конвертації PDF в один колірний простір, практичний результат такий: RecolorPage і RecolorDocument більше не обриваються на системі з комовою десятковою. Значення правил, які викликач вписує в CheckDocumentPolicy, — єдиний випадок розбору, що користується натомість м'яким хелпером, з причини, яку пояснює наступний розділ

Що станеться, якщо виправити лише один кінець зворотного шляху?

Виправлення лише одного кінця локального зворотного шляху ламає код, що раніше працював, саме тому зміна атрибутів структури в v3.539.32 зрушила писаря й читача разом. Обгортки SetStructElem* передають числа як рядки: SetStructElemBBox формантує чотири значення в один рядок, зберігає його через AddTagAttribute, а писар /A пізніше розбирає той рядок, щоб вирішити, чи стане він числом, масивом чи іменем. Обидва кінці користувалися системною локаллю, тож на системі з комовою десятковою зворотний шлях був самосузгодженим. Баг вилазив, лише коли викликач пішов за документацією і передав "0.5" у AddTagAttribute: читач не міг його розібрати і видавав PDF-ім'я /0.5. Заповнювач PDF/VCR мав дзеркальну проблему, бо бібліотека генерувала GTS_BBox з крапкою, а валідувала його локаллю перед збереженням

Зміна лише писаря на крапку була б гіршою за нічого не робити, бо кожне значення SetStructElem* тоді провалювало б читача, прив'язаного до локалі, і деградувало в ім'я. Тож писарі тепер користуються PLDoubleToStrConst(v, 6), а читач — новим PLTryStrToFloatLenient, який спершу пробує крапкову форму, а потім відкатується до системної локалі. Викликач із комовою локаллю, який передавав "1,25" у минулому, досі отримує число 1.25. Компроміс навмисний і задокументований: на німецькій системі "1.500" раніше ставало іменем, бо StrToFloat відхиляє розділювачі тисяч, а тепер читається як 1.5, тоді як буквальні рядки NAN і INF більше не приймаються як числа

PDFlibPas SetStructElemBBox і його брати передають числа як рядки через AddTagAttribute, а писар /A розбирає ті рядки назад, тож v3.539.32 зрушив обидва кінці разом: PLDoubleToStrConst пише з крапкою, а PLTryStrToFloatLenient читає спершу крапку з відкатом до локалі, тож комове 1,25 досі читається як 1.25
Виправлення лише писаря деградувало б кожен атрибут структурного елемента в PDF-ім'я, саме тому зворотний шлях рухає обидва кінці разом або взагалі нічого не рухає
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // викликач із комовою десятковою
  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