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

เลข PDF แบบ locale-neutral สำหรับแอป Delphi ที่ใช้จุลภาค

PDFlibPas หรือ losLab PDF Developer Library for Delphi เขียนทุกตัวเลขที่ลงไปใน content stream ด้วยตัวคั่นทศนิยมแบบจุดและไม่มีเลขชี้กำลัง ไม่ว่า regional setting ของ Windows จะว่าอย่างไร ตั้งแต่ v3.539.26 AddPageMatrix, ScalePage, DeskewPage, RedactRegion, output ของ text-to-path และการเปลี่ยนสีจัดรูปแบบ operand ผ่าน PLDoubleToStrConst และตั้งแต่ v3.539.33 parser ที่อ่านตัวเลขพวกนี้กลับใช้ PLTryStrToFloatInvariant แทน locale ของระบบ บนเครื่องเยอรมัน ฝรั่งเศสหรือบราซิล โค้ดชุดเดิมตอนนี้ผลิตไบต์เดียวกับบนเครื่องสหรัฐ ซึ่งเป็นพฤติกรรมเดียวที่ file format ยอมรับได้

ทำไม locale แบบจุลภาคคั่นทศนิยมถึงทำ PDF เสียโดยไม่มี error

locale แบบจุลภาคคั่นทศนิยมทำ PDF เสียเงียบ ๆ เพราะจุลภาคไม่ใช่อักขระตัวเลขใน syntax ของ PDF ความเสียหายจึงอ่านออกมาเป็น token ที่ถูกต้องแต่มีความหมายผิด ก่อนการแก้ PLFloatToStr ไม่ต่างจากการเรียก FloatToStr เปล่า ๆ และ FloatToStr ฟัง FormatSettings.DecimalSeparator เมื่อตัวคั่นเป็นจุลภาค AddPageMatrix(0.5, 0.5, 0, 0) จึงเขียน 0,5 0 0 0,5 0 0 cm ออกมา ISO 32000-1 §7.3.3 อนุญาตแค่ตัวเลข จุดหนึ่งตัวและเครื่องหมายขึ้นต้นในเลขหนึ่งตัว อย่างอื่นไม่ได้ parser ของ content จึงอ่านบรรทัดนั้นเป็นเลข 0 ตามด้วย token ปริศนา ,5 และ operator cm ก็ได้ operand ผิดไป ไม่มีอะไร raise ไม่มีอะไร log หน้าก็แค่ render ออกมาด้วย matrix การแปลงที่เลื่อนไปเอง และการไล่ย้อนจากภาพวาดที่ผิดตำแหน่งไปเจอตัวตั้ง locale คือบ่ายที่ทรมานเต็ม ๆ

บั๊กที่สองซ่อนอยู่หลังบั๊กแรก FloatToStr ใช้รูปแบบ ffGeneral ซึ่งสลับไปเขียนเลขชี้กำลังพอขนาดตัวเลขต่ำกว่า 1E-4 offset จิ๋วจึงออกมาเป็น 1E-5 §7.3.3 บทเดียวกันระบุว่า PDF ไม่รองรับรูปเลขชี้กำลัง แปลว่าแม้แต่เครื่อง locale สหรัฐก็เขียน operand ที่ไม่ถูกต้องได้ถ้าค่าเล็กพอ ชุดทดสอบ regression ของรีลีสนี้ตอกทั้งสองรูปแบบความพัง: พลิกตัวคั่นเป็นจุลภาค เรียก API แล้วกวาดหา token ใด ๆ ใน content ที่ไปมีจุลภาคหรือเลขชี้กำลัง

uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  OldSeparator: Char;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetPageDimensions(300, 200);
    Lib.DrawBox(10, 10, 20, 20, 1);
    OldSeparator := FormatSettings.DecimalSeparator;
    try
      FormatSettings.DecimalSeparator := ',';   // จำลองเดสก์ท็อป de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 ขึ้นไปเขียน:   0.5 0 0 0.25 0.00001 12.75 cm
      // รุ่นเก่าเขียน:             0,5 0 0 0,25 1E-5 12,75 cm
      Writeln(Lib.GetPageContentToString);
    finally
      FormatSettings.DecimalSeparator := OldSeparator;
    end;
  finally
    Lib.Free;
  end;
end;
AddPageMatrix ของ PDFlibPas บนเดสก์ท็อป comma-decimal เขียน 0,5 0 0 0,25 1E-5 12,75 cm ก่อนการแก้ parser ของ PDF อ่านมันเป็นเลข 0 บวก token ปริศนา cm จึงได้ operand ผิดและหน้าถูกแปลงไปเงียบ ๆ ขณะที่ PLDoubleToStrConst เขียนทศนิยมจุดที่ถูกต้อง
จุลภาคไม่ใช่อักขระตัวเลขใน syntax ของ PDF ความเสียหายจึงอ่านออกมาเป็น token ที่ถูกต้องแต่ความหมายผิด — และเลขชี้กำลังแบบ ffGeneral อย่าง 1E-5 ไม่ถูกต้องบนทุก locale ไม่ใช่แค่พวกจุลภาค

