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, и отрисовщик добросовестно выдал чуть другие цвета из чуть другой калибровки. Байты изображения были невиновны; числа вокруг них — нет. Неприятно то, насколько незаметным это было. Сравнение декодированных байт потоков этого не видит, потому что числа живут в словаре, а не в потоке. Сравнение полезных нагрузок вложений этого не видит. Даже сравнение ревизий, описанное в статье про уровни изменения, берёт отпечаток нормализованного тела объекта, поэтому обе ревизии дают один и тот же хеш, и сравнение объявляет их одинаковыми. Поймала это только отрисовка — поэтому базовый прогон корпуса рендерит каждую страницу, а не полагается на одни структурные проверки
Разобранное значение — не тот литерал, который нужно писать
Вещественное число в PDF — это десятичная строка, и ISO 32000-1 §7.3.3 прямо говорит, что это только десятичная строка: никакой записи через основание, никакой экспоненциальной формы. Annex C затем перечисляет точность, которую реализация обязана соблюдать, — примерно пять значащих десятичных цифр в дробной части. Точность вывода по умолчанию, четыре знака, уже ниже этого, и рядом с нулём всё становится хуже: PLDoubleToStr масштабирует значение, округляет до целого и выдаёт 0, когда результат равен нулю, так что элемент матрицы -0.000012345 не теряет разряд — он исчезает целиком
Поднять умолчание означало бы лишь отодвинуть обрыв. Исправление — перестать делать вид, что число и есть Double. Когда токенизатор в TPDFStructure.Decode распознаёт стандартное вещественное, то есть токен содержит десятичную точку и не содержит маркера экспоненты, он сохраняет исходный текст в новом поле FOriginalText рядом с преобразованным значением. Затем Output предпочитает этот текст и возвращается к форматированию, только когда предпочесть нечего
// 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) для каждого числа достаточно, поскольку это ровно та операция, которая очищает текст. Сырые данные встроенных изображений остаются нетронутыми, как и всегда
// 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