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

Сохранение точности десятичных чисел PDF при записи в Delphi

PDFlibPas, losLab PDF Developer Library, хранит ровно тот десятичный текст, который разобрал для каждого вещественного числа в документе, и пишет этот текст обратно дословно всякий раз, когда значение не менялось. Начиная с v3.539.19 настройка SetPrecision управляет только теми числами, которые библиотека создаёт или правит, так что обычная загрузка с сохранением больше не округляет /Gamma в CalRGB с 2.22221 до 2.2222 и не сдвигает цвета страницы, которую никто не трогал. Изменение невелико по коду и велико по тому, что оно говорит о парсерах: значение, которое вы декодируете, и литерал, который вы выдаёте, — две разные вещи, и круговой рейс через Double не является тождественным преобразованием

Почему сохранение без изменений сдвигало цвета страницы?

Потому что переформатировались параметры цветового пространства, а не изображение. Файл, который это вскрыл, — офисный документ на 35 страниц из локального регрессионного корпуса, с изображением шапки, повторяющимся на каждой странице. Его загрузка и немедленная запись обратно дали потоки изображений, побайтово совпадающие со входом, а сравнение хешей потоков сообщило, что документ не изменился. Сравнение отрисованных страниц не согласилось: все 35 страниц показывали различия пикселей в шапке и больше нигде

Изображение шапки рисуется через цветовое пространство CalRGB, которое ISO 32000-1 §8.6.5.3 определяет через /WhitePoint, необязательный массив /Gamma из трёх элементов и необязательный массив /Matrix из девяти. Эти массивы — обычные числовые объекты в словаре цветового пространства. TPDFNumeric хранил каждый из них как Double и больше ничего, а TPDFNumeric.Output форматировал этот Double через PDFPrecNum, по умолчанию равный четырём знакам после запятой. Так /Gamma ушло с 2.22221 на 2.2222, элемент матрицы — с 0.71519 на 0.7152, и отрисовщик добросовестно выдал чуть другие цвета из чуть другой калибровки. Байты изображения были невиновны; числа вокруг них — нет. Неприятно то, насколько незаметным это было. Сравнение декодированных байт потоков этого не видит, потому что числа живут в словаре, а не в потоке. Сравнение полезных нагрузок вложений этого не видит. Даже сравнение ревизий, описанное в статье про уровни изменения, берёт отпечаток нормализованного тела объекта, поэтому обе ревизии дают один и тот же хеш, и сравнение объявляет их одинаковыми. Поймала это только отрисовка — поэтому базовый прогон корпуса рендерит каждую страницу, а не полагается на одни структурные проверки

Где PDFlibPas теряла точность CalRGB при сохранении без правок в Delphi: разобранные /Gamma 2.22221 и элемент матрицы 0.71519 лежат в TPDFNumeric как Double, Output форматирует их через PLDoubleToStr с PDFPrecNum в четыре знака, все структурные проверки сообщают, что документ не изменился, и только сравнение отрисованного показывает сдвиг во всех 35 изображениях шапки
Байты изображения были невиновны: TPDFNumeric переформатировал числа калибровки вокруг них через PDFPrecNum, поэтому хеши потоков и сравнение отпечатков одинаково сообщали о совпадающих ревизиях, а отрисовщик давал чуть другие цвета на каждой странице

Разобранное значение — не тот литерал, который нужно писать

Вещественное число в PDF — это десятичная строка, и ISO 32000-1 §7.3.3 прямо говорит, что это только десятичная строка: никакой записи через основание, никакой экспоненциальной формы. Annex C затем перечисляет точность, которую реализация обязана соблюдать, — примерно пять значащих десятичных цифр в дробной части. Точность вывода по умолчанию, четыре знака, уже ниже этого, и рядом с нулём всё становится хуже: PLDoubleToStr масштабирует значение, округляет до целого и выдаёт 0, когда результат равен нулю, так что элемент матрицы -0.000012345 не теряет разряд — он исчезает целиком

Поднять умолчание означало бы лишь отодвинуть обрыв. Исправление — перестать делать вид, что число и есть Double. Когда токенизатор в TPDFStructure.Decode распознаёт стандартное вещественное, то есть токен содержит десятичную точку и не содержит маркера экспоненты, он сохраняет исходный текст в новом поле FOriginalText рядом с преобразованным значением. Затем Output предпочитает этот текст и возвращается к форматированию, только когда предпочесть нечего

Как PDFlibPas сохраняет разобранный десятичный текст в Delphi: токенизатор в TPDFStructure.Decode держит исходный литерал в FOriginalText для любого токена с десятичной точкой и без экспоненты, Output пишет этот текст дословно вместо вызова PLDoubleToStr, а SetTo его очищает, потому что изменённое число — это новое число
Значение, которое вы декодируете, и литерал, который вы выдаёте, — две разные вещи: предпочтение разобранного текста сохраняет 2.22221 точным, а созданные библиотекой и отредактированные числа по-прежнему следуют PDFPrecNum, и настройка никогда не достаёт нетронутый вход
// Lib/PDFlibStruct.pas — всё исправление на стороне вывода
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // изменённое число — это новое число
  FValue:= Value;
  FChanged:= True;
End;

Две границы намеренные. Целые не сохраняются, потому что форматирование целых и так без потерь. Экспоненциальные формы вроде 6.02E23 допускаются на входе ради сломанных производителей, но не сохраняются на выходе, поскольку их запись обратно закрепляла бы синтаксис, запрещённый §7.3.3; они идут через форматтер, как любое созданное библиотекой число. Токенизатор ещё и применяет свою обычную минимальную починку до сохранения текста, так что литерал с ведущей точкой вроде .5 сохраняется как 0.5, а литерал с точкой в конце вроде 5. — как 5.0. Для любого читателя это одно и то же число, а принимаются такие записи куда шире

