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

Збереження точності десяткових чисел PDF при записі в Delphi

PDFlibPas, losLab PDF Developer Library, зберігає точний десятковий текст, який він розібрав для кожного дійсного числа в документі, і пише цей текст назад дослівно щоразу, коли значення не змінювали. Від версії 3.539.19 налаштування SetPrecision керує лише числами, які бібліотека створює або редагує, тож звичайне завантаження з наступним збереженням більше не округлює CalRGB /Gamma зі значення 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, і рендерер сумлінно давав трохи інші кольори з трохи іншої калібровки. Байти зображення були безневинні; числа навколо них — ні. Найнеприємніше тут те, наскільки це було невидимо. Порівняння декодованих байтів потоків його не бачить, бо числа живуть у словнику, а не в потоці. Порівняння вмісту вкладень його не бачить. Навіть diff ревізій, описаний у статті про рівні модифікації, знімає відбиток із нормалізованого тіла об'єкта, тож обидві ревізії хешуються в одне значення і diff звітує їх як ідентичні. Зловив це лише рендеринг — саме тому базовий рівень корпусу рендерить кожну сторінку, а не покладається на самих лише структурних перевірках

Де PDFlibPas втрачав точність CalRGB при no-op збереженні в Delphi: розібрані /Gamma 2.22221 і елемент матриці 0.71519 лежать у TPDFNumeric як Double, Output форматує їх через PLDoubleToStr із PDFPrecNum на чотири знаки, усі структурні перевірки звітують документ незміненим, і лише порівняння рендерів показує зсув усіх 35 зображень заголовка
Байти зображення були безневинні: TPDFNumeric переформатував числа калібровки навколо них через PDFPrecNum, тож хеші потоків і fingerprint diff обидва звітували ідентичні ревізії, поки рендерер давав трохи інші кольори на кожній сторінці

Значення, яке ви розібрали, — це не той літерал, який варто писати

Дійсне число 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. Зауважте, що текст, декодований через object 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 обіцяє канонічні числові операнди, і ця обіцянка вартує більше за дослівний текст усередині content stream. Редагована модель вмісту — та сама, на якій побудований трекер стану графіки, — існує для того, щоб NormalizeContentStreams, оптимізатор і Emit давали стабільний, порівнюваний вивід із довільного входу. Якби розібраний операнд ніс свій вихідний текст у модель, послідовність операторів на кшталт 0.50000 0 0 RG видавалася б інакше, ніж 0.5 0 0 RG, і кожне порівняння нижче за течією дрейфувало б разом зі звичками форматування продюсера

Тож модель зчищає вихідний текст у двох своїх точках входу. NormalizeContentNumbers виконується для кожного операнда, коли парсер його додає, і знову всередині SetOperand, коли декодується надане викликом джерело, і вона рекурсивно проходить масиви та словники, тож покриті й dash-патерни, і масиви TJ, і словники властивостей marked content. Викликати SetTo(AsDouble) для кожного числа достатньо, бо це саме та операція, яка очищає текст. Сирі дані inline-зображень лишаються недоторканими, як і завжди

Чому модель вмісту PDFlibPas усе ще нормалізує числа: NormalizeContentNumbers виконується там, де парсер додає кожен операнд, і знову всередині SetOperand, рекурсивно проходить масиви та словники, тож покриті dash-патерни, масиви TJ і словники властивостей marked content, а SetTo AsDouble очищає вихідний текст, тож 0.50000 і 0.5 видаються ідентично
Канонічні числові операнди — це обіцянка моделі вмісту: сирі дані inline-зображень лишаються недоторканими, а незмінені числа у словниках поза content streams зберігають гарантію дослівності, тож звичайна пара 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 лишає незмінені content streams і незмінені числа у словниках такими, як вони були. Сторінка, яка пройшла через NormalizeContentStreams або будь-яку правку, зроблену через модель вмісту, виходить канонічною за задумом, а решта документа все одно зберігається. Це два різні запити, і тепер вони роблять дві різні речі

Скільки це коштує і де гарантія закінчується

Кожен TPDFNumeric тепер несе один додатковий рядковий AnsiString-вказівник, і кожне розібране десяткове число тримає свій вихідний текст живим поки живе об'єкт. На документі з мільйонами дійсних чисел це справжня пам'ять, і це варто враховувати в будь-якому вимірюванні великих документів, а не відмахуватися. Гарантія також обмежена рідним документом числа: копіювання об'єктів між документами або відтворення значень через object API дає нові числа, які йдуть за точністю виводу, як і будь-яке інше нове число. Варто бути точним у тому, що реліз заявляє, а що ні. Завантаження й збереження незміненого документа тепер зберігає числа калібровки, які рендерер справді споживає, — і саме цю властивість перевіряє базовий рівень корпусу. Він не заявляє побайтово ідентичного виводу, який залежить ще й від нумерації об'єктів, стиснення потоків і ідентифікатора trailer, розібраного в статті про детермінований PDF ID. І він не змушує fingerprint diff бачити відмінності округлення у файлах, зроблених іншим софтом, бо ті все одно хешують нормалізоване тіло. Урок узагальнюється далеко за межі CalRGB: коли парсер тримає лише перетворене значення, кожне збереження є правкою, і єдиний спосіб це помітити — дивитися на результат рендерингу. Обробка чисел і семантика SetPrecision задокументовані на сторінці продукту losLab PDF Developer Library