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

Инвариантное форматирование чисел PDF в Delphi с HotPDF

Каждое вещественное число, попадающее в PDF-файл, обязано быть записано с точкой, что бы операционная система ни считала десятичным разделителем. HotPDF Delphi Component прогоняет все их через один инвариантный слой конвертации в HPDFTypes, так что немецкая, французская или бразильская рабочая станция выдаёт 0.5 0 0 0.5 5.5 7.25 cm, а не вариант с запятой, который не разберёт ни один конформный читатель

Звучит как фикс в две строки. Им это не было. Дефект годами маскировался побочным эффектом, он всплывал в трёх подсистемах, которые никто не считает кодом форматирования чисел, исчезал под одним компилятором и выживал под другим, а его последний слой не имел к десятичному разделителю никакого отношения

Почему локаль с десятичной запятой портит поток содержимого PDF?

Потому что ISO 32000-1 §7.3.3 определяет числовой объект как необязательный знак, десятичные цифры и максимум одну точку. Запятая не часть этой продукции, и по §7.2.2 она и не разделитель, так что читатель видит не два операнда там, где вы имели в виду один. Он видит токен, который не лексится, и оператор, содержавший его, отбрасывается. Один 0,5 в матрице состояния графики выносит весь блок q ... Q вокруг себя. У отказа есть и характерная форма в поле: он никогда не воспроизводится на машине разработчика, приходит тикетом поддержки из одной страны и читается как баг рендеринга, а не ошибка синтаксиса, потому что большинство вьюеров молча роняют битый оператор и рендерят остаток страницы

Диаграмма локального искажения в потоке содержимого PDF, произведённом HotPDF: хост-процесс с десятичной запятой испускает 0,5 внутри операторов rg и cm, грамматика чисел ISO 32000-1 принимает только точку, так что токен не лексится, и вьюер молча отбрасывает операторы вместе с блоком q Q вокруг них
Запятая не часть числовой продукции и не разделитель, поэтому один битый операнд убирает прямоугольник редактирования и стачанную страницу без единой ошибки
// Хост-процесс работает с DecimalSeparator = ','
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);

// Что раньше несло добавленное /Contents:
//   0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
//   q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// В ISO 32000-1 7.3.3 нет продукции для "0,5", поэтому прямоугольник
// редактирования и стачанная страница исчезают без ошибки

Побочный эффект, который годами прятал дефект

HotPDF пишет большинство чисел пути создания через _CutFloat, и ранние ревизии этой функции делали кое-что тихо враждебное: на каждом вызове она присваивала '.' глобальному FormatSettings.DecimalSeparator хост-процесса. Это портит состояние локали приложения, хостящего компонент, и это не потокобезопасно, но интересное следствие — третье. После генерации любого контента разделитель процесса становился точкой, так что каждый голый FloatToStr и StrToFloat в остальной библиотеке оказывался случайно корректным. Поэтому порядок «создай — затем редактируй», который использует почти каждый пример, проходил, а маскировал он каждый путь, работающий до первого вызова _CutFloat: редакторы загруженных документов StitchLoadedPage и RedactLoadedRect, текстовые и графические штампы, составные водяные знаки, рисование штрихкодов через Format('%.2f'), парсер /DA default-appearance и вся подсистема шрифтов Type 1

Таймлайн побочного эффекта _CutFloat в HotPDF: первый вызов присваивает точку глобальному FormatSettings.DecimalSeparator, что делает каждый поздний голый FloatToStr случайно корректным, пока редакторы загруженных документов StitchLoadedPage и RedactLoadedRect, путь штрихкодов, парсер /DA и подсистема шрифтов Type 1 остаются битыми
Порядок «создай — затем редактируй», который использует каждый пример, держал дефект невидимым, потому что всё после первой конвертации наследовало перевёрнутый глобальный разделитель