Что гарантирует SetPrecision после v3.539.19?

TPDFlib.SetPrecision теперь управляет числом знаков после запятой у чисел, которые библиотека производит сама: значений, нарисованных через painter, чисел, созданных из Double, например через NewNumeric, и любого разобранного значения, которое с тех пор правили через SetTo. Заметьте, что текст, декодированный через объектный API, например литерал, переданный в SetObjectFromString, проходит через тот же токенизатор и сохраняется так же. Разобранное десятичное число, которое никогда не меняли, сохраняет свою входную точность независимо от настройки, и смена настройки после загрузки его задним числом не затрагивает. Описание SetPrecision в справочнике обновили в том же выпуске, чтобы сказать ровно это, потому что прежняя формулировка подразумевала, что настройка применяется к каждому числу в файле

Очистка происходит в SetTo, а не выводится из флага Changed, и это различие важно. Конвейер сохранения сбрасывает Changed у объектов после того, как они записаны, поэтому проверка вида «выдавать исходный текст, пока не изменено» начала бы выдавать устаревший текст для значения, которое отредактировали, сохранили и отредактировали снова в той же сессии. Привязка исходного текста к самому присваиванию делает расхождение между ними невозможным. Регрессионный тест закрепляет каждое из этих поведений значениями из исходного файла

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // Неотредактированный вход выживает дословно, включая значение,
    // которое формат с четырьмя знаками схлопнул бы в 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Правка отбрасывает исходный текст и следует PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Понижение точности после этого не достаёт неотредактированный вход
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Почему модель содержимого всё равно нормализует числа?

Потому что TPDFContentProgram обещает канонические числовые операнды, и это обещание дороже дословного текста внутри потока содержимого. Редактируемая модель содержимого — та же самая, на которой построен трекер графического состояния, — существует, чтобы NormalizeContentStreams, оптимизатор и Emit давали стабильный, сравнимый вывод из произвольного входа. Если бы разобранный операнд проносил свой исходный текст в модель, последовательность операторов вроде 0.50000 0 0 RG выдавалась бы не так, как 0.5 0 0 RG, и каждое последующее сравнение плыло бы вместе с привычками форматирования производителя

Поэтому модель срезает исходный текст в двух своих точках входа. NormalizeContentNumbers выполняется для каждого операнда, когда парсер его добавляет, и ещё раз внутри SetOperand, когда декодируется источник, переданный вызывающим, и она рекурсивно обходит массивы и словари, так что штриховые шаблоны, массивы TJ и словари свойств размеченного содержимого попадают под неё. Вызова SetTo(AsDouble) для каждого числа достаточно, поскольку это ровно та операция, которая очищает текст. Сырые данные встроенных изображений остаются нетронутыми, как и всегда

Почему модель содержимого PDFlibPas всё равно нормализует числа: NormalizeContentNumbers идёт там, где парсер добавляет каждый операнд, и ещё раз внутри SetOperand, рекурсивно обходит массивы и словари, чтобы покрыть штриховые шаблоны, массивы TJ и словари свойств размеченного содержимого, а SetTo AsDouble очищает исходный текст, поэтому 0.50000 и 0.5 выдаются одинаково
Канонические числовые операнды — это обещание модели содержимого: сырые данные встроенных изображений не трогают, а нетронутые числа словарей вне потоков содержимого сохраняют дословную гарантию, так что пара обычных LoadFromFile и SaveToFile их всё равно сохраняет
// Lib/PDFlibContentModel.pas — модель содержимого держит свой контракт
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

Практическое правило для вызывающих поэтому простое. Обычные LoadFromFile, за которым следует SaveToFile, оставляют нетронутые потоки содержимого и нетронутые числа словарей такими, какими они были. Страница, прошедшая через NormalizeContentStreams или через любую правку в модели содержимого, выходит канонической по замыслу, а остальной документ всё равно сохраняется. Это два разных запроса, и теперь они делают две разные вещи

Сколько это стоит и где кончается гарантия

Каждый TPDFNumeric теперь несёт лишнюю ссылку на AnsiString, и каждое разобранное десятичное число держит свой исходный текст живым всю жизнь объекта. На документе с миллионами вещественных чисел это настоящая память, и это место принадлежит любым измерениям на больших документах, а не отмахиванию. Гарантия к тому же ограничена документом самого числа: копирование объектов между документами или восстановление значений через объектный API порождает новые числа, которые следуют точности вывода, как любое другое новое число. Стоит быть точным в том, что выпуск утверждает и чего не утверждает. Загрузка и сохранение нетронутого документа теперь сохраняют числа калибровки, которые отрисовщик действительно потребляет, — это и есть свойство, которое проверяет базовый прогон корпуса. Он не утверждает побайтовую одинаковость вывода, которая зависит ещё и от нумерации объектов, сжатия потоков и идентификатора трейлера, разобранного в статье про детерминированный ID PDF. И он не заставляет сравнение отпечатков видеть различия округления в файлах, созданных другим софтом, поскольку те по-прежнему хешируются по нормализованному телу. Урок обобщается далеко за пределы CalRGB: когда парсер хранит только преобразованное значение, каждое сохранение — это правка, и заметить это можно, лишь посмотрев на отрисованный результат. Работа с числами и семантика SetPrecision описаны на странице продукта losLab PDF Developer Library