Xóa một trang khỏi PDF không xóa font, hình ảnh hay content stream của trang đó. losLab PDF Library thu hồi chúng bằng một bộ thu gom mark-sweep duyệt đồ thị đối tượng tiến từ các gốc trailer và loại bỏ mọi đối tượng gián tiếp mà không còn gì trỏ tới. Nó chạy khi lưu đầy đủ (full save), mặc định tắt, và trả về số lượng đối tượng đã loại bỏ
Vì sao xóa trang PDF không làm giảm dung lượng tệp?
Bởi vì xóa trang là một thao tác chỉnh sửa tham chiếu, không phải thao tác lưu trữ. DeletePages(StartPage, PageCount) gỡ liên kết các đối tượng trang khỏi cây trang (page tree) và sửa lại các mục outline từng trỏ tới chúng. Điều nó không thể làm là quyết định rằng font, content stream và image XObject mà các trang đó dùng nay đã "chết", vì tại thời điểm xóa, không có gì trong tệp ghi lại ai khác có thể vẫn đang trỏ tới chúng. Các đối tượng đó vẫn nằm trong danh sách đối tượng của tài liệu, và một lần lưu đầy đủ sẽ ghi lại từng đối tượng đó ra tệp. Kết quả là lời phàn nàn mở đầu phần lớn các luồng hỗ trợ dạng này: khách hàng xóa chín mươi phần trăm số trang, lưu lại, và tệp chỉ nhỏ đi hai phần trăm. Tệ hơn, sự rò rỉ này còn cộng dồn. Tải lên, xóa, lưu, tải lại, xóa tiếp, lưu tiếp — tệp lớn dần đều trong khi số trang giảm dần. Đây là một vấn đề khác với vấn đề được giải quyết bởi subset hóa font và giảm mẫu ảnh (downsampling), vốn làm cho các đối tượng còn sống nhỏ đi. Ở đây, vấn đề không phải là đối tượng quá lớn. Chúng chỉ đơn giản là không còn thuộc về tài liệu nữa
Tập gốc là trailer, không phải cây trang
Đồ thị đối tượng PDF không có trường tham chiếu ngược. Định dạng này không định nghĩa bộ đếm tham chiếu (reference count) hay danh sách con trỏ ngược, và các khóa /Parent tồn tại thì lại thuộc về những cấu trúc cụ thể như cây trang, chứ không thuộc về toàn bộ đồ thị đối tượng. Không có gì trong một đối tượng gián tiếp cho biết ai đang trỏ tới nó, nên câu hỏi "còn ai đang dùng đối tượng 47 không" chỉ có đúng một cách trả lời: duyệt tiến từ một gốc đã biết và xem có tới được nó hay không. Đó là lý do bộ thu gom trong losLab PDF Library là một bộ thu gom mark-sweep, chứ không phải cơ chế đếm tham chiếu
Các gốc đến từ trailer của tệp (ISO 32000-1 §7.5.5). Ba khóa mang chúng: /Root, document catalog của §7.7.2 mà từ đó cây trang, tên (names), outline, AcroForm và metadata đều treo vào; /Info, dictionary thông tin tài liệu; và /Encrypt, dictionary mã hóa. Hai khóa trailer còn lại chỉ là "bẫy". /ID là một mảng gồm hai chuỗi byte, còn /Prev là một số nguyên chỉ độ lệch byte tới phần cross-reference trước đó. Cả hai đều không phải tham chiếu gián tiếp, nên cả hai đều không đóng góp gốc nào. losLab PDF Library đưa toàn bộ trailer dictionary vào hàng đợi thay vì chỉ ba khóa được nêu tên, việc này không tốn chi phí gì và giữ cho bất kỳ phần mở rộng trailer riêng nào vẫn còn sống
Bản thân bước duyệt là lặp (iterative) chứ không đệ quy. Khi việc duyệt gặp một tham chiếu gián tiếp, nó chỉ ghi lại số đối tượng và thế hệ (generation), đánh dấu ô tương ứng và đẩy vào một hàng đợi FIFO thay vì giải tham chiếu ngay lập tức, nhờ đó các cây trang sâu và chuỗi outline dài không chiếm call stack và tránh việc cùng một đối tượng bị giải mã hai lần. Các dictionary, mảng và stream dictionary trực tiếp được đưa vào một hàng đợi thứ hai có canh giữ bằng tập "đã thăm" (visited set), vì tài liệu thực tế chứa những vòng lặp có thật: /Parent của một trang trỏ ngược lại về node cây trang của nó, và các mục outline nối chuỗi qua /Prev và /Next theo cả hai chiều. Số thế hệ là một phần của việc so khớp, không phải chi tiết trang trí. Một tham chiếu chỉ được giải quyết khi cả số đối tượng lẫn thế hệ đều khớp; một tham chiếu tới một số tồn tại ở thế hệ khác được xem là đối tượng null theo yêu cầu của đặc tả, không bao giờ được xem là một cạnh còn sống
Làm sao để bật thu gom rác khi lưu?
Thu gom rác là tùy chọn (opt-in) và thuộc về bản ghi tùy chọn lưu (save options). Nó mặc định là False vì bộ thu gom là một bước xử lý mang tính phá hủy trên đồ thị đối tượng, và không thư viện nào nên âm thầm xóa các đối tượng mà bên gọi chưa từng yêu cầu xem xét
var
Pdf: TPDFlib;
Opt: TPDFlibSaveOptions;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
Exit;
Pdf.DeletePages(11, 490); // keep the first ten pages
FillChar(Opt, SizeOf(Opt), 0);
Opt.CompressContent := True;
Opt.CompressFonts := True;
Opt.OptimizeContentStreams := True;
Opt.PackObjectStreams := True;
Opt.GarbageCollect := True; // drop everything the pages left behind
Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
finally
Pdf.Free;
end;
end;
Hai điểm vào khác cũng dẫn tới cùng bộ thu gom đó. SetGarbageCollect(1) đặt cờ này trên tài liệu đang chọn để một SaveToFile thông thường tuân theo nó, còn GarbageCollectObjects chạy bước xử lý ngay lập tức và trả về số lượng đối tượng gián tiếp mồ côi đã bị loại bỏ. Dạng chạy ngay là dạng nên dùng khi bạn muốn có một con số để ghi log hoặc để assert, và điều này đáng để kiểm tra, vì một giá trị trả về âm không phải là một số đếm
var
Removed: Integer;
begin
Pdf.DeletePages(11, 490);
Removed := Pdf.GarbageCollectObjects;
if Removed < 0 then
// The graph could not be fully decoded. Nothing was swept and the
// document is unchanged; save it without GC or reject the input.
LogWarning('object graph incomplete, GC skipped')
else
LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;
Đường lỗi đó quan trọng hơn vẻ ngoài của nó. Các đối tượng được giải mã theo kiểu lazy (chỉ khi cần), và một đối tượng chưa từng được giải mã thì không hề bộc lộ tham chiếu nào cả. Nếu bộ thu gom coi một đối tượng không thể giải mã là một node rỗng, nó sẽ quét sạch mọi thứ chỉ có thể đến được qua đối tượng đó. Vì vậy bước duyệt buộc phải giải mã khi chạm tới từng đối tượng, và chỉ một lỗi giải mã duy nhất sẽ hủy toàn bộ bước xử lý với kết quả âm và để tài liệu nguyên vẹn từng byte. Quét một đồ thị mà bạn chỉ hiểu một phần chính là cách một bộ thu gom biến một tệp bị hỏng thành một tệp bị phá hủy hoàn toàn
Điều gì làm hỏng một bộ thu gom PDF ngây thơ?
Hai chi tiết, và cả hai đều thất bại âm thầm chứ không ồn ào. Chi tiết đầu tiên là object stream. Kể từ PDF 1.5, một đối tượng không phải stream có thể sống ở dạng nén bên trong một container /ObjStm (§7.5.7), và mục cross-reference của nó là một mục kiểu 2 nêu tên container cùng chỉ số bên trong đó. Vì vậy, một đối tượng nén chỉ có thể tới được thông qua container của nó. Nếu đánh dấu thành viên nhưng quét bỏ container vì không có gì tham chiếu tới nó như một đối tượng tài liệu, bạn sẽ tạo ra một tệp mà xref trỏ vào một đối tượng không còn tồn tại. Container là kho lưu trữ mang tính cấu trúc, không phải dữ liệu tài liệu, nên nó không bao giờ xuất hiện như một cạnh trong đồ thị đối tượng mà bạn đang duyệt. losLab PDF Library xử lý việc này bằng cách tách mọi thành viên nén còn sống sót ra khỏi container nguồn trước khi các container biến mất, sau đó bước lưu đóng gói lại các thành viên sống sót vào các object stream mới. Chi tiết thứ hai là những gì một đối tượng stream thực sự tham chiếu. Các byte dữ liệu không phải là một phần của đồ thị. Một content stream vẽ văn bản bằng /F1 12 Tf nêu tên font qua tên tài nguyên (resource name), và tên đó được giải quyết qua dictionary /Resources của trang, nên cạnh khả đạt (reachability edge) chạy từ trang → /Resources → /Font → đối tượng font, không bao giờ đi qua phần payload của stream. Tham chiếu duy nhất mà một stream đóng góp đến từ dictionary của nó, nơi /Length, /Filter và /DecodeParms đều được phép là gián tiếp. Một bộ thu gom phân tích byte của stream để tìm tham chiếu là đang làm việc tốn kém một cách vô ích; một bộ thu gom bỏ qua stream dictionary sẽ mất đối tượng length và làm hỏng tệp
Chuyện gì xảy ra với các số đối tượng đã được giải phóng
Chúng trở thành các mục trống (free entries), và không được tái sử dụng ngay trong cùng một lần lưu. Bước quét duyệt danh sách đối tượng theo thứ tự giảm dần để việc xóa vẫn ổn định về chỉ số, xây lại chỉ mục tra cứu một lần duy nhất ở cuối thay vì sau mỗi lần xóa, và với mỗi đối tượng bị xóa, ghi số của nó vào free list với thế hệ tăng thêm một, đúng như §7.5.4 quy định cho một mục có thể được tái sử dụng sau này. Một thế hệ đã ở mức 65535 sẽ giữ nguyên ở đó, đánh dấu số đó là đã nghỉ hưu vĩnh viễn. Số đối tượng cố tình không được dồn lại (compact). Sau một lần thu gom, tệp vẫn giữ các "lỗ hổng": đối tượng 12 có thể trống trong khi 13 và 14 đang được dùng, và /Size trong trailer vẫn báo cáo số cao nhất cộng một chứ không phải số lượng còn sống sót. Điều đó hợp lệ và bình thường. Đánh số lại sẽ tiết kiệm được vài byte trong bảng cross-reference nhưng đòi hỏi phải viết lại mọi tham chiếu trong tài liệu — loại thay đổi âm thầm làm mất hiệu lực bất cứ thứ gì đang giữ số đối tượng từ bên ngoài. Dung lượng bạn thu hồi được đến từ phần thân đối tượng, không phải từ bảng xref
Khi nào bạn không được chạy bộ thu gom
Không bao giờ trên một bản cập nhật tăng dần (incremental update). Bộ thu gom bị chặn ở các lần lưu đầy đủ và cờ này đơn giản là không được đọc khi tài liệu đang được nối thêm vào, và đó không phải là một hạn chế cần lách qua. Một incremental update (§7.5.6) giữ nguyên các byte gốc và nối thêm một phần cross-reference mới được nối chuỗi với phần trước đó thông qua /Prev. Mọi phiên bản (revision) trước đó vẫn trỏ tới các đối tượng mà nó luôn trỏ tới, nên một đối tượng không thể tới được ở phiên bản hiện tại vẫn hoàn toàn có thể tới được ở một phiên bản cũ hơn. Xóa nó sẽ phá vỡ mọi phiên bản trừ phiên bản cuối cùng, và cơ chế của việc này được trình bày trong bài viết về incremental update và lưu ở chế độ append. Cùng lý lẽ đó loại trừ việc thu gom rác trên một tài liệu đã ký, vì chính việc viết lại toàn bộ (full rewrite) khiến việc thu gom trở nên khả thi lại là điều làm mất hiệu lực chữ ký
Cũng đáng nói rõ việc thu gom không phải là gì. Nó không phải một trình khử trùng (sanitizer). Bộ thu gom loại bỏ những đối tượng không còn gì tham chiếu tới; nó không có quan điểm gì về việc nội dung của chúng có nhạy cảm hay không, và một đối tượng vẫn còn được tham chiếu thì vẫn giữ nguyên như cũ. Nếu mục tiêu là làm cho thông tin không thể khôi phục được thay vì làm cho tệp nhỏ hơn, đồ thị đối tượng là sai tầng để xử lý, và biên tập che khuất (redaction) ở mức lệnh và làm sạch tài liệu mới là tầng đúng. Hai thứ này kết hợp tốt theo đúng thứ tự đó: che khuất và làm sạch trước, rồi mới thu gom, để các đối tượng mà việc che khuất đã tách ra thực sự rời khỏi tệp. Sự kết hợp tương tự cũng tồn tại trong API dọn tài nguyên (resource purge), nơi việc truyền tùy chọn thu gom rác khiến bước purge chạy một lượt thu gom ngay sau đó và báo cáo số đối tượng mồ côi đã loại bỏ trong OrphanObjectsRemoved
Một thói quen cuối cùng đáng áp dụng. Hãy ghi log giá trị trả về của GarbageCollectObjects trong bất kỳ tác vụ batch nào thực hiện việc xóa trang của bạn, và theo dõi nó qua vài tuần với tài liệu thực tế. Một giá trị bằng không trên một tệp bạn vừa cắt giảm một nửa nghĩa là có gì đó ở phía trên vẫn đang giữ một tham chiếu bạn không ngờ tới, thường là một mục name tree, một đích outline (outline destination) hoặc một trường AcroForm còn sống sót qua cả trang mà nó gắn vào. Bộ thu gom là công cụ gỡ lỗi khả đạt (reachability debugger) rẻ nhất mà bạn từng có, vì nó trả lời câu hỏi mà chính định dạng PDF từ chối trả lời
Bộ thu gom rác, bản ghi tùy chọn lưu và API dọn tài nguyên được mô tả ở đây là một phần của losLab PDF Library cho Delphi và C++Builder, trang sản phẩm của thư viện này có đầy đủ tài liệu tham khảo về pipeline lưu tệp, bao gồm cả sự tương tác giữa việc thu gom, đóng gói object stream và linearization