Teknisk artikel

PDF-talgrammatik vs JSON: NaN, Infinity og null i Delphi

PDF Library for Delphi (PDFlibPas) udsender gyldig JSON for hvert PDF-tal siden v3.539.31. GetObjectJSON omskriver tokens, som ISO 32000-1 accepterer, men RFC 8259 afviser, som -.25, +1.5 og 007.5, til -0.25, 1.5 og 7.5 ciffer for ciffer; GetDocumentJSON og analyserapporterne skriver null for NaN og Infinity; og PLDoubleToStr skriver 0 for NaN i stedet for at rejse EInvalidOp halvvejs inde i en eksport. Før fixet kunne biblioteket producere JSON, som dets egen reader nægtede at indlæse

Hvorfor bryder et gyldigt PDF-tal JSON?

Fordi de to grammatikker er uenige om fire små detaljer, og en PDF-parser, der respekterer kildeteksten, transporterer de detaljer direkte ud i outputtet. ISO 32000-1 §7.3.3 tillader, at et tal starter med et plustegn, udelader heltalsdelen (.5), slutter på et bart punktum (4.) og bærer indledende nuller (007.5). RFC 8259 §6 tillader intet af det: et valgfrit minus, en heltalsdel, der enten er 0 eller starter med 1 til 9, og mindst ét ciffer efter et decimalpunktum. Producenter står frit til at skrive PDF-formerne, og masser af generatorer og håndredigerede filer gør det

