Khi HotPDF Delphi Component nạp một tệp PDF 1.5 bằng LoadFromFile, nó không phân tích các object được đóng gói bên trong container /Type /ObjStm. Nó ghi nhận mỗi thành viên nén nằm ở đâu và chỉ phân tích khi có thứ gì đó cần tới. Bất biến lazy ấy là thứ giữ cho thời gian nạp tỷ lệ với những gì bạn thật sự chạm tới, và cũng là lý do một lần ghi lại toàn bộ phải làm thêm một việc trước khi xuất ra byte nào: mở rộng mọi thành viên còn chưa được phân tích, vì lần ghi lại sắp sửa vứt bỏ chính những container mà chúng đang sống trong đó
Triệu chứng dẫn tới ghi chú này thì dễ mô tả mà khó gỡ. Nạp một tệp có font, color space và structure tree nằm trong object stream, cho nó đi qua cặp sinh tài liệu BeginDoc và EndDoc, và đầu ra mở lên không kêu ca gì. Số trang đúng, văn bản vẫn nhìn thấy trên những trang bạn kiểm tra mẫu. Rồi một đồng nghiệp mở trang 40 và thấy phần thân văn bản hiển thị bằng font thay thế, hoặc lệnh Extract Text trả về rác ở đúng chỗ trước kia có một thay thế ActualText. Không có gì sập cả. Bộ ghi chỉ đơn giản tuần tự hóa một object chưa từng được nạp, và một object chưa nạp thì tuần tự hóa ra không có gì
LoadFromFile thật ra giữ lại gì cho một object nén?
Với mỗi entry cross-reference type 2, LoadFromFile giữ một record nhỏ trong FCompactObjects: số object, chỉ số của stream chứa nó trong bảng container, vị trí của thành viên bên trong stream đó, và một con trỏ ParsedObject khởi đầu bằng nil. Bản thân container thì được định vị, được giải mã nếu tài liệu có mã hóa, và được inflate, nhưng phần thân của các thành viên vẫn để nguyên dưới dạng byte. ISO 32000-1 §7.5.7 định nghĩa bố cục container khiến điều này khả thi: một header gồm các cặp số object và offset, rồi phần thân các thành viên được nối liền sau /First, nên bất kỳ thành viên nào cũng có thể được cắt ra mà không đụng tới hàng xóm của nó
EnsureCompressedObjectLoaded là con đường duy nhất biến một record thành một object. Nó tìm record theo số object, và nếu ParsedObject đã được gán thì trả về object đã cache đó và tính một lần cache hit. Ngược lại, nó nạp lại container nếu container từng bị đẩy ra, tính khoảng byte của thành viên từ bảng offset, đưa cho parser một góc nhìn zero-copy trên lát cắt đó, và lưu kết quả trở lại record. Từ đó về sau, object là gián tiếp, mang đúng số object thật của nó, và được đăng ký vào chỉ mục object của tài liệu như mọi object được phân tích từ phần thân tệp. Catalog, dictionary info, root cây trang và các object trang đều đi qua con đường này ngay lúc nạp vì việc điều hướng cần chúng. Font, color space, dictionary ExtGState và structure element thì không, và chúng nằm lại dưới dạng record cho tới khi một lần render trang hay một lần ghi lại chạm tới
Bạn có thể quan sát điều này từ bên ngoài. GetLoadedObjectStreamCacheInfo cho biết có bao nhiêu container, bao nhiêu thành viên đã được đánh chỉ mục, và bao nhiêu trong số đó đã được phân tích tới thời điểm hiện tại:
var
Pdf: THotPDF;
Info: THPDFObjectStreamCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('tagged-report.pdf');
if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
Writeln(Format('%d containers, %d members indexed, %d parsed so far',
[Info.ContainerCount, Info.IndexedObjectCount,
Info.MaterializedObjectCount]));
finally
Pdf.Free;
end;
end;
Với một tệp nhiều cấu trúc, con số thứ ba chỉ là một phần nhỏ của con số thứ hai ngay sau khi nạp. Khoảng cách đó chính là toàn bộ ý nghĩa của việc nạp lazy, và cũng đúng là tập object mà một lần ghi lại toàn bộ phải quay lại lấy
Vì sao ghi lại toàn bộ làm mất font mà lưu tăng dần vẫn giữ?
Một lần ghi lại toàn bộ vứt bỏ các container /ObjStm và /XRef của tệp nguồn rồi tuần tự hóa lại đồ thị object từ đầu, nên mọi thành viên còn ParsedObject bằng nil đều không còn cách biểu diễn nào trong đầu ra. Một lần cập nhật tăng dần không bao giờ gặp vấn đề này, vì nó nối các object mới vào sau các byte gốc và để nguyên những container cũ cho phần cross-reference trước đó đánh địa chỉ. Khác biệt không nằm ở cách hai chế độ đối xử với font. Nó nằm ở chỗ các container gốc có sống sót để trình xem kế tiếp đọc được hay không
Bản sửa nằm trong SaveToStream, bộ tuần tự hóa mà EndDoc điều khiển dù bạn đặt FileName hay OutputStream. Trước khi chuyển sang bất kỳ nhánh writer nào, nó duyệt FCompactObjects và gọi EnsureCompressedObjectLoaded trên từng entry. Nếu một thành viên không nạp được, lần lưu raise thay vì đi tiếp, vì một lần ghi lại âm thầm đánh rơi một dictionary font còn tệ hơn một lần ghi lại dừng lại. Việc mở rộng buộc phải nằm ở tầng đó, phía trên các nhánh classic, packed và linearized, và phía trên cả bước cắt tỉa các stream cấu trúc được nạp lại của đường linearized. Một phiên bản trước chỉ mở rộng thành viên bên trong SaveLoadedDocument, tức chỉ phủ được từ vựng tài liệu-đã-nạp và bỏ sót hoàn toàn từ vựng sinh tài liệu. Chuỗi LoadFromFile rồi BeginDoc, sửa trang, rồi EndDoc đi thẳng tới writer với mọi thành viên chưa bị đụng vẫn chưa được phân tích
// Cả hai từ vựng ghi lại giờ đều mở rộng thành viên compact trước khi writer nào chạy.
// Đường tài liệu đã nạp:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Đường sinh tài liệu trên một tệp đã nạp:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc; // SaveToStream hiện thực hóa mọi entry trong FCompactObjects trước tiên
Các thành viên đã cache giữ nguyên mọi thứ bạn đã làm với chúng. Một object được phân tích, sửa, và đánh dấu bẩn trước khi lưu sẽ được trả về từ cache kèm các sửa đổi của nó, và một thành viên bạn đã xóa vẫn giữ trạng thái đã xóa qua nhiều lần lưu. Lượt mở rộng này bất biến về mặt cấu trúc theo nghĩa nó chỉ luôn lấp vào những ô nil
Vì sao kiểm tra pixel trên ba trang lại bỏ sót ca ActualText
Structure element chính là chỗ lỗi này ẩn lâu nhất. Một entry ActualText trên một marked-content sequence, được định nghĩa ở ISO 32000-1 §14.9.4, thay thế các glyph cho việc trích xuất và khả năng truy cập nhưng không ảnh hưởng tới hiển thị. Nếu structure element đó nằm trong một object stream và lần ghi lại làm mất nó, trang vẫn vẽ đúng, trang đầu, trang giữa và trang cuối vẫn khớp từng pixel với bản gốc, và lỗi hồi quy chỉ lộ ra khi có ai chạy trích xuất văn bản hay một trình đọc màn hình. Một bài kiểm thử ghi lại chỉ render trang thì không phải là bài kiểm thử ghi lại cho PDF có tag. Hãy diff cả văn bản trích xuất lẫn structure tree
Mật khẩu người dùng rỗng làm thay đổi việc nạp thế nào?
Một mật khẩu người dùng rỗng vẫn có nghĩa là tệp đã được mã hóa, và object stream trong một tệp như vậy là bản mã cho tới khi khóa của tệp được khôi phục. ISO 32000-1 §7.6.3.4 Algorithm 2 suy ra khóa đó từ mật khẩu, entry /O, /P, và định danh tài liệu đầu tiên, và HotPDF buộc phải chạy nó với chuỗi rỗng trước khi lượt type-2 có thể inflate nổi một container. Đó là lý do BeginDoc trên một tài liệu đã nạp có mã hóa gọi DecryptLoadedDocument với mật khẩu rỗng trước mọi thứ khác: đồ thị object phải được xác thực và giải mã trước khi một lần ghi lại có thể bắt đầu, bất kể bên gọi có ý định bảo vệ đầu ra hay không. Việc mã hóa đầu ra là một quyết định riêng, do thiết lập bảo vệ của bên gọi chi phối, và BeginDoc khôi phục những thiết lập đó sau lượt giải mã để một đầu vào đã mã hóa không âm thầm biến thành một đầu ra đã mã hóa
Chính sách container được đọc từ dictionary /Encrypt trước khi thử bất kỳ mật khẩu nào. Với /V 1 và 2, mọi stream đều được mã hóa bằng khóa của tệp. Với crypt filter, HotPDF phân giải /StmF qua /CF: filter Identity hay /CFM bằng None nghĩa là container văn bản thuần, còn V2 và AESV2 nghĩa là container đã mã hóa. Câu trả lời nằm trong FReloadObjectStreamsEncrypted, và nó quan trọng ở một trường hợp cụ thể. Khi container là văn bản thuần nhưng string thì không, các thành viên mang những string đã mã hóa phải được giải mã riêng lẻ, nên MaterializeMembersOfPlaintextObjectStreams mở rộng mọi thành viên compact trước lượt giải mã theo từng object. Nó không làm gì khi chính sách chưa được biết và cũng không làm gì khi chính container đã được mã hóa, vì thành viên của một container đã mã hóa vốn đã được giải mã cùng với nó và không bao giờ được giải mã hai lần
Điều gì xảy ra khi một container không giải mã được?
Một container giải mã thất bại sẽ bị cách ly, chứ không phải lỗi chí mạng. Lượt type-2 ghi một entry THPDFObjStmQuarantineInfo vào FObjStmQuarantine gồm số object của container, một THPDFObjStmQuarantineReason, một chuỗi chẩn đoán, và danh sách các số object thành viên mà cross-reference đã dẫn vào nó. osqrDecryptFailed được raise trong bốn tình huống khác nhau: không phân giải được crypt filter nào, phép giải mã AES-256 hay AES-GCM ném lỗi, phép giải mã RC4 hay AES-128 cũ ném lỗi, hoặc hoàn toàn không có khóa tệp dùng được. Những container độc lập vẫn tiếp tục nạp, nên một tài liệu có một container hỏng vẫn mở được và vẫn render mọi trang không phụ thuộc vào nó
Danh sách cách ly sống sót qua bước dự phòng của parser. Nếu lần nạp cross-reference chính thất bại và HotPDF dựng lại bảng object bằng cách quét tệp, cờ encrypted từ lần thử đầu có thể không sống sót qua lần dựng lại đó, nhưng các bản ghi cách ly thì có. Đó là lý do BeginDoc kiểm tra danh sách cách ly thay vì cờ encrypted: trên một tài liệu đã nạp, nó duyệt FObjStmQuarantine và raise ở entry osqrDecryptFailed đầu tiên, nêu tên container và yêu cầu nạp lại bằng một mật khẩu hợp lệ. Một lần ghi lại vượt qua điểm đó sẽ ghi những thành viên mà container lẽ ra phải chứa thành các object rỗng rồi báo thành công. Bạn có thể tự chạy đúng phép kiểm tra đó, sớm hơn và theo chính sách của mình, qua các accessor công khai:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // mật khẩu người dùng rỗng
for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
(Info.Reason = osqrDecryptFailed) then
raise Exception.CreateFmt(
'Object stream %d is unreadable (%s); %d members unresolved',
[Info.ContainerObjNum, String(Info.Diagnostic),
Length(Info.MemberObjNums)]);
// an toàn để ghi lại từ đây
end;
Những lý do cách ly khác bao phủ các lỗi phi mã hóa: một container không phải stream, thiếu dictionary, /N hoặc /First không hợp lệ, kích thước stream nằm ngoài khoảng được chấp nhận, giải nén thất bại, /First trỏ vượt quá dữ liệu, hoặc phần thân của một thành viên giải mã ra nhưng không phân tích được. Những trường hợp này đáng được ghi log lúc nhận tệp, vì mỗi trường hợp nêu đích xác những thành viên mà bạn sẽ thiếu về sau
Vì sao một lần ghi lại cần đến token số nguyên gốc?
HotPDF lưu mọi object số dưới dạng Single, và một Single không thể tái tạo văn bản nguồn của một số thực. ISO 32000-1 §7.3.3 cho phép bộ ghi phát ra 0.750000, .75 hay 0.75 cho cùng một giá trị, và không dạng nào trong số đó sống sót nguyên vẹn qua một vòng round trip qua số nhị phân 24 bit cùng một bộ định dạng tổng quát. Tệ hơn, một giá trị như 0.7 hoàn toàn không biểu diễn được trong một Single; nó phân tích thành số float gần nhất, và định dạng lại số float đó có thể cho ra 0.69999999 hoặc một giá trị lân cận đã làm tròn, tùy vào vòng lặp chữ số. Trên một màu tô hay một hằng số trong suốt /CA, đó là chênh lệch một đơn vị trong kênh 8 bit, đủ để trượt một phép so sánh pixel với bản gốc và, ở các ranh giới gradient, đủ để nhìn thấy
THPDFNumericObject.RememberSourceToken giải quyết việc này cho trường hợp chưa bị sửa. Parser gọi nó với token thô ngay sau khi gán Value; phương thức chỉ nhận những token tạo từ chữ số, nhiều nhất một dấu chấm thập phân, và một dấu đầu tùy chọn, rồi lưu token cùng giá trị mà nó tương ứng vào FSourceValue. Thuộc tính SourceToken chỉ trả về đoạn văn bản đã lưu khi Value vẫn bằng FSourceValue. Đổi con số thì token biến mất, nên một giá trị đã bị sửa luôn đi qua đường định dạng sẵn có và không bao giờ phát ra văn bản cũ. SaveNumericObject kiểm tra SourceToken trước và ghi nguyên văn khi có, rồi chỉ rơi xuống các nhánh số nguyên, tham chiếu color space và phân số với những con số được tạo ra hoặc sửa trong bộ nhớ
Bất biến này nhỏ và đáng được phát biểu thẳng: một con số bạn không đụng tới được ghi bằng đúng những byte mà nó được đọc vào, còn một con số bạn có đụng tới được ghi bằng bộ định dạng của chính HotPDF. Các thành viên compact hưởng lợi từ điều này y như các object trong phần thân tệp, vì EnsureCompressedObjectLoaded chạy đúng bộ parser đó trên lát cắt thành viên. Bản thân việc định dạng số, cùng tính độc lập của nó với locale của tiến trình, được bàn trong bài về định dạng số PDF bất biến theo locale trong HotPDF
Kiểm thử một đường ghi lại với object stream
Ba phép kiểm tra bắt được mọi kiểu hỏng hóc mô tả ở trên, và không phép nào cần Acrobat. Thứ nhất, so sánh IndexedObjectCount với MaterializedObjectCount sau khi lưu; trong một lần ghi lại toàn bộ, hai con số phải bằng nhau, và mọi khoảng cách là một thành viên đã bị đánh rơi. Thứ hai, trích văn bản và liệt kê structure tree trên cả hai tệp, chứ không chỉ render chúng, để một ActualText bị mất hay một structure element bị mất hiện ra thành một khác biệt. Thứ ba, nạp đầu ra bằng một instance mới và khẳng định GetLoadedQuarantinedObjStmCount bằng không, điều này cũng chứng minh bộ ghi đã không tạo ra một container mà bộ đọc không mở được. Các tổ hợp crypt filter quyết định FReloadObjectStreamsEncrypted được trình bày trong bài về chính sách StmF, StrF và EFF. Phía bộ ghi của câu chuyện này, tức cách phát ra object stream và khi nào nên chọn cập nhật tăng dần thay vì ghi lại, nằm trong hướng dẫn về object stream và cập nhật tăng dần
Việc nạp thành viên lazy, lượt mở rộng trước khi ghi, cách ly giải mã, và việc giữ token nguồn đều có trong HotPDF Delphi Component cho Delphi và C++Builder. Trang sản phẩm có liên kết tới tài liệu tham chiếu API nếu bạn muốn đối chiếu GetLoadedObjectStreamCacheInfo cùng các accessor cách ly với pipeline nhận tệp của riêng mình