Type 1-парсинг падал самым уродливым из всех способов. FontMatrix, BlueValues, хинты стволов и вещественные числа внутри Type 2 charstrings — это PostScript-реалы, записанные с точками, согласно спецификации Adobe Type 1 Font Format. Парсинг 0.001 под локалью с запятой бросает EConvertError, охватывающий try/except его глотал, и матрица шрифта оставалась нулевой. Каждый глиф этого шрифта затем схлопывался, что выглядит как проблема шрифта, а не локали, и отправляет вас отлаживать рендеринг встроенных глифов на полдня

Один слой конвертации вместо разбросанных патчей

Правило, которому HotPDF теперь следует, прямое: любое число, записанное в синтаксис PDF, PostScript, JSON или JavaScript или прочитанное из него, идёт через явную инвариантную конвертацию, и ни один код нигде не зависит от побочного эффекта глобального FormatSettings. HPDFTypes владеет четырьмя конвертациями, плюс записью настроек, которую они делят, и одним хелпером фиксированной точности для пути штрихкодов

// Lib/HPDFTypes.pas — единственная поверхность инвариантной конвертации
function HPDFInvariantFormatSettings: TFormatSettings;

function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
  out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
  const Default: Extended): Extended;

function HPDFFormatInvariant2(Value: Extended): string;

Два юнита уже несли собственный приватный InvariantFormatSettings: HPDFContentStream и HPDFTextExtraction, и оба были сведены к однострочному forward, а не оставлены параллельными реализациями. Подсистема Type 1 — намеренное исключение: она не несёт зависимости от VCL и не ссылается на HPDFTypes, так что держит локальное зеркало с именем HPDFType1Core.Type1StrToFloat с идентичной семантикой, по теории, что продублированная функция в десять строк — меньший долг, чем тащить графический слой в парсер шрифтов. Два других места оставлены намеренно. HPDFHTMLImport парсит CSS-длины, намеренно меняя точку на разделитель локали и затем парся с локалью, что правильно для этого входа, а HPDFType1CB.GlyphCommands вместе со строками FontBBox — это публичный API, который вызывающие читают обратно своим собственным StrToFloat, где производство и потребление в одной локали самосогласовано, а инвариантный вывод сломал бы каждого вызывающего в регионе с запятой

Что HPDF_HAS_FORMAT_SETTINGS гарантирует между компиляторами?

Он гарантирует, что инвариантный путь реально компилируется в каждой поддерживаемой тулчейне, чего очевидный гейт версий не делал. Delphi XE2 ввёл TFormatSettings.Create и перегрузки FloatToStr / TryStrToFloat, принимающие TFormatSettings, так что первый инстинкт — писать {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} в каждом месте вызова. У этого инстинкта слепое пятно: HotPDF.inc определяет D2009+ под Free Pascal, но никогда не определяет XE2+, потому что XE2+ отвечает на вопрос про номера версий Delphi, тогда как Free Pascal 3.2.2 поставляет эти перегрузки независимо. Поэтому каждый такой гейт в FPC-сборках молча деградировал до локале-зависимой ветви, ровно на тех платформах, откуда пришли исходные отчёты. HPDF_HAS_FORMAT_SETTINGS определён и в FPC-блоке, и в блоке XE2+ файла HotPDF.inc, и это единственный символ, который проверяют числовые хелперы

