Technisch artikel

PDF-getalgrammatica vs JSON: NaN, Infinity en null in Delphi

PDF Library for Delphi (PDFlibPas) zendt sinds v3.539.31 geldige JSON uit voor elk PDF-getal. GetObjectJSON herschrijft tokens die ISO 32000-1 accepteert maar RFC 8259 weigert, zoals -.25, +1.5 en 007.5, cijfer voor cijfer naar -0.25, 1.5 en 7.5; GetDocumentJSON en de analyserapporten schrijven null voor NaN en Infinity; en PLDoubleToStr schrijft 0 voor NaN in plaats van halverwege een export een EInvalidOp te gooien. Vóór de fix kon de library JSON produceren die zijn eigen lezer weigerde terug te laden

Waarom breekt een geldig PDF-getal JSON?

Omdat de twee grammatica's op vier kleine punten van mening verschillen, en een PDF-parser die de brontekst respecteert sleept die details vanzelf de uitvoer in. ISO 32000-1 §7.3.3 laat een getal beginnen met een plusteken, de integerpartie weglaten (.5), eindigen op een kaal puntje (4.) en voorloopnullen dragen (007.5). RFC 8259 §6 staat er geen van allemaal toe: een optioneel minteken, een integerpartie die of 0 is of met 1 tot 9 begint, en minstens één cijfer na elke decimale punt. Producenten zijn vrij om de PDF-vormen te schrijven, en genoeg generatoren en met de hand bewerkte bestanden doen dat ook

Het lek kwam uit een bewuste precisiefunctie. Sinds v3.539.19 geeft TPDFNumeric.Output voor reële getallen de exacte tekst terug die de tokenizer parste, en dat is wat een gekalibreerde kleurwaarde exact houdt bij het opslaan, zoals beschreven in het behouden van geparsde PDF-decimale precisie. De tokenizer plakt .5 al om naar 0.5 en 4. naar 4.0 bij het binnenkomen, en integers worden opnieuw geformatteerd vanuit hun waarde, dus +3 komt terug als 3. Wat letterlijk overleeft is de rest: een getekende voorlooppunt (-.25), een expliciet plusteken op een reëel getal (+1.5) en voorloopnullen (007.5). De oude object writer plakte Output direct achter "value":, en TJSONParser.ParseNumber in de eigen lezer van de library stopt bij elk daarvan met "Invalid JSON number", dus de export slaagde en de herimport faalde met PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON herschrijft de PDF-getaltokens die RFC 8259 weigert cijfer voor cijfer: -.25 wordt -0.25, +1.5 verliest het plusteken, 007.5 laat zijn voorloopnullen vallen, en fractionele cijfers zoals 1.250000 overleven, want formatteren vanuit de opgeslagen Double zou binaire ruis toevoegen
De oude writer plakte de exact geparsde tekst vast, de eigen lezer van de library stopte met Invalid JSON number, en fout 105 brak een round-trip die de exportkant een succes noemde
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');

    // Object 12 is een array geschreven als [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 en nieuwer: de waarden komen binnen als -0.25, 1.5 en 7.5

    // SetObjectJSON accepteert geen opties, dus geef 0 door
    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;

Hoe houdt PDFNumberTextToJSON elk cijfer vast?

PDFNumberTextToJSON spelt het token opnieuw uit in plaats van het te herberekenen vanuit een Double. De functie in PDFlibObjectJSON leest een optioneel teken, verzamelt cijfers voor en na één decimale punt, en past daarna alleen de bewerkingen toe die JSON eist: hij laat een plusteken vallen, haalt voorloopnullen weg terwijl hij er één houdt, levert 0 als de integerpartie leeg is, laat een kaal sluitpunt vallen en zet het minteken terug. Een token met een ander teken erin, of zonder cijfers, valt terug op PLJSONNumber(Value, 10), die null schrijft als de waarde niet eindig is

PDFlibPas PDFNumberTextToJSON leest het teken, verzamelt cijfers rond één decimale punt en past alleen de bewerkingen toe die JSON eist, terwijl elk ander teken of een lege cijferreeks terugvalt op PLJSONNumber, die null schrijft voor NaN en Infinity in plaats van een getal
Herspellen wint het van herberekenen: de tokenizer plakte .5 en 4. al om bij het binnenkomen, dus de writer houdt elk overlevend cijfer vast en de round-trip recreëert exact dezelfde waarde
  • -.25 wordt -0.25, en +.5 wordt 0.5
  • +1.5 wordt 1.5
  • 007.5 wordt 7.5, terwijl 0.75 blijft zoals hij is
  • 4. wordt 4 als zo'n token de writer ooit bereikt
  • 2.22221 en 1.250000 houden elk fractioneel cijfer, afsluitende nullen inbegrepen

Formatteren vanuit de opgeslagen Double was korter geweest en fout, om dezelfde reden dat de precisiefix bestaat: de standaarduitvoerprecisie is vier decimalen, en zelfs een conversie met volledige precisie kan binaire ruis toevoegen aan een decimale literal. De cijfers vasthouden betekent dat SetObjectJSON en ImportObjectJSON, die elke JSON-getaltekst aan de PDF-tokenizer geven, exact dezelfde waarde recreëren. De garantie dekt de waarde, niet de bytes: na een herimport wordt -.25 opgeslagen en bewaard als -0.25. Beide spellingen zijn gelijkwaardig onder §7.3.3, maar een diff op byteniveau vlagt de wijziging, dus behandel een export-en-import-cyclus niet als een no-op op een document waarvan de bytes door een handtekening gedekt worden

