HotPDF Delphi Component tạo ra đầu ra PDF giống hệt từng byte qua các lần lưu khi thuộc tính ReproducibleOutput là True: nó ghim /CreationDate và /ModDate trong Info về một ngày cố định, thay định danh tài liệu vốn lấy theo đồng hồ thực bằng một hash có gieo hạt hoặc suy ra từ nội dung, thay bằng hằng số mọi byte ngẫu nhiên mà các đường mã hóa AES lẽ ra sẽ rút ra, và sắp xếp mọi dictionary nó tuần tự hóa. Cờ này tồn tại cho các bộ kiểm thử hồi quy và cho việc so sánh artifact build, chứ không dành cho tài liệu sản xuất, và lý do cho ranh giới đó mới là phần thú vị. Tình huống thúc đẩy tính năng này là một bài kiểm thử golden file. Bạn render một hóa đơn, commit tệp PDF, và khẳng định rằng bản build ngày mai sẽ tạo ra đúng những byte đó. Nó không bao giờ như vậy. Tệp mở lên ngon lành trong mọi trình xem, văn bản giống hệt, cây trang giống hệt, và phép diff vẫn sáng lên ở bốn năm chỗ. Ai từng thử đặt một bộ sinh PDF dưới một bài kiểm thử hồi quy ở mức byte đều đã đụng bức tường này, và cách sửa không phải là “cắt bỏ các dấu thời gian” mà là kiểm kê chính xác mọi chỗ mà bộ ghi tra cứu thứ gì đó không phải chính tài liệu
Vì sao hai lần lưu cùng một PDF lại khác nhau?
Hai lần lưu cùng một tài liệu khác nhau vì một bộ ghi PDF, kể cả HotPDF, tra cứu bốn nguồn nhiễu loạn không liên quan gì tới nội dung trang: đồng hồ thực, định danh tài liệu, bộ sinh số ngẫu nhiên mật mã, và thứ tự trong bộ nhớ của các entry dictionary. Mỗi nguồn tự nó là hợp lý. ISO 32000-1 muốn chúng có mặt. Chúng chỉ đơn giản biến tệp thành một hàm của thời điểm và nơi nó được ghi ra thay vì của những gì nó chứa
- Đồng hồ. Dictionary Info mang
/CreationDatevà/ModDate(ISO 32000-1 §14.3.3, Bảng 317) dưới dạng chuỗiD:YYYYMMDDHHmmSSkèm hậu tố múi giờ (§7.9.4), và packet XMP lặp lại đúng thời điểm đó dướixmp:CreateDatecùngxmp:ModifyDate. HotPDF đóng dấu cả hai từFCreationDate, trường mà constructor khởi tạo bằngNow, nên hai lần lưu khác nhau ở đúng giây chúng được ghi - Định danh. Mảng
/IDtrong trailer (ISO 32000-1 §14.4) giữ một định danh vĩnh viễn và một định danh sửa đổi. Công thức mặc định của HotPDF băm tên tệp cùng thời gian hiện tại chính xác tới mili giây cho phần tử thứ nhất, rồi băm giá trị đó cộngGetTickCountcho phần tử thứ hai. Hai định danh, hai giá trị mới toanh mỗi lượt chạy - Các byte ngẫu nhiên. Bảo mật chuẩn phụ thuộc vào định danh và vào độ ngẫu nhiên thật. Với AES-256, khóa mã hóa tệp, các salt xác thực và salt khóa, cùng mọi vector khởi tạo CBC đều được rút từ nguồn ngẫu nhiên của hệ thống (ISO 32000-2 §7.6.4.4.7 yêu cầu salt ngẫu nhiên). Vì
/U,/UE,/Ovà/OEđều được tính từ những byte đó, một tài liệu đã mã hóa thay đổi toàn bộ ngay cả khi phần văn bản thuần không đổi. Các thuật toán cũ gấp phần tử/IDthứ nhất vào khóa (ISO 32000-1 §7.6.3.3, §7.6.3.4), nên chỉ riêng một định danh mới đã đủ để đổi khóa cả tệp - Thứ tự. Một dictionary PDF là ánh xạ không có thứ tự, còn bộ ghi đi qua danh sách trong bộ nhớ của nó sẽ phát ra các khóa theo thứ tự chèn. Bất kỳ đường mã nào dựng một dictionary resource theo trình tự khác, hay một tài liệu đã nạp được phân tích từ một bố cục khác, đều tạo ra một tệp hợp lệ nhưng khác nhau về mặt văn bản
ReproducibleOutput ghim những gì?
Đặt ReproducibleOutput := True trước BeginDoc hoặc trước SaveLoadedDocument sẽ thay mỗi nguồn trong bốn nguồn bằng một giá trị cố định, và nó làm việc đó ngay trong chính những đường mã mà lẽ ra sẽ với tới đồng hồ hay bộ sinh ngẫu nhiên, nên không cần một lượt dọn dẹp riêng. Hãy để ý thứ còn thiếu trong danh sách trên: nội dung. Font, page stream, dữ liệu ảnh và bảng cross-reference vốn đã tất định với cùng một đầu vào; nhiễu loạn nằm hoàn toàn trong metadata và tầng bảo mật, và đó là lý do chỉ một thuộc tính được nhắm đúng chỗ có thể xóa được nó. Thuộc tính này mặc định là False và không có gì trong thư viện tự bật nó thay bạn
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // trước BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Bên trong BeginDoc, nhánh tái lập gán FCreationDate := EncodeDate(2026, 1, 1) và gieo hạt cho định danh tài liệu bằng MD5CalcString('HotPDF-reproducible-seed') thay cho phần băm của tên tệp cộng đồng hồ. Chỉ một phép gán đó đã phủ cả hai ngày trong Info lẫn hai ngày trong XMP, vì cả bốn đều được render từ cùng một trường. Tới lúc tệp được ghi ra, BuildDocumentIdentifiers hỏi ComputeCanonicalDocumentIdentifier để lấy định danh cho trailer: nó xuất toàn bộ đồ thị object theo thứ tự chuẩn tắc, đặt các chữ số của mọi chuỗi ngày D: tìm thấy về không để các dấu thời gian không rò ngược vào qua phần băm, rồi lấy MD5 của kết quả. Cả hai phần tử của /ID đều nhận giá trị đó. Cùng định danh suy ra từ nội dung ấy cũng được dùng khi một tài liệu đã nạp bị mã hóa mà không hề đi qua BeginDoc, đúng trường hợp ActivateProtection trên một tệp bạn mở bằng LoadFromFile
Các byte ngẫu nhiên là phép thay thế ít hiển nhiên nhất. Thủ tục sinh khóa AES-256 bọc nguồn ngẫu nhiên của nó trong một helper cục bộ mà, dưới cờ này, gọi FillChar(P^, Count, $5A) cho khóa mã hóa tệp dài 32 byte và cho từng salt 8 byte, còn các bộ mã hóa string và stream AES-128 lẫn AES-256 chuyển từ AESGenerateRandomIV sang AESGenerateStaticIV, hàm này lấp vector khởi tạo bằng 14 * (1 + I) cho ô số I. Khi khóa, các salt và các vector đều đã cố định, /U, /UE, /O, /OE cùng mọi stream đã mã hóa đều ra giống hệt nhau ở lượt chạy thứ hai. Cuối cùng, SaveToStream bật DeterministicDictionaryOrder mỗi khi cờ tái lập được đặt, và bộ tuần tự hóa khi đó sắp xếp insertion sort từng dictionary theo byte thô của tên khóa, tiền tố ngắn hơn đứng trước, với chỉ số gốc làm tiêu chí phá hòa. Đó cũng chính là thứ tự mà bộ ghi chẩn đoán dùng, được mô tả trong bài về sửa PDF bằng tay rồi repair lại; cờ tái lập chỉ mượn thứ tự đó, không mượn phần còn lại trong bố cục văn bản thuần của bộ ghi ấy
Vì sao ngày cố định vẫn để lọt đồng hồ thực?
Bản sửa v2.752.2 tồn tại vì ngày tạo cố định ban đầu được quyết định trong constructor, mà constructor thì không thể biết một thuộc tính mà bên gọi chưa đặt. Trình tự gọi thông thường là Create, rồi ReproducibleOutput := True, rồi BeginDoc. Ở thời điểm dựng đối tượng, FReproducibleOutput vẫn là False, nên FCreationDate nhận Now và giữ nguyên giá trị đó. Định danh cùng các byte ngẫu nhiên thì được ghim đúng, nên hai tệp khớp nhau ở gần như mọi chỗ và chỉ khác đúng hai chuỗi ngày cùng hai trường XMP. Chuyển phép gán vào nhánh tái lập của BeginDoc, ngay cạnh định danh được gieo hạt, đặt quyết định tại đúng thời điểm mà thuộc tính đã có giá trị cuối cùng
Bài kiểm thử hồi quy đã bỏ sót điều này đáng giá hơn cả bản sửa. Hai lần lưu cùng chạy trong một giây đồng hồ sẽ tình cờ ghi ra cùng một chuỗi D:, và phép so sánh byte đạt cho một lỗi mà trên bất kỳ máy chậm hơn nào cũng sẽ hỏng. Bài kiểm thử đã sửa ngủ 1100 ms giữa hai lần lưu để dấu thời gian PDF chắc chắn vượt qua một ranh giới giây, chạy ca này cho đầu ra thường, AES-128 và AES-256 với mật khẩu thật trên hai biến thể đã mã hóa, và so sánh hai buffer bằng CompareMem, báo offset khác nhau đầu tiên khi thất bại để phép diff chỉ vào một object cụ thể thay vì cả tệp. Một phép so sánh byte chỉ chứng minh tính tất định và không chứng minh gì khác, nên hãy giữ một khẳng định riêng nạp lại đầu ra đã mã hóa bằng mật khẩu người dùng và đọc số trang; một thay đổi khiến tệp vừa ổn định vừa không đọc được thì không được lọt qua nhờ một phép diff xanh
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;
// trong thân bài kiểm thử
A := SaveOnce(PathA);
TThread.Sleep(1100); // ép dấu thời gian PDF sang giây khác
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');
Một PDF đã mã hóa mà tái lập được thì vẫn an toàn chứ?
Không. Một tài liệu được mã hóa dưới ReproducibleOutput không được bảo vệ theo bất kỳ nghĩa nào đáng kể, và cờ này phải tắt với mọi thứ rời khỏi thư mục kiểm thử. Khóa mã hóa tệp AES-256 là ba mươi hai byte $5A, các salt là tám byte $5A, và các vector khởi tạo theo một quy luật số học đã công bố. Mật khẩu vẫn gác các wrapper /UE và /OE, nhưng khóa được gói bên trong là một hằng số, nên ai biết hằng số đó có thể giải mã mọi content stream mà không cần mật khẩu nào cả. Việc các salt cố định còn xóa đi tính duy nhất theo từng tài liệu mà ISO 32000-2 §7.6.4.4.7 dựa vào để giữ cho những mật khẩu giống nhau không cho ra những chuỗi /U giống nhau giữa các tệp. Đọc bài về thiết lập AES-256 để biết các thuộc tính mã hóa cam kết điều gì khi nguồn ngẫu nhiên còn nguyên vẹn; dưới cờ tái lập thì những cam kết ấy bị đình chỉ
Sự đánh đổi về định danh thì tinh vi hơn. ISO 32000-1 §14.4 muốn phần tử /ID thứ hai thay đổi sau mỗi lần sửa để các công cụ phân biệt được một tệp đã cập nhật với tổ tiên của nó, còn một lần lưu tái lập lại ghi cùng một giá trị vào cả hai ô. Vì giá trị đó là hash của đồ thị object chuẩn tắc, hai tài liệu có nội dung khác nhau vẫn nhận định danh khác nhau, và như vậy tốt hơn một hằng số. Nhưng hạt giống mà BeginDoc dùng để suy ra khóa lại là cùng một chuỗi cho mọi tài liệu trên mọi máy, và một bộ đọc dựa vào /ID để phân biệt các tệp, ví dụ một cache annotation hay một file phụ chứa dữ liệu form, sẽ nhập nhằng mọi tệp tái lập tình cờ băm ra cùng giá trị
Cờ này không bao phủ những gì?
ReproducibleOutput gỡ bỏ phần nhiễu loạn mà bộ ghi tự tạo ra; nó không thể gỡ phần nhiễu loạn đi vào từ môi trường hay qua những đường mã nó không kiểm soát, và có ba trường hợp như vậy rất dễ vấp phải
- Hậu tố múi giờ.
_DateTimeToPdfDatenối thêm độ lệch UTC của máy, nênD:20260101000000+08'00'trên một build agent vàD:20260101000000-05'00'trên một agent khác là hai chuỗi byte khác nhau cho cùng một ngày cố định. Tính tái lập chỉ đúng giữa các lượt chạy trên một máy, hoặc giữa những máy dùng chung một múi giờ; hãy ghim múi giờ của agent nếu các golden file của bạn phải đi xa - Cập nhật tăng dần.
SaveIncrementalUpdatetính định danh sửa đổi của nó từ đường dẫn đích,GetTickCountvà thời gian hiện tại mà không có nhánh tái lập nào, vì một phần tăng dần theo định nghĩa là một lần sửa đổi mới. Hãy so sánh những lần ghi lại toàn bộ, chứ không phải những đoạn delta được nối thêm - Đường tắt passthrough.
SaveLoadedDocumentthông thường copy nguyên từng byte một tệp nguồn chưa sửa và chưa mã hóa thay vì tuần tự hóa lại nó. Cờ tái lập tắt đường tắt đó và buộc ghi lại toàn bộ để các quy tắc về thứ tự cùng định danh được áp dụng, nghĩa là một lần lưu tái lập trên tệp đã nạp sẽ chậm hơn mặc định và không bao giờ là bản sao của đầu vào. Hãy diff nó với một lần lưu tái lập trước đó, đừng bao giờ diff với bản gốc
Còn một bài học nữa từ cùng bản phát hành, về việc một phép kiểm tra đạt chứng minh và không chứng minh điều gì. Một fixture kiểm thử PDF/X-6 gọi CharProcs.DeleteValue('A'), tức free một glyph stream được giữ trực tiếp, rồi chèn lại đúng con trỏ đó, và tách ra còn đưa cùng một object ExtGState trực tiếp cho cả một dictionary resource lẫn một pattern. Trình kiểm tra tuân thủ lúc đạt lúc không trên cái use-after-free và cái sở hữu kép đó vì nó đang đọc đúng thứ mà vùng nhớ đã free tình cờ còn giữ. Khi một phép kiểm tra cấu trúc chập chờn, hãy nhìn vào quyền sở hữu của dữ liệu đầu vào trước khi nhìn vào trình kiểm tra. Đầu ra tái lập khiến kỷ luật đó rẻ hơn: một khi hai lần lưu giống hệt từng byte, nguồn chập chờn duy nhất còn lại chính là đồ thị object, và một phép diff cấu trúc từ catalog trở xuống sẽ tìm ra nó
Các thuộc tính ReproducibleOutput, DeterministicDictionaryOrder và mã hóa mô tả ở đây đều có trong bản chuẩn HotPDF Delphi Component cho Delphi và C++Builder, và chính cờ đó cũng điều khiển kho mẫu hồi quy của thư viện, nên hành vi bạn nhận được trong một bộ kiểm thử cũng là hành vi mà component được kiểm thử cùng