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

เก็บความละเอียดทศนิยมของ PDF ไว้ตอนเซฟบน Delphi

PDFlibPas ซึ่งเป็น losLab PDF Developer Library เก็บข้อความทศนิยมที่มัน parse มาไว้ตามเดิมสำหรับจำนวนจริงทุกตัวในเอกสาร และเขียนข้อความนั้นกลับไปคำต่อคำทุกครั้งที่ค่าไม่เคยถูกแก้ ตั้งแต่ v3.539.19 การตั้งค่า SetPrecision คุมเฉพาะตัวเลขที่ library สร้างหรือแก้เอง การโหลดแล้วเซฟธรรมดาจึงไม่ปัด /Gamma ของ CalRGB จาก 2.22221 ลงไปเป็น 2.2222 และไม่เลื่อนสีของหน้าที่ไม่มีใครแตะอีกต่อไป การเปลี่ยนนี้เล็กในแง่โค้ดและใหญ่ในแง่สิ่งที่มันบอกเกี่ยวกับ parser: ค่าที่คุณ decode กับ literal ที่คุณเขียนออกมาเป็นสองสิ่งต่างกัน และการไปกลับผ่าน Double ไม่ใช่ identity transform

ทำไมการเซฟที่ไม่เปลี่ยนอะไรถึงเลื่อนสีของหน้า

เพราะ parameter ของ color space ถูกจัดรูปแบบใหม่ ไม่ใช่ตัวภาพ ไฟล์ที่เปิดโปงเรื่องนี้เป็นเอกสารออฟฟิศ 35 หน้าในคลัง regression ท้องถิ่น มีภาพหัวกระดาษที่ใช้ซ้ำทุกหน้า โหลดแล้วเซฟกลับทันทีได้ stream ของภาพที่เหมือน input ทุกไบต์ และการเทียบ hash ของ stream รายงานว่าเอกสารไม่เปลี่ยน แต่การเทียบภาพที่ render ไม่เห็นด้วย: ทั้ง 35 หน้าแสดงความต่างของพิกเซลที่หัวกระดาษ และไม่มีที่อื่นเลย

ภาพหัวกระดาษวาดผ่าน color space แบบ CalRGB ซึ่ง ISO 32000-1 §8.6.5.3 นิยามด้วย /WhitePoint, array /Gamma สามสมาชิกซึ่งมีหรือไม่ก็ได้ และ /Matrix เก้าสมาชิกซึ่งมีหรือไม่ก็ได้ array เหล่านั้นเป็น numeric object ธรรมดาใน dictionary ของ color space TPDFNumeric เก็บแต่ละตัวเป็น Double และเท่านั้น และ TPDFNumeric.Output จัดรูปแบบ Double นั้นผ่าน PDFPrecNum ซึ่งดีฟอลต์ที่ทศนิยมสี่ตำแหน่ง /Gamma จึงไปจาก 2.22221 เป็น 2.2222 สมาชิกในเมทริกซ์ไปจาก 0.71519 เป็น 0.7152 และ renderer ก็ผลิตสีที่ต่างออกไปเล็กน้อยจาก calibration ที่ต่างออกไปเล็กน้อยอย่างซื่อสัตย์ ไบต์ของภาพบริสุทธิ์ แต่ตัวเลขรอบ ๆ มันไม่ ส่วนที่ไม่สบายใจคือเรื่องนี้มองไม่เห็นแค่ไหน การเทียบไบต์ของ stream ที่ decode แล้วมองไม่เห็น เพราะตัวเลขอยู่ใน dictionary ไม่ได้อยู่ใน stream การเทียบ payload ของไฟล์แนบก็มองไม่เห็น แม้แต่ revision diff ที่อธิบายในบทความเรื่อง modification level ก็ทำ fingerprint จาก object body ที่ normalize แล้ว ทั้งสอง revision จึง hash ได้ค่าเดียวกันและ diff รายงานว่าเหมือนกัน มีแค่การ render ที่จับได้ ซึ่งเป็นเหตุผลที่ baseline ของคลัง render ทุกหน้าแทนที่จะเชื่อการตรวจเชิงโครงสร้างอย่างเดียว

