PDF Library for Delphi v3.539.18 và v3.539.20 sửa hai con đường khiến một lần lưu PDF không thay đổi gì vẫn làm hỏng metadata tài liệu: khi /CreationDate và /ModDate cùng tham chiếu một đối tượng string, thao tác cập nhật ModDate tự động đã ghi đè cả hai, và khi đối tượng XMP được tạo trước lúc stream /Metadata gốc được đọc, một packet mặc định đã thay thế packet gốc. Các bản sửa thay tham chiếu trong dictionary thay vì sửa đổi đối tượng dùng chung, và chụp lại packet sẵn có trước khi XMP khởi tạo muộn
Tình huống này là thao tác kém thú vị nhất mà một thư viện PDF có thể làm: nạp một tệp, lưu nó dưới tên khác, không đụng gì ở giữa. Các trang hiển thị y hệt trước và sau. Hash của content stream khớp nhau. Tệp vượt qua mọi phép kiểm tra chúng tôi có, và nó vẫn sai ở hai chỗ mà không trình hiển thị nào cho bạn thấy. Cả hai khiếm khuyết đều nằm trên đường read-modify-write mà mọi thao tác sửa thật đều đi qua, nên bất kỳ lần lưu nào cũng đủ kích hoạt chúng, và cả hai chỉ được tìm ra khi một parser thứ hai, độc lập, so sánh ngữ nghĩa phi hình ảnh của hai tệp
Vì sao lưu một PDF lại làm đổi CreationDate của nó?
Vì dictionary thông tin tài liệu được phép tham chiếu cùng một đối tượng string gián tiếp từ hai khóa, còn thư viện lại cập nhật đối tượng thay vì cập nhật khóa. ISO 32000-1 §7.3.10 cho phép mọi giá trị trong dictionary là một tham chiếu gián tiếp, và không chỗ nào trong §14.3.3 Bảng 317 nói rằng giá trị dưới /CreationDate phải là một đối tượng khác với giá trị dưới /ModDate. Một producer ghi cùng một mốc thời gian hai lần lúc tạo tệp hoàn toàn hợp lệ khi cho cả hai khóa cùng trỏ vào một 2728 0 R, và đó đúng là thứ một tài liệu thiết kế tiếng CJK trong kho mẫu nội bộ của chúng tôi đã làm
Thứ kích hoạt là ngày sửa đổi tự động. Trừ khi UserModDate được đặt, SaveToFile gọi SetInfo('ModDate', ...) với thời gian hiện tại trước khi ghi, và lệnh này đi tới SetRawInfo. SetRawInfo cũ tra đối tượng dưới khóa đó và, nếu thấy một TPDFString, gọi SetTo lên nó. Đó là một lệnh ghi tại chỗ vào bất kỳ đối tượng nào mà khóa đang trỏ tới, và khi đối tượng đó dùng chung thì /CreationDate giờ cũng báo thời gian lưu. Tài liệu vẫn mở, in và hiển thị từng pixel như trước, nên một bộ kiểm thử hồi quy hình ảnh sẽ vượt qua mà không hề chớp mắt
var
Lib: TPDFlib;
Before, After: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('design.pdf', '');
Before := Lib.GetInformation(7); // 7 = CreationDate, 8 = ModDate
Lib.SaveToFile('design-resaved.pdf');
Lib.LoadFromFile('design-resaved.pdf', '');
After := Lib.GetInformation(7);
if Before <> After then
Log('a save that changed nothing rewrote CreationDate');
finally
Lib.Free;
end;
end;
Bản sửa trong TPDFDocument.SetRawInfo nhỏ, còn nguyên lý đằng sau nó thì tổng quát: cập nhật một entry trong dictionary là thay thế tham chiếu của entry đó, không bao giờ là sửa đối tượng mà nó tình cờ trỏ tới. Đoạn mã mới đọc TPDFStringMode hiện có để hex string vẫn là hex và literal string vẫn là literal, rồi thêm một string mới từ FStructure.NewString(Value, StringMode) dưới khóa đó. Hai chi tiết khác cũng quan trọng ngang thay đổi chính. Nhánh cũ dành cho entry mang giá trị stream đã xóa trắng stream bằng SetTo('') trước khi thay thế, việc đó sẽ làm rỗng giá trị của mọi khóa khác còn trỏ vào stream ấy, nên thao tác xóa trắng đó đã bị bỏ. Và đối tượng bị thay thế không bị xóa, vì cấu trúc sở hữu nó và các tham chiếu khác có thể vẫn cần đến nó
// Trước: sửa đổi bất kỳ đối tượng nào mà khóa đang trỏ tới
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Sau: giữ nguyên cách biểu diễn, chỉ thay tham chiếu của khóa này
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Bài kiểm thử hồi quy trong Tests\SharedInfoSemantics.inc dựng tình huống alias một cách có chủ đích thay vì dựa vào một tệp trong kho mẫu: một hex string được cả hai khóa ngày tham chiếu, một string trực tiếp được /Title và /Subject chia sẻ, một stream được /Author và /Keywords chia sẻ. Sau khi cập nhật một khóa của mỗi cặp, khóa còn lại vẫn phải đọc ra giá trị gốc và string đã cập nhật vẫn phải là hex. Tài liệu tham chiếu công khai cho SetInformation giờ phát biểu bảo đảm đó trong một câu: cập nhật một trường Info chỉ thay thế chính trường đó, kể cả khi các trường khác tham chiếu cùng một đối tượng
Vì sao một packet XMP sẵn có lại bị packet mặc định thay thế?
Vì thứ tự của hai dòng lệnh. TPDFDocument.GetMetadata có một đường nhanh: khi trường XMP đã được gán, nó trả về XMP.SaveToString thay vì giải mã stream /Metadata từ catalog. Một số chỗ gọi khởi tạo muộn bằng XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, đọc thì rất tự nhiên mà lại sai: tới lúc GetMetadata chạy thì XMP đã được gán, nên “nguồn” đang được nạp chính là packet mặc định đã tuần tự hóa của một đối tượng vừa tạo ở dòng trước. Packet gốc, với dc:creator, các namespace tùy biến và mọi định danh chuẩn, không bao giờ tới được đối tượng đó và bị ghi đè khi lưu. Cùng thứ ngày sửa đổi tự động là đủ kích hoạt, vì SetInfo khởi tạo XMP trước khi đụng vào dictionary Info để xmp:ModifyDate luôn khớp với /ModDate. Hãy để ý khiếm khuyết này ẩn sau cái gì: phép so sánh dictionary Info từ lỗi thứ nhất vẫn đạt, vì /Author và /Title trong /Info không bị đụng tới. Chỉ cây XMP thay đổi, và chỉ một phép kiểm tra phân tích rồi so sánh cây đó mới nhận ra
// Sai: GetMetadata giờ tuần tự hóa đối tượng vừa tạo ở dòng trước
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Đúng: chụp stream /Metadata trước, rồi mới tạo và nạp
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Bản sửa làm hai việc. TPDFDocument.EnsureXMP giờ chụp Source := GetMetadata trước TPDFlibXMP.Create, và mọi chỗ khởi tạo muộn trong tài liệu đều được thay bằng một lệnh gọi nó: SetInfo, SetXMPInformation, GetXMPInformation, các hàm đặt chế độ PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR và PDF/UA, cùng đường sửa chữa metadata. Những điểm vào công khai như SetXMPProperty vốn đã đi qua EnsureXMP, còn GetXMPProperty đọc qua GetDocumentMetadata, nên toàn bộ bề mặt dùng chung một thứ tự khởi tạo. Một bản sao đúng duy nhất của một chuỗi ba dòng đáng giá hơn mười bản sao tình cờ khớp nhau hôm nay
Hai cái bẫy nhỏ hơn trên cùng con đường
Bộ tuần tự hóa XMP trên Windows dùng XML writer của nền tảng, thứ này phát ra một khai báo XML mà packet không được phép mang. Đoạn mã cũ gỡ nó bằng cách xóa ký tự cho tới khi gặp <?xpacket. ISO 16684-1 §7.3.2 coi phần bao xpacket là tùy chọn, và một producer ghi thẳng phần tử <x:xmpmeta> trần vẫn nằm trong chuẩn, nên với packet như vậy vòng lặp đó đã xóa sạch cả một tài liệu hợp lệ. Bộ tuần tự hóa giờ định vị dấu ?> đóng khai báo và chỉ gỡ đúng phần đó. Tests\XMPRetentionSemantics.inc chạy phép kiểm tra giữ lại hai lần, một lần có phần bao và một lần đã cắt nó đi, rồi khẳng định rằng dấu namespace tùy biến cùng tác giả gốc vẫn sống sót qua SetInfo, GetMetadata, SaveToString và một lần nạp lại. Cái bẫy thứ hai là một symbol tiền xử lý: việc đồng bộ Info sang XMP trong SetInfo được canh bằng NOVCL, vốn được định nghĩa cho các bản build Free Pascal, nhưng backend XMP lại được quyết định bởi hệ điều hành chứ không bởi framework, vì PDFlibXMP.pas chỉ định nghĩa NO_XMP khi thiếu OS_WINDOWS. Thành ra một bản build Lazarus trên Windows có đối tượng XMP chạy tốt mà SetInfo lại âm thầm bỏ qua việc cập nhật nó. Chốt canh giờ là NO_XMP, nên một ứng dụng Free Pascal trên Windows nhận được đúng mức đồng bộ như Delphi
Làm sao giữ nguyên ModDate gốc khi lưu kiểu pass-through?
Đặt KeepModDate trong TPDFlibSaveOptions rồi lưu qua SaveToFileOptions. Tùy chọn này đặt UserModDate trong suốt thời gian của lệnh gọi, và SaveToFile khi đó bỏ qua mốc thời gian tự động, cũng chính là bước khởi tạo muộn đối tượng XMP. Một tài liệu mà bạn chưa từng đụng vào metadata và không bật chế độ tuân thủ nào sẽ giữ nguyên cả dictionary Info lẫn stream /Metadata như khi nạp. Gọi SetInformation(8, ...) có tác dụng tương tự nhưng vĩnh viễn, vì tự đặt ngày sửa đổi đánh dấu nó là do người dùng kiểm soát
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // không có /ModDate tự động, không khởi tạo XMP muộn
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Hãy trung thực về thứ bạn nhận được. KeepModDate là lựa chọn đúng cho một bước pass-through mà đầu ra phải mô tả cùng một bản sửa đổi như đầu vào, và là lựa chọn sai cho bất cứ thứ gì thật sự sửa nội dung, vì §14.3.3 kỳ vọng /ModDate phản ánh lần sửa đổi gần nhất. Nó cũng không sửa ngược được một thư viện vốn sửa đổi các đối tượng dùng chung; nó chỉ né đúng một lệnh ghi đã phơi ra khiếm khuyết. Hai bản sửa ở trên mới là thứ khiến một lần lưu bình thường trở nên an toàn, còn tùy chọn này là thứ khiến một lần no-op có chủ đích trở nên trung thực
Làm sao kiểm chứng rằng một lần lưu chỉ đổi ModDate mà thôi?
Không bằng pixel và cũng không bằng hash của stream, vì cả hai khiếm khuyết đều để mọi trang và mọi content stream giống hệt từng byte. Phép kiểm tra bắt được chúng là một ảnh chụp ngữ nghĩa phi hình ảnh do một parser độc lập thực hiện, một parser không chia sẻ mã nguồn nào với thư viện đang được kiểm, chụp từ tệp gốc và từ tệp đã lưu, rồi so sánh cấu trúc. Ảnh chụp bao gồm dictionary Info với /ModDate bị loại trừ, cây outline với mỗi bookmark được quy về số trang thay vì số đối tượng, named destination và đích của liên kết cũng quy về theo cách đó, giá trị của form field, byte của tệp đính kèm dưới dạng hash, và packet XMP được phân tích thành cây thay vì so sánh như văn bản. Số đối tượng cố ý không nằm trong đó, vì một lần ghi lại toàn bộ sẽ đánh số lại mọi thứ và một phép so sánh dựa trên chúng sẽ chỉ báo nhiễu
Những phần bị loại trừ quan trọng ngang những phần được đưa vào. /ModDate, xmp:ModifyDate và xmp:MetadataDate được mong đợi là thay đổi nên bị bỏ trước khi so sánh; một tệp mà bản gốc không mang XMP cũng không bị trừ điểm vì có thêm packet. Điều phép kiểm tra không khẳng định cũng rõ ràng không kém: giữ được một packet sẵn có không nói gì về việc packet đó có hợp lệ schema hay tài liệu có đạt PDF/UA hay bất kỳ phần PDF/A nào. Đó là những câu hỏi riêng với công cụ riêng, và đánh đồng “metadata còn sống sót” với “metadata tuân thủ” chính là cách lỗi đầu tiên ẩn mình lâu đến vậy. Về phía thư viện, hai bài kiểm thử hồi quy giờ chạy trong mọi lượt kiểm thử nhắm mục tiêu trên Delphi Win32 lẫn Win64 và Free Pascal Win32 lẫn Win64, còn phép so sánh ngữ nghĩa là một điều kiện đạt của benchmark trên kho tài liệu thật
Nếu bạn làm việc ở tầng thấp hơn những bản sửa này, cơ chế một lần lưu ghi lại đối tượng được trình bày trong cập nhật tăng dần và lưu kiểu append-only, tức chế độ lưu duy nhất mà một đối tượng dùng chung đơn giản được để nguyên tại chỗ, và trong các mức sửa đổi và so sánh khác biệt giữa các revision, tức chỗ khác mà một ngày cũ hoặc bị ghi lại làm người đọc hiểu sai. Góc nhìn phía sửa chữa của cùng cặp Info và XMP, nơi hai nửa được làm cho khớp nhau thay vì chỉ được giữ lại, nằm trong chuyển sang PDF/A và sửa chữa metadata
PDF Library for Delphi là một thư viện PDF thuần Pascal cho Delphi, C++Builder và Lazarus, và đường read-modify-write mô tả ở đây cũng chính là đường mà mọi thao tác sửa trong tiến trình của bạn đi qua, nên những bảo đảm trên đúng dù bạn lưu một lần hay một nghìn lần mỗi ngày — xem trang sản phẩm PDF Library for Delphi để biết các trình biên dịch và nền tảng được hỗ trợ