HotPDF Delphi Component ผลิต output PDF ที่เหมือนกันทุกไบต์ข้ามการเซฟหลายรอบเมื่อ property ReproducibleOutput เป็น True: มันตรึง /CreationDate กับ /ModDate ของ Info ไว้ที่วันที่คงที่ แทนที่ document identifier ที่มาจากนาฬิกาจริงด้วย hash ที่ seed ไว้หรือที่ได้จากเนื้อหา ใช้ค่าคงที่แทนทุกไบต์สุ่มที่เส้นทางเข้ารหัส AES จะดึงมา และเรียง dictionary ทุกตัวที่มัน serialize flag นี้มีไว้สำหรับชุด regression และการเทียบชิ้นงานบิลด์ ไม่ใช่สำหรับเอกสาร production และเหตุผลของขอบเขตนั้นคือส่วนที่น่าสนใจ สถานการณ์ที่ขับเคลื่อนฟีเจอร์นี้คือ golden-file test คุณ render ใบแจ้งหนี้ commit ไฟล์ PDF แล้ว assert ว่าบิลด์ของพรุ่งนี้จะผลิตไบต์เดียวกัน มันไม่เคยเป็นอย่างนั้น ไฟล์เปิดได้ปกติในทุก viewer ข้อความเหมือนกัน page tree เหมือนกัน และ diff ก็ยังสว่างขึ้นสี่ห้าจุดอยู่ดี ใครที่เคยลองเอา PDF generator เข้า byte-level regression test เจอกำแพงนี้มาแล้ว และ fix ไม่ใช่การตัด timestamp ออก แต่เป็นการบัญชีให้ครบว่าตัวเขียนไปปรึกษาอะไรนอกเหนือจากตัวเอกสารเองตรงไหนบ้าง
ทำไมการเซฟ PDF เดียวกันสองครั้งถึงได้ไฟล์ต่างกัน
การเซฟเอกสารเดียวกันสองครั้งได้ไฟล์ต่างกันเพราะตัวเขียน PDF รวมถึง HotPDF ปรึกษาแหล่ง entropy สี่แหล่งที่ไม่เกี่ยวกับเนื้อหาหน้าเลย: นาฬิกาจริง, document identifier, ตัวสร้างตัวเลขสุ่มเชิงรหัส และลำดับในหน่วยความจำของ entry ใน dictionary แต่ละแหล่งก็ชอบด้วยเหตุผลของตัวเอง ISO 32000-1 ต้องการให้มันอยู่ตรงนั้น พวกมันแค่ทำให้ไฟล์เป็นฟังก์ชันของว่าเขียนเมื่อไรและที่ไหน แทนที่จะเป็นฟังก์ชันของว่ามันบรรจุอะไร
- นาฬิกา Info dictionary พา
/CreationDateกับ/ModDate(ISO 32000-1 §14.3.3, ตาราง 317) เป็นสตริงD:YYYYMMDDHHmmSSพร้อมส่วนต่อท้ายเขตเวลา (§7.9.4) และ XMP packet พร้อมเวลาเดียวกันเป็นxmp:CreateDateกับxmp:ModifyDateHotPDF ประทับทั้งคู่จากFCreationDateซึ่ง constructor initialize เป็นNowการเซฟสองครั้งจึงต่างกันที่วินาทีที่เขียน - identifier array
/IDใน trailer (ISO 32000-1 §14.4) ถือ identifier แบบถาวรกับ identifier ของการแก้ไข สูตรดีฟอลต์ของ HotPDF hash ชื่อไฟล์พร้อมเวลาปัจจุบันลงถึงมิลลิวินาทีสำหรับสมาชิกตัวแรก และ hash ค่านั้นบวกGetTickCountสำหรับตัวที่สอง identifier สองตัว สองค่าใหม่ทุกรอบ - ไบต์สุ่ม standard security ขึ้นกับ identifier และความสุ่มจริง ๆ สำหรับ AES-256 file encryption key, salt ของ validation และ key และ initialization vector ของ CBC ทุกตัวถูกดึงจากแหล่งสุ่มของระบบ (ISO 32000-2 §7.6.4.4.7 กำหนดให้ salt ต้องสุ่ม) เพราะ
/U,/UE,/Oและ/OEคำนวณจากไบต์เหล่านั้นทั้งหมด เอกสารที่เข้ารหัสจึงเปลี่ยนทั้งฉบับแม้ plaintext จะไม่เปลี่ยน อัลกอริทึมรุ่นเก่าพับสมาชิกตัวแรกของ/IDเข้าไปในคีย์ด้วย (ISO 32000-1 §7.6.3.3, §7.6.3.4) identifier ใหม่ตัวเดียวจึงพอจะเปลี่ยนคีย์ทั้งไฟล์ - ลำดับ dictionary ของ PDF เป็น mapping ที่ไม่มีลำดับ และตัวเขียนที่เดินตาม list ในหน่วยความจำก็ปล่อยคีย์ตามลำดับที่ใส่ code path ใดที่สร้าง resource dictionary ด้วยลำดับต่างออกไป หรือเอกสารที่โหลดมาซึ่งถูก parse จากเลย์เอาต์ต่างออกไป ย่อมผลิตไฟล์ที่ถูกต้องตามสเปกแต่ข้อความต่างกัน
ReproducibleOutput ตรึงอะไรไว้บ้าง
การตั้ง ReproducibleOutput := True ก่อน BeginDoc หรือก่อน SaveLoadedDocument แทนแหล่งทั้งสี่ด้วยค่าคงที่ และมันทำใน code path เดียวกันกับที่จะไปหยิบนาฬิกาหรือตัวสร้างค่าสุ่ม จึงไม่ต้องมีรอบเก็บกวาดแยก สังเกตว่าอะไรหายไปจากรายการข้างบน: เนื้อหา ฟอนต์, page stream, ข้อมูลภาพ และตาราง cross-reference กำหนดได้แน่นอนอยู่แล้วสำหรับ input เดียวกัน เสียงรบกวนอยู่ใน metadata กับชั้นความปลอดภัยทั้งหมด ซึ่งเป็นเหตุผลที่ property ตัวเดียวที่เล็งถูกจุดลบมันได้ property นี้ดีฟอลต์เป็น False และไม่มีอะไรใน library เปิดมันให้คุณ
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // ก่อน BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
ภายใน BeginDoc branch ที่ reproducible assign FCreationDate := EncodeDate(2026, 1, 1) และ seed document identifier ด้วย MD5CalcString('HotPDF-reproducible-seed') แทน digest ของชื่อไฟล์บวกนาฬิกา การ assign ครั้งเดียวนั้นครอบคลุมทั้งวันที่ใน Info และวันที่ใน XMP เพราะทั้งสี่ตัวถูก render จากฟิลด์เดียวกัน เมื่อไฟล์ถูกเขียนจริง BuildDocumentIdentifiers ขอ identifier ของ trailer จาก ComputeCanonicalDocumentIdentifier: มัน export object graph ทั้งก้อนตามลำดับ canonical ตั้งตัวเลขทุกหลักของสตริงวันที่ D: ที่เจอให้เป็นศูนย์เพื่อไม่ให้ timestamp รั่วกลับเข้าไปใน hash แล้วเอา MD5 ของผลลัพธ์ สมาชิกทั้งสองตัวของ /ID ได้ค่านั้น identifier ที่ได้จากเนื้อหาตัวเดียวกันถูกใช้เมื่อเอกสารที่โหลดมาถูกเข้ารหัสโดยไม่ผ่าน BeginDoc เลย ซึ่งเป็นกรณีของ ActivateProtection บนไฟล์ที่คุณเปิดด้วย LoadFromFile
ไบต์สุ่มคือการแทนที่ที่เห็นได้ชัดน้อยที่สุด routine ของคีย์ AES-256 ห่อแหล่งสุ่มของมันใน helper ท้องถิ่นซึ่งภายใต้ flag นี้เรียก FillChar(P^, Count, $5A) สำหรับ file encryption key 32 ไบต์และสำหรับ salt 8 ไบต์ทุกตัว และตัวเข้ารหัสสตริงกับ stream ของ AES-128 และ AES-256 เปลี่ยนจาก AESGenerateRandomIV ไปเป็น AESGenerateStaticIV ซึ่งเติม initialization vector ด้วย 14 * (1 + I) สำหรับช่อง I เมื่อคีย์, salt และ vector ถูกตรึงทั้งหมด /U, /UE, /O, /OE และ stream ที่เข้ารหัสทุกตัวจึงออกมาเหมือนกันในรอบที่สอง สุดท้าย SaveToStream เปิด DeterministicDictionaryOrder ทุกครั้งที่ flag reproducible ถูกตั้ง และ serializer จะ insertion sort dictionary แต่ละตัวตามไบต์ดิบของชื่อคีย์ โดย prefix ที่สั้นกว่ามาก่อน และใช้ index เดิมเป็นตัวตัดสินเมื่อเท่ากัน นั่นคือลำดับเดียวกับที่ตัวเขียน diagnostic ใช้ ซึ่งอธิบายในบทความเรื่องการแก้ PDF ด้วยมือแล้วซ่อมทีหลัง flag reproducible ยืมแค่ลำดับ ไม่ได้ยืมเลย์เอาต์ plain-text ส่วนอื่นของตัวเขียนนั้น
ทำไมวันที่คงที่ยังรั่วนาฬิกาจริงออกมา
fix ใน v2.752.2 มีอยู่เพราะวันที่สร้างแบบคงที่เดิมถูกตัดสินใน constructor และ constructor ไม่มีทางรู้ property ที่ caller ยังไม่ได้ตั้ง ลำดับการเรียกปกติคือ Create, แล้ว ReproducibleOutput := True, แล้ว BeginDoc ตอนสร้าง object FReproducibleOutput ยังเป็น False FCreationDate จึงได้ Now ไปและเก็บไว้ identifier กับไบต์สุ่มถูกตรึงถูกต้อง ไฟล์สองไฟล์จึงตรงกันเกือบทุกที่และต่างกันพอดีที่สตริงวันที่สองตัวกับฟิลด์ XMP สองฟิลด์ การย้ายการ assign เข้าไปใน branch reproducible ของ BeginDoc ข้าง ๆ identifier ที่ seed ไว้ ทำให้การตัดสินใจอยู่ตรงจุดที่ property มีค่าสุดท้ายแล้ว
regression test ที่พลาดเรื่องนี้มีค่ามากกว่า fix เสียอีก การเซฟสองครั้งที่รันภายในวินาทีเดียวกันเขียนสตริง D: เดียวกันโดยบังเอิญ และการเทียบไบต์ก็ผ่านให้กับ bug ที่จะล้มบนเครื่องที่ช้ากว่า test ที่แก้แล้ว sleep 1100 ms ระหว่างการเซฟสองครั้งเพื่อให้ timestamp ของ PDF ข้ามขอบวินาทีแน่ ๆ รันเคสสำหรับ output แบบ plain, AES-128 และ AES-256 ด้วยรหัสผ่านจริงบนสองตัวที่เข้ารหัส และเทียบ buffer สองก้อนด้วย CompareMem รายงาน offset ที่ต่างกันตัวแรกเมื่อล้มเหลว เพื่อให้ diff ชี้ไปที่ object ตัวหนึ่งแทนที่จะชี้ทั้งไฟล์ การเทียบไบต์พิสูจน์ความแน่นอนได้แค่นั้น จึงควรมี assertion แยกที่โหลด output ที่เข้ารหัสกลับด้วยรหัสผ่านผู้ใช้และอ่านจำนวนหน้า การเปลี่ยนแปลงที่ทำให้ไฟล์เสถียรและอ่านไม่ได้ในเวลาเดียวกันต้องไม่รอดผ่านไปได้เพราะ diff เขียว
function SaveOnce(const Target: string): TBytes;
var
Pdf: THotPDF;
Stream: TFileStream;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := aes256;
Pdf.ActivateProtection := True;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, Stream.Size);
if Stream.Size > 0 then
Stream.ReadBuffer(Result[0], Stream.Size);
finally
Stream.Free;
end;
end;
// ใน body ของ test
A := SaveOnce(PathA);
TThread.Sleep(1100); // บังคับให้วินาทีของ timestamp ต่างกัน
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
'two saves under ReproducibleOutput must be byte-identical');
PDF ที่เข้ารหัสแบบ reproducible ยังปลอดภัยอยู่ไหม
ไม่ เอกสารที่เข้ารหัสภายใต้ ReproducibleOutput ไม่ได้รับการป้องกันในความหมายที่มีนัยอะไรเลย และ flag ต้องปิดสำหรับอะไรก็ตามที่ออกจากไดเรกทอรีทดสอบ file encryption key ของ AES-256 คือ $5A สามสิบสองไบต์ salt คือ $5A แปดไบต์ และ initialization vector เดินตามรูปแบบเลขคณิตที่เปิดเผย รหัสผ่านยังกั้น wrapper /UE กับ /OE อยู่ แต่คีย์ที่ถูกห่อเป็นค่าคงที่ ใครที่รู้ค่าคงที่นั้นจึงถอดรหัส content stream ทุกตัวได้โดยไม่ต้องใช้รหัสผ่านเลย การตรึง salt ยังลบความไม่ซ้ำต่อเอกสารที่ ISO 32000-2 §7.6.4.4.7 พึ่งพาเพื่อไม่ให้รหัสผ่านเดียวกันให้สตริง /U เหมือนกันข้ามไฟล์ อ่านบทความเรื่องการตั้งค่า AES-256 เพื่อดูว่า property การเข้ารหัสสัญญาอะไรไว้เมื่อแหล่งสุ่มยังสมบูรณ์ ภายใต้ flag reproducible คำสัญญาเหล่านั้นถูกระงับ
การแลกเปลี่ยนเรื่อง identifier ซับซ้อนกว่า ISO 32000-1 §14.4 ตั้งใจให้สมาชิกตัวที่สองของ /ID เปลี่ยนทุกครั้งที่มีการแก้ไข เพื่อให้เครื่องมือแยกไฟล์ที่อัปเดตจากบรรพบุรุษได้ และการเซฟแบบ reproducible เขียนค่าเดียวกันลงทั้งสองช่อง เพราะค่านั้นเป็น hash ของ object graph แบบ canonical เอกสารสองฉบับที่เนื้อหาต่างกันจึงยังได้ identifier ต่างกัน ซึ่งดีกว่าค่าคงที่ แต่ seed ที่ BeginDoc ใช้สร้างคีย์เป็นสตริงเดียวกันสำหรับทุกเอกสารบนทุกเครื่อง และ reader ที่ใช้ /ID แยกไฟล์ เช่น annotation cache หรือไฟล์ข้างเคียงของข้อมูลฟอร์ม จะรวมไฟล์ reproducible ทุกไฟล์ที่ hash ตรงกันเข้าเป็นไฟล์เดียว
flag นี้ไม่ครอบคลุมอะไร
ReproducibleOutput ลบ entropy ที่ตัวเขียนสร้างเอง แต่ลบ entropy ที่เข้ามาทางสภาพแวดล้อมหรือทาง code path ที่มันไม่ได้ควบคุมไม่ได้ และมีสามข้อที่ชนง่าย
- ส่วนต่อท้ายเขตเวลา
_DateTimeToPdfDateต่อท้ายด้วย offset UTC ท้องถิ่นD:20260101000000+08'00'บน build agent ตัวหนึ่งกับD:20260101000000-05'00'บนอีกตัวจึงเป็นไบต์คนละชุดสำหรับวันที่คงที่เดียวกัน ความสามารถทำซ้ำได้จึงใช้ได้ข้ามการรันบนเครื่องเดียว หรือข้ามเครื่องที่อยู่เขตเวลาเดียวกัน ตรึงเขตของ agent ถ้า golden file ของคุณต้องเดินทาง - incremental update
SaveIncrementalUpdateคำนวณ identifier ของการแก้ไขจาก path เป้าหมาย,GetTickCountและเวลาปัจจุบันโดยไม่มี branch reproducible เพราะส่วน incremental โดยนิยามคือการแก้ไขใหม่ เทียบการเขียนใหม่เต็มรูปแบบ ไม่ใช่ delta ที่ต่อท้าย - ทางลัด passthrough
SaveLoadedDocumentปกติคัดลอกไฟล์ต้นทางที่ไม่ถูกแก้และไม่ถูกเข้ารหัสทีละไบต์แทนที่จะ serialize ใหม่ flag reproducible ปิดทางลัดนั้นและบังคับให้เขียนใหม่ทั้งไฟล์เพื่อให้กฎเรื่องลำดับและ identifier มีผลด้วย การเซฟแบบ reproducible ของไฟล์ที่โหลดมาจึงช้ากว่าดีฟอลต์และไม่เคยเป็นสำเนาของ input จง diff มันกับเซฟ reproducible ครั้งก่อน ไม่ใช่กับต้นฉบับ
อีกบทเรียนหนึ่งจาก release เดียวกัน ว่าการตรวจที่ผ่านพิสูจน์อะไรและไม่พิสูจน์อะไร test fixture PDF/X-6 ตัวหนึ่งเรียก CharProcs.DeleteValue('A') ซึ่ง free glyph stream ที่ถือไว้ตรง ๆ แล้วใส่ pointer เดิมกลับเข้าไป และแยกกันนั้นส่ง object ExtGState แบบ direct ตัวหนึ่งให้ทั้ง resource dictionary และ pattern ตัวตรวจ conformance ผ่านบ้างไม่ผ่านบ้างบน use-after-free และ double ownership นั้น เพราะมันอ่านสิ่งที่หน่วยความจำที่ถูก free บังเอิญถืออยู่ เมื่อการตรวจเชิงโครงสร้างกะพริบ ให้ดูความเป็นเจ้าของของ input ที่ทดสอบก่อนจะไปดู validator output ที่ทำซ้ำได้ทำให้วินัยนี้ถูกลง: เมื่อการเซฟสองครั้งเหมือนกันทุกไบต์ แหล่งที่เหลืออยู่ของการกะพริบคือ object graph เอง และการ diff เชิงโครงสร้างจาก catalog ลงไปจะหามันเจอ
property ReproducibleOutput, DeterministicDictionaryOrder และการเข้ารหัสที่อธิบายตรงนี้ ship อยู่ใน HotPDF Delphi Component รุ่นมาตรฐานสำหรับ Delphi และ C++Builder และ flag ตัวเดียวกันก็ขับชุด regression ของ library เองด้วย พฤติกรรมที่คุณได้ในชุดทดสอบจึงเป็นพฤติกรรมที่ component ถูกทดสอบมาด้วย