Teknisk artikel

Invariant PDF-nummerformatering i Delphi med HotPDF

Varje reellt tal som når en PDF-fil måste skrivas med en punkt, oavsett vad operativsystemet tycker att decimalseparatorn är. HotPDF Delphi Component dirigerar alla genom ett enda invariant konverteringslager i HPDFTypes, så en tysk, fransk eller brasiliansk arbetsstation sänder ut 0.5 0 0 0.5 5.5 7.25 cm och inte den kommaseparerade varianten som ingen konform läsare kommer att parse:a

Det låter som en tvåradig fix. Det var det inte. Defekten hade kamouflerats i åratal av en sidoeffekt, den dök upp igen i tre subsystem som ingen tänker på som nummerformateringskod, den försvann under en kompilator och överlevde under en annan, och det sista lagret av den hade ingenting med decimalseparatorn att göra över huvud taget

Varför korrumperar en decimalkomma-locale en PDF-content stream?

Därför att ISO 32000-1 §7.3.3 definierar ett numeriskt objekt som ett valfritt tecken, decimalsiffror och högst en punkt. Ett komma är inte en del av den produktionen, och enligt §7.2.2 är det inte heller en avgränsare, så läsaren ser inte två operander där du menade en. Den ser ett token som misslyckas med att lexa, och operatorn som innehöll det kasseras. Ett enda 0,5 i en grafikstatus-matris tar ut hela q ... Q-blocket runt omkring sig. Misslyckandet har också en distinkt form i fältet: det reproduceras aldrig på utvecklarmaskinen, det anländer som en supportbiljett från ett land, och det läses som en renderingsbugg snarare än ett syntaxfel, för de flesta viewers tappar den felformade operatorn tyst och renderar resten av sidan

Diagram över locale-korruption i en PDF-content stream producerad av HotPDF: en värdprocess med decimalkomma sänder ut 0,5 inuti operatorerna rg och cm, ISO 32000-1:s nummergrammatik accepterar bara en punkt så tokenet misslyckas med att lexa, och viewern kasserar tyst operatorerna tillsammans med q Q-blocket runt omkring dem
Ett komma är inte en del av den numeriska produktionen och inte en avgränsare, så en felformad operand tar bort redaktionsrektangeln och den ihopsydda sidan utan något fel
// Värdprocess som körs 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);

// Vad den tillagda /Contents brukade bära:
//   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 för "0,5", så redaktions-
// rektangeln och den ihopsydda sidan försvinner båda utan ett fel

Sidoeffekten som gömde defekten i åratal

HotPDF skriver de flesta skapelsevägsnummer genom _CutFloat, och tidiga revisioner av den funktionen gjorde något tyst fientligt: vid varje enskilt anrop tilldelade den '.' till värdprocessens globala FormatSettings.DecimalSeparator. Det korrumperar locale-tillståndet i applikationen som är värd för komponenten och det är inte trådsäkert, men den intressanta konsekvensen är den tredje. När något innehåll väl genererats var processseparatorn en punkt, så varje bar FloatToStr och StrToFloat på andra håll i biblioteket blev av misstag korrekt. Skapa-sedan-redigera-ordningen som nästan varje exempel använder passerade därför, och vad den maskerade var varje väg som körs före det första _CutFloat-anropet: de inlästa dokumentredigerarna StitchLoadedPage och RedactLoadedRect, text- och bildstämplarna, sammansatta vattenstämplar, streckkodsritning genom Format('%.2f'), /DA-default-appearance-parsern och hela Type 1-teckensnittssubsystemet

Tidslinje över _CutFloat-sidoeffekten i HotPDF: det första anropet tilldelar en punkt till den globala FormatSettings.DecimalSeparator, vilket gör varje senare bar FloatToStr av misstag korrekt medan de inlästa redigerarna StitchLoadedPage och RedactLoadedRect, streckkodsvägen, /DA-parsern och Type 1-teckensnittssubsystemet förblir trasiga
Skapa-sedan-redigera-ordningen som varje exempel använder höll defekten osynlig, för allt efter den första konverteringen ärvde den omkastade globala separatorn