Wat gebeurt er met een getal dat JSON niet kan representeren?

GetDocumentJSON schrijft nu null voor elk getal dat NaN of oneindig is, want RFC 8259 §6 heeft voor geen van beide een syntaxis. Infinity is makkelijker te produceren dan het klinkt: de PDF-tokenizer hoopt cijfers op door herhaalde vermenigvuldiging in een Double, die rond 1.8 × 10308 zijn plafond raakt, dus een integerliteral van iets meer dan 300 cijfers wordt stilletjes +Inf. Eerlijke bestanden bevatten zo'n literal nooit; gefuzzde en vijandige wel, en daarom horen ze thuis in hetzelfde testcorpus als de gevallen uit het verharden van een Pascal PDF-parser tegen kwaadaardige bestanden. De oude document writer formatteerde niet-integers met Str(D:0:6), en voor +Inf schrijft dat de tekst +Inf, die geen enkele JSON-consument zal parsen

De null is bewust verliesgevend. Consumenten van de uitvoer van GetDocumentJSON moeten null overal accepteren waar een getal kan staan, en moeten hem lezen als "er was een waarde maar die is niet representabel", niet als een ontbrekende key. De oorspronkelijke literal is niet uit de document-JSON terug te halen, dus een pijplijn die er om geeft logt het object en behandelt het bestand als verdacht in plaats van een standaardwaarde te substitueren

Waarom kon één NaN een SVG- of JSON-export afbreken?

Omdat PLDoubleToStr, de invariant getalformatter achter content streams, SVG, XML, CSV en de meeste JSON in de library, zijn invoer opschaalde en Round aanriep, en Round(NaN) op doelen als Win32 een EInvalidOp gooit, waar Delphi de x87 invalid-operation-exception niet maskeert. De exceptie vuurde nadat de writer al een deel van zijn uitvoer had uitgezonden, dus één gedegenereerde meting, een 0/0 in een metriek of een door een aanroeper doorgegeven NaN, liet een afgebroken bestand achter. PLDoubleToStr geeft nu 0 terug voor NaN, en zijn integer-tak klemmt af op ±9.2e18 net als de fractionele tak, dus ook Infinity komt eruit als een eindige literal

Nul is het juiste antwoord voor een content stream, waar een getal-plek een getal moet bevatten, en het verkeerde antwoord voor een rapport, waar 0 een geloofwaardige meting is. JSON-writers die het verschil moeten vasthouden gebruiken PLJSONNumber(Value, Decimals) uit PDFlibExtra, die null schrijft voor NaN of Infinity en verder invariant cijfers. PLJSONNumber ligt nu onder GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON en de barcode-, deskew-, structured text- en PDF/VCR-rapporten; het deskew-rapport schreef vroeger 0 voor een niet-eindige hoek en schrijft nu null

PDFlibPas houdt NaN en Infinity op drie manieren tegen: AddPageMatrix, ScalePage en RedactRegion wijzen niet-eindige argumenten vooraf af, PLDoubleToStr schrijft 0 voor content-stream-plekken, en PLJSONNumber schrijft null in rapporten, waar nul als geloofwaardige meting zou lezen, nadat Round(NaN) vroeger EInvalidOp gooide midden in een export
Nul is het juiste antwoord voor een content stream en het verkeerde voor een rapport, dus de rapport writers geven elke Double aan PLJSONNumber en laten null zeggen dat de waarde aanwezig was maar niet representabel
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Formatteer elke Double eerst naar tekst; PLJSONNumber schrijft null
    // voor NaN of Infinity en gebruikt altijd een decimale punt
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Nooit B.Append(Angle): de Double-overload volgt de gebruikers-locale
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Waar glipt de gebruikers-locale nog steeds de JSON binnen?

Via elke formatter die regionale instellingen raadpleegt, en een volledige audit van machineleesbare uitvoer vond er nog precies één: maxAcceptedMeanError in GetSimilarImageDeduplicationReportJSON, geschreven met PLFloatToStr, een dunne wrapper om FloatToStr. Op een bureaublad waarvan het decimale scheidingsteken een komma is, bevatte het rapport "maxAcceptedMeanError":1,5, wat een JSON-parser leest als de waarde 1 gevolgd door een zwervend token. Het veld rapporteert de slechtste geaccepteerde pixel-fout uit perceptual image deduplication, en hij gaat nu door PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Een resterende valkuil is PLStringBuilder: op Delphi is het een kale alias van System.SysUtils.TStringBuilder, waarvan de Append(Double)-overload via de gebruikers-locale formatteert, terwijl FPC-builds een library-klasse gebruiken, dus een test op Free Pascal of een en-US-machine vangt hem nooit

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reproduceer een Duits of Frans bureaublad binnen de testrun
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Gebruik een fixture die echt near-duplicate afbeeldingen bevat,
    // anders is de mean error 0 en blijft de bug verborgen
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Dry run met drempels 2, 2, 4: het document wordt niet gewijzigd
    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;

Een regressiesuite voor JSON-uitvoer heeft drie fixtures nodig om eerlijk te blijven: een pagina met -.25, +1.5 en 007.5, een object met een integer van 400 cijfers, en een willekeurig rapport dat onder een komma-locale draait, elk gevalideerd met een strenge parser in plaats van met het oog. Object-JSON, document-JSON en de analyserapporten in PDF Library for Delphi delen dezelfde getalregels op Delphi, C++Builder en Free Pascal; de volledige functielijst staat op de productpagina van PDF Library for Delphi