Teknisk artikel

PDF-talsgrammatik mot JSON: NaN, Infinity och null i Delphi

PDF Library for Delphi (PDFlibPas) sänder ut giltig JSON för varje PDF-tal sedan v3.539.31. GetObjectJSON skriver om token som ISO 32000-1 accepterar men RFC 8259 avvisar, som -.25, +1.5 och 007.5, till -0.25, 1.5 och 7.5 siffra för siffra; GetDocumentJSON och analysrapporterna skriver null för NaN och Infinity; och PLDoubleToStr skriver 0 för NaN i stället för att kasta EInvalidOp halvvägs genom en export. Före fixen kunde biblioteket producera JSON som dess egen läsare vägrade läsa in

Varför bryter ett giltigt PDF-tal JSON?

För att de två grammatikerna är oense om fyra små detaljer, och en PDF-parser som respekterar källtexten får med sig de detaljerna rakt in i utdatan. ISO 32000-1 §7.3.3 låter ett tal börja med plustecken, utelämna heltalsdelen (.5), sluta på en naken punkt (4.) och bära inledande nollor (007.5). RFC 8259 §6 tillåter inget av det: ett valfritt minus, en heltalsdel som antingen är 0 eller börjar med 1 till 9, och minst en siffra efter varje decimalpunkt. Producenter får gärna skriva PDF-formerna, och gott om generatorer och handredigerade filer gör det

Läckan kom från en medveten precisionsfunktion. Sedan v3.539.19 returnerar TPDFNumeric.Output den exakta text som tokenizeren parsade för reella tal, vilket är det som håller ett kalibrerat färgvärde exakt vid sparande, som beskrivs i att bevara parsad PDF-decimalprecision. Tokenizeren patchar redan .5 till 0.5 och 4. till 4.0 på vägen in, och heltal formateras om från sitt värde, så +3 kommer tillbaka som 3. Det som överlever ordagrant är resten: ett tecken framför en inledande punkt (-.25), ett explicit plus på ett reellt tal (+1.5) och inledande nollor (007.5). Den gamla objektsskrivaren bifogade Output direkt efter "value":, och TJSONParser.ParseNumber i bibliotekets egen läsare stannar vid vare ett av dem med "Invalid JSON number", så exporten lyckades och återimporten fallerade med PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON skriver om de PDF-tal-token som RFC 8259 avvisar siffra för siffra: -.25 blir -0.25, +1.5 tappar pluset, 007.5 släpper sina inledande nollor, och bråkdelssiffror som 1.250000 överlever, eftersom formatering från den lagrade Double:en skulle addera binärt brus
Den gamla skrivaren bifogade den exakta parsade texten, bibliotekets egen läsare stannade med Invalid JSON number, och fel 105 bröt en roundtrip som exportsidan kallade framgång
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  JSON: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('legacy-drawing.pdf', '') = 0 then
      raise Exception.Create('load failed');

    // Objekt 12 är en array skriven som [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 och senare: värdena anländer som -0.25, 1.5 och 7.5

    // SetObjectJSON tar inga alternativ, så skicka 0
    if Lib.SetObjectJSON(12, JSON, 0) = 0 then
      raise Exception.CreateFmt('round trip rejected, error %d',
        [Lib.LastErrorCode]);

    Lib.SaveToFile('legacy-drawing-roundtrip.pdf');
  finally
    Lib.Free;
  end;
end;

Hur håller PDFNumberTextToJSON varje siffra?

PDFNumberTextToJSON stavar om token i stället för att räkna om den från en Double. Funktionen i PDFlibObjectJSON läser ett valfritt tecken, samlar siffror före och efter en enskild decimalpunkt och tillämpar sedan bara de redigeringar JSON kräver: den släpper ett plus, klipper inledande nollor men behåller en, levererar 0 när heltalsdelen är tom, släpper en naken avslutande punkt och sätter tillbaka minustecknet. En token som innehåller något annat tecken, eller inga siffror alls, faller tillbaka till PLJSONNumber(Value, 10), som skriver null när värdet inte är ändligt

PDFlibPas PDFNumberTextToJSON läser tecknet, samlar siffror runt en enskild decimalpunkt och tillämpar bara de redigeringar JSON kräver, medan varje annat tecken eller tom sifferföljd faller tillbaka till PLJSONNumber, som skriver null för NaN och Infinity i stället för ett tal
Att stavas om slår att räknas om: tokenizeren har redan patchat .5 och 4. på vägen in, så skrivaren behåller varje överlevande siffra och roundtripsen återskapar exakt samma värde
  • -.25 blir -0.25, och +.5 blir 0.5
  • +1.5 blir 1.5
  • 007.5 blir 7.5, medan 0.75 förblir som det är
  • 4. blir 4 om en sådan token någonsin når skrivaren
  • 2.22221 och 1.250000 behåller varje bråkdelssiffra, avslutande nollor inkluderade

Att formatera från den lagrade Double:en hade varit kortare och fel, av samma skäl precisionsfixen finns: standardprecisionen för utdata är fyra decimaler, och även en fullprecisionskonvertering kan addera binärt brus till en decimalliteral. Att behålla siffrorna innebär att SetObjectJSON och ImportObjectJSON, som räcker varje JSON-taltext till PDF-tokenizeren, återskapar exakt samma värde. Garantin täcker värdet, inte bytena: efter en återimport lagras och sparas -.25 som -0.25. Båda stavningarna är lika under §7.3.3, men en diff på bytenivå kommer att flagga ändringen, så behandla inte en export- och importcykel som en no-op på ett dokument vars byte täcks av en signatur

Vad händer med ett tal JSON inte kan representera?

