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

Deterministic PDF ID ใน Delphi สำหรับ Reproducible Builds

losLab PDF Library สามารถสร้างผลลัพธ์ PDF ที่เหมือนกันทุกไบต์สำหรับข้อมูลนำเข้าเดียวกันได้ เมื่อเรียก SetDeterministicDocumentID(1) โดยค่าเริ่มต้น array /ID ใน trailer คือ MD5 digest ของเวลาตามนาฬิกา ดังนั้นการรันตัวสร้างเดียวกันสองครั้งจะได้ผลต่างกันอย่างน้อยในไบต์เหล่านั้น โหมด deterministic จะคำนวณ /ID จาก seed ที่คงที่แทน ซึ่งคืนความสามารถ reproducible builds กลับมา

อาการนี้มักปรากฏใน CI ก่อนที่ใครจะไปตามหามันด้วยซ้ำ template ไม่ได้เปลี่ยน ข้อมูล record นำเข้าไม่ได้เปลี่ยน ฟอนต์ไม่ได้เปลี่ยน แต่ PDF ที่สร้างขึ้นยังคง hash ต่างกันทุกครั้งที่รัน pipeline build cache ไม่เคย hit เลย content addressable storage สะสม blob ใหม่ทุกคืนที่ build byte level regression diff ก็ขึ้นเตือนในไฟล์ที่ไม่มีใครแตะต้อง ไล่ตาม diff ลงไปถึงไบต์จริงก็มักพบเลขฐานสิบหกไม่กี่ตัวที่ซ้ำ ๆ กันอยู่ใน trailer ของไฟล์เสมอ

trailer ID array มีไว้ทำอะไร

trailer /ID เป็นตัวระบุความเป็นตัวตนของไฟล์ ไม่ใช่ checksum ของเนื้อหา ISO 32000-1 §14.4 กำหนดให้เป็น array ของ byte string สองตัว ตัวแรกคือตัวระบุถาวรที่กำหนดตอนสร้างเอกสารและควรคงอยู่ตลอดการแก้ไขครั้งต่อ ๆ ไป ส่วนตัวที่สองคือตัวระบุที่เปลี่ยนแปลง ซึ่งตัวเขียนจะปรับใหม่ทุกครั้งที่ไฟล์ถูกแก้ไข ทั้งสองตัวรวมกันช่วยให้ระบบตัดสินได้ว่าไฟล์สองไฟล์เป็นฉบับแก้ไขของเอกสารเดียวกัน หรือเป็นเอกสารคนละฉบับที่ไม่เกี่ยวข้องกัน §7.5.5 ทำให้ entry นี้เป็นสิ่งที่แทบจะบังคับในทางปฏิบัติ เพราะ trailer ต้องมี /ID เมื่อมี /Encrypt ด้วย

ในสเปกไม่มีข้อไหนบอกวิธีคำนวณค่านี้ คำแนะนำคือ digest ของสิ่งต่าง ๆ เช่น เวลาปัจจุบัน path ของไฟล์ ขนาดไฟล์ และ document information dictionary และนาฬิกาคือส่วนผสมที่ทำให้ผลลัพธ์ไม่ซ้ำใคร ซึ่งเป็นคุณสมบัติที่พอดีสำหรับความเป็นตัวตน แต่ก็เป็นคุณสมบัติที่ทำลาย reproducibility พอดีเช่นกัน จึงเป็นเหตุผลว่าทำไมสิ่งนี้ต้องเป็นสวิตช์ที่เปิดใช้อย่างชัดเจน มากกว่าเปลี่ยนพฤติกรรมแบบเงียบ ๆ

ทำไม build เดียวกันถึงสร้าง PDF ต่างกันทุกครั้ง

เพราะตัวระบุเริ่มต้นถูกคำนวณจากเวลาที่สร้างเอกสาร แต่เดิม losLab PDF Library สร้างสาย /ID จาก MD5 ของ timestamp ปัจจุบัน ดังนั้นเอกสารที่ถูกสร้างสองครั้งห่างกันวินาทีเดียวจะมีตัวระบุถาวรต่างกันสองแบบ แม้ว่าไบต์อื่นทุกไบต์ในไฟล์จะเหมือนกันทุกประการ ต้นทุนปลายทางนั้นเป็นเรื่องจริง ระบบ build ที่ key artifact ด้วย hash ไม่สามารถนำขั้นตอน PDF กลับมาใช้ซ้ำได้เลย object store แบบ deduplicate จะเก็บสำเนาละหนึ่งชุดต่อ build แทนที่จะเป็นหนึ่งชุดต่อเอกสาร และผู้ตรวจสอบที่ดู binary diff ต้องพิสูจน์ว่าความเปลี่ยนแปลงเดียวคือ noise ก่อนจะเชื่อ diff ที่เหลือ การสร้าง /ID แบบ deterministic มีไว้เพื่อกำจัด noise นั้น ในจิตวิญญาณเดียวกับงานเรื่องความเสถียรของ layout ที่กล่าวถึงในบทความ object stream และ cross reference stream

