Teknisk artikel

Invariant PDF-talformatering i Delphi med HotPDF

Ethvert reelt tal, der når ind i en PDF-fil, skal skrives med et punktum, uanset hvad operativsystemet mener, decimalseparatoren er. HotPDF Delphi Component ruter dem alle gennem ét invariant konverteringslag i HPDFTypes, så en tysk, fransk eller brasiliansk arbejdsstation emitterer 0.5 0 0 0.5 5.5 7.25 cm og ikke den komma-separerede variant, som ingen konform reader vil parse

Det lyder som en to-linjers fix. Det var det ikke. Defekten havde i årevis været camoufleret af en sideeffekt, den dukkede op igen i tre delsystemer, ingen tænker på som talformateringskode, den forsvandt under én compiler og overlevede under en anden, og dens sidste lag havde slet intet med decimalseparatoren at gøre

Hvorfor korrumperer en decimal-komma-locale en PDF-content-stream?

Fordi ISO 32000-1 §7.3.3 definerer et numerisk objekt som et valgfrit fortegn, decimale cifre og højst ét punktum. Et komma er ikke en del af den produktion, og under §7.2.2 er det heller ikke en delimiter, så readeren ser ikke to operander, hvor du mente én. Den ser et token, der fejler lexing, og operatoren, der indeholdt det, kasseres. Et enkelt 0,5 i en graphics-state-matrix tager hele q ... Q-blokken omkring sig med. Fejlen har også en karakteristisk form i felten: Den reproducerer aldrig på udviklerens maskine, den ankommer som en support-ticket fra ét land, og den læses som en rendering-bug frem for en syntaksfejl, fordi de fleste viewere dropper den malformede operator lydløst og renderer resten af siden

Diagram over locale-korruption i en PDF-content-stream produceret af HotPDF: En host-proces med et decimal-komma emitterer 0,5 inde i en rg- og en cm-operator, ISO 32000-1-talgrammatikken accepterer kun et punktum, så tokenet fejler lexing, og viewer'en kasserer lydløst operatorerne sammen med q Q-blokken omkring dem
Et komma er ikke en del af den numeriske produktion og ikke en delimiter, så én malformed operand fjerner redaction-rektanglet og den stitched side uden nogen fejl
// Host-proces kørende med DecimalSeparator = ','
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);

// Hvad den tilføjede /Contents plejede at bære:
//   0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
//   q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3 har ingen produktion for "0,5", så redaction-
// rektanglet og den stitched side forsvinder begge uden en fejl

Sideeffekten, der skjulte defekten i årevis

HotPDF skriver de fleste creation-path-tal gennem _CutFloat, og tidlige revisioner af den funktion gjorde noget stille og fjendtligt: Ved hvert eneste kald tildelte den '.' til host-proces's globale FormatSettings.DecimalSeparator. Det korrumperer locale-tilstanden i den applikation, der hoster komponenten, og det er ikke thread-safe, men den interessante konsekvens er den tredje. Når først noget indhold var genereret, var proces-separatoren et punktum, så hver nøgen FloatToStr og StrToFloat andre steder i biblioteket blev ved et uheld korrekt. Create-derefter-edit-rækkefølgen, som næsten hvert sample bruger, bestod derfor, og det, den maskerede, var hver vej, der kører før det første _CutFloat-kald: loaded-document-editorerne StitchLoadedPage og RedactLoadedRect, tekst- og billed-stamps, komposit-vandmærker, stregkode-tegning gennem Format('%.2f'), /DA-default-appearance-parseren og hele Type 1-font-delsystemet

Tidslinje over _CutFloat-sideeffekten i HotPDF: Det første kald tildeler et punktum til den globale FormatSettings.DecimalSeparator, hvilket gør hver senere nøgen FloatToStr ved et uheld korrekt, mens loaded-editorerne StitchLoadedPage og RedactLoadedRect, stregkodevejen, /DA-parseren og Type 1-font-delsystemet forbliver ødelagte
Create-derefter-edit-rækkefølgen, som hvert sample bruger, holdt defekten usynlig, fordi alt efter den første konvertering nedarvede den væltede globale separator

