Teknisk artikkel

PDF-tallgrammatikk mot JSON: NaN, Infinity og null i Delphi

PDF Library for Delphi (PDFlibPas) sender ut gyldig JSON for hvert PDF-nummer siden v3.539.31. GetObjectJSON omskriver token som ISO 32000-1 godtar men RFC 8259 avviser, slik som -.25, +1.5 og 007.5, til -0.25, 1.5 og 7.5 siffer for siffer; GetDocumentJSON og analysrapportene skriver null for NaN og Infinity; og PLDoubleToStr skriver 0 for NaN i stedet for å reise EInvalidOp midt i en eksport. Før fiksen kunne biblioteket produsere JSON som dets egen leser nektet å laste inn

Hvorfor ødelegger et gyldig PDF-nummer JSON?

Fordi de to grammatikkene er uenige om fire små detaljer, og en PDF-parser som respekterer kildeteksten vil bære de detaljene rett inn i outputen. ISO 32000-1 §7.3.3 lar et tall starte med et plusstegn, utelate heltallsdelen (.5), slutte på et bart punktum (4.) og bære ledende nuller (007.5). RFC 8259 §6 tillater ingenting av det: et valgfritt minus, en heltallsdel som enten er 0 eller starter med 1 til 9, og minst ett siffer etter ethvert desimaltegn. Produsenter står fritt til å skrive PDF-formene, og massevis av generatorer og håndredigerte filer gjør det