การสลับไปใช้ตัวระบุแบบ reproducible

โหมด deterministic เป็นแบบ opt in ต่อเอกสาร และปิดอยู่โดยค่าเริ่มต้น ดังนั้นผลลัพธ์เดิมที่มีอยู่จะไม่เปลี่ยนแปลงจนกว่าคุณจะเรียกใช้ SetDeterministicDocumentID รับค่า 0 หรือ 1 และคืน 1 เมื่อค่าถูกยอมรับ คืน 0 เมื่อค่านอกช่วงที่กำหนด GetDeterministicDocumentID รายงานสถานะปัจจุบัน SetDocumentIDSeed รับสาย seed ที่กำหนดเองซึ่งมีความสำคัญเหนือกว่าทุกอย่าง และการส่ง seed ว่างเปล่าจะกลับไปใช้ seed แบบคำนวณอัตโนมัติ GetDocumentFileID อ่านค่า /ID[0] กลับมาหลังบันทึกไฟล์ เพื่อให้คุณบันทึกลง log หรือ assert ได้

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

การรีเฟรชเกิดขึ้นตอนบันทึกไฟล์ ไม่ใช่ตอนที่คุณตั้งค่า flag ดังนั้นการเปิดโหมด deterministic ช้าในระหว่างการสร้างเอกสารก็ยังมีผล นั่นหมายความว่า seed ที่เปลี่ยนไปจะสะท้อนลงไฟล์ในการบันทึกแบบเต็มครั้งถัดไป ตั้ง seed A บันทึก ตั้ง seed B บันทึก ไฟล์ทั้งสองจะมีตัวระบุต่างกัน ในขณะที่การคืนค่ากลับไปเป็น seed A ก็คืนค่าตัวระบุเดิม การกำหนด seed เองเป็นทางเลือกที่เหมาะสมเมื่อเอกสารของคุณมี key ที่เสถียรตามธรรมชาติ เช่น หมายเลขใบแจ้งหนี้ revision ของ record หรือ commit identifier ของ git เพราะมันแยกตัวระบุออกจาก metadata ที่ไม่ตั้งใจ

seed มาจากไหนเมื่อคุณไม่ได้กำหนดเอง

เมื่อไม่มี seed ที่กำหนดเอง losLab PDF Library จะคำนวณ seed จากสถานะเอกสารที่ควรคงที่ในการสร้างซ้ำที่เหมือนกัน ได้แก่ PDF version header จำนวนหน้า และทุก entry ใน document information dictionary ค่าประเภท string และ name ถูกนำมาใช้ตามที่ปรากฏ object ประเภทอื่นมีส่วนร่วมด้วยรูปแบบ serialized ของมัน และทั้งหมดจะถูก hash เข้าไปในสาย /ID ผลที่สำคัญคือ CreationDate และ ModDate เป็นส่วนหนึ่งของ information dictionary จึงเป็นส่วนหนึ่งของ seed โดยการออกแบบ การรันสองครั้งจะได้ตัวระบุเดียวกันก็ต่อเมื่อทั้งสองครั้งสร้าง metadata ของเอกสารเหมือนกันจริง ๆ เท่านั้น

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

การ pin ModDate ด้วย key 8 ทำหน้าที่สองอย่างพร้อมกัน และนี่คือจุดที่มักทำให้คนพลาด /ID แบบ deterministic เพียงอย่างเดียวไม่ได้ทำให้ไฟล์เหมือนกันทุกไบต์ เพราะขั้นตอนบันทึกจะประทับ ModDate ด้วยเวลาปัจจุบัน เว้นแต่ผู้เรียกจะตั้งค่ามันเองอย่างชัดเจน การตั้งค่า key 8 จะทำเครื่องหมายว่าค่านั้นผู้เรียกกำหนดเองและระงับการประทับเวลานั้น ถ้าต้องการไฟล์ที่ reproducible จริง ไม่ใช่แค่ตัวระบุที่ reproducible ให้ปฏิบัติต่อ timestamp ใน metadata เหมือน build input มาจาก source record หรือจาก epoch คงที่ ไม่ใช่จาก Now

ทำไมการเขียน ID ใหม่ถึงทำให้ PDF ที่เข้ารหัสเสียหาย