Type 1-parsing fejlede på den grimmeste måde af dem alle. FontMatrix, BlueValues, stem-hints og de reelle tal inde i Type 2-charstrings er PostScript-reals skrevet med punktum, ifølge Adobe Type 1 Font Format-specifikationen. At parse 0.001 under en komma-locale rejser EConvertError, en omgivende try/except slugte den, og font-matrixen forblev på nul. Hver glyph fra den font kollapsede derefter, hvilket ligner et font-problem frem for et locale-problem og sender dig i gang med at debugge embedded glyph-rendering en hel eftermiddag

Ét konverteringslag i stedet for spredte patches

Reglen, HotPDF nu følger, er ligefrem: Ethvert tal skrevet ind i eller læst ud af PDF-, PostScript-, JSON- eller JavaScript-syntaks går gennem en eksplicit invariant konvertering, og ingen kode nogen steder afhænger af en global FormatSettings-sideeffekt. HPDFTypes ejer de fire konverteringer plus den settings-record, de deler, og en fixed-precision-helper til stregkodevejen

// Lib/HPDFTypes.pas - den ene invariante konverteringsflade
function HPDFInvariantFormatSettings: TFormatSettings;

function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
  out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
  const Default: Extended): Extended;

function HPDFFormatInvariant2(Value: Extended): string;

To units bar allerede deres egen private InvariantFormatSettings-funktion, HPDFContentStream og HPDFTextExtraction, og begge blev reduceret til et one-line-forward i stedet for at stå som parallelle implementeringer. Type 1-delsystemet er den bevidste undtagelse: Det har ingen VCL-afhængighed og refererer ikke HPDFTypes, så det beholder et lokalt spejl ved navn HPDFType1Core.Type1StrToFloat med identisk semantik, ud fra teorien om, at en duplikeret ti-linjers funktion er en mindre forpligtelse end at trække grafiklaget ind i en font-parser. To andre steder blev bevidst efterladt. HPDFHTMLImport parser CSS-længder ved bevidst at bytte punktummet om med locale-separatoren og derefter parse med locale'en, hvilket er rigtigt for det input, og HPDFType1CB.GlyphCommands sammen med FontBBox-strengene er offentligt API, som callers læser tilbage med deres egen StrToFloat, hvor at producere og forbruge i samme locale er selvkonsistent, og invariant output ville bryde hver caller i en komma-region

Hvad garanterer HPDF_HAS_FORMAT_SETTINGS på tværs af compilere?

Det garanterer, at den invariante vej faktisk kompilerer i enhver understøttet toolchain, hvilket den oplagte versionsgate ikke gjorde. Delphi XE2 introducerede TFormatSettings.Create og de FloatToStr / TryStrToFloat-overloads, der tager en TFormatSettings, så den første instinkt er at skrive {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} ved hvert kaldested. Det instinkt har en blind vinkel: HotPDF.inc definerer D2009+ under Free Pascal, men definerer aldrig XE2+, fordi XE2+ besvarer et spørgsmål om Delphi-versionsnumre, mens Free Pascal 3.2.2 shipper de overloads uanset hvad. Hver eneste af de gates forfaldt derfor til den locale-afhængige branch i FPC-builds, lydløst, præcis på de platforme, de oprindelige rapporter kom fra. HPDF_HAS_FORMAT_SETTINGS er defineret i både FPC-blokken og XE2+-blokken i HotPDF.inc, og det er det eneste symbol, tal-helperne tester