จุดที่ PDFlibPas สูญเสียความละเอียดของ CalRGB ในการเซฟที่ไม่แก้อะไรบน Delphi: ค่า /Gamma 2.22221 ที่ parse มาและสมาชิกเมทริกซ์ 0.71519 อยู่ใน TPDFNumeric เป็น Double, Output จัดรูปแบบผ่าน PLDoubleToStr ด้วย PDFPrecNum ที่ทศนิยมสี่ตำแหน่ง ทุกการตรวจเชิงโครงสร้างรายงานว่าเอกสารไม่เปลี่ยน และมีแค่การเทียบภาพที่ render ที่แสดงว่าภาพหัวกระดาษทั้ง 35 หน้าเลื่อนไป
ไบต์ของภาพบริสุทธิ์: TPDFNumeric จัดรูปแบบตัวเลข calibration รอบ ๆ มันใหม่ผ่าน PDFPrecNum ทั้ง hash ของ stream และ fingerprint diff จึงรายงานว่า revision เหมือนกัน ขณะที่ renderer ผลิตสีต่างออกไปเล็กน้อยทุกหน้า

ค่าที่คุณ parse ไม่ใช่ literal ที่คุณควรเขียน

จำนวนจริงใน PDF เป็นสตริงทศนิยม และ ISO 32000-1 §7.3.3 ระบุชัดว่ามันเป็นเพียงสตริงทศนิยม: ไม่มี radix notation ไม่มีรูปแบบยกกำลัง Annex C จึงไล่ความละเอียดที่ implementation ควรเคารพ คือประมาณห้าหลักทศนิยมที่มีนัยสำคัญในส่วนทศนิยม ความละเอียด output ดีฟอลต์ที่สี่ตำแหน่งนั้นต่ำกว่านั้นอยู่แล้ว และยิ่งแย่ลงใกล้ศูนย์: PLDoubleToStr ปรับสเกลค่า ปัดเป็นจำนวนเต็ม และปล่อย 0 เมื่อผลลัพธ์เป็นศูนย์ สมาชิกเมทริกซ์ที่เป็น -0.000012345 จึงไม่ได้แค่เสียหลักไปหนึ่งหลัก แต่มันหายไปทั้งตัว

การเพิ่มค่าดีฟอลต์ก็แค่เลื่อนหน้าผาไปที่อื่น fix คือเลิกทำเป็นว่า Double คือตัวเลขนั้น เมื่อ tokenizer ใน TPDFStructure.Decode รู้จักจำนวนจริงมาตรฐาน คือ token ที่มีจุดทศนิยมและไม่มีเครื่องหมายยกกำลัง มันเก็บข้อความต้นทางไว้ในฟิลด์ใหม่ FOriginalText คู่กับค่าที่แปลงแล้ว Output จึงเลือกข้อความนั้นก่อน และถอยไปใช้การจัดรูปแบบเฉพาะเมื่อไม่มีอะไรให้เลือก

วิธีที่ PDFlibPas เก็บข้อความทศนิยมที่ parse ไว้บน Delphi: tokenizer ใน TPDFStructure.Decode เก็บ literal ต้นทางไว้ใน FOriginalText สำหรับ token ที่มีจุดทศนิยมและไม่มีเลขยกกำลัง Output เขียนข้อความนั้นคำต่อคำแทนการเรียก PLDoubleToStr และ SetTo ล้างมันเพราะตัวเลขที่ถูกแก้คือตัวเลขตัวใหม่
ค่าที่คุณ decode กับ literal ที่คุณเขียนออกมาเป็นสองสิ่งต่างกัน: การเลือกใช้ข้อความที่ parse มาเก็บ 2.22221 ไว้เป๊ะ ส่วนตัวเลขที่ library สร้างและตัวเลขที่ถูกแก้ยังทำตาม PDFPrecNum และค่าตั้งนี้ไม่เคยไปถึง input ที่ไม่ถูกแตะ
// Lib/PDFlibStruct.pas — fix ทั้งหมดในฝั่ง output
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // ตัวเลขที่ถูกแก้คือตัวเลขตัวใหม่
  FValue:= Value;
  FChanged:= True;
End;

