Teknisk artikel

Bevaring af parset PDF-decimalpræcision ved gemning i Delphi

PDFlibPas, losLab PDF Developer Library, gemmer den præcise decimaltekst, den parsede, for hvert reelt tal i et dokument og skriver den tekst ordret tilbage, hver gang værdien aldrig er blevet ændret. Siden v3.539.19 styrer SetPrecision-indstillingen kun tal, som biblioteket selv opretter eller redigerer, så en almindelig load-and-save ikke længere runder en CalRGB /Gamma på 2.22221 ned til 2.2222 og flytter farverne på en side, ingen rørte. Ændringen er lille i kode og stor i det, den siger om parsere: værdien, du dekoder, og literalen, du emitterer, er to forskellige ting, og en Double-roundtrip er ikke en identitetstransformation

Hvorfor flyttede en gemning uden ændringer sidens farver?

Fordi farverumsparametrene blev omformateret, ikke billedet. Filen, der udstillede det, er et kontordokument på 35 sider i det lokale regressionskorpus, med et headerbillede genbrugt på hver side. At loade den og gemme den direkte tilbage producerede image streams, der var byte for byte identiske med inputtet, og en stream-hash-sammenligning rapporterede dokumentet uændret. En rendered sammenligning var uenig: samtlige 35 sider viste pixelafvigelser i headeren og ingen andre steder

Headerbilledet tegner gennem et CalRGB-farverum, som ISO 32000-1 §8.6.5.3 definerer ved et /WhitePoint, et valgfrit /Gamma-array med tre elementer og et valgfrit /Matrix-array med ni. De arrays er almindelige numeriske objekter i farverumsdictionaryen. TPDFNumeric gemte hver af dem som en Double og intet andet, og TPDFNumeric.Output formaterede den Double gennem PDFPrecNum, som som default står til fire decimaler. Så /Gamma gik fra 2.22221 til 2.2222, et matrix-entry gik fra 0.71519 til 0.7152, og rendereren producerede trofast let forskellige farver ud fra en let forskellig kalibrering. Billedbytesene var uskyldige; tallene omkring dem var det ikke. Det ubehagelige er, hvor usynligt dette var. At sammenligne dekodede stream-bytes kan ikke se det, for tallene bor i en dictionary, ikke i en stream. At sammenligne attachment-payloads kan ikke se det. Selv revision-diffen beskrevet i artiklen om modification levels fingerprinter en normaliseret objektkrop, så begge revisioner hasher til samme værdi, og diffen rapporterer dem identiske. Kun rendering fangede det, hvilket er grunden til, at korpus-baselineen renderer hver side i stedet for at stole alene på strukturelle tjek

Hvor PDFlibPas mistede CalRGB-præcision ved en no-op-gemning i Delphi: den parsede /Gamma 2.22221 og et matrix-entry på 0.71519 bor i TPDFNumeric som en Double, Output formaterer dem gennem PLDoubleToStr med PDFPrecNum på fire decimaler, hvert strukturelt tjek rapporterer dokumentet uændret, og kun den rendered sammenligning viser alle 35 headerbilleder forskudt
Billedbytesene var uskyldige: TPDFNumeric omformaterede kalibreringstallene omkring dem gennem PDFPrecNum, så stream-hashes og fingerprint-diffen begge rapporterede identiske revisioner, mens rendereren producerede let forskellige farver på hver side

Værdien, du parsede, er ikke literalen, du bør skrive

Et PDF reelt tal er en decimalstreng, og ISO 32000-1 §7.3.3 er eksplicit: det er kun en decimalstreng, ingen radix-notation, ingen eksponentform. Annex C lister derefter den præcision, en implementation forventes at ære, cirka fem signifikante decimalcifre i brøkdelen. En default outputpræcision på fire er allerede under det, og det bliver værre nær nul: PLDoubleToStr skalerer værdien, runder til et heltal og emitterer 0, når resultatet er nul, så et matrix-entry på -0.000012345 mister ikke et ciffer, det forsvinder helt

At hæve defaulten ville bare flytte klippekanten. Fixet er at holde op med at lade som om, at en Double er tallet. Når tokenizeren i TPDFStructure.Decode genkender et standardreelt tal, altså tokenen indeholder et decimalpunktum og ingen eksponentmarkør, gemmer den kildeteksten i det nye FOriginalText-felt ved siden af den konverterede værdi. Output foretrækker så den tekst og falder kun tilbage til formatering, når der ikke er noget at foretrække

Hvordan PDFlibPas bevarer parsede decimaltekster i Delphi: tokenizeren i TPDFStructure.Decode gemmer kildeliteralen i FOriginalText for enhver token med et decimalpunktum og ingen eksponent, Output skriver den tekst ordret i stedet for at kalde PLDoubleToStr, og SetTo rydder den, for et redigeret tal er et nyt tal
Værdien, du dekoder, og literalen, du emitterer, er to forskellige ting: at foretrække den parsede tekst holder 2.22221 eksakt, mens biblioteksoprettede og redigerede tal stadig følger PDFPrecNum, og indstillingen når aldrig urørt input
// Lib/PDFlibStruct.pas — hele fixet på outputsiden
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:= '';   // et redigeret tal er et nyt tal
  FValue:= Value;
  FChanged:= True;
End;

To grænser er bevidste. Heltal bevares ikke, for heltalsformatering er allerede tabsfri. Eksponentformer som 6.02E23 tolereres på input for ødelagte producers skyld, men bevares ikke på output, da at skrive dem tilbage ville videreføre en syntaks, §7.3.3 forbyder; de går gennem formatteren som ethvert biblioteksgenereret tal. Tokenizeren anvender også sin sædvanlige minimale reparation, før teksten gemmes, så en literal med indledende punktum som .5 gemmes som 0.5, og en literal med afsluttende punktum som 5. som 5.0. Begge er det samme tal for enhver læser og accepteres langt mere udbredt