{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
  Result := DefaultFormatSettings;   // ingen TFormatSettings.Create i FPC
{$ELSE}
  Result := TFormatSettings.Create;
{$ENDIF}
  Result.DecimalSeparator := '.';
  Result.ThousandSeparator := ',';
end;
{$ENDIF}

Én compiler-branch overlever inde i funktionen, fordi Free Pascal ikke har TFormatSettings.Create, og du er nødt til at starte fra DefaultFormatSettings. Kun de to talseparatorer fodrer konverteringerne, så dato- og valuta-felterne beholder, hvad end den aktuelle locale leverer. Pre-XE2-Delphi har slet ingen overloads, og der falder helperne tilbage på at konvertere med proces-locale'en og derefter omskrive separator-tegnet i den resulterende string, hvilket er grimt, men aldrig rører global tilstand

Det tredje lag: eksponenter, som SVG tillader, og PDF ikke gør

At fikse decimalseparatoren er nødvendigt og ikke tilstrækkeligt. W3C SVG 1.1 §4.2 optager en eksponent i sin talproduktion, og XPS-markup gør det samme, så 1E-6 er en fuldstændig gyldig koordinat på vej ind, mens ISO 32000-1 §7.3.3 slet ikke optager nogen eksponent. En værdi, der overlever invariant formatering, kan stadig lande i en content-stream som 1E-6 og blive afvist, og FloatToStrF i fixed format kan selv falde tilbage til eksponentform, når værdien forlader RTL'ens cifferområde. HPDFSafeSVG.SVGFloat laver derfor sit eget arbejde efter den invariante konvertering: Den nulstiller alt under 1.0E-7, fjerner derefter, hvis et E overlever, eksponenten og flytter decimalpunktet i hånden, med nul-padding på den side, skiftet kræver. SVGFinite værner indgangen ved at afvise NaN og alt uden for ±1.0E30, før det når formatteren, og SVGReadNumber med SVGSkipSeparators håndterer indgående retning tegn for tegn i stedet for at overlade det til RTL'en

Pipeline, der viser, hvordan HotPDF fjerner eksponenter, før den skriver PDF-operander: SVGFinite afviser NaN og værdier ud over 1.0E30, den invariante konvertering efterlader et punktum, små størrelser nulstilles, og HPDFFloatToInvariantStr-output med et overlevende E får fjernet eksponenten og decimalpunktet flyttet i hånden
SVG 1.1 optager en eksponent, og ISO 32000-1 gør ikke, så konverteringsgrænsen omskriver 1E-6 til almindelig decimalnotation, en konform reader kan parse
// Lib/HPDFSafeSVG.pas - decimalnotation til PDF-operander
if Abs(Value) < 0.0000001 then
  Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
  ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
  SetLength(S, ExponentAt - 1);
  // ... flyt decimalpunktet med ExponentValue, padding med '0'
  // til venstre eller højre, og sæt så fortegnet på igen
end;

Det er den samme TSVGBuffer, der fodrer SVG-side-eksport og dens graphics-state-håndtering, og den samme numeriske disciplin gælder på importsiden, når XPS- og OXPS-dokumenter konverteres til PDF gennem HPDFCreateXPSDocumentHandler og hdfXPS-medlemmet af THPDFHandledDocumentFormat. Markup-formater er generøse om talsyntaks. PDF-content-streamen er ikke, og konverteringsgrænsen er der, hvor den forskel skal løses

Regressionstests, der reproducerer locale'en i stedet for at antage den

En locale-bug, der kun optræder på en andens maskine, er værd at fastnåle med tests, der selv sætter locale'en, og HotPDF har to. LoadedEditsEmitInvariantNumbersUnderCommaLocale tildeler et komma til FormatSettings.DecimalSeparator før den første edit, kører RedactLoadedRect, StampLoadedPageText, StitchLoadedPage og en komposit-stamp, læser derefter side-content-bytes tilbage og hævder både, at de præcise operator-strenge er til stede, og at bufferen ingen steder indeholder et komma. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale dækker den anden halvdel og den oprindelige synd: Den driver creation-vejen under den samme komma-locale, inklusive DirectDrawBarcode med dens faste to-decimal-formatering, og hævder derefter, at content-streamen ikke bærer noget komma, og at FormatSettings.DecimalSeparator stadig er et komma, når EndDoc returnerer

Den anden assertion er den, der ville have fanget den oprindelige defekt samme dag, den blev skrevet, fordi den tester, hvad biblioteket gør ved sin host frem for kun det, det skriver til disk. Læren generaliserer ud over PDF: Ethvert format med en fast leksikalsk grammatik, inklusive PostScript, JSON og JavaScript, har brug for en eksplicit invariant konvertering ved grænsen. At række ud efter en global indstilling for at få det rigtige svar virker, indtil nogen kalder din kode i en anden rækkefølge, på en anden tråd eller fra en anden compiler, og på det tidspunkt ligner fejlen alt andet end et formateringsproblem

Talformateringslaget beskrevet her shipper som en del af HotPDF Delphi Component til Delphi og C++Builder, sammen med loaded-document-edit-, stregkode-, SVG-eksport- og XPS-konverteringsvejene, der afhænger af det