Teknisk artikel

Locale-neutral PDF-tal för Delphi-appar med komma-decimaler

PDFlibPas, losLab PDF Developer Library for Delphi, skriver vartenda tal den lägger i en innehållsström med punkt som decimaltecken och utan exponent, oavsett vad Windows regionala inställningar säger. Sedan v3.539.26 formaterar AddPageMatrix, ScalePage, DeskewPage, RedactRegion, text-till-path-utdata och omfärgning sina operander genom PLDoubleToStrConst, och sedan v3.539.33 använder parsarna som läser talen tillbaka PLTryStrToFloatInvariant i stället för systemlokalen. På en tysk, fransk eller brasiliansk maskin ger samma kod nu samma byte som på en amerikansk, vilket är det enda beteende ett filformat kan tolerera

Varför förstör en komma-decimal-locale en PDF utan felmeddelande?

En komma-decimal-locale förstör en PDF tyst för att kommat inte är ett taltecken i PDF-syntax, så skadan framstår som giltiga token med fel betydelse. Före fixen var PLFloatToStr inget mer än ett naket FloatToStr-anrop, och FloatToStr följer FormatSettings.DecimalSeparator. Med kommatecken skrev AddPageMatrix(0.5, 0.5, 0, 0) 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 tillåter siffror, en period och ett inledande tecken i ett tal och inget annat, så en innehållsparsare läser den raden som talet 0 följt av en okänd token ,5, och cm-operatorn hamnar med fel operander. Inget kastas, inget loggas. Sidan renderas helt enkelt med en transformationsmatris som drivit ur, och att arbeta baklänges från en felplacerad ritning till en locale-inställning är en eländig eftermiddag

Den andra defekten gömmer sig bakom den första. FloatToStr använder formatet ffGeneral, som växlar till exponentnotation så snart storleken understiger 1E-4, så en liten offset blev 1E-5. Samma §7.3.3 säger att PDF inte stöder exponentformen, vilket betyder att även en maskin med US-locale kunde skriva en ogiltig operand givet ett tillräckligt litet värde. Regressionstesterna för denna release låser båda feltyperna: de växlar separatorn till komma, anropar API:et och söker igenom det resulterande innehållet efter varje token som innehåller ett komma eller en exponent

uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  OldSeparator: Char;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetPageDimensions(300, 200);
    Lib.DrawBox(10, 10, 20, 20, 1);
    OldSeparator := FormatSettings.DecimalSeparator;
    try
      FormatSettings.DecimalSeparator := ',';   // simulera ett de-DE-skrivbord
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 och senare skriver: 0.5 0 0 0.25 0.00001 12.75 cm
      // äldre byggen skrev:           0,5 0 0 0,25 1E-5 12,75 cm
      Writeln(Lib.GetPageContentToString);
    finally
      FormatSettings.DecimalSeparator := OldSeparator;
    end;
  finally
    Lib.Free;
  end;
end;
PDFlibPas AddPageMatrix på ett skrivbord med komma-decimaler skrev 0,5 0 0 0,25 1E-5 12,75 cm före fixen, vilket en PDF-parsare läser som talet 0 plus okända token, vilket lämnar cm med fel operander och sidan tyst transformerad, medan PLDoubleToStrConst skriver giltiga punkt-decimaler
Kommat är inte ett taltecken i PDF-syntax, så skadan framstår som giltiga token med fel betydelse — och ffGeneral-exponenter som 1E-5 var ogiltiga i alla locales, inte bara i komma-locales

Två slags tal, två familjer av hjälpare

Fixen i PDFlibPas är en strikt delning: tal som visas för människor får följa lokalens, och tal som skrivs för en maskin gör det aldrig. PLFloatToStr och PLStrToFloat stannar kvar i PDFlibExtra.pas för användarriktad text, och deras deklaration bär nu en kommentar som säger exakt det. Allt som slutar som PDF-syntax går genom PLDoubleToStrConst med ett fast antal decimaler valt för jobbet: sex för matriser, fyra för koordinater och TJ-justeringar, tre för färger och FDF-rektanglar. Revisionen för v3.539.26 rörde fler anropsställen än den ursprungliga buggrapporten antydde:

  • AddPageMatrix, ScalePage och DeskewPage, som alla lägger en cm först i det befintliga sidinnehållet
  • Sid-elementbyggarna som sänder ut Tm-återställningar, TJ-framflyttningar och cm-transformationer
  • Glyphplaceringsmatriser och konturpunkter i text-till-path-konverteraren
  • Den svarta fyllnadsrutan som RedactRegion lägger först, /Rect-värdena i FDF-exporten och operanderna som omfärgningen skriver