Lekkasjen kom fra en bevisst presisjonsfunksjon. Siden v3.539.19 returnerer TPDFNumeric.Output den nøyaktige teksten tokenizeren parset for reelle tall, noe som holder en kalibrert fargeverdi nøyaktig ved lagring, som beskrevet i bevaring av parset PDF-desimalpresisjon. Tokenizeren lapper allerede .5 til 0.5 og 4. til 4.0 på vei inn, og heltall reformateres fra sin verdi, så +3 kommer tilbake som 3. Det som overlever verbatim, er resten: et signert ledende punktum (-.25), et eksplisitt plusstegn på et reelt tall (+1.5) og ledende nuller (007.5). Den gamle objektskriveren la ved Output rett etter "value":, og TJSONParser.ParseNumber i bibliotekets egen leser stopper på hver av dem med «Invalid JSON number», så eksporten lyktes og re-importen feilet med PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON omskriver PDF-talltokenene RFC 8259 avviser, siffer for siffer: -.25 blir -0.25, +1.5 mister plusset, 007.5 slipper sine ledende nuller, og brøksiffer som 1.250000 overlever, for formatering fra lagret Double ville lagt til binær støy
Den gamle skriveren la ved den nøyaktige parsede teksten, bibliotekets egen leser stoppet med Invalid JSON number, og feil 105 knekket en rundtur eksportsiden kalte en suksess
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 er en array skrevet som [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 og senere: verdiene ankommer som -0.25, 1.5 og 7.5

    // SetObjectJSON godtar ingen options, så send 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;

Hvordan beholder PDFNumberTextToJSON hvert siffer?

PDFNumberTextToJSON staver tokenen om i stedet for å regne den ut på nytt fra en Double. Funksjonen i PDFlibObjectJSON leser et valgfritt fortegn, samler siffer før og etter ett enkelt desimaltegn og anvender så bare redigeringene JSON krever: den slipper et plusstegn, stripper ledende nuller mens den beholder én, supplierer 0 når heltallsdelen er tom, slipper et bart etterfølgende punktum og setter minusen tilbake. En token som inneholder et annet tegn, eller ingen siffer i det hele tatt, faller tilbake til PLJSONNumber(Value, 10), som skriver null når verdien ikke er endelig

PDFlibPas PDFNumberTextToJSON leser fortegnet, samler siffer rundt ett enkelt desimaltegn og anvender bare redigeringene JSON krever, mens ethvert annet tegn eller et tomt sifferløp faller tilbake til PLJSONNumber, som skriver null for NaN og Infinity i stedet for et tall
Å stave om slår å regne ut på nytt: tokenizeren lappet allerede .5 og 4. på vei inn, så skriveren beholder hvert overlevende siffer og rundturen gjenskaper nøyaktig samme verdi
  • -.25 blir -0.25, og +.5 blir 0.5
  • +1.5 blir 1.5
  • 007.5 blir 7.5, mens 0.75 forblir som den er
  • 4. blir 4 hvis en slik token noensinne når skriveren
  • 2.22221 og 1.250000 beholder hvert brøksiffer, etterfølgende nuller inkludert

Å formatere fra den lagrede Double ville vært kortere og feil, av samme grunn som presisjonsfiksen finnes: standard outputpresisjon er fire desimaler, og selv en fullpresisjonskonvertering kan legge binær støy til en desimalliteral. Å beholde sifrene betyr at SetObjectJSON og ImportObjectJSON, som gir hver JSON-talltekst til PDF-tokenizeren, gjenskaper nøyaktig samme verdi. Garantien dekker verdien, ikke bytene: etter en re-import blir -.25 lagret og skrevet ut som -0.25. Begge stavemåtene er like under §7.3.3, men en byte-nivå-diff vil flagge endringen, så ikke behandle en eksport-og-import-syklus som en no-op på et dokument hvis byte er dekket av en signatur

Hva skjer med et tall JSON ikke kan representere?

GetDocumentJSON skriver nå null for ethvert tall som er NaN eller uendelig, for RFC 8259 §6 har ingen syntaks for noen av dem. Infinity er lettere å produsere enn det høres ut som: PDF-tokenizeren akkumulerer siffer ved gjentatt multiplikasjon inn i en Double, som topper seg nær 1.8 × 10308, så en heltallsliteral litt over 300 siffer lang blir i stillhet +Inf. Ærlige filer inneholder aldri en slik literal; fuzzede og fiendtlige gjør det, og det er derfor de hører hjemme i samme testkorpus som tilfellene i herding av en Pascal-PDF-parser mot ondsinnede filer. Den gamle dokumentskriveren formaterte ikke-heltall med Str(D:0:6), og for +Inf skriver den teksten +Inf, som ingen JSON-konsument vil parse

null-en er bevisst tapfull. Konsumenter av GetDocumentJSON-output må akseptere null hvor som helst et tall kan dukke opp, og bør lese den som «en verdi var til stede men kan ikke representeres», ikke som en manglende nøkkel. Den opprinnelige literalen kan ikke gjenopprettes fra dokument-JSON-en, så en pipeline som bryr seg, bør logge objektet og behandle filen som mistenkelig i stedet for å erstatte med en standardverdi

Hvorfor kunne én enkelt NaN avbryte en SVG- eller JSON-eksport?

Fordi PLDoubleToStr, den invariante tallformatteren bak content streams, SVG, XML, CSV og mesteparten av JSON-en i biblioteket, skalerte inputen sin og kalte Round, og Round(NaN) reiser EInvalidOp på mål som Win32, der Delphi lar x87-unntaket for ugyldige operasjoner stå umaskert. Unntaket fykte etter at skriveren allerede hadde sendt ut deler av outputen sin, så én degenerert måling, en 0/0 i en metrikk eller en NaN sendt inn av en kaller, etterlot en avkortet fil. PLDoubleToStr returnerer nå 0 for NaN, og heltallsgrenen klemmer til ±9.2e18 som brøkgrenen, så Infinity kommer også ut som en endelig literal

Null er det riktige svaret for en content stream, der en nummerplass må holde et tall, og det feile svaret for en rapport, der 0 er en plausibel måling. JSON-skrivere som må holde forskjellen, bruker PLJSONNumber(Value, Decimals) fra PDFlibExtra, som skriver null for NaN eller Infinity og invariante siffer ellers. PLJSONNumber står nå bak GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON og strekkode-, deskew-, strukturert tekst- og PDF/VCR-rapportene; deskew-rapporten skrev tidligere 0 for en ikke-endelig vinkel og skriver nå null

PDFlibPas stopper NaN og Infinity på tre måter: AddPageMatrix, ScalePage og RedactRegion avviser ikke-endelige argumenter på forhånd, PLDoubleToStr skriver 0 for content stream-plasser, og PLJSONNumber skriver null i rapporter, der null ville lest som en plausibel måling, etter at Round(NaN) pleide å reise EInvalidOp midt i en eksport
Null er det riktige svaret for en content stream og det feile svaret for en rapport, så rapportskriverne gir hver Double til PLJSONNumber og lar null si at verdien var til stede men ikke kunne representeres
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Formater hver Double til tekst først; PLJSONNumber skriver null
    // for NaN eller Infinity og bruker alltid et desimaltegn
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Aldri B.Append(Angle): Double-overloaden følger brukerlocelen
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Hvor sniker brukerlocelen seg fortsatt inn i JSON?

Gjennom enhver formatter som spør de regionale innstillingene, og en full revisjon av maskinlesbar output fant nøyaktig én igjen: maxAcceptedMeanError i GetSimilarImageDeduplicationReportJSON, skrevet med PLFloatToStr, en tynn wrapper over FloatToStr. På et skrivebord med komma som desimalseparator inneholdt rapporten "maxAcceptedMeanError":1,5, som en JSON-parser leser som verdien 1 fulgt av en vill token. Feltet rapporterer den verste aksepterte pikselfeilen fra perseptuell bildededuplikering, og den går nå gjennom PLJSONNumber(Stats.MaxAcceptedMeanError, 6). En gjenværende felle er PLStringBuilder: på Delphi er den en ren alias av System.SysUtils.TStringBuilder, hvis Append(Double)-overload formaterer gjennom brukerlocelen, mens FPC-bygg bruker en bibliotekklasse i stedet, så en test på Free Pascal eller en en-US-maskin fanger den aldri

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reproduser et tysk eller fransk skrivebord inne i testkjøringen
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Bruk en fixture som virkelig inneholder nesten dupliserte bilder,
    // ellers er gjennomsnittsfeilen 0 og bug-en forblir skjult
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Tørrkjøring med terskler 2, 2, 4: dokumentet endres ikke
    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 regresjonssuite for JSON-output trenger tre fixtures for å forbli ærlig: en side som bærer -.25, +1.5 og 007.5, et objekt som holder et 400-siffers heltall, og en vilkårlig rapport kjørt under en komma-locale, hver validert med en streng parser i stedet for å vurderes med øynene. Objekt-JSON, dokument-JSON og analysrapportene i PDF Library for Delphi deler de samme nummerreglene på tvers av Delphi, C++Builder og Free Pascal; den komplette funksjonslisten ligger på PDF Library for Delphi produktsiden