GetDocumentJSON skriver nu null för varje tal som är NaN eller oändligt, för RFC 8259 §6 har ingen syntax för något av dem. Oändlighet är lättare att åstadkomma än det låter: PDF-tokenizeren ackumulerar siffror genom upprepad multiplikation i en Double, som tar slut runt 1.8 × 10308, så en heltalsliteral något över 300 siffror lång blir tyst +Inf. Ärliga filer innehåller aldrig en sådan literal; fuzzade och fientliga gör det, vilket är därför de hör hemma i samma testkorpus som fallen i att härda en Pascal-PDF-parser mot skadliga filer. Den gamla dokumentskrivaren formaterade icke-heltal med Str(D:0:6), och för +Inf skriver det texten +Inf, som ingen JSON-konsument kommer att parsa

null innebär medvetet en förlust. Konsumenter av GetDocumentJSON-utdata måste acceptera null varhelst ett tal kan förekomma, och bör läsa det som "ett värde fanns men kan inte representeras", inte som en saknad nyckel. Den ursprungliga literalen går inte att återfå ur dokument-JSON:en, så en pipeline som bryr sig bör logga objektet och behandla filen som misstänkt i stället för att byta in ett standardvärde

Varför kunde en ensam NaN avbryta en SVG- eller JSON-export?

För att PLDoubleToStr, den invarianta talsformateraren bakom innehållsströmmar, SVG, XML, CSV och det mesta JSON:et i biblioteket, skalade sin indata och anropade Round, och Round(NaN) kastar EInvalidOp på mål som Win32, där Delphi lämnar x87:ans invalid-operation-undantag omaskerat. Undantaget utlöstes efter att skrivaren redan hade satt ut en del av sin utdata, så en enda degenererad mätning, en 0/0 i ett mått eller en NaN skickad in av en anropare, lämnade en avhuggen fil efter sig. PLDoubleToStr returnerar nu 0 för NaN, och dess heltalsgren clampar till ±9.2e18 som bråkdelsgrenen, så Infinity kommer också ut som en ändlig literal

Noll är det rätta svaret för en innehållsström, där en talsplats måste hålla ett tal, och det felaktiga svaret för en rapport, där 0 är en rimlig mätning. JSON-skrivare som måste behålla skillnaden använder PLJSONNumber(Value, Decimals) från PDFlibExtra, som skriver null för NaN eller Infinity och invarianta siffror i övrigt. PLJSONNumber står nu bakom GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON och rapporterna för barcode, deskew, structured text och PDF/VCR; deskew-rapporten skrev tidigare 0 för en icke-ändlig vinkel och skriver nu null

PDFlibPas stoppar NaN och Infinity på tre sätt: AddPageMatrix, ScalePage och RedactRegion avvisar icke-ändliga argument i förväg, PLDoubleToStr skriver 0 för innehållsströmplatser, och PLJSONNumber skriver null i rapporter, där nolla skulle läsas som en rimlig mätning, sedan Round(NaN) brukade kasta EInvalidOp mitt i en export
Noll är det rätta svaret för en innehållsström och det felaktiga svaret för en rapport, så rapportskrivarna räcker varje Double till PLJSONNumber och låter null säga att värdet fanns men inte kunde representeras
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Formatera varje Double till text först; PLJSONNumber skriver null
    // för NaN eller Infinity och använder alltid en decimalpunkt
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Aldrig B.Append(Angle): Double-överloaden följer användarens locale
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Var smyger användarens locale fortfarande in i JSON?

Genom vilken formaterare som helst som frågar de regionala inställningarna, och en fullständig granskning av maskinläsbar utdata fann exakt en kvarvarande: maxAcceptedMeanError i GetSimilarImageDeduplicationReportJSON, skriven med PLFloatToStr, en tunn wrapper runt FloatToStr. På ett skrivbord vars decimaltecken är komma innehöll rapporten "maxAcceptedMeanError":1,5, som en JSON-parser läser som värdet 1 följt av en vilsekommen token. Fältet rapporterar det sämsta accepterade pixelfelet från perceptuell bilddeduplicering, och det går nu genom PLJSONNumber(Stats.MaxAcceptedMeanError, 6). En återstående fälla är PLStringBuilder: i Delphi är det ett rent alias för System.SysUtils.TStringBuilder, vars Append(Double)-överload formaterar genom användarens locale, medan FPC-byggen använder en biblioteksklass i stället, så ett test på Free Pascal eller en en-US-maskin kommer aldrig att fånga den

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Återskapa ett tyskt eller franskt skrivbord inuti testkörningen
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Använd en fixture som verkligen innehåller nästan-dubblettbilder,
    // annars är medelfelet 0 och buggen förblir gömd
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Torrkörning med trösklar 2, 2, 4: dokumentet ändras inte
    Lib.GetSimilarImageDeduplicationReportJSON(2, 2, 4, Report);
    Parsed := TJSONObject.ParseJSONValue(Report);
    if Parsed = nil then
      raise Exception.Create('report is not valid JSON on a comma locale');
    Parsed.Free;
  finally
    Lib.Free;
  end;
end;

En regressionssvit för JSON-utdata behöver tre fixtures för att förbli ärlig: en sida som bär -.25, +1.5 och 007.5, ett objekt som håller ett 400-siffrigt heltal, och vilken rapport som helst körd under en komma-locale, var och en validerad med en strikt parser i stället för ögonmått. Objekt-JSON, dokument-JSON och analysrapporterna i PDF Library for Delphi delar samma talregler över Delphi, C++Builder och Free Pascal; den fullständiga funktionslistan finns på produktsidan för PDF Library for Delphi