{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
  Result := DefaultFormatSettings;   // в FPC нет TFormatSettings.Create
{$ELSE}
  Result := TFormatSettings.Create;
{$ENDIF}
  Result.DecimalSeparator := '.';
  Result.ThousandSeparator := ',';
end;
{$ENDIF}

Одна компиляторная ветвь выживает внутри функции, потому что у Free Pascal нет TFormatSettings.Create и приходится стартовать с DefaultFormatSettings. В конвертации участвуют только два числовых разделителя, так что поля дат и валюты получают то, что даёт текущая локаль. В Delphi до XE2 перегрузок нет вовсе, и там хелперы откатываются к конвертации с локалью процесса и затем переписывают символ разделителя в получившейся строке — некрасиво, но никогда не трогает глобальное состояние

Третий слой: экспоненты, которые SVG допускает, а PDF — нет

Починить десятичный разделитель необходимо и недостаточно. W3C SVG 1.1 §4.2 допускает экспоненту в своей числовой продукции, и разметка XPS делает то же, так что 1E-6 — вполне валидная координата на входе, тогда как ISO 32000-1 §7.3.3 не допускает экспоненту вообще. Значение, пережившее инвариантное форматирование, всё ещё может приземлиться в поток содержимого как 1E-6 и быть отвергнуто, и сам FloatToStrF в фиксированном формате может откатиться к экспоненциальной форме, как только значение выйдет за цифровой диапазон RTL. HPDFSafeSVG.SVGFloat поэтому делает свою собственную работу после инвариантной конвертации: сбрасывает всё ниже 1.0E-7 в ноль, затем, если E выжило, снимает экспоненту и двигает десятичную точку руками, добивая нулями ту сторону, куда требует сдвиг. SVGFinite охраняет вход, отвергая NaN и всё вне ±1.0E30 до того, как значение дойдёт до форматтера, а SVGReadNumber с SVGSkipSeparators обрабатывает входящее направление посимвольно, вместо того чтобы передоверять RTL

Пайплайн, показывающий, как HotPDF снимает экспоненты перед записью PDF-операндов: SVGFinite отвергает NaN и значения за 1.0E30, инвариантная конвертация оставляет точку, крошечные величины сбрасываются в ноль, а у вывода HPDFFloatToInvariantStr с выжившей E экспонента удаляется и десятичная точка сдвигается руками
SVG 1.1 допускает экспоненту, а ISO 32000-1 — нет, поэтому граница конвертации переписывает 1E-6 в обычную десятичную запись, которую разберёт конформный читатель
// Lib/HPDFSafeSVG.pas — десятичная запись для PDF-операндов
if Abs(Value) < 0.0000001 then
  Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
  ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
  SetLength(S, ExponentAt - 1);
  // ... сдвинуть десятичную точку на ExponentValue, добить '0' слева
  // или справа, затем вернуть знак
end;

Это тот же TSVGBuffer, который питает SVG-экспорт страниц и его обработку состояния графики, и та же числовая дисциплина применяется на стороне импорта при конвертации документов XPS и OXPS в PDF через HPDFCreateXPSDocumentHandler и член hdfXPS записи THPDFHandledDocumentFormat. Форматы разметки щедры на синтаксис чисел. Поток содержимого PDF — нет, и граница конвертации — то место, где эту разницу надо разрешать

Регрессионные тесты, которые воспроизводят локаль, а не предполагают её

Баг локали, проявляющийся только на чужой машине, стоит закреплять тестами, которые сами ставят локаль, и HotPDF несёт два таких. LoadedEditsEmitInvariantNumbersUnderCommaLocale присваивает запятую FormatSettings.DecimalSeparator перед первой правкой, гоняет RedactLoadedRect, StampLoadedPageText, StitchLoadedPage и составной штамп, затем читает байты содержимого страницы обратно и утверждает и то, что точные строки операторов присутствуют, и то, что в буфере нет ни одной запятой. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale покрывает вторую половину и исходный грех: гонит путь создания под той же локалью с запятой, включая DirectDrawBarcode с его фиксированным двухдесятичным форматированием, затем утверждает, что поток содержимого не несёт запятых и что FormatSettings.DecimalSeparator всё ещё запятая, когда возвращается EndDoc

Второе утверждение — то, которое поймало бы исходный дефект в день его написания, потому что оно тестирует, что библиотека делает со своим хостом, а не только то, что пишет на диск. Урок обобщается за пределы PDF: любой формат с фиксированной лексической грамматикой, включая PostScript, JSON и JavaScript, нуждается в явной инвариантной конвертации на границе. Тянуться к глобальной настройке ради правильного ответа работает, пока кто-то не вызовет ваш код в другом порядке, в другом потоке или из другого компилятора, и к тому моменту сбой выглядит как что угодно, только не как проблема форматирования

Слой форматирования чисел, описанный здесь, поставляется как часть HotPDF Delphi Component для Delphi и C++Builder, вместе с путями редактирования загруженных документов, штрихкодов, SVG-экспорта и XPS-конвертации, которые от него зависят