เลขสองพวก คนละตระกูล helper

การแก้ใน PDFlibPas คือการแบ่งแยกเด็ดขาด: เลขที่แสดงให้คนดูตาม locale ได้ แต่เลขที่เขียนให้เครื่องอ่านไม่มีวันตาม PLFloatToStr กับ PLStrToFloat อยู่ใน PDFlibExtra.pas สำหรับข้อความที่หันหน้าหาผู้ใช้ และ declaration ของพวกมันตอนนี้แปะคอมเมนต์บอกไว้แบบนั้นเป๊ะ ทุกอย่างที่จะกลายเป็น syntax ของ PDF ลอดผ่าน PLDoubleToStrConst ด้วยจำนวนตำแหน่งทศนิยมตายตัวที่เลือกตามงาน: หกตำแหน่งสำหรับ matrix, สี่สำหรับพิกัดกับ adjustment ของ TJ, สามสำหรับสีและสี่เหลี่ยม FDF การตรวจสอบของ v3.539.26 แตะจุดเรียกมากกว่าที่รายงานบั๊กเดิมบอกไว้:

  • AddPageMatrix, ScalePage กับ DeskewPage ซึ่งทั้งหมดเติม cm ขึ้นหน้าเนื้อหาของหน้าที่มีอยู่
  • ตัวสร้าง page element ที่พ่น reset ของ Tm, advance ของ TJ และ transform ของ cm
  • matrix วาง glyph กับจุด outline ในตัวแปลง text-to-path
  • กล่องเติมสีดำที่ RedactRegion เติมขึ้นหน้า ค่า /Rect ในการ export ฝั่ง FDF และ operand ที่การเปลี่ยนสีเขียน

PLDoubleToStrConst เป็น formatter ที่เขียนเองมือ ไม่ใช่ wrapper ครอบ FloatToStrF และสามสมบัติของมันสำคัญในที่นี้ มันเขียนจุดเสมอและตัดศูนย์ท้ายทิ้ง 0.5 จึงยังเป็น 0.5 ไม่กลายเป็น 0.500000 มันไม่เขียนเลขชี้กำลังกับ input ที่จำกัด (finite) เลย และค่าไม่ศูนย์ที่เล็กกว่าความละเอียดที่ขอไว้จะเก็บเลขนัยสำคัญไว้แทนการยุบเป็นศูนย์ PLDoubleToStrConst(1E-9, 6) จึงคืน 0.000000001 ค่าที่ต่ำกว่าราว ๆ 5E-16 เท่านั้นที่กลายเป็น 0 กฎข้อสุดท้ายนี้มีอยู่เพราะการปัด scale factor จิ๋วเป็นศูนย์เปลี่ยน matrix ที่ถูกต้องให้เป็น singular ซึ่งเป็นบั๊กที่แย่กว่าตัวที่กำลังแก้ซะอีก

PDFlibPas แบ่งการจัดรูปแบบตัวเลขเป็นสองพวก: PLFloatToStr กับ PLStrToFloat ผูกกับ locale สำหรับข้อความหันหน้าหาผู้ใช้ ขณะที่ PLDoubleToStrConst กับ PLTryStrToFloatInvariant จัดรูปแบบทุกอย่างที่จะกลายเป็น syntax ของ PDF ด้วยจุด ไม่มีเลขชี้กำลัง และความละเอียดตายตัวตามงานที่หก สี่ หรือสามตำแหน่ง
formatter แบบ invariant ถูกเขียนเองมือโดยตั้งใจ: ตัดศูนย์ท้าย ไม่เขียนเลขชี้กำลังเด็ดขาด และเก็บเลขนัยสำคัญของค่าจิ๋วไว้ เพราะการปัด scale factor เป็นศูนย์จะทำให้ matrix ที่ถูกต้องกลายเป็น singular
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // output ของเครื่อง: จุดทศนิยม ไม่มีเลขชี้กำลัง ตัดศูนย์ท้ายออก
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // เก็บเลขนัยสำคัญ 4 ตัว
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // input ของเครื่อง: ล้มแบบนุ่มนวลแทน EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // เลขใน content ไม่มีวันใช้จุลภาค
end;

ทำไมฝั่ง parse อันตรายกว่าฝั่งเขียน