PLDoubleToStrConst är en handskriven formaterare i stället för en wrapper runt FloatToStrF, och tre av dess egenskaper spelar roll här. Den skriver alltid en period och klipper avslutande nollor, så 0.5 förblir 0.5 i stället för 0.500000. Den skriver aldrig exponent för ändlig indata. Och ett värde skilt från noll som är mindre än den begärda precisionen behåller sina signifikanta siffror i stället för att kollapsa till noll, så PLDoubleToStrConst(1E-9, 6) returnerar 0.000000001; bara värden under ungefär 5E-16 blir 0. Den sista regeln finns för att avrundning av en liten skalfaktor till noll förvandlar en giltig matris till en singulär, vilket är en värre bugg än den som fixas

PDFlibPas delar talsformateringen i två: PLFloatToStr och PLStrToFloat förblir locale-bundna för användarriktad text, medan PLDoubleToStrConst och PLTryStrToFloatInvariant formaterar allt som blir PDF-syntax med punkt, ingen exponent och fast per-jobb-precision på sex, fyra eller tre decimaler
Den invarianta formateraren är medvetet handskriven: den klipper avslutande nollor, skriver aldrig exponent och behåller de signifikanta siffrorna hos små värden, för att avrunda en skalfaktor till noll vore att göra en giltig matris singulär
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Maskinutdata: punkt-decimal, ingen exponent, avslutande nollor borttagna
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // behåller 4 signifikanta siffror
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // Maskinindata: mjukt misslyckande i stället för EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // innehållstal använder aldrig komma
end;

Varför är parsidan farligare än skrivsidan?

Parsidan är farligare för att en locale-bunden parser inte producerar ett felaktigt tal, den kastar. PLStrToFloat anropar StrToFloat, som kastar EConvertError när texten inte matchar systemets separator. På ett system med komma-decimaler innebar det att RecolorPage avbröts i ögonblicket den mötte en vanlig 0.5 g-operator, så varje sida ur verkligheten misslyckades, inte bara exotiska. RenderPageRegionToFile avvisade sitt eget dokumenterade clip-format "10.5,20.5,50.5,40.5", och SVG-längdattribut, SVG-exportfärger, annoteringarnas vertexlistor och output intent-soliditetsvärden avvisades eller ersattes tyst med standardvärden. Ett bibliotek som fungerar perfekt på utvecklarens maskin och fallerar hos den första kunden i München är precis den sorts kod som, liksom fallen i artikeln om Delphi-kod som fungerar av en slump, bara ser korrekt ut på grund av var den testades

v3.539.33 klassificerade varje StrToFloat- och TryStrToFloat-anrop efter var dess indata kommer ifrån. Innehållsström-operander, SVG-attribut, painter-färgsträngar och komma-separerade clip- och vertexlistor har alla en fast punkt-syntax, så de går nu genom PLTryStrToFloatInvariant, som trimmar texten, parsar den med PLInvariantFormatSettings och returnerar False för tom, felformad eller icke-ändlig indata i stället för att kasta. En komma-separerad lista lämnar inte utrymme för kompromiss, eftersom ett komma inte kan vara både listavgränsare och decimaltecken. Samma pass fixade också en skrivning utanför gränserna: RenderPageRegionToFile brukade lagra ett femte clip-värde förbi sin fyraelementsbuffert. För omfärgningspipelinen som beskrivs i guiden till att konvertera en PDF till en färgrymd är det praktiska resultatet att RecolorPage och RecolorDocument inte längre avbryts på ett system med komma-decimaler. Regelvärden som en anropare skriver in i CheckDocumentPolicy är det enda parsningfall som i stället använder den toleranta hjälparen, av det skäl nästa avsnitt förklarar

Vad händer om du fixar bara ena änden av en roundtrip?