เพราะ /ID[0] ในเอกสารที่เข้ารหัสไม่ใช่แค่ metadata แต่เป็นวัสดุกุญแจ (key material) ISO 32000-1 §7.6.3.3 Algorithm 2 นำองค์ประกอบแรกของตัวระบุไฟล์ป้อนเข้าสู่การคำนวณ encryption key สำหรับ standard security handler ที่ revision 2 ถึง 4 ควบคู่ไปกับรหัสผ่านที่เติมช่องว่าง ค่า /O และ permission bit กุญแจที่ได้จะสร้างสาย validation /U ที่ตัวอ่านตรวจสอบตอนเปิดไฟล์ และกุญแจไฟล์จะถูกคำนวณและ cache ไว้เมื่อคุณเรียก Encrypt หรือเมื่อเอกสารที่เข้ารหัสถูกโหลด ซึ่งทั้งสองเกิดขึ้นก่อนการบันทึก การเขียนตัวระบุใหม่ระหว่างการบันทึกจึงจะสร้างไฟล์ที่มีโครงสร้างถูกต้องแต่การตรวจสอบ /U ล้มเหลวเมื่อเปิดใหม่ ไม่ใช่ความเสียหายเล็กน้อย แต่เป็นเอกสารที่ไม่มีใครเปิดได้เลย รวมถึงตัวคุณเองด้วย นี่คือเหตุผลว่าทำไมการรีเฟรชแบบ deterministic ถูกจำกัดเฉพาะเอกสารที่ไม่มีสถานะการเข้ารหัสอยู่ และทำไมเอกสารที่เข้ารหัสจึงคง /ID เดิมที่มันมีอยู่แล้วไว้ ไม่ว่าจะเปิดโหมด deterministic หรือไม่ก็ตาม และการตั้งค่านี้ไม่มีผลใด ๆ ต่อเส้นทางนั้นเลย การจัดการ revision และความหมายของสิทธิ์ที่เกี่ยวข้องอยู่ใน การตรวจสอบการเข้ารหัสและสิทธิ์ PDF โปรดสังเกตด้วยว่า เส้นทางการกู้คืนการเข้ารหัสจะรีเฟรชเฉพาะ /ID[1] ซึ่งเป็นตัวระบุการเปลี่ยนแปลง ตรงตามเจตนารมณ์ของ §14.4 พอดี

ทำไมการบันทึกแบบ incremental จึงคงตัวระบุเดิมไว้

ขอบเขตที่สองคือโหมด append การอัปเดตแบบ incremental จะไม่แตะไบต์ก่อนหน้าของไฟล์เลยแม้แต่ตัวเดียว และเขียน revision ใหม่ต่อท้ายเข้าไป ความคงอยู่ถาวรของ /ID[0] ตาม §14.4 คือสิ่งที่บอกผู้ใช้ว่า revision ใหม่นี้เป็นของเอกสารเดียวกับ revision เก่า การเขียนมันใหม่จะตัดความเชื่อมโยงนั้น ขัดแย้งกับ revision ที่มีอยู่แล้วในไฟล์ และรบกวนความหมายของลายเซ็น เพราะลายเซ็นครอบคลุมช่วงไบต์ของ revision เฉพาะของเอกสารเฉพาะ ดังนั้น losLab PDF Library จึงรีเฟรชตัวระบุแบบ deterministic เฉพาะตอนบันทึกแบบเต็มเท่านั้น และไม่เคยทำระหว่างโหมด append ซึ่งรักษาการรับประกันที่กล่าวไว้ในบทความ การอัปเดตแบบ incremental และการ append เข้า stream ของ PDF ให้คงอยู่

จุดรวมศูนย์เดียวสำหรับการสร้างตัวระบุ

การสร้าง /ID ทั้งหมดใน losLab PDF Library ตอนนี้ไหลผ่านฟังก์ชันภายในตัวเดียว คือ NewFileIDString ซึ่งเป็นสิ่งที่ทำให้สวิตช์ deterministic น่าเชื่อถือ แทนที่จะเป็นแค่แพตช์บน code path เดียว การสร้างเอกสารเปล่า การสร้าง /ID array แบบ lazy เมื่อไม่มีอยู่ และเส้นทางการกู้คืน fingerprint การเข้ารหัส ล้วนเรียกฟังก์ชันนี้ ดังนั้นจึงมีจุดเดียวเท่านั้นที่นาฬิกาอาจรั่วไหลกลับเข้ามาได้ มันยังหมายความว่าตัวแปรในอนาคต เช่น ตัวระบุที่คำนวณจากเนื้อหา จะเป็นการเปลี่ยนแค่ฟังก์ชันเดียว ไม่ใช่การตรวจสอบ serializer ทั้งหมด

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

ควรต่อการเปรียบเทียบนี้เข้ากับชุดทดสอบของคุณก่อนที่จะพึ่งพาผลลัพธ์ reproducible ที่อื่นใด เพราะมันจะล้มเหลวอย่างชัดเจนทันทีที่ฟีเจอร์ใหม่แอบเอา timestamp กลับเข้ามา Reproducibility เป็นคุณสมบัติที่เสื่อมสลายไปอย่างเงียบ ๆ ถ้าไม่ทำแบบนี้ และ assertion เดียวเทียบผลบันทึกในหน่วยความจำสองครั้งก็แทบไม่มีต้นทุนอะไรเลยหากรันทุก build

API ตัวระบุแบบ deterministic ที่แสดงในบทความนี้ มาพร้อมกับ losLab PDF Library สำหรับ Delphi และ C++Builder พร้อมเอกสารอ้างอิงฉบับเต็มเรื่อง document information, การเข้ารหัส และการบันทึกแบบ incremental