ขอบเขตสองข้อตั้งใจไว้ จำนวนเต็มไม่ถูกเก็บไว้ เพราะการจัดรูปแบบจำนวนเต็มไม่มีอะไรเสียหายอยู่แล้ว รูปแบบยกกำลังอย่าง 6.02E23 ถูกยอมรับตอนอ่านเพื่อเห็นแก่ producer ที่ผิดรูป แต่ไม่ถูกเก็บไว้ตอนเขียน เพราะการเขียนกลับไปจะสืบทอดไวยากรณ์ที่ §7.3.3 ห้ามไว้ พวกมันจึงผ่านตัวจัดรูปแบบเหมือนตัวเลขที่ library สร้างทุกตัว tokenizer ยังใช้การซ่อมเล็กน้อยตามปกติก่อนเก็บข้อความ literal ที่เริ่มด้วยจุดอย่าง .5 จึงถูกเก็บเป็น 0.5 และ literal ที่ลงท้ายด้วยจุดอย่าง 5. เป็น 5.0 ทั้งคู่เป็นจำนวนเดียวกันสำหรับทุก reader และได้รับการยอมรับกว้างกว่ามาก

SetPrecision รับประกันอะไรหลัง v3.539.19

TPDFlib.SetPrecision ตอนนี้คุมจำนวนตำแหน่งทศนิยมของตัวเลขที่ library ผลิตเอง: ค่าที่วาดผ่าน painter, ตัวเลขที่สร้างจาก Double เช่นผ่าน NewNumeric และค่าที่ parse มาแล้วถูกแก้ด้วย SetTo ในภายหลัง สังเกตว่าข้อความที่ decode ผ่าน object API เช่น literal ที่ส่งให้ SetObjectFromString ก็ผ่าน tokenizer เดียวกันและถูกเก็บไว้วิธีเดียวกัน ทศนิยมที่ parse มาและไม่เคยถูกแก้จะคงความละเอียดของ input ไว้ไม่ว่าค่าตั้งนั้นเป็นเท่าไร และการเปลี่ยนค่าตั้งหลังการโหลดก็ไม่ย้อนไปแตะมัน เอกสารอ้างอิงของ SetPrecision ถูกอัปเดตใน release เดียวกันให้ระบุแบบนี้เป๊ะ ๆ เพราะถ้อยคำเดิมทำให้เข้าใจได้ว่าค่าตั้งนี้ใช้กับทุกตัวเลขในไฟล์

การล้างข้อความเกิดขึ้นใน SetTo ไม่ได้อนุมานจากธง Changed และความต่างนั้นสำคัญ pipeline การเซฟรีเซ็ต Changed บน object หลังจากเขียนมันแล้ว การตรวจแบบ ถ้าไม่ถูกแก้ก็เขียนข้อความเดิม จึงจะเริ่มเขียนข้อความค้างเก่าให้ค่าที่ถูกแก้ เซฟ แล้วแก้ซ้ำในเซสชันเดียวกัน การผูกข้อความเดิมไว้กับการ assign เองทำให้สองอย่างขัดกันไม่ได้ test regression ล็อกพฤติกรรมเหล่านี้ด้วยค่าจากไฟล์ต้นฉบับ

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // input ที่ไม่ถูกแก้อยู่รอดคำต่อคำ รวมทั้งค่าที่
    // การจัดรูปแบบสี่ตำแหน่งจะยุบให้เหลือ 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // การแก้ทิ้งข้อความเดิมและทำตาม PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // การลดความละเอียดทีหลังไม่ไปถึง input ที่ไม่ถูกแก้
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

ทำไม content model ยัง normalize ตัวเลข

เพราะ TPDFContentProgram สัญญาว่าจะให้ numeric operand แบบ canonical และคำสัญญานั้นมีค่ามากกว่าข้อความคำต่อคำใน content stream content model ที่แก้ได้ ซึ่งเป็นตัวเดียวกับที่ตัวติดตาม graphics state สร้างขึ้นมานั้น มีไว้เพื่อให้ NormalizeContentStreams, ตัว optimizer และ Emit ผลิต output ที่เสถียรและเทียบกันได้จาก input อะไรก็ตาม ถ้า operand ที่ parse มาแบกข้อความเดิมของมันเข้าไปใน model ลำดับ operator อย่าง 0.50000 0 0 RG จะเขียนออกมาต่างจาก 0.5 0 0 RG และการเทียบปลายน้ำทุกอย่างจะเลื่อนไปตามนิสัยการจัดรูปแบบของ producer

