losLab PDF Library có thể tạo ra đầu ra PDF giống hệt nhau về byte cho cùng một đầu vào ngay khi bạn gọi SetDeterministicDocumentID(1). Theo mặc định, mảng /ID trong trailer là một digest MD5 của đồng hồ hệ thống, nên hai lần chạy cùng một trình sinh PDF sẽ khác nhau ít nhất ở những byte đó. Chế độ tất định thay vào đó suy ra /ID từ một hạt giống (seed) ổn định, nhờ đó khôi phục được khả năng build tái lập
Triệu chứng này thường xuất hiện trong CI trước khi ai đó thật sự đi tìm nó. Template không đổi, bản ghi đầu vào không đổi, font không đổi, mà PDF sinh ra vẫn cho hash khác nhau ở mỗi lần chạy pipeline. Build cache không bao giờ trúng. Kho lưu trữ theo địa chỉ nội dung tích lũy một blob mới cho mỗi bản build hằng đêm. Diff so sánh mức byte sáng đèn trên những file không ai đụng vào. Truy đến tận byte thật sự thì gần như luôn là cùng vài ký tự hex nằm trong trailer của file
Mảng ID trong trailer dùng để làm gì?
Trailer /ID là dấu hiệu nhận dạng file, không phải checksum của nội dung. ISO 32000-1 §14.4 định nghĩa nó là mảng gồm hai chuỗi byte: phần tử đầu tiên là định danh vĩnh viễn được gán khi tài liệu được tạo và có nghĩa là phải tồn tại qua mọi lần chỉnh sửa sau này, còn phần tử thứ hai là định danh thay đổi mà một trình ghi làm mới mỗi lần file bị chỉnh sửa. Cùng nhau, chúng cho phép một hệ thống quyết định xem hai file có phải là các phiên bản của cùng một tài liệu hay là hai tài liệu không liên quan. §7.5.5 khiến mục này gần như bắt buộc trong thực tế, vì trailer phải mang /ID bất cứ khi nào nó cũng mang /Encrypt
Đặc tả không nói cách tính giá trị này như thế nào. Khuyến nghị là một digest của những thứ như thời gian hiện tại, đường dẫn file, kích thước file và dictionary thông tin tài liệu, và đồng hồ hệ thống chính là thành phần khiến kết quả trở nên duy nhất. Đó chính xác là đặc tính bạn muốn cho việc nhận dạng và chính xác là đặc tính phá vỡ khả năng tái lập, đó là lý do vì sao đây cần là một công tắc tường minh thay vì một thay đổi hành vi âm thầm
Vì sao cùng một bản build lại tạo ra PDF khác nhau mỗi lần?
Vì định danh mặc định được suy ra từ thời điểm sinh file. Trước đây losLab PDF Library xây dựng chuỗi /ID từ một MD5 của dấu thời gian hiện tại, nên một tài liệu được tạo hai lần cách nhau một giây mang hai định danh vĩnh viễn khác nhau dù mọi byte khác trong file đều giống hệt nhau. Cái giá phải trả về sau là có thật: một hệ thống build đánh chỉ mục artifact theo hash không bao giờ có thể tái sử dụng một bước tạo PDF, một kho object khử trùng lặp giữ một bản sao cho mỗi lần build thay vì một bản sao cho mỗi tài liệu, và một người review nhìn vào diff nhị phân phải chứng minh rằng thay đổi duy nhất là nhiễu trước khi tin tưởng phần còn lại của diff. Việc sinh /ID tất định tồn tại để loại bỏ nhiễu đó, cùng tinh thần với công việc ổn định bố cục được mô tả trong bài về object stream và cross reference stream
Chuyển sang định danh có thể tái lập
Chế độ tất định là opt-in, theo từng tài liệu, và tắt theo mặc định nên đầu ra hiện có không đổi cho đến khi bạn yêu cầu. SetDeterministicDocumentID nhận 0 hoặc 1 và trả về 1 khi giá trị được chấp nhận, 0 cho bất kỳ giá trị ngoài phạm vi nào; GetDeterministicDocumentID báo cáo trạng thái hiện tại. SetDocumentIDSeed cung cấp một chuỗi seed tường minh, chuỗi này thắng mọi thứ khác, và truyền một seed rỗng sẽ quay về seed suy ra tự động. GetDocumentFileID đọc lại /ID[0] sau khi lưu để bạn có thể ghi log hoặc assert trên nó
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;
Việc làm mới diễn ra tại thời điểm lưu, không phải khi bạn bật cờ, nên bật chế độ tất định muộn trong quá trình xây dựng tài liệu vẫn có hiệu lực. Điều đó cũng có nghĩa một seed đã đổi sẽ chạm vào file ở lần lưu toàn bộ tiếp theo: đặt seed A, lưu, đặt seed B, lưu, và hai file mang hai định danh khác nhau, trong khi khôi phục seed A sẽ khôi phục lại giá trị ban đầu. Một seed tường minh là lựa chọn đúng bất cứ khi nào tài liệu của bạn có sẵn một khóa ổn định tự nhiên như số hóa đơn, số phiên bản bản ghi hoặc mã định danh git commit, vì nó tách định danh khỏi metadata ngẫu nhiên
Seed đến từ đâu khi bạn không tự cung cấp?
Không có seed tường minh, losLab PDF Library suy ra một seed từ trạng thái tài liệu đáng lẽ phải bất biến qua các lần sinh lại giống hệt nhau: dòng tiêu đề phiên bản PDF, số trang, và mọi mục trong dictionary thông tin tài liệu. Giá trị chuỗi và tên được lấy nguyên văn, các kiểu object khác đóng góp dạng đã serialize của chúng, và toàn bộ được hash vào chuỗi /ID. Hệ quả quan trọng là CreationDate và ModDate là một phần của dictionary thông tin và do đó là một phần của seed theo thiết kế. Hai lần chạy chỉ nhận được cùng một định danh khi chúng thật sự tạo ra cùng metadata tài liệu
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');
Cố định ModDate bằng khóa 8 làm việc kép, và đây là phần khiến người ta hay bị vấp. Chỉ riêng một /ID tất định không làm cho file giống hệt nhau về byte, vì đường lưu sẽ đóng dấu ModDate bằng thời gian hiện tại trừ khi bên gọi đã tự đặt nó tường minh. Đặt khóa 8 đánh dấu giá trị là do người gọi cung cấp và ngăn việc đóng dấu đó. Nếu bạn muốn một file có thể tái lập chứ không chỉ một định danh có thể tái lập, hãy coi các dấu thời gian trong metadata là đầu vào của build: suy ra chúng từ bản ghi nguồn hoặc từ một epoch cố định, không bao giờ từ Now
Vì sao ghi lại ID lại phá hỏng một PDF đã mã hóa?
Vì /ID[0] không chỉ là metadata trong một tài liệu đã mã hóa, nó là vật liệu khóa. ISO 32000-1 §7.6.3.3 Thuật toán 2 đưa phần tử đầu tiên của định danh file vào phép tính khóa mã hóa cho security handler chuẩn ở các revision 2 đến 4, cùng với mật khẩu đã đệm, giá trị /O và các bit quyền hạn. Khóa suy ra sau đó tạo ra chuỗi xác thực /U mà một trình đọc kiểm tra khi mở file, và khóa file được suy ra và lưu cache khi bạn gọi Encrypt hoặc khi một tài liệu đã mã hóa được tải, cả hai đều xảy ra trước khi lưu. Vì vậy ghi lại định danh trong lúc lưu sẽ tạo ra một file hợp lệ về cấu trúc nhưng kiểm tra /U lại thất bại khi mở lại: không phải một lỗi hỏng nhẹ mà là một tài liệu không ai mở được, kể cả bạn. Đó là lý do việc làm mới tất định bị giới hạn cho các tài liệu không mang trạng thái mã hóa, và vì sao một tài liệu đã mã hóa giữ nguyên bất kỳ /ID nào nó đã có, bất kể chế độ tất định bật hay tắt, và thiết lập này đơn giản không có tác dụng trên đường đó. Phần xử lý revision liên quan và ngữ nghĩa quyền hạn được trình bày trong bài kiểm toán mã hóa và quyền hạn PDF. Cũng lưu ý rằng đường khôi phục mã hóa chỉ làm mới /ID[1], định danh thay đổi, đúng như §14.4 dự định
Vì sao lưu gia tăng vẫn giữ nguyên định danh gốc
Ranh giới thứ hai là chế độ append. Một bản cập nhật gia tăng giữ nguyên mọi byte trước đó của file và ghi thêm một revision mới phía sau, và tính vĩnh viễn của /ID[0] theo §14.4 chính là điều báo cho một bên tiêu thụ biết rằng revision mới thuộc về cùng một tài liệu với revision cũ. Ghi lại nó sẽ cắt đứt liên kết đó, mâu thuẫn với các revision đã có sẵn trong file, và can thiệp vào ngữ nghĩa chữ ký, vì một chữ ký bao phủ một dải byte của một revision cụ thể của một tài liệu cụ thể. Vì vậy losLab PDF Library chỉ làm mới định danh tất định trong các lần lưu toàn bộ và không bao giờ trong chế độ append, giữ nguyên vẹn cam kết được mô tả trong bài về cập nhật gia tăng PDF và append vào stream
Một điểm hội tụ duy nhất cho việc sinh định danh
Toàn bộ việc sinh /ID trong losLab PDF Library giờ đều đi qua một routine nội bộ duy nhất, NewFileIDString, đó là điều khiến công tắc tất định đáng tin cậy thay vì chỉ là một bản vá trên một đường code. Việc tạo tài liệu trống, tạo lười mảng /ID còn thiếu khi cần, và đường khôi phục vân tay mã hóa đều gọi hàm này, nên có đúng một chỗ duy nhất mà đồng hồ hệ thống có thể lọt lại vào. Nó cũng có nghĩa các biến thể tương lai, chẳng hạn một định danh suy ra từ nội dung, chỉ là một thay đổi trên một hàm thay vì một cuộc kiểm toán toàn bộ trình serialize
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');
Hãy đưa phép so sánh đó vào bộ test của bạn trước khi tin cậy vào đầu ra tái lập ở bất cứ đâu khác, vì nó sẽ báo lỗi rõ ràng ngay khi một tính năng mới nào đó vô tình đưa dấu thời gian trở lại. Khả năng tái lập là một đặc tính tự phân rã âm thầm nếu không được canh giữ, và một assertion duy nhất trên hai lần lưu trong bộ nhớ tốn gần như không đáng kể để chạy trên mỗi bản build
API định danh tất định trình bày ở đây đi kèm trong losLab PDF Library cho Delphi và C++Builder, cùng với tài liệu tham chiếu đầy đủ về thông tin tài liệu, mã hóa và lưu gia tăng