Teknisk artikel

Bevara tolkad decimalprecision i PDF vid sparning i Delphi

PDFlibPas, losLab PDF Developer Library, behåller den exakta decimaltexten den tolkade för varje reellt tal i ett dokument och skriver tillbaka den texten ordagrant när värdet aldrig har ändrats. Sedan v3.539.19 styr inställningen SetPrecision bara tal som biblioteket skapar eller redigerar, så en vanlig inläsning-och-sparning rundar inte längre ett CalRGB-/Gamma på 2.22221 ned till 2.2222 och förskjuter färgerna på en sida ingen har rört. Ändringen är liten i kod och stor i vad den säger om parsers: värdet du avkodar och literalen du skickar ut är två olika saker, och en Double-rundtur är inte en identitetstransform

Varför sköt en sparning utan ändringar sidfärgerna?

Därför att det var färgrymdsparametrarna som formaterades om, inte bilden. Filen som avslöjade det är ett 35-sidigt kontorsdokument i den lokala regressionskorpusen, med en sidhuvudsbild som återanvänds på varje sida. Att läsa in den och spara den rakt tillbaka gav bildströmmar som var byte-för-byte identiska med indatan, och en jämförelse av strömhashar rapporterade dokumentet som oförändrat. En renderad jämförelse var av annan åsikt: var och en av de 35 sidorna visade pixelskillnader i sidhuvudet, och ingen annanstans

Sidhuvudsbilden ritas genom en CalRGB-färgrymd, som ISO 32000-1 §8.6.5.3 definierar med en /WhitePoint, en valfri /Gamma-array med tre element och en valfri /Matrix med nio element. De arrayerna är vanliga numeriska objekt i färgrymdsordboken. TPDFNumeric lagrade var och en som en Double och inget annat, och TPDFNumeric.Output formaterade den Double:en genom PDFPrecNum, som standard är fyra decimaler. Så /Gamma gick från 2.22221 till 2.2222, en matrispost gick från 0.71519 till 0.7152, och renderaren producerade troget något annorlunda färger från en något annorlunda kalibrering. Bildbyten var oskyldiga; talen omkring dem var det inte. Det obehagliga är hur osynligt det var. Att jämföra avkodade strömbyten kan inte se det, för talen bor i en ordbok, inte i en ström. Att jämföra bilagepayloader kan inte se det. Till och med revisionsdiffen som beskrivs i artikeln om ändringsnivåer tar ett fingeravtryck av en normaliserad objektkropp, så båda revisionerna hashar till samma värde och diffen rapporterar dem som identiska. Bara rendering fångade det, vilket är varför korpusens baslinje renderar varje sida i stället för att lita på strukturella kontroller allena

Var PDFlibPas tappade CalRGB-precision vid en no-op-sparning i Delphi: det tolkade /Gamma 2.22221 och en matrispost 0.71519 ligger i TPDFNumeric som en Double, Output formaterar dem genom PLDoubleToStr med PDFPrecNum på fyra decimaler, varje strukturell kontroll rapporterar dokumentet som oförändrat, och bara den renderade jämförelsen visar att alla 35 sidhuvudsbilder har förskjutits
Bildbyten var oskyldig: TPDFNumeric formaterade om kalibreringstalen omkring den genom PDFPrecNum, så både strömhashar och fingeravtrycksdiffen rapporterade identiska revisioner medan renderaren producerade något annorlunda färger på varje sida

Värdet du tolkade är inte literalen du bör skriva

Ett reellt tal i PDF är en decimalsträng, och ISO 32000-1 §7.3.3 är tydlig med att det bara är en decimalsträng: ingen basnotation, ingen exponentform. Annex C listar sedan den precision en implementation förväntas hålla, ungefär fem signifikanta decimalsiffror i bråkdelen. En standardprecision på fyra decimaler vid utdata ligger redan under det, och det blir värre nära noll: PLDoubleToStr skalar värdet, avrundar till ett heltal och skickar ut 0 när resultatet är noll, så en matrispost på -0.000012345 tappar inte en siffra, den försvinner helt

Att höja standardvärdet skulle bara flytta stupet. Fixen är att sluta låtsas att en Double är talet. När tokenizern i TPDFStructure.Decode känner igen ett standard-reellt tal, det vill säga att tokenet innehåller en decimalpunkt och ingen exponentmarkör, lagrar den källtexten i det nya fältet FOriginalText vid sidan av det konverterade värdet. Output föredrar sedan den texten och faller bara tillbaka på formatering när det inte finns något att föredra

Hur PDFlibPas bevarar tolkad decimaltext i Delphi: tokenizern i TPDFStructure.Decode behåller källliteralen i FOriginalText för varje token med decimalpunkt och utan exponent, Output skriver den texten ordagrant i stället för att anropa PLDoubleToStr, SetTo rensar den eftersom ett redigerat tal är ett nytt tal
Värdet du avkodar och literalen du skickar ut är två olika saker: att föredra den tolkade texten håller 2.22221 exakt, medan tal som biblioteket skapat och redigerat fortfarande följer PDFPrecNum och inställningen aldrig når orörd indata
// Lib/PDFlibStruct.pas — hela fixen på utdatasidan
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:= '';   // ett redigerat tal är ett nytt tal
  FValue:= Value;
  FChanged:= True;
End;

Två gränser är avsiktliga. Heltal bevaras inte, eftersom heltalsformatering redan är förlustfri. Exponentformer som 6.02E23 tolereras vid indata för trasiga producenters skull men bevaras inte vid utdata, eftersom att skriva tillbaka dem skulle föreviga en syntax som §7.3.3 förbjuder; de går genom formattern som vilket biblioteksgenererat tal som helst. Tokenizern tillämpar också sin vanliga minimala reparation innan den lagrar texten, så en literal med inledande punkt som .5 behålls som 0.5 och en med avslutande punkt som 5. som 5.0. Båda är samma tal för varje läsare och är långt mer allmänt accepterade