Hvad garanterer SetPrecision efter v3.539.19?

TPDFlib.SetPrecision styrer nu decimalerne af tal, som biblioteket selv producerer: værdier tegnet gennem painteren, tal oprettet fra en Double, for eksempel gennem NewNumeric, og enhver parset værdi, som siden er blevet redigeret med SetTo. Bemærk, at tekst dekodet gennem objekt-API'en, for eksempel en literal givet til SetObjectFromString, går gennem den samme tokenizer og bevares på samme måde. En parset decimal, der aldrig blev ændret, beholder sin inputpræcision uanset indstillingen, og at ændre indstillingen efter load berører den ikke retrospektivt. SetPrecision-referenceentryet blev opdateret i samme release til at sige præcis dette, for den gamle formulering lod som om, at indstillingen gjaldt hvert tal i filen

Rydningen sker i SetTo i stedet for at blive udledt af Changed-flaget, og den skel betyder noget. Save-pipelinen nulstiller Changed på objekter, når de først er skrevet, så et tjek af formen "emitter original tekst medmindre ændret" ville begynde at emittere forældet tekst for en værdi, der blev redigeret, gemt og redigeret igen i samme session. At binde originalteksten til selve tildelingen gør det umuligt for de to at være uenige. Regressionstesten låser hver af disse adfærd fast med værdierne fra den oprindelige fil

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]'));
    // Uredigeret input overlever ordret, inklusive den værdi, som
    // fire-cifret formatering ville have kollapset til 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // En redigering kasserer originalteksten og følger PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // At sænke præcisionen bagefter når ikke uredigeret input
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Hvorfor normaliserer indholdsmodellen stadig tal?

Fordi TPDFContentProgram lover kanoniske numeriske operander, og det løfte er mere værd end ordret tekst inde i en content stream. Den redigerbare indholdsmodel, den samme som graphics-state trackeren er bygget på, findes, for at NormalizeContentStreams, optimizatoren og Emit kan producere stabilt, sammenligneligt output ud fra vilkårligt input. Hvis en parset operande bar sin originaltekst ind i modellen, ville en operatorsekvens som 0.50000 0 0 RG emitte forskelligt fra 0.5 0 0 RG, og hver downstream-sammenligning ville drive med producentens formateringsvaner

Modellen stripper derfor originalteksten ved sine to indgangspunkter. NormalizeContentNumbers kører på hver operande, efterhånden som parseren skubber den ind, og igen inde i SetOperand, når caller-leveret kilde dekodes, og den rekurrerer gennem arrays og dictionaries, så stregmønstre (dash patterns), TJ-arrays og marked contents property-dictionaries er dækket. At kalde SetTo(AsDouble) på hvert tal er nok, for det er præcis den operation, der rydder teksten. Rå inline-billeddata røres ikke, som det altid har været

Hvorfor PDFlibPas indholdsmodel stadig normaliserer tal: NormalizeContentNumbers kører, hvor parseren skubber hver operande ind, og igen inde i SetOperand, rekurrerer gennem arrays og dictionaries, så dash patterns, TJ-arrays og marked-content property-dictionaries er dækket, og SetTo AsDouble rydder originalteksten, så 0.50000 og 0.5 emitterer identisk
Kanoniske numeriske operander er indholdsmodellens løfte: rå inline-billeddata røres ikke, og urørte dictionary-tal uden for content streams beholder det ordrette løfte, så et almindeligt LoadFromFile- og SaveToFile-par stadig bevarer dem
// Lib/PDFlibContentModel.pas — indholdsmodellen holder sin 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 praktiske regel for callers er derfor simpel. En almindelig LoadFromFile efterfulgt af SaveToFile efterlader urørte content streams og urørte dictionary-tal, som de var. En side, der går gennem NormalizeContentStreams, eller enhver redigering foretaget gennem indholdsmodellen, kommer kanonisk ud af designet, og resten af dokumentet er stadig bevaret. Det er to forskellige ønsker, og de gør nu to forskellige ting

Hvad det koster, og hvor garantien stopper

Hver TPDFNumeric bærer nu én ekstra AnsiString-reference, og hver parset decimal holder sin kildetekst i live i objektets levetid. På et dokument med millioner af reelle tal er det rigtig hukommelse, og det hører hjemme i enhver stor-dokument-måling i stedet for at blive viftet væk. Garantien er også afgrænset til et tals eget dokument: at kopiere objekter mellem dokumenter eller rekonstruere værdier gennem objekt-API'en producerer nye tal, som følger outputpræcisionen som ethvert andet nyt tal. Det er værd at være præcis om, hvad releasen påstår, og hvad den ikke gør. En load-and-save af et urørt dokument bevarer nu de kalibreringstal, som rendereren faktisk indtager, hvilket er den egenskab, korpus-baselineen tjekker. Den påstår ikke byte-identisk output, hvilket også afhænger af objektnummerering, stream-komprimering og trailer-id'en behandlet i artiklen om deterministisk PDF-ID. Og den får ikke fingerprint-diffen til at se rundingsforskelle i filer produceret af anden software, da den stadig hasher den normaliserede krop. Lektien generaliserer langt ud over CalRGB: når en parser kun gemmer den konverterede værdi, er hver gemning en redigering, og den eneste måde at bemærke det på er at kigge på det renderede resultat. Den numeriske håndtering og SetPrecision-semantikken er dokumenteret på losLab PDF Developer Library-produktsiden