Lækken kom fra en bevidst præcisionsfunktion. Siden v3.539.19 returnerer TPDFNumeric.Output den eksakte tekst, tokenizeren parsed for reelle tal, hvilket er det, der holder en kalibreret farveværdi eksakt ved gemning, som beskrevet i at bevare parsede PDF-decimalers præcision. Tokenizeren patcher allerede .5 til 0.5 og 4. til 4.0 på vej ind, og heltal formateres om fra deres værdi, så +3 kommer tilbage som 3. Det, der overlever ordret, er resten: et fortegnet indledende punktum (-.25), et eksplicit plus på et reelt tal (+1.5) og indledende nuller (007.5). Den gamle object-writer satte Output direkte efter "value":, og TJSONParser.ParseNumber i bibliotekets egen reader stopper ved hver af dem med "Invalid JSON number", så eksporten lykkedes, og re-importen fejlede med PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON omskriver de PDF-taltokens, RFC 8259 afviser, ciffer for ciffer: -.25 bliver -0.25, +1.5 mister plusset, 007.5 dropper sine indledende nuller, og fraktionscifre som 1.250000 overlever, fordi formatering fra den gemte Double ville tilføje binær støj
Den gamle writer satte den eksakte parsede tekst ind, bibliotekets egen reader standsede med Invalid JSON number, og fejl 105 brød en round-trip, som eksportsiden kaldte en succes
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 et array skrevet som [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 og senere: værdierne ankommer som -0.25, 1.5 og 7.5

    // SetObjectJSON tager ingen options, så giv 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 holder PDFNumberTextToJSON hvert ciffer?

PDFNumberTextToJSON staver tokenen om i stedet for at genberegne den fra en Double. Funktionen i PDFlibObjectJSON læser et valgfrit fortegn, samler cifre før og efter ét decimalpunktum og anvender derefter kun de redigeringer, JSON kræver: den dropper et plus, fjerner indledende nuller, mens den beholder én, leverer 0, når heltalsdelen er tom, dropper et bart afsluttende punktum og sætter minuset tilbage. En token med ethvert andet tegn, eller slet ingen cifre, falder tilbage til PLJSONNumber(Value, 10), som skriver null, når værdien ikke er endelig

PDFlibPas PDFNumberTextToJSON læser fortegnet, samler cifre omkring ét decimalpunktum og anvender kun de redigeringer, JSON kræver, mens ethvert andet tegn eller en tom ciffer-række falder tilbage til PLJSONNumber, som skriver null for NaN og Infinity i stedet for et tal
At stavere om slår at genberegne: tokenizeren har allerede patchet .5 og 4. på vej ind, så writeren beholder hvert overlevende ciffer, og round-trip'en genskaber præcis samme værdi
  • -.25 bliver til -0.25, og +.5 bliver til 0.5
  • +1.5 bliver til 1.5
  • 007.5 bliver til 7.5, mens 0.75 forbliver, som det er
  • 4. bliver til 4, hvis en sådan token nogensinde når writeren
  • 2.22221 og 1.250000 beholder hvert fraktionsciffer, nuller i enden inkluderet

Formatering fra den gemte Double ville have været kortere og forkert, af samme grund som præcisionsfixet findes: standardoutputpræcisionen er fire decimaler, og selv en fuldpræcisionskonvertering kan tilføje binær støj til en decimal literal. At beholde cifrene betyder, at SetObjectJSON og ImportObjectJSON, som giver hver JSON-taltekst til PDF-tokenizeren, genskaber præcis samme værdi. Garantien dækker værdien, ikke bytes: efter en re-import gemmes -.25 og skrives som -0.25. Begge stavemåder er lige under §7.3.3, men en byte-level diff vil markere ændringen, så behandl ikke en eksport- og importcyklus som en no-op på et dokument, hvis bytes er dækket af en signatur

Hvad sker der med et tal, JSON ikke kan repræsentere?

GetDocumentJSON skriver nu null for ethvert tal, der er NaN eller uendelig, for RFC 8259 §6 har ingen syntaks for nogen af dem. Infinity er lettere at frembringe, end det lyder: PDF-tokenizeren akkumulerer cifre ved gentagen multiplikation ind i en Double, som topper omkring 1,8 × 10308, så en integer literal lidt over 300 cifre bliver i stilhed til +Inf. Ærlige filer indeholder aldrig en sådan literal; fuzzede og fjendtlige gør, hvilket er grunden til, at de hører hjemme i samme testkorpus som tilfældene i hærdning af en Pascal-PDF-parser mod ondsindede filer. Den gamle document-writer formaterede ikke-heltal med Str(D:0:6), og for +Inf skriver den teksten +Inf, som ingen JSON-forbruger parser

null'et er bevidst tabsgivende. Forbrugere af GetDocumentJSON-output skal acceptere null alle steder, hvor et tal kan optræde, og bør læse det som "en værdi var til stede, men kan ikke repræsenteres", ikke som en manglende nøgle. Den oprindelige literal kan ikke gendannes fra dokument-JSON'en, så en pipeline, der holder af det, bør logge objektet og behandle filen som mistænkelig i stedet for at erstatte med en default

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

Fordi PLDoubleToStr, den invariante talformatter bag content streams, SVG, XML, CSV og det meste JSON i biblioteket, skalerede sit input og kaldte Round, og Round(NaN) rejser EInvalidOp på targets som Win32, hvor Delphi lader x87 invalid-operation-exceptionen være umasket. Exceptionen affyredes, efter writeren allerede havde udsendt en del af sit output, så én degenereret måling, en 0/0 i en metrik eller en NaN givet videre af en caller efterlod en afkortet fil. PLDoubleToStr returnerer nu 0 for NaN, og dens heltalsgren clamper til ±9.2e18 ligesom grenen for fraktionerede tal, så Infinity også kommer ud som en endelig literal

Nul er det rigtige svar for en content stream, hvor en talslot skal holde et tal, og det forkerte svar for en rapport, hvor 0 er en plausibel måling. JSON-writere, der skal holde forskellen adskilt, bruger PLJSONNumber(Value, Decimals) fra PDFlibExtra, som skriver null for NaN eller Infinity og invariante cifre ellers. PLJSONNumber ligger nu bag GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON og barcode-, deskew-, structured text- og PDF/VCR-rapporterne; deskew-rapporten skrev tidligere 0 for en ikke-endelig vinkel og skriver nu null

PDFlibPas stopper NaN og Infinity på tre måder: AddPageMatrix, ScalePage og RedactRegion afviser ikke-endelige argumenter på forhånd, PLDoubleToStr skriver 0 til content-stream-slots, og PLJSONNumber skriver null i rapporter, hvor nul ville læses som en plausibel måling, efter Round(NaN) tidligere rejste EInvalidOp midt i en eksport
Nul er det rigtige svar for en content stream og det forkerte svar for en rapport, så rapportwriterne giver hver Double til PLJSONNumber og lader null sige, at værdien var til stede, men ikke kunne repræsenteres
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Formatér hver Double til tekst først; PLJSONNumber skriver null
    // for NaN eller Infinity og bruger altid et decimalpunktum
    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-overloaden følger brugerens locale
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Hvor smyger brugerens locale sig stadig ind i JSON?

Gennem enhver formatter, der spørger regionale indstillinger, og en fuld audit af maskinlæsbart output fandt præcis én tilbage: maxAcceptedMeanError i GetSimilarImageDeduplicationReportJSON, skrevet med PLFloatToStr, en tynd wrapper over FloatToStr. På en desktop, hvis decimaltegn er et komma, indeholdt rapporten "maxAcceptedMeanError":1,5, som en JSON-parser læser som værdien 1 efterfulgt af en løs token. Feltet rapporterer den værste accepterede pixelfejl fra perceptuel billed-deduplikering og går nu gennem PLJSONNumber(Stats.MaxAcceptedMeanError, 6). En resterende fælde er PLStringBuilder: på Delphi er den en ren alias af System.SysUtils.TStringBuilder, hvis Append(Double)-overload formaterer gennem brugerens locale, mens FPC-builds bruger en biblioteksklasse i stedet, så en test på Free Pascal eller en en-US-maskine fanger den aldrig

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Genskab en tysk eller fransk desktop i testkørslen
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Brug en fixture, der virkelig indeholder næsten-duplikerede billeder,
    // ellers er middelfejlen 0, og fejlen forbliver skjult
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Dry run med tærskler 2, 2, 4: dokumentet ændres 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 regression-suite for JSON-output behøver tre fixtures for at forblive ærlig: en side med -.25, +1.5 og 007.5, et objekt med et 400-cifret heltal og enhver rapport kørt under et komma-locale, hver valideret med en streng parser frem for at blive set efter. Object JSON, document JSON og analyserapporterne i PDF Library for Delphi deler samme talregler på tværs af Delphi, C++Builder og Free Pascal; den komplette funktionsliste ligger på PDF Library for Delphi-produktsiden