model จึงถอดข้อความเดิมออกที่จุดเข้าสองจุดของมัน NormalizeContentNumbers รันบน operand ทุกตัวตอนที่ parser push มันเข้าไป และอีกรอบใน SetOperand เมื่อ source ที่ caller ส่งมาถูก decode และมันไล่ลงไปใน array กับ dictionary ด้วย dash pattern, array TJ และ property dictionary ของ marked content จึงถูกครอบคลุมทั้งหมด การเรียก SetTo(AsDouble) บน numeric แต่ละตัวก็เพียงพอ เพราะนั่นคือ operation ที่ล้างข้อความพอดี ข้อมูล inline image ดิบถูกปล่อยไว้เหมือนเดิมเช่นเคย

ทำไม content model ของ PDFlibPas ยัง normalize ตัวเลข: NormalizeContentNumbers รันตรงจุดที่ parser push operand แต่ละตัวและอีกรอบใน SetOperand ไล่ลงไปใน array กับ dictionary เพื่อครอบคลุม dash pattern, array TJ และ property dictionary ของ marked content และ SetTo AsDouble ล้างข้อความเดิมเพื่อให้ 0.50000 กับ 0.5 เขียนออกมาเหมือนกัน
numeric operand แบบ canonical คือคำสัญญาของ content model: ข้อมูล inline image ดิบถูกปล่อยไว้ และตัวเลขใน dictionary นอก content stream ที่ไม่ถูกแตะยังได้หลักประกันคำต่อคำ คู่ LoadFromFile กับ SaveToFile ธรรมดาจึงยังรักษามันไว้
// Lib/PDFlibContentModel.pas — content model รักษาสัญญาของมันไว้
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

กฎในทางปฏิบัติสำหรับ caller จึงง่าย LoadFromFile ธรรมดาตามด้วย SaveToFile ทิ้ง content stream และตัวเลขใน dictionary ที่ไม่ถูกแตะไว้อย่างเดิม หน้าที่ผ่าน NormalizeContentStreams หรือผ่านการแก้ใด ๆ ด้วย content model จะออกมาแบบ canonical ตามการออกแบบ ส่วนที่เหลือของเอกสารถูกเก็บไว้อยู่ดี สองอย่างนี้เป็นคำขอคนละแบบ และตอนนี้มันทำคนละอย่างจริง ๆ

มันมีต้นทุนอะไร และหลักประกันหยุดตรงไหน

TPDFNumeric ทุกตัวตอนนี้แบก reference ของ AnsiString เพิ่มหนึ่งตัว และทศนิยมที่ parse มาทุกตัวเก็บข้อความต้นทางของมันไว้ตลอดอายุของ object บนเอกสารที่มีจำนวนจริงเป็นล้านตัวนั่นคือหน่วยความจำจริง และควรอยู่ในผลวัดใด ๆ ของเอกสารใหญ่ ไม่ใช่ถูกปัดทิ้ง หลักประกันยังจำกัดอยู่ที่เอกสารของตัวเลขเองด้วย: การคัดลอก object ข้ามเอกสารหรือการสร้างค่าใหม่ผ่าน object API ให้ตัวเลขใหม่ ซึ่งทำตามความละเอียด output เหมือนตัวเลขใหม่ตัวอื่น ควรพูดให้ชัดว่า release นี้ทำอะไรและไม่อ้างอะไร การโหลดแล้วเซฟเอกสารที่ไม่ถูกแตะตอนนี้เก็บตัวเลข calibration ที่ renderer กินจริงไว้ได้ ซึ่งเป็นคุณสมบัติที่ baseline ของคลังตรวจ มันไม่ได้อ้างว่า output เหมือนกันทุกไบต์ ซึ่งยังขึ้นกับหมายเลข object การบีบอัด stream และ trailer identifier ที่พูดถึงในบทความเรื่อง PDF ID แบบ deterministic และมันไม่ได้ทำให้ fingerprint diff มองเห็นความต่างจากการปัดเศษในไฟล์ที่ซอฟต์แวร์อื่นผลิต เพราะตัวนั้นยัง hash body ที่ normalize แล้ว บทเรียนนี้ใช้ได้กว้างกว่า CalRGB มาก: เมื่อ parser เก็บแค่ค่าที่แปลงแล้ว ทุกการเซฟคือการแก้ และวิธีเดียวที่จะรู้ตัวคือดูผลลัพธ์ที่ render การจัดการตัวเลขและความหมายของ SetPrecision มีเอกสารอยู่ที่หน้าผลิตภัณฑ์ losLab PDF Developer Library