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

Опазване на точността на PDF десетични числа при запис

PDFlibPas, losLab PDF Developer Library, пази точния десетичен текст, който е парсвал за всяко реално число в документ, и изписва този текст дословно, стига стойността да не е била модифицирана. От v3.539.19 нататък настройката SetPrecision управлява само числата, които библиотеката създава или редактира, така че обикновен load-and-save вече не закръгля CalRGB /Gamma от 2.22221 на 2.2222 и не мести цветовете на страница, която никой не е пипал. Промяната е малка по код и голяма по това, което казва за парсерите: стойността, която декодирате, и literal-ът, който изписвате, са две различни неща, а round trip през Double не е идентично преобразуване

Защо запис, който не променя нищо, измести цветовете на страницата?

Защото се преформатираха параметрите на цветовото пространство, а не изображението. Файлът, извадил това наяве, е офис документ на 35 страници в локалния регресионен корпус, с изображение в header-а, преизползвано на всяка страница. Зареждането му и записът му веднага обратно дадоха image stream-ове, байт по байт идентични с входа, а сравнение по stream хешове докладва документа непроменен. Рендирано сравнение се противопостави: всяка от 35-те страници показваше пикселни разлики в header-а, и никъде другаде

Изображението в header-а се рисува през 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, и renderer-ът вярно изведе леко различни цветове от леко различна калибровка. Байтовете на изображението бяха невинни; числата около тях — не. Некомфортната част е колко невидимо беше всичко това. Сравнение на декодирани stream байтове не го вижда, защото числата живеят в речник, а не в stream. Сравнение на attachment payload-и не го вижда. Дори revision diff-ът, описан в статията за modification levels, фингърпринтива нормализирано тяло на обект, така че и двете ревизии хешират до една стойност и diff-ът ги докладва идентични. Само рендирането го хвана — затова corpus базовата линия рендира всяка страница, вместо да вярва само на структурните проверки

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

Стойността, която сте парснали, не е literal-ът, който трябва да изпишете

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

Повдигането на стойността по подразбиране само би преместило стръма. Поправката е да спрете да се преструвате, че Double е числото. Когато токенизаторът в TPDFStructure.Decode разпознае стандартно реално число — тоест токенът съдържа десетична точка и няма експоненциален маркер — той пази изходния текст в новото поле FOriginalText редом с превърнатата стойност. Output тогава предпочита този текст и се връща към форматиране само когато няма какво да предпочете

Как PDFlibPas пази парснат десетичен текст в Delphi: токенизаторът в TPDFStructure.Decode пази изходния literal в FOriginalText за всеки токен с десетична точка и без експонента, Output изписва този текст дословно вместо да вика PLDoubleToStr, а SetTo го изчиства, защото редактирано число е ново число
Стойността, която декодирате, и literal-ът, който изписвате, са две различни неща: предпочитането на парснатия текст пази 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 се толерират на входа заради развалени producer-и, но не се опазват на изхода, тъй като записването им обратно би увековечило синтаксис, който §7.3.3 забранява; те минават през форматера като всяко число, генерирано от библиотеката. Токенизаторът приложи и обичайния си минимален ремонт преди да запази текста, така че literal с водеща точка като .5 се пази като 0.5, а literal със завършваща точка като 5. — като 5.0. И двете са същото число за всеки читател и са много по-широко приемани

Какво гарантира SetPrecision след v3.539.19?

TPDFlib.SetPrecision сега контролира десетичните знаци на числата, които самата библиотека произвежда: стойности, рисувани през painter-а, числа, създадени от Double — например през NewNumeric — и всяка парсната стойност, която отсам е била редактирана с SetTo. Обърнете внимание, че текст, декодиран през object API — например literal, подаден на SetObjectFromString — минава през същия токенизатор и се пази по същия начин. Парснато десетично число, никога не модифицирано, пази входната си точност независимо от настройката, а смяната на настройката след зареждането не го пипа ретроактивно. Референтният запис на SetPrecision беше обновен в същото издание да каже точно това, защото старата формулировка подсказваше, че настройката важи за всяко число във файла

Изчистването става в SetTo, вместо да се извежда от флага Changed, и тази разлика има значение. Записващият pipeline рестартира 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;

Защо content моделът все още нормализира числа?

Защото TPDFContentProgram обещава канонични числови операнди, и това обещание струва повече от дословен текст в content stream. Редактируемият content модел — същият, върху който е строен tracker-ът на graphics state — съществува, за да дават NormalizeContentStreams, оптимизаторът и Emit стабилен, сравним изход от произволен вход. Ако парснат операнд носеше оригиналния си текст в модела, операторна последователност като 0.50000 0 0 RG би излязла различно от 0.5 0 0 RG, а всяко сравнение надолу по веригата би се носило по форматиращите навици на producer-а

Затова моделът обелва оригиналния текст на двата си входа. NormalizeContentNumbers върви върху всеки операнд, докато парсерът го бута, и отново вътре в SetOperand, когато се декодира подаден от извикващия източник, и се рекурсира през масиви и речници, така че dash патерните, масивите TJ и property речниците на marked content са покрити. Извикването на SetTo(AsDouble) върху всяко число е достатъчно, тъй като точно това е операцията, която изчиства текста. Суровите inline image данни остават на мира, както винаги

Защо content моделът на PDFlibPas все още нормализира числа: NormalizeContentNumbers върви там, където парсерът бута всеки операнд, и отново вътре в SetOperand, рекурсира през масиви и речници, така че dash патерни, масиви TJ и property речници на marked content са покрити, а SetTo AsDouble изчиства оригиналния текст, така че 0.50000 и 0.5 излизат идентично
Каноничните числови операнди са обещанието на content модела: суровите inline image данни остават на мира, а нетрогнатите речникови числа извън content stream-овете пазят дословната гаранция, така че обикновена двойка LoadFromFile и SaveToFile пак ги опазва
// Lib/PDFlibContentModel.pas — content моделът пази своя договор
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 stream-ове и нетрогнати речникови числа както са били. Страница, минала през NormalizeContentStreams, или всяка редакция, направена през content модела, излиза канонична по конструкция, а останалата част от документа пак се пази. Това са две различни заявки, и сега правят две различни неща

Какво струва и докъде стига гаранцията

Всеки TPDFNumeric сега носи една допълнителна справка към AnsiString, и всяко парснато десетично число държи изходния си текст жив за времето на живота на обекта. На документ с милиони реални числа това е реална памет, и принадлежи на всяко измерване с големи документи, вместо да се маха с ръка. Гаранцията е обвързана и с собствения документ на числото: копиране на обекти между документи или възстановяване на стойности през object API дава нови числа, които следват точността на изхода като всяко друго ново число. Си струва да бъдем точни за това, което изданието твърди и не твърди. Load-and-save на нетрогнат документ сега пази калибровъчните числа, които renderer-ът действително консумира — свойството, което corpus базовата линия проверява. Не твърди байт-идентичен изход, който зависи още от номерирането на обектите, компресията на stream-овете и trailer идентификатора, обсъдени в статията за детерминистичния PDF ID. И не кара fingerprint diff-ът да вижда разлики при закръгляне във файлове, произведени от друг софтуер, тъй като те пак хешират нормализираното тяло. Урокът се обобщава добре отвъд CalRGB: когато парсер пази само превърнатата стойност, всеки запис е редакция, и единственият начин да забележите е да погледнете рендираното. Числовата обработка и семантиките на SetPrecision са документирани на продуктовата страница на losLab PDF Developer Library