Type 1-parsning misslyckades på det fulaste sättet av alla. FontMatrix, BlueValues, stem-hints och de reella talen inuti Type 2-charstrings är PostScript-reals skrivna med punkter, enligt Adobe Type 1 Font Format-specifikationen. Att parse:a 0.001 under en komma-locale kastar EConvertError, ett omslutande try/except svalde det, och teckensnittsmatrisen förblev noll. Varje glyph från det teckensnittet kollapsade sedan, vilket ser ut som ett teckensnittsproblem snarare än ett locale-problem och skickar iväg dig på att felsöka rendering av inbäddade glyphs i en eftermiddag

Ett konverteringslager i stället för utspridda lappar

Regeln HotPDF nu följer är rättfram: varje tal som skrivs in i eller läses ur PDF-, PostScript-, JSON- eller JavaScript-syntax går genom en explicit invariant konvertering, och ingen kod någonstans förlitar sig på en global FormatSettings-sidoeffekt. HPDFTypes äger de fyra konverteringarna, plus den settings-post de delar och en fixed-precision-hjälpare för streckkodsvägen

// Lib/HPDFTypes.pas - den enda invarianta konverteringsytan
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;

Två unit hade redan en privat InvariantFormatSettings-funktion var, HPDFContentStream och HPDFTextExtraction, och båda reducerades till en rads forward snarare än att lämnas som parallella implementationer. Type 1-subsystemet är det medvetna undantaget: det bär inget VCL-beroende och refererar inte HPDFTypes, så det behåller en lokal spegel vid namn HPDFType1Core.Type1StrToFloat med identisk semantik, på teorin att en duplicerad tio-rads funktion är ett mindre ansvar än att dra in grafiklagret i en teckensnittsparser. Två andra ställen lämnades ifred medvetet. HPDFHTMLImport parsar CSS-längder genom att medvetet byta ut punkten mot locale-separatorn och sedan parse:a med locale:n, vilket är rätt för den indatan, och HPDFType1CB.GlyphCommands tillsammans med FontBBox-strängarna är publikt API som anropare läser tillbaka med sin egen StrToFloat, där att producera och konsumera i samma locale är självkonsekvent och invariant utdata skulle bryta varje anropare i ett kommaområde

Vad garanterar HPDF_HAS_FORMAT_SETTINGS över kompilatorer?

Det garanterar att den invarianta vägen faktiskt kompilerar i varje stödd verktygskedja, vilket den uppenbara versionsgrinden inte gjorde. Delphi XE2 introducerade TFormatSettings.Create och FloatToStr / TryStrToFloat-overloadsen som tar en TFormatSettings, så den första instinkten är att skriva {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} vid varje anropsställe. Den instinkten har en blind fläck: HotPDF.inc definierar D2009+ under Free Pascal men definierar aldrig XE2+, för XE2+ besvarar en fråga om Delphi-versionsnummer, medan Free Pascal 3.2.2 levererar de overloadsen ändå. Var och en av de grindarna degraderades därför till den locale-beroende grenen i FPC-byggen, tyst, på exakt de plattformar där de ursprungliga rapporterna kom ifrån. HPDF_HAS_FORMAT_SETTINGS definieras i både FPC-blocket och XE2+-blocket i HotPDF.inc, och det är den enda symbol nummerhjälparna testar

{$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}

En kompilatorgren överlever inuti funktionen, för Free Pascal har ingen TFormatSettings.Create och du måste utgå från DefaultFormatSettings. Bara de två nummerseparatorerna matar konverteringarna, så datum- och valutfälten behåller vad den aktuella locale:n tillhandahåller. Pre-XE2-Delphi har inga overloads alls, och där faller hjälparna tillbaka på att konvertera med processens locale och sedan skriva om separator-tecknet i den resulterande strängen, vilket är fult men rör aldrig globalt tillstånd

Det tredje lagret: exponenter som SVG tillåter och PDF inte

