Usunięcie strony z PDF nie usuwa jej czcionek, obrazów ani strumieni treści. losLab PDF Library odzyskuje je za pomocą kolektora mark-sweep, który przechodzi graf obiektów w przód od korzeni trailera i usuwa każdy obiekt pośredni, do którego nic już nie prowadzi. Działa podczas pełnego zapisu, jest domyślnie wyłączony i zwraca liczbę usuniętych obiektów
Dlaczego usuwanie stron PDF nie zmniejsza pliku?
Ponieważ usunięcie strony jest edycją odwołań, a nie operacją na magazynie danych. DeletePages(StartPage, PageCount) odłącza obiekty stron od drzewa stron i naprawia wpisy konspektu, które do nich wskazywały. Czego nie potrafi zrobić, to zdecydować, że program czcionki, strumień treści i obraz XObject używane przez te strony są teraz martwe, ponieważ w chwili usuwania nic w pliku nie zapisuje, kto jeszcze mógłby do nich wskazywać. Te obiekty pozostają na liście obiektów dokumentu, a pełny zapis wypisuje każdy z nich z powrotem. Efektem jest skarga, od której zaczyna się większość wątków wsparcia: klient usuwa dziewięćdziesiąt procent stron, zapisuje, a plik kurczy się o dwa procent. Co gorsza, wyciek się kumuluje. Wczytaj, usuń, zapisz, wczytaj ponownie, usuń ponownie, zapisz ponownie — plik rośnie monotonicznie, podczas gdy liczba stron spada. To inny problem niż ten rozwiązywany przez subsetting czcionek i downsampling obrazów, które zmniejszają żywe obiekty. Tutaj obiekty nie są zbyt duże. Po prostu nie są już częścią dokumentu
Zbiór korzeni to trailer, nie drzewo stron
Graf obiektów PDF nie ma pola odwołania zwrotnego. Format nie definiuje licznika referencji ani listy wskaźników wstecznych, a klucze /Parent, które faktycznie istnieją, należą do konkretnych struktur, takich jak drzewo stron, a nie do grafu obiektów jako całości. Nic w obiekcie pośrednim nie mówi, kto do niego wskazuje, więc pytanie „czy ktoś nadal używa obiektu 47” ma dokładnie jedną odpowiedź: przejść w przód od znanego korzenia i sprawdzić, czy się do niego dotrze. Dlatego kolektor w losLab PDF Library jest kolektorem mark-sweep, a nie schematem opartym na liczniku referencji
Korzenie pochodzą z trailera pliku (ISO 32000-1 §7.5.5). Niosą je trzy klucze: /Root, katalog dokumentu z §7.7.2, z którego zwisają drzewo stron, nazwy, konspekt, AcroForm i metadane; /Info, słownik informacji o dokumencie; oraz /Encrypt, słownik szyfrowania. Dwa pozostałe klucze trailera to atrapy. /ID to tablica dwóch ciągów bajtowych, a /Prev to liczba całkowita będąca przesunięciem bajtowym do poprzedniej sekcji cross-reference. Żaden z nich nie jest odwołaniem pośrednim, więc żaden nie wnosi korzenia. losLab PDF Library kolejkuje cały słownik trailera zamiast trzech nazwanych kluczy, co nic nie kosztuje i utrzymuje przy życiu wszelkie prywatne rozszerzenia trailera
Samo przejście jest iteracyjne, a nie rekurencyjne. Gdy trawersowanie natrafia na odwołanie pośrednie, zapisuje jedynie numer obiektu i generację, oznacza odpowiedni slot i wrzuca go do kolejki FIFO zamiast natychmiast go dereferencjonować, co utrzymuje głębokie drzewa stron i długie łańcuchy konspektu z dala od stosu wywołań i zapobiega dwukrotnemu dekodowaniu tego samego obiektu. Bezpośrednie słowniki, tablice i słowniki strumieni trafiają do drugiej kolejki chronionej zbiorem odwiedzonych, ponieważ rzeczywiste dokumenty zawierają prawdziwe cykle: /Parent strony wskazuje z powrotem na jej węzeł drzewa stron, a elementy konspektu łączą się przez /Prev i /Next w obie strony. Numery generacji są częścią dopasowania, a nie ozdobą. Odwołanie rozwiązuje się tylko wtedy, gdy numer obiektu i generacja się zgadzają; odwołanie do numeru istniejącego w innej generacji jest traktowane jako obiekt null wymagany przez specyfikację, nigdy jako żywa krawędź
Jak włączyć garbage collection przy zapisie?
Garbage collection jest opcją opt-in i należy do rekordu opcji zapisu. Domyślnie ma wartość False, ponieważ kolektor jest destrukcyjnym przejściem po grafie obiektów i żadna biblioteka nie powinna po cichu usuwać obiektów, o zbadanie których wywołujący nigdy nie prosił
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;
Dwa inne punkty wejścia prowadzą do tego samego kolektora. SetGarbageCollect(1) ustawia flagę na wybranym dokumencie, dzięki czemu zwykłe SaveToFile ją respektuje, a GarbageCollectObjects uruchamia przejście natychmiast i zwraca liczbę usuniętych osieroconych obiektów pośrednich. Postać natychmiastowa to ta, której warto użyć, gdy chcesz mieć liczbę do zalogowania lub sprawdzenia w asercji, i warto ją sprawdzić, ponieważ wartość ujemna nie jest licznikiem
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;
Ta ścieżka błędu ma większe znaczenie, niż się wydaje. Obiekty są dekodowane leniwie, a obiekt, który nigdy nie został zdekodowany, nie ujawnia żadnych odwołań. Gdyby kolektor traktował obiekt niemożliwy do zdekodowania jako pusty węzeł, zmiótłby wszystko, co jest osiągalne wyłącznie przez niego. Dlatego trawersowanie wymusza dekodowanie w miarę dotykania każdego obiektu, a pojedynczy błąd dekodowania przerywa całe przejście z wynikiem ujemnym i pozostawia dokument identyczny co do bajtu. Zamiatanie grafu, który rozumiesz tylko częściowo, to sposób, w jaki kolektor zamienia uszkodzony plik w zniszczony
Co psuje naiwny kolektor PDF?
Dwa szczegóły, i oba zawodzą po cichu, a nie głośno. Pierwszy to strumienie obiektów. Od PDF 1.5 obiekt niebędący strumieniem może żyć skompresowany wewnątrz kontenera /ObjStm (§7.5.7), a jego wpis cross-reference jest wpisem typu 2 wskazującym kontener plus indeks w nim. Skompresowany obiekt jest więc osiągalny wyłącznie przez swój kontener. Oznacz członka, zamieć kontener, ponieważ nic nie odwoływało się do niego jako do obiektu dokumentu, i masz zapisany plik, którego xref wskazuje na obiekt, który już nie istnieje. Kontener jest magazynem strukturalnym, a nie danymi dokumentu, więc nigdy nie pojawia się jako krawędź w grafie obiektów, po którym idziesz. losLab PDF Library radzi sobie z tym, odłączając każdego przetrwałego skompresowanego członka od jego kontenera źródłowego, zanim kontenery znikną, po czym zapis rekompresuje przetrwałych do świeżych strumieni obiektów. Drugi szczegół dotyczy tego, do czego faktycznie odwołuje się obiekt strumienia. Bajty nie są częścią grafu. Strumień treści rysujący tekst za pomocą /F1 12 Tf nazywa czcionkę nazwą zasobu, a ta nazwa jest rozwiązywana przez słownik /Resources strony, więc krawędź osiągalności biegnie strona → /Resources → /Font → obiekt czcionki, nigdy przez ładunek strumienia. Jedyne odwołania, jakie wnosi strumień, pochodzą z jego słownika, gdzie /Length, /Filter i /DecodeParms wszystkie mogą być pośrednie. Kolektor, który parsuje bajty strumienia w poszukiwaniu odwołań, wykonuje kosztowną pracę na próżno; kolektor, który pomija słowniki strumieni, traci obiekt długości i psuje plik
Co dzieje się z numerami obiektów, które zwalniasz
Stają się wolnymi wpisami i nie są ponownie używane w tym samym zapisie. Zamiatanie przechodzi po liście obiektów w porządku malejącym, dzięki czemu usunięcia pozostają stabilne indeksowo, przebudowuje indeks wyszukiwania raz na końcu zamiast po każdym usunięciu, a dla każdego usuniętego obiektu zapisuje numer na liście wolnych z generacją zwiększoną o jeden, dokładnie tak, jak wymaga tego §7.5.4 dla wpisu, który może zostać później ponownie użyty. Generacja już na poziomie 65535 pozostaje tam, oznaczając ten numer jako trwale wycofany. Numery obiektów celowo nie są kompaktowane. Po kolekcji plik zachowuje dziury: obiekt 12 może być wolny, podczas gdy 13 i 14 są w użyciu, a trailer /Size nadal raportuje najwyższy numer plus jeden, a nie liczbę przetrwałych. To jest legalne i normalne. Przenumerowanie zaoszczędziłoby garść bajtów w tabeli cross-reference i wymagałoby przepisania każdego odwołania w dokumencie, co jest rodzajem zmiany, która po cichu unieważnia wszystko, co trzyma numery obiektów z zewnątrz. Rozmiar, który odzyskujesz, pochodzi z ciał obiektów, nie z tabeli xref
Kiedy nie wolno uruchamiać kolektora
Nigdy przy aktualizacji przyrostowej. Kolektor jest ograniczony do pełnych zapisów, a flaga po prostu nie jest odczytywana, gdy dokument jest dopisywany, i to ograniczenie nie jest limitacją do obejścia. Aktualizacja przyrostowa (§7.5.6) pozostawia oryginalne bajty nietknięte i dopisuje nową sekcję cross-reference połączoną z poprzednią przez /Prev. Każda wcześniejsza wersja nadal wskazuje na obiekty, na które zawsze wskazywała, więc obiekt nieosiągalny w bieżącej wersji jest jak najbardziej osiągalny w starszej. Usunięcie go zepsułoby każdą wersję poza ostatnią, a mechanika tego dlaczego jest omówiona w artykule o aktualizacjach przyrostowych i zapisach w trybie append. To samo rozumowanie wyklucza garbage collection na podpisanym dokumencie, ponieważ pełne przepisanie, które umożliwia kolekcję, samo w sobie unieważnia podpis
Warto też jasno powiedzieć, czym kolekcja nie jest. Nie jest sanitizerem. Kolektor usuwa obiekty, do których nic się nie odwołuje; nie ma zdania na temat tego, czy ich zawartość była wrażliwa, a obiekt, do którego wciąż się odwołuje, pozostaje tym, czym był. Jeśli celem jest uczynienie informacji nieodzyskiwalną, a nie zmniejszenie pliku, graf obiektów to zła warstwa, a właściwa to redakcja na poziomie instrukcji i sanityzacja dokumentu. Te dwa dobrze się komponują w tej kolejności: najpierw redaguj i sanityzuj, potem zbieraj, aby obiekty odłączone przez redakcję faktycznie opuściły plik. To samo połączenie istnieje w API czyszczenia zasobów, gdzie przekazanie opcji garbage-collect powoduje, że czyszczenie uruchamia potem kolekcję i raportuje usunięte sieroty w OrphanObjectsRemoved
Jeszcze jeden nawyk wart przyjęcia. Loguj wartość zwracaną przez GarbageCollectObjects w dowolnym zadaniu wsadowym, które wykonuje twoje usunięcia stron, i obserwuj przez kilka tygodni na rzeczywistych dokumentach. Zero na pliku, który właśnie przepołowiłeś, oznacza, że coś dalej wciąż trzyma odwołanie, którego się nie spodziewałeś, zwykle wpis drzewa nazw, cel konspektu lub pole AcroForm, które przetrwało stronę, do której było przypięte. Kolektor jest najtańszym debuggerem osiągalności, jaki kiedykolwiek będziesz mieć, ponieważ odpowiada na pytanie, na które sam format PDF odmawia odpowiedzi
Garbage collector, rekord opcji zapisu i API czyszczenia zasobów opisane tutaj są częścią losLab PDF Library dla Delphi i C++Builder, której strona produktu zawiera pełne odniesienie do pipeline'u zapisu, w tym interakcję między kolekcją, pakowaniem strumieni obiektów i linearyzacją