ฝั่ง parse อันตรายกว่าเพราะ parser ที่ผูกกับ locale ไม่ได้ผลิตเลขผิด แต่มันโยน exception PLStrToFloat เรียก StrToFloat ซึ่งยก EConvertError เมื่อข้อความไม่ตรงกับตัวคั่นของระบบ บนระบบจุลภาคคั่นทศนิยมแปลว่า RecolorPage ล้มตั้งแต่เจอ operator ธรรมดาอย่าง 0.5 g หน้าจริงทุกหน้าจึงพังหมด ไม่ใช่แค่หน้าแปลก ๆ RenderPageRegionToFile ปฏิเสธรูป clip ที่เอกสารของตัวเองระบุเองคือ "10.5,20.5,50.5,40.5" และ attribute ความยาวของ SVG สีในการ export ฝั่ง SVG ลิสต์จุดยอดของ annotation และค่าความทึบของ output intent ล้วนถูกปฏิเสธหรือถูกแทนที่ด้วยค่าเริ่มต้นแบบเงียบ ๆ library ที่สมบูรณ์แบบบนเครื่องนักพัฒนาแต่ล้มตั้งแต่ลูกค้าคนแรกในมิวนิก คือโค้ดพันธุ์ที่ว่าไว้ในบทความเรื่องโค้ด Delphi ที่ทำงานได้เพราะความบังเอิญ มันดูถูกต้องเพราะจุดที่มันถูกทดสอบเท่านั้น

v3.539.33 จำแนกการเรียก StrToFloat กับ TryStrToFloat ทุกจุดตามแหล่งที่มาของ input operand ของ content stream, attribute ของ SVG, string สีของ painter และลิสต์ clip กับจุดยอดที่คั่นด้วยจุลภาค ล้วนมี syntax จุดตายตัว ทุกอย่างจึงผ่าน PLTryStrToFloatInvariant ที่ trim ข้อความ parse ด้วย PLInvariantFormatSettings และคืน False กับ input ว่าง เพี้ยนหรือไม่จำกัด แทนการยก exception ลิสต์ที่คั่นด้วยจุลภาคไม่มีที่ให้ประนีประนอม เพราะจุลภาคเป็นได้ทั้งตัวคั่นลิสต์และตัวคั่นทศนิยมพร้อมกันไม่ได้ รอบเดียวกันนั้นยังแก้การเขียนทะลุขอบด้วย: RenderPageRegionToFile เคยเก็บค่า clip ที่ห้าทะลุ buffer สี่ช่องของตัวเอง สำหรับ pipeline เปลี่ยนสีที่เล่าไว้ในคู่มือแปลง PDF ไปหนึ่ง color space ผลจริงคือ RecolorPage กับ RecolorDocument ไม่ล้มบนระบบจุลภาคคั่นทศนิยมอีกต่อไป ค่ากฎที่ผู้เรียกพิมพ์ลง CheckDocumentPolicy เป็นกรณี parse เดียวที่ใช้ helper แบบหลวมแทน ด้วยเหตุผลที่หัวข้อถัดไปจะอธิบาย

ถ้าแก้แค่ปลายเดียวของ round trip จะเกิดอะไรขึ้น

การแก้แค่ปลายเดียวของ round trip ฝั่ง locale ทำให้โค้ดที่เคยทำงานพังไป การเปลี่ยน attribute ด้านโครงสร้างใน v3.539.32 จึงขยับทั้งฝั่งเขียนและฝั่งอ่านพร้อมกัน wrapper ตระกูล SetStructElem* ส่งต่อตัวเลขในรูป string: SetStructElemBBox จัดรูปแบบค่าสี่ตัวลง string เดียว เก็บผ่าน AddTagAttribute และตัวเขียน /A ตอนหลัง parse string นั้นกลับเพื่อตัดสินว่ามันจะกลายเป็นเลข array หรือ name ทั้งสองฝั่งเคยใช้ locale ของระบบ บนเครื่องจุลภาคคั่นทศนิยม round trip จึงสอดคล้องกับตัวเอง บั๊กโผล่เฉพาะเมื่อผู้เรียกทำตามเอกสารส่ง "0.5" เข้า AddTagAttribute: ฝั่งอ่าน parse ไม่ออกและพ่นออกมาเป็น PDF name /0.5 placeholder ของ PDF/VCR เจอปัญหาสะท้อนกลับด้าน เพราะ library สร้าง GTS_BBox ด้วยจุดแล้วไป validate ด้วย locale ก่อนบันทึก