Att fixa decimalseparatorn är nödvändigt och inte tillräckligt. W3C SVG 1.1 §4.2 tillåter en exponent i sin talproduktion och XPS-markup gör detsamma, så 1E-6 är en fullt giltig koordinat på vägen in, medan ISO 32000-1 §7.3.3 inte tillåter någon exponent alls. Ett värde som överlever invariant formatering kan fortfarande hamna i en content stream som 1E-6 och avvisas, och FloatToStrF i fast format kan själv falla tillbaka på exponentform när värdet lämnar RTL:ns siffromfång. HPDFSafeSVG.SVGFloat gör därför sitt eget arbete efter den invarianta konverteringen: den spolar allt under 1.0E-7 till noll, och om ett E överlever, strippar den exponenten och flyttar decimalkommat för hand, med utfyllnad av nollor på vilken sida skiftet kräver. SVGFinite vaktar ingången genom att avvisa NaN och allt utanför ±1.0E30 innan det når formatteraren, och SVGReadNumber med SVGSkipSeparators hanterar inriktningen tecken för tecken i stället för att skjuta över till RTL:n

Pipeline som visar hur HotPDF strippar exponenter innan PDF-operander skrivs: SVGFinite avvisar NaN och värden bortom 1.0E30, den invarianta konverteringen lämnar en punkt, små magnituder spolas till noll, och HPDFFloatToInvariantStr-utdata med ett överlevande E får exponenten borttagen och decimalkommat flyttat för hand
SVG 1.1 tillåter en exponent och ISO 32000-1 gör inte det, så konverteringsgränsen skriver om 1E-6 till vanlig decimalnotation som en konform läsare kan parse:a
// Lib/HPDFSafeSVG.pas - decimalnotation för 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);
  // ... flytta decimalkommat med ExponentValue, utfyllnad med '0'
  // på vänster eller höger sida, och fäst sedan tecknet igen
end;

Det är samma TSVGBuffer som matar SVG-sidexport och dess grafikstatus-hantering, och samma numeriska disciplin gäller på importsidan när XPS- och OXPS-dokument konverteras till PDF genom HPDFCreateXPSDocumentHandler och medlemmen hdfXPS i THPDFHandledDocumentFormat. Markup-format är generösa med nummersyntax. PDF-content stream är inte det, och konverteringsgränsen är där den skillnaden måste lösas

Regressionstester som reproducerar locale:n i stället för att anta den

En locale-bugg som bara dyker upp på någon annans maskin är värd att låsa fast med tester som sätter locale:n själva, och HotPDF bär två. LoadedEditsEmitInvariantNumbersUnderCommaLocale tilldelar ett komma till FormatSettings.DecimalSeparator före den första redigeringen, kör RedactLoadedRect, StampLoadedPageText, StitchLoadedPage och en sammansatt stämpel, läser sedan tillbaka sidans innehållsbyte och hävdar både att de exakta operatorsträngarna finns och att bufferten inte innehåller något komma någonstans. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale täcker den andra halvan och den ursprungliga synden: den driver skapelsevägen under samma komma-locale, inklusive DirectDrawBarcode med sin fasta två-decimal-formatering, hävdar sedan att content stream inte bär något komma och att FormatSettings.DecimalSeparator fortfarande är ett komma när EndDoc returnerar

Den andra hävdelsen är den som skulle ha fångat den ursprungliga defekten den dag den skrevs, för den testar vad biblioteket gör mot sin värd snarare än bara vad det skriver till disk. Läxan generaliserar förbi PDF: varje format med en fast lexikal grammatik, PostScript och JSON och JavaScript inkluderade, behöver en explicit invariant konvertering vid gränsen. Att sträcka sig efter en global inställning för att få rätt svar fungerar tills någon anropar din kod i en annan ordning, på en annan tråd eller från en annan kompilator, och då ser misslyckandet ut som allt utom ett formateringsproblem

Nummerformateringslagret som beskrivs här levereras som en del av HotPDF Delphi Component för Delphi och C++Builder, tillsammans med vägarna för redigering av inlästa dokument, streckkoder, SVG-export och XPS-konvertering som beror på det