Bài viết kỹ thuật

Ngữ pháp số PDF với JSON: NaN, Infinity và null

PDF Library for Delphi (PDFlibPas) phát JSON hợp lệ cho mọi con số PDF kể từ v3.539.31. GetObjectJSON viết lại các token mà ISO 32000-1 chấp nhận nhưng RFC 8259 từ chối, như -.25, +1.5 và 007.5, thành -0.25, 1.5 và 7.5 đúng từng chữ số; GetDocumentJSON cùng các báo cáo phân tích ghi null cho NaN và Infinity; còn PLDoubleToStr ghi 0 cho NaN thay vì văng EInvalidOp giữa chừng một lần export. Trước bản sửa, thư viện có thể sinh ra JSON mà chính bộ đọc của nó từ chối nạp lại

Vì sao một số PDF hợp lệ lại làm gãy JSON?

Vì hai ngữ pháp bất đồng ở bốn chi tiết nhỏ, và một parser PDF tôn trọng văn bản gốc sẽ mang những chi tiết đó thẳng vào output. ISO 32000-1 §7.3.3 cho phép một số mở đầu bằng dấu cộng, bỏ phần nguyên (.5), kết thúc bằng một dấu chấm trơ trọi (4.) và mang các số zero đầu (007.5). RFC 8259 §6 chẳng cho phép điều nào trong số đó: một dấu trừ tùy chọn, phần nguyên hoặc là 0 hoặc bắt đầu từ 1 tới 9, và ít nhất một chữ số sau bất kỳ dấu thập phân nào. Producer được tự do ghi các dạng PDF đó, và không ít generator cùng tệp sửa tay đã làm vậy

Lỗ hổng đến từ một tính năng giữ chính xác đầy chủ đích. Kể từ v3.539.19, TPDFNumeric.Output trả về đúng văn bản mà tokenizer đã parse cho các số thực, chính là thứ giữ một giá trị màu đã hiệu chuẩn nguyên vẹn khi lưu, như được mô tả trong giữ độ chính xác thập phân PDF đã parse. Tokenizer đã vá sẵn .5 thành 0.5 và 4. thành 4.0 ngay trên đường vào, còn các số nguyên được định dạng lại từ giá trị của chúng, nên +3 quay về thành 3. Những gì sống sót nguyên văn là phần còn lại: một dấu chấm đầu có dấu (-.25), một dấu cộng tường minh trên số thực (+1.5) và các zero đầu (007.5). Bộ ghi object cũ nối Output ngay sau "value":, còn TJSONParser.ParseNumber trong chính bộ đọc của thư viện dừng lại ở từng cái một trong số đó với thông báo "Invalid JSON number", nên export thành công còn import lại thất bại với PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON viết lại từng chữ số một các token số PDF mà RFC 8259 từ chối: -.25 thành -0.25, +1.5 mất dấu cộng, 007.5 rơi bỏ các zero đầu, còn các chữ số thập phân kiểu 1.250000 sống sót, vì định dạng lại từ Double đã lưu sẽ thêm nhiễu nhị phân
Writer cũ nối nguyên văn văn bản đã parse, chính bộ đọc của thư viện dừng lại với Invalid JSON number, và lỗi 105 làm gãy một vòng round-trip mà phía export gọi là thành cô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');

    // Object 12 là một mảng được ghi thành [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 trở đi: các giá trị về dưới dạng -0.25, 1.5 và 7.5

    // SetObjectJSON không nhận option nào, nên truyền 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;

PDFNumberTextToJSON giữ trọn từng chữ số ra sao?

PDFNumberTextToJSON đánh vần lại token thay vì tính toán lại nó từ một Double. Hàm trong PDFlibObjectJSON đọc một dấu tùy chọn, thu các chữ số trước và sau một dấu thập phân duy nhất, rồi chỉ áp đúng những phép sửa mà JSON đòi hỏi: bỏ dấu cộng, cắt các zero đầu nhưng giữ lại một, cung cấp 0 khi phần nguyên rỗng, bỏ dấu chấm đuôi trơ trọi và gắn lại dấu trừ. Một token chứa bất kỳ ký tự nào khác, hay chẳng có lấy một chữ số, sẽ rơi về PLJSONNumber(Value, 10), thứ ghi null khi giá trị không hữu hạn

PDFlibPas PDFNumberTextToJSON đọc dấu, thu các chữ số quanh một dấu thập phân duy nhất và chỉ áp những phép sửa mà JSON đòi hỏi, còn ký tự lạ hay chuỗi chữ số rỗng sẽ rơi về PLJSONNumber, thứ ghi null cho NaN và Infinity thay vì một con số
Đánh vần lại thắng tính toán lại: tokenizer đã vá sẵn .5 và 4. trên đường vào, nên writer giữ trọn từng chữ số còn sót và vòng round-trip dựng lại đúng giá trị cũ
  • -.25 thành -0.25, còn +.5 thành 0.5
  • +1.5 thành 1.5
  • 007.5 thành 7.5, trong khi 0.75 đứng yên như cũ
  • 4. thành 4 nếu một token như vậy bao giờ tới tay writer
  • 2.22221 và 1.250000 giữ trọn từng chữ số thập phân, kể cả các zero đuôi

Định dạng lại từ Double đã lưu sẽ ngắn gọn hơn và sai, vì cùng lý do khiến bản sửa độ chính xác tồn tại: độ chính xác output mặc định là bốn chữ số thập phân, và ngay cả một phép chuyển full-precision cũng có thể thêm nhiễu nhị phân vào một literal thập phân. Giữ các chữ số đồng nghĩa SetObjectJSON và ImportObjectJSON, những phương thức trao từng văn bản số JSON cho tokenizer PDF, dựng lại đúng giá trị cũ. Cam kết này phủ giá trị chứ không phủ byte: sau một lần import lại, -.25 được lưu và ghi thành -0.25. Cả hai cách đánh vần đều ngang giá dưới §7.3.3, nhưng một diff mức byte sẽ gắn cờ thay đổi, nên đừng coi một vòng export rồi import là no-op trên một tài liệu mà byte của nó bị chữ ký phủ

Chuyện gì xảy ra với một con số JSON không biểu diễn nổi?

GetDocumentJSON giờ ghi null cho mọi số là NaN hay vô cùng, vì RFC 8259 §6 chẳng có cú pháp cho cả hai. Infinity dễ sản sinh hơn nghe qua: tokenizer PDF cộng dồn các chữ số bằng phép nhân lặp vào một Double, thứ chạm trần cỡ 1.8 × 10308, nên một literal nguyên dài hơn chút ít 300 chữ số lặng lẽ thành +Inf. Các tệp tử tế chẳng bao giờ chứa literal như vậy; các tệp fuzzed và ác ý thì có, nên chúng thuộc về cùng kho test với các ca trong củng cố một parser PDF viết bằng Pascal trước các tệp ác ý. Bộ ghi tài liệu cũ format các số không nguyên bằng Str(D:0:6), và với +Inf điều đó ghi ra văn bản +Inf, thứ chẳng consumer JSON nào parse nổi

null này là có chủ đích mang tính mất mát. Các consumer của output GetDocumentJSON phải chấp nhận null ở bất cứ chỗ nào một con số có thể xuất hiện, và nên đọc nó là "đã từng có một giá trị nhưng không biểu diễn được", chứ không phải một khóa bị thiếu. Literal gốc không khôi phục được từ document JSON, nên pipeline nào quan tâm hãy log object và coi tệp là đáng ngờ thay vì thay vào một giá trị mặc định

Vì sao một NaN duy nhất có thể làm sập một lần export SVG hay JSON?

Vì PLDoubleToStr, formatter số invariant đứng sau content stream, SVG, XML, CSV và phần lớn JSON trong thư viện, nhân lên input của nó rồi gọi Round, mà Round(NaN) văng EInvalidOp trên các target như Win32, nơi Delphi để nguyên exception invalid-operation của x87 không bị mask. Exception bắn ra sau khi writer đã kịp phát một phần output, nên một phép đo suy biến, một phép 0/0 trong một metric hay một NaN caller truyền vào, để lại một tệp cụt đuôi. PLDoubleToStr giờ trả về 0 cho NaN, và nhánh số nguyên của nó kẹp tại ±9.2e18 như nhánh phân số, nên Infinity cũng ra một literal hữu hạn

Zero là câu trả lời đúng cho một content stream, nơi một slot số phải chứa một con số, và là câu trả lời sai cho một báo cáo, nơi 0 là một phép đo rất hợp lý. Các JSON writer phải giữ được sự khác biệt đó hãy dùng PLJSONNumber(Value, Decimals) từ PDFlibExtra, thứ ghi null cho NaN hay Infinity và các chữ số invariant cho phần còn lại. PLJSONNumber giờ đỡ đầu GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON cùng các báo cáo barcode, deskew, structured text và PDF/VCR; báo cáo deskew trước đây ghi 0 cho một góc không hữu hạn và giờ ghi null

PDFlibPas chặn NaN và Infinity ba đường: AddPageMatrix, ScalePage và RedactRegion từ chối tham số không hữu hạn ngay từ đầu, PLDoubleToStr ghi 0 cho các slot content stream, còn PLJSONNumber ghi null trong các báo cáo, nơi zero sẽ đọc như một phép đo hợp lý, sau khi Round(NaN) từng văng EInvalidOp giữa chừng export
Zero đúng cho content stream và sai cho báo cáo, nên các writer báo cáo trao mọi Double cho PLJSONNumber và để null nói rằng giá trị đã từng có nhưng không biểu diễn được
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Format mọi Double thành văn bản trước; PLJSONNumber ghi null
    // cho NaN hay Infinity và luôn dùng dấu chấm thập phân
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Đừng bao giờ B.Append(Angle): overload Double nghe theo user locale
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

User locale vẫn chui vào JSON từ đâu?

Từ bất kỳ formatter nào mà hỏi han regional settings, và một cuộc audit trọn vẹn trên output cho máy đọc chỉ còn tìm thấy đúng một: maxAcceptedMeanError trong GetSimilarImageDeduplicationReportJSON, được ghi bằng PLFloatToStr, một lớp bọc mỏng quanh FloatToStr. Trên một desktop mà separator thập phân là dấu phẩy, báo cáo chứa "maxAcceptedMeanError":1,5, thứ parser JSON đọc là giá trị 1 theo sau một token lạc loài. Trường này báo lỗi pixel được chấp nhận tệ nhất từ dedup ảnh theo nhận thức, và giờ nó đi qua PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Một cái bẫy còn sót là PLStringBuilder: trên Delphi nó chỉ là một alias trơ của System.SysUtils.TStringBuilder, mà overload Append(Double) của lớp này format qua user locale, trong khi bản build FPC dùng một lớp của thư viện thay thế, nên test trên Free Pascal hay máy en-US sẽ không bao giờ bắt được nó

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Tái hiện một desktop Đức hay Pháp ngay trong lần chạy test
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Dùng một fixture thực sự chứa các ảnh gần trùng nhau,
    // nếu không mean error là 0 và bug cứ ẩn mãi
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Chạy thử với ngưỡng 2, 2, 4: tài liệu không bị sửa
    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;

Một bộ hồi quy cho output JSON cần ba fixture để giữ được sự trung thực: một trang mang -.25, +1.5 và 007.5, một object giữ một số nguyên 400 chữ số, và bất kỳ báo cáo nào chạy dưới locale phẩy, từng cái được xác thực bằng một parser nghiêm ngặt thay vì nhìn bằng mắt. Object JSON, document JSON và các báo cáo phân tích trong PDF Library for Delphi chia sẻ cùng bộ luật số trên Delphi, C++Builder và Free Pascal; danh sách tính năng đầy đủ nằm trên trang sản phẩm PDF Library for Delphi