Att fixa bara ena änden av en locale-roundtrip bryter kod som tidigare fungerade, vilket är därför strukturattributändringen i v3.539.32 flyttade skrivaren och läsaren tillsammans. SetStructElem*-wrapparna vidarebefordrar tal som strängar: SetStructElemBBox formaterar fyra värden till en sträng, lagrar den genom AddTagAttribute, och /A-skrivaren parsar senare den strängen för att avgöra om den blir ett tal, en array eller ett namn. Båda ändarna använde systemlokalen, så på ett system med komma-decimaler var roundtripsen självkonsistent. Buggen dök bara upp när en anropare följde dokumentationen och skickade "0.5" till AddTagAttribute: läsaren kunde inte parsa den och sände ut PDF-namnet /0.5. PDF/VCR-platshållaren hade spegelbildsproblemet, eftersom biblioteket genererade GTS_BBox med punkt och sedan validerade den med localen före sparande

Att bara byta skrivaren till punkt hade varit värre än att inte göra något, eftersom varje SetStructElem*-värde då hade fallerat den locale-bundna läsaren och degraderat till ett namn. Så skrivarna använder nu PLDoubleToStrConst(v, 6), och läsaren använder den nya PLTryStrToFloatLenient, som provar punktformen först och faller tillbaka till systemlokalen. En anropare med komma-locale som skickade "1,25" förr får fortfarande talet 1.25. Avvägningen är medveten och dokumenterad: på ett tyskt system blev "1.500" tidigare ett namn eftersom StrToFloat avvisar tusentalsseparatorer, och det läses nu som 1.5, medan de literala NAN- och INF-strängarna inte längre accepteras som tal

PDFlibPas SetStructElemBBox och dess syskon vidarebefordrar tal som strängar genom AddTagAttribute, och /A-skrivaren parsar strängarna tillbaka, så v3.539.32 flyttade båda ändarna tillsammans: PLDoubleToStrConst skriver med punkt och PLTryStrToFloatLenient läser punkt först med locale-fallback så att en 1,25 från komma-locale fortfarande läses som 1.25
Att fixa bara skrivaren skulle ha degraderat varje strukturerat elementattribut till ett PDF-namn, vilket är därför en roundtrip flyttar båda ändarna tillsammans eller ingen alls
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // anropare med komma-decimal
  Lib := TPDFlib.Create;
  try
    Lib.BeginTag('Figure', 'Sales chart', '');
    Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
    Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5');   // /SpaceAfter 0.5, var /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // fortfarande /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

Där NaN och oändlighet stoppas

AddPageMatrix, ScalePage och RedactRegion avvisar nu NaN och oändliga argument i förväg och returnerar 0, för inget PDF-tal kan representera dem. ScalePage vägrade redan faktorer på noll eller mindre, men NaN passerar ett <= 0-test, så en NaN-skala brukade färdas hela vägen till formateraren. I v3.539.26 anropade den formateraren fortfarande Round på NaN, vilket kastar EInvalidOp på Win32 där x87-enheten inte maskerar ogiltiga operationer; v3.539.31 lät PLDoubleToStrConst skriva 0 för NaN som en sista försvarslinje, men en nolla i en matris är en singulär transform, så kontrollen på API-nivå är fortfarande den verkliga fixen. Två gränslinjer är kvarvarande med flit. Metafiltillståndssträngar skrivs och läses med localen inuti en process och lämnar den aldrig, så de lämnades orörda. Och ett test som formaterar 1E-5 genom sid-elementvägen måste läsa innehållet innan lagret skrivs om, eftersom återutsändning av operander i dokumentprecision legitimt gör det värdet till 0

Om din applikation levereras till kunder utanför punkt-decimal-världen är den säkraste vanan den som PDFlibPas-testsviten nu använder: kör de PDF-producerande vägarna en gång med FormatSettings.DecimalSeparator satt till komma och sök utdatan efter komman och exponenter. Artikeln om att bevara parsad decimalprecision täcker den andra halvan av samma historia, hur tal som lästs ur en befintlig fil behåller sin exakta text vid sparande. Nedladdningar, den fullständiga API-referensen och testversionen finns på produktsidan för PDFlibPas Delphi PDF library