Vad garanterar SetPrecision efter v3.539.19?

TPDFlib.SetPrecision styr nu antalet decimaler för tal som biblioteket självt producerar: värden som ritas via painter-API:t, tal som skapas från en Double, till exempel genom NewNumeric, och varje tolkat värde som sedan har redigerats med SetTo. Notera att text som avkodas genom objekt-API:t, till exempel en literal som skickas till SetObjectFromString, går genom samma tokenizer och bevaras på samma sätt. En tolkad decimal som aldrig ändrades behåller sin indataprecision oavsett inställningen, och att ändra inställningen efter inläsningen rör den inte i efterhand. Referensposten för SetPrecision uppdaterades i samma release för att säga exakt detta, eftersom den gamla formuleringen antydde att inställningen gällde varje tal i filen

Rensningen sker i SetTo i stället för att härledas från flaggan Changed, och den distinktionen spelar roll. Sparpipelinen nollställer Changed på objekt när de väl har skrivits, så en kontroll av formen "skicka ut originaltexten om inte ändrad" skulle börja skicka ut inaktuell text för ett värde som redigerades, sparades och redigerades igen i samma session. Att binda originaltexten till själva tilldelningen gör det omöjligt för de två att gå isär. Regressionstestet fäster vart och ett av dessa beteenden med värdena från originalfilen

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]'));
    // Oredigerad indata överlever ordagrant, inklusive värdet som
    // fyra-decimalers formatering skulle ha kollapsat till 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // En redigering kastar originaltexten och följer PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Att sänka precisionen efteråt når inte oredigerad indata
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Varför normaliserar innehållsmodellen fortfarande tal?

Därför att TPDFContentProgram lovar kanoniska numeriska operander, och det löftet är värt mer än ordagrann text inuti en innehållsström. Den redigerbara innehållsmodellen, samma som grafikspårningen är byggd på, finns för att NormalizeContentStreams, optimeraren och Emit ska producera stabil, jämförbar utdata från godtycklig indata. Om en tolkad operand bar med sig sin originaltext in i modellen skulle en operatorsekvens som 0.50000 0 0 RG skickas ut annorlunda än 0.5 0 0 RG, och varje jämförelse nedströms skulle driva med producentens formateringsvanor

Så modellen strippar originaltexten vid sina två ingångar. NormalizeContentNumbers körs på varje operand när parsern skjuter in den och igen inuti SetOperand när anropartillverkad källa avkodas, och den rekurerar genom arrayer och ordböcker så att streckmönster, TJ-arrayer och egenskapsordböckerna för markerat innehåll täcks. Att anropa SetTo(AsDouble) på varje numerisk post räcker, eftersom det är precis den operation som rensar texten. Rå inline-bilddata lämnas i fred, som den alltid har gjort

Varför PDFlibPas innehållsmodell fortfarande normaliserar tal: NormalizeContentNumbers körs där parsern skjuter in varje operand och igen inuti SetOperand, rekurerar genom arrayer och ordböcker så att streckmönster, TJ-arrayer och egenskapsordböcker för markerat innehåll täcks, och SetTo AsDouble rensar originaltexten så att 0.50000 och 0.5 skickas ut identiskt
Kanoniska numeriska operander är innehållsmodellens löfte: rå inline-bilddata lämnas i fred, och orörda ordbokstal utanför innehållsströmmar behåller den ordagranna garantin, så ett vanligt LoadFromFile och SaveToFile-par bevarar dem fortfarande
// Lib/PDFlibContentModel.pas — innehållsmodellen håller sitt kontrakt
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;

Den praktiska regeln för anropare är därför enkel. En vanlig LoadFromFile följd av SaveToFile lämnar orörda innehållsströmmar och orörda ordbokstal som de var. En sida som går genom NormalizeContentStreams, eller vilken redigering som helst gjord genom innehållsmodellen, kommer ut kanonisk till sin natur, och resten av dokumentet är fortfarande bevarat. Det är två olika förfrågningar, och de gör nu två olika saker

Vad det kostar, och var garantin tar slut

Varje TPDFNumeric bär nu en extra AnsiString-referens, och varje tolkad decimal håller sin källtext vid liv så länge objektet lever. På ett dokument med miljontals reella tal är det riktigt minne, och det hör hemma i varje mätning på stora dokument i stället för att viftas bort. Garantin är dessutom begränsad till ett tals eget dokument: att kopiera objekt mellan dokument eller rekonstruera värden genom objekt-API:t producerar nya tal, som följer utdataprecisionen som vilket annat nytt tal som helst. Det är värt att vara precis om vad releasen gör och inte gör anspråk på. En inläsning-och-sparning av ett orört dokument bevarar nu de kalibreringstal som renderaren faktiskt konsumerar, vilket är egenskapen korpusens baslinje kontrollerar. Den gör inte anspråk på byteidentisk utdata, vilket också beror på objektnumrering, strömkomprimering och trailer-identifieraren som diskuteras i artikeln om deterministiskt PDF-ID. Och den gör inte att fingeravtrycksdiffen ser avrundningsskillnader i filer producerade av annan programvara, eftersom de fortfarande hashar den normaliserade kroppen. Lärdomen generaliserar långt bortom CalRGB: när en parser bara behåller det konverterade värdet är varje sparning en redigering, och det enda sättet att märka det är att titta på det renderade resultatet. Talhanteringen och semantiken för SetPrecision finns dokumenterade på produktsidan för losLab PDF Developer Library