การเปลี่ยนเฉพาะฝั่งเขียนไปเป็นจุดจะแย่กว่าไม่แก้เลย เพราะค่า SetStructElem* ทุกตัวจะตกมาโดนฝั่งอ่านที่ผูกกับ locale ปฏิเสธแล้วเสื่อมเป็น name ฝั่งเขียนจึงใช้ PLDoubleToStrConst(v, 6) และฝั่งอ่านใช้ PLTryStrToFloatLenient ตัวใหม่ ที่ลองรูปจุดก่อนแล้วค่อยย้อนไปใช้ locale ของระบบ ผู้เรียกบนเครื่อง comma-locale ที่เคยส่ง "1,25" ก็ยังได้เลข 1.25 เหมือนเดิม trade-off นี้ตั้งใจและเขียนไว้ในเอกสาร: บนระบบเยอรมัน "1.500" เคยกลายเป็น name เพราะ StrToFloat ปฏิเสธตัวคั่นพัน ตอนนี้มันอ่านเป็น 1.5 ขณะที่ string literal NAN กับ INF ไม่ถูกรับเป็นตัวเลขอีกต่อไป

SetStructElemBBox ของ PDFlibPas กับพี่น้องส่งต่อตัวเลขเป็น string ผ่าน AddTagAttribute และตัวเขียน /A parse string พวกนั้นกลับ v3.539.32 จึงขยับทั้งสองปลายพร้อมกัน: PLDoubleToStrConst เขียนด้วยจุดและ PLTryStrToFloatLenient อ่านจุดก่อนโดยมี locale เป็น fallback จุลภาค 1,25 จากเครื่อง comma-locale จึงยังอ่านเป็น 1.25
การแก้เฉพาะฝั่งเขียนจะทำให้ attribute ของ structured element ทุกตัวเสื่อมเป็น PDF name ไปหมด round trip จึงต้องขยับทั้งสองปลายพร้อมกัน หรือไม่ขยับเลย
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // ผู้เรียกบนเครื่อง comma-decimal
  Lib := TPDFlib.Create;
  try
    Lib.BeginTag('Figure', 'Sales chart', '');
    Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
    Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5');   // /SpaceAfter 0.5, เดิมเป็น /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // ยังเป็น /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

ที่ที่ NaN กับ infinity ถูกหยุดไว้

AddPageMatrix, ScalePage กับ RedactRegion ตอนนี้ปฏิเสธ argument แบบ NaN กับ infinity ตั้งแต่หัวมุมแล้วคืน 0 เพราะไม่มีเลขใน PDF ใดแทนพวกมันได้ ScalePage เคยปฏิเสธ factor ที่ศูนย์หรือติดลบอยู่แล้ว แต่ NaN ลอดผ่านเทสต์ <= 0 ได้ scale แบบ NaN จึงเดินทางไปถึงตัว formatter ครบ ๆ ใน v3.539.26 formatter ตัวนั้นยังเรียก Round กับ NaN ซึ่งยก EInvalidOp บน Win32 ที่ยูนิต x87 ไม่ mask invalid operation v3.539.31 ทำให้ PLDoubleToStrConst เขียน 0 ให้ NaN เป็นแนวป้องกันด่านสุดท้าย แต่ศูนย์ใน matrix คือ transform แบบ singular เช็คระดับ API จึงยังเป็นการแก้จริง เส้นแบ่งสองจุดถูกคงไว้โดยตั้งใจ string สถานะของ metafile เขียนกับอ่านด้วย locale ภายในกระบวนการเดียวและไม่เคยออกไปข้างนอก จึงถูกปล่อยไว้ตามเดิม และชุดทดสอบที่จัดรูปแบบ 1E-5 ผ่านเส้นทาง page element ต้องอ่าน content ก่อนเลเยอร์ถูกเขียนใหม่ เพราะการพ่น operand ใหม่ที่ความละเอียดของเอกสารทำให้ค่านั้นกลายเป็น 0 อย่างถูกต้องตามกฎ

ถ้าแอปพลิเคชันของคุณส่งไปถึงลูกค้านอกโลกจุดคั่นทศนิยม นิสัยที่ปลอดภัยที่สุดคือแบบที่ชุดทดสอบของ PDFlibPas ใช้อยู่ตอนนี้: รันเส้นทางที่ผลิต PDF สักรอบโดยตั้ง FormatSettings.DecimalSeparator เป็นจุลภาค แล้วกวาดหาจุลภาคกับเลขชี้กำลังใน output บทความเรื่องการรักษาความละเอียดทศนิยมที่ parse มาเล่าอีกครึ่งของเรื่องเดียวกันนี้ ว่าตัวเลขที่อ่านจากไฟล์ที่มีอยู่รักษาข้อความเดิมไว้ตอนบันทึกอย่างไร ดาวน์โหลด API reference ครบถ้วนและ build ทดลองอยู่ที่หน้าผลิตภัณฑ์ PDFlibPas Delphi PDF library