บทความเทคนิค

ไวยากรณ์เลข PDF vs JSON: NaN, Infinity และ null ใน Delphi

ตั้งแต่ v3.539.31 PDF Library for Delphi (PDFlibPas) พ่น JSON ที่ถูกต้องออกมาจากเลข PDF ทุกตัว GetObjectJSON เขียน token ที่ ISO 32000-1 ยอมรับแต่ RFC 8259 ปฏิเสธ อย่าง -.25, +1.5 กับ 007.5 ใหม่เป็น -0.25, 1.5 กับ 7.5 ทีละหลัก GetDocumentJSON กับรายงานการวิเคราะห์เขียน null แทน NaN กับ Infinity และ PLDoubleToStr เขียน 0 แทน NaN แทนการยก EInvalidOp กลางทางของการ export ก่อนการแก้ library ผลิต JSON ที่ตัวอ่านของตัวเองปฏิเสธจะโหลดกลับได้

ทำไมเลข PDF ที่ถูกต้องถึงทำ JSON พัง

เพราะสองไวยากรณ์เห็นไม่ตรงกันสี่จุดเล็ก ๆ และ parser ของ PDF ที่เคารพข้อความต้นทางจะหอบจุดพวกนี้ไปส่งตรงถึง output ISO 32000-1 §7.3.3 ให้เลขขึ้นต้นด้วยเครื่องหมายบวกได้ ตัดส่วนจำนวนเต็มทิ้งได้ (.5) จบด้วยจุดเปล่า ๆ ได้ (4.) และแบกศูนย์นำหน้าได้ (007.5) RFC 8259 §6 ไม่ยอมสักอย่าง: เครื่องหมายลบเป็นทางเลือก ส่วนจำนวนเต็มต้องเป็น 0 หรือขึ้นต้นด้วย 1 ถึง 9 และมีตัวเลขอย่างน้อยหนึ่งตัวหลังจุดทศนิยมเสมอ ฝั่งผู้ผลิตจะเขียนรูปแบบ PDF ก็ได้ตามใจ และ generator กับไฟล์ที่แกนมือก็เขียนจริงไม่ใช่น้อย

รอยรั่วมาจากฟีเจอร์รักษาความละเอียดที่ตั้งใจทำไว้ ตั้งแต่ v3.539.19 TPDFNumeric.Output คืนข้อความตรงตามที่ tokenizer parse ได้สำหรับเลขจริง ซึ่งเป็นสิ่งที่ทำให้ค่าสีที่สอบเทียบไว้ยังเป๊ะตอนบันทึก ตามที่เล่าไว้ในการรักษาความละเอียดทศนิยมของ PDF ที่ parse มา tokenizer แก้ .5 เป็น 0.5 กับ 4. เป็น 4.0 ตั้งแต่ตอนเข้าไว้แล้ว และเลขจำนวนเต็มถูกจัดรูปใหม่จากค่าของมัน +3 จึงกลับมาเป็น 3 ที่ลอดรอดมาแบบเดิม ๆ คือส่วนที่เหลือ: จุดนำหน้าที่มีเครื่องหมาย (-.25), เครื่องหมายบวกชัด ๆ บนเลขจริง (+1.5) และศูนย์นำหน้า (007.5) ตัวเขียน object รุ่นเก่าต่อ Output ไว้หลัง "value": ทันที และ TJSONParser.ParseNumber ในตัวอ่านของ library เองหยุดทุกตัวตามรายการนี้ด้วย "Invalid JSON number" การ export จึงสำเร็จแล้วการ import กลับพังด้วย PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

GetObjectJSON ของ PDFlibPas เขียน token เลข PDF ที่ RFC 8259 ปฏิเสธใหม่ทีละหลัก: -.25 กลายเป็น -0.25, +1.5 เสียเครื่องหมายบวก, 007.5 ทิ้งศูนย์นำหน้า ขณะที่เลขหลังจุดอย่าง 1.250000 รอดครบ เพราะการจัดรูปจาก Double ที่เก็บไว้จะเติมเสียงรบกวนเชิงเลขฐานสองเข้าไป
ตัวเขียนรุ่นเก่าต่อข้อความที่ parse ได้ตรง ๆ ตัวอ่านของ library เองก็หยุดด้วย Invalid JSON number และ error 105 ทำลาย round trip ที่ฝั่ง export เรียกว่าสำเร็จ
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 เป็น array ที่เขียนไว้เป็น [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 ขึ้นไป: ค่าที่ได้คือ -0.25, 1.5 กับ 7.5

    // SetObjectJSON ไม่รับ options จึงส่ง 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 เก็บทุกหลักไว้ได้อย่างไร

PDFNumberTextToJSON สะกด token ใหม่แทนการคำนวณซ้ำจาก Double ฟังก์ชันใน PDFlibObjectJSON อ่านเครื่องหมายที่เป็นทางเลือก เก็บตัวเลขก่อนและหลังจุดทศนิยมหนึ่งตัว แล้วทำเฉพาะการแก้ที่ JSON เรียกร้อง: ทิ้งเครื่องหมายบวก ลอกศูนย์นำหน้าออกแต่เก็บไว้หนึ่งตัว เติม 0 เมื่อส่วนจำนวนเต็มว่าง ทิ้งจุดท้ายเปล่า ๆ แล้วคืนเครื่องหมายลบกลับมา token ที่มีอักขระอื่นหรือไม่มีตัวเลขเลย จะ fallback ไปที่ PLJSONNumber(Value, 10) ซึ่งเขียน null เมื่อค่าไม่จำกัด (ไม่ใช่ finite)

PDFNumberTextToJSON ของ PDFlibPas อ่านเครื่องหมาย เก็บตัวเลขรอบจุดทศนิยมหนึ่งตัวแล้วทำเฉพาะการแก้ที่ JSON เรียกร้อง ขณะที่อักขระอื่นหรือช่วงตัวเลขว่างจะ fallback ไปที่ PLJSONNumber ซึ่งเขียน null แทน NaN กับ Infinity แทนการเขียนเป็นตัวเลข
การสะกดใหม่ชนะการคำนวณซ้ำ: tokenizer แก้ .5 กับ 4. ตั้งแต่ตอนเข้าแล้ว ตัวเขียนจึงเก็บทุกหลักที่รอดมาได้ครบ และ round trip สร้างค่าเดิมกลับมาได้เป๊ะ
  • -.25 กลายเป็น -0.25 และ +.5 กลายเป็น 0.5
  • +1.5 กลายเป็น 1.5
  • 007.5 กลายเป็น 7.5 ขณะที่ 0.75 อยู่ตามเดิม
  • 4. กลายเป็น 4 ถ้า token แบบนั้นไปถึงมือตัวเขียนสักครั้ง
  • 2.22221 กับ 1.250000 เก็บทุกหลักหลังจุดครบ ศูนย์ท้ายด้วย

การจัดรูปแบบจาก Double ที่เก็บไว้จะสั้นกว่าแต่ผิด ด้วยเหตุผลเดียวกับที่การแก้เรื่องความละเอียดมีอยู่: ความละเอียด output เริ่มต้นคือสี่ตำแหน่งทศนิยม และแม้การแปลงเต็มความละเอียดก็เติมเสียงรบกวนเชิงเลขฐานสองใส่ literal ทศนิยมได้ การเก็บหลักไว้ทำให้ SetObjectJSON กับ ImportObjectJSON ที่ส่งข้อความเลข JSON แต่ละตัวให้ tokenizer ของ PDF สร้างค่าเดิมกลับมาได้เป๊ะ การรับประกันนี้ครอบคลุมค่า ไม่ใช่ไบต์: หลัง import กลับ -.25 ถูกเก็บและบันทึกเป็น -0.25 สองการสะกดเท่ากันตาม §7.3.3 แต่ diff ระดับไบต์จะติงการเปลี่ยนนี้ อย่าไปถือว่ารอบ export กับ import เป็น no-op กับเอกสารที่ไบต์ถูกครอบด้วยลายเซ็น

เลขที่ JSON แทนไม่ได้แล้วมันไปไหน

GetDocumentJSON ตอนนี้เขียน null ให้เลขที่เป็น NaN หรืออนันต์ทุกตัว เพราะ RFC 8259 §6 ไม่มี syntax สำหรับทั้งคู่ Infinity ผลิตง่ายกว่าที่ฟังดู: tokenizer ของ PDF สะสมตัวเลขด้วยการคูณซ้ำลงใน Double ที่ไปได้ไกลสุดราว ๆ 1.8 × 10308 literal จำนวนเต็มยาวเกิน 300 หลักนิดหน่อยจึงกลายเป็น +Inf แบบเงียบ ๆ ไฟล์ซื่อสัตย์ไม่มี literal แบบนี้ แต่ไฟล์จาก fuzz กับไฟล์มุ่งร้ายมี จึงสมควรอยู่ใน test corpus เดียวกับกรณีในการทำให้ Pascal PDF parser แข็งแรงขึ้นต้านไฟล์ประสงค์ร้าย ตัวเขียนเอกสารรุ่นเก่าจัดรูปแบบเลขที่ไม่ใช่จำนวนเต็มด้วย Str(D:0:6) และกับ +Inf มันเขียนข้อความออกมาเป็น +Inf ที่ consumer ของ JSON ไม่มีตัวไหน parse ออก

null นี้เสียข้อมูลโดยตั้งใจ consumer ของ output จาก GetDocumentJSON ต้องยอมรับ null ได้ทุกที่ที่เลขปรากฏได้ และควรอ่านมันว่า "มีค่าอยู่แต่แทนไม่ได้" ไม่ใช่ key หายไป literal เดิมกู้คืนจาก JSON ของเอกสารไม่ได้ pipeline ที่ใส่ใจจึงควร log object นั้นและถือว่าไฟล์น่าสงสัย แทนการเอาค่าเริ่มต้นมาแทนให้

ทำไม NaN ตัวเดียวถึงล้มการ export ฝั่ง SVG หรือ JSON ได้

เพราะ PLDoubleToStr ตัวจัดรูปเลขแบบ invariant ที่อยู่เบื้องหลัง content stream, SVG, XML, CSV และ JSON ส่วนใหญ่ใน library คูณ input ของมันก่อนแล้วเรียก Round และ Round(NaN) ยก EInvalidOp บน target อย่าง Win32 ที่ Delphi ปล่อย exception invalid-operation ของ x87 ไว้ไม่ mask exception จึงยิงหลังจากตัวเขียนพ่น output ออกไปแล้วบางส่วน ค่าวัดเสีย ๆ หนึ่งตัว 0/0 ในเมตริกหรือ NaN ที่ผู้เรียกส่งเข้ามาจึงทิ้งไฟล์ตัดค้างไว้เบื้องหลัง PLDoubleToStr ตอนนี้คืน 0 แทน NaN และกิ่งจำนวนเต็ม clamp ที่ ±9.2e18 เหมือนกิ่งเศษส่วน Infinity จึงออกมาเป็น literal ที่จำกัดได้เช่นกัน

ศูนย์เป็นคำตอบที่ถูกสำหรับ content stream ที่ช่องเลขต้องใส่เลข และเป็นคำตอบที่ผิดสำหรับรายงาน ที่ที่ 0 เป็นค่าวัดที่ฟังดูเชื่อได้ ตัวเขียน JSON ที่ต้องรักษาความต่างนี้ใช้ PLJSONNumber(Value, Decimals) จาก PDFlibExtra ซึ่งเขียน null แทน NaN หรือ Infinity และเขียนเลขแบบ invariant ในกรณีอื่น PLJSONNumber ตอนนี้หนุนหลัง GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON และรายงาน barcode, deskew, structured text กับ PDF/VCR รายงาน deskew เคยเขียน 0 ให้มุมที่ไม่จำกัด ตอนนี้เขียน null

PDFlibPas หยุด NaN กับ Infinity สามทาง: AddPageMatrix, ScalePage กับ RedactRegion ปฏิเสธ argument ที่ไม่จำกัดตั้งแต่หัวมุม PLDoubleToStr เขียน 0 ลงช่องของ content stream และ PLJSONNumber เขียน null ลงรายงาน ที่ที่ศูนย์จะอ่านเป็นค่าวัดที่เชื่อได้ หลังจาก Round(NaN) เคยยก EInvalidOp กลางการ export
ศูนย์เป็นคำตอบที่ถูกสำหรับ content stream และผิดสำหรับรายงาน ตัวเขียนรายงานจึงส่ง Double ทุกตัวให้ PLJSONNumber แล้วปล่อยให้ null บอกว่ามีค่าอยู่แต่แทนไม่ได้
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // จัดรูปแบบ Double ทุกตัวเป็นข้อความก่อน; PLJSONNumber เขียน null
    // ให้ NaN หรือ Infinity และใช้จุดทศนิยมเสมอ
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // ห้าม B.Append(Angle): overload ของ Double ฟัง locale ของผู้ใช้
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

locale ของผู้ใช้ยังแอบซุกเข้า JSON ทางไหน

ผ่าน formatter ใดก็ตามที่ไปถาม regional setting และการตรวจ output ที่อ่านด้วยเครื่องทั้งหมดพบเหลืออยู่จุดเดียวพอดี: maxAcceptedMeanError ใน GetSimilarImageDeduplicationReportJSON ที่เขียนด้วย PLFloatToStr wrapper บาง ๆ ครอบ FloatToStr บนเดสก์ท็อปที่ตัวคั่นทศนิยมเป็นจุลภาค รายงานจะมี "maxAcceptedMeanError":1,5 ที่ parser ของ JSON อ่านเป็นค่า 1 ตามด้วย token ลอย ๆ ฟิลด์นี้รายงาน pixel error ที่ยอมรับแล้วแย่ที่สุดจากการหาภาพซ้ำแบบ perceptual และตอนนี้มันผ่าน PLJSONNumber(Stats.MaxAcceptedMeanError, 6) กับดักที่เหลืออยู่คือ PLStringBuilder: บน Delphi มันเป็นแค่ alias ของ System.SysUtils.TStringBuilder ที่ overload Append(Double) จัดรูปผ่าน locale ของผู้ใช้ ขณะที่ build ฝั่ง FPC ใช้คลาสของ library แทน ชุดทดสอบบน Free Pascal หรือเครื่อง en-US จึงไม่มีวันจับมันเจอ

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // จำลองเดสก์ท็อปเยอรมันหรือฝรั่งเศสข้างในการรันชุดทดสอบ
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // ใช้ fixture ที่มีภาพเกือบซ้ำกันจริง ๆ
    // ไม่งั้น mean error เป็น 0 และบั๊กก็ยังซ่อนอยู่
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // dry run ด้วย threshold 2, 2, 4: เอกสารไม่ถูกแตะ
    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;

ชุดทดสอบ regression สำหรับ output ฝั่ง JSON ต้องมี fixture สามชิ้นเพื่อความซื่อสัตย์: หน้าที่แบก -.25, +1.5 กับ 007.5, object ที่ถือจำนวนเต็มยาว 400 หลัก และรายงานใด ๆ ที่รันใต้ locale แบบจุลภาค โดย validate ด้วย parser แบบเข้มทุกชิ้น ไม่ใช่ค้อนสายตา Object JSON, document JSON และรายงานการวิเคราะห์ใน PDF Library for Delphi ใช้กฎตัวเลขชุดเดียวกันครอบคลุม Delphi, C++Builder และ Free Pascal รายการฟีเจอร์เต็มอยู่ที่หน้าผลิตภัณฑ์ PDF Library for Delphi