Artykuł techniczny

Odchudzanie PDF w Delphi: obiekty sierocy i dedup zasobów

PDFium Component dla Delphi (PDFiumPas) odchudza pliki PDF trzema API przepisu na TPdf: SaveAsReachable wyrzuca obiekty, na które nic nie wskazuje, SaveAsDeduplicated scala bajt w bajt identyczne fonty, obrazy i Form XObjects, a SaveAsFlateRecompressed ponownie koduje istniejące strumienie Flate na maksymalnym poziomie kompresji. Każde zwraca rekord raportu, więc widzisz, co zostało usunięte i ile bajtów zaoszczędziło

Zwykłym wyzwalaczem jest zgłoszenie wsparcia brzmiące "usunęliśmy dwadzieścia stron, a plik urósł". Ktoś otworzył umowę, wyciął załącznik, dołączył stronę tytułową i zapisał. Każdy krok był aktualizacją przyrostową, więc stare obiekty wciąż siedzą w pliku, a gdy ten dokument jest później wczytywany i zapisywany przez FPDF_SaveAsCopy, wychodzą z powrotem. PDFium serializuje cokolwiek siedzi w jego tabeli obiektów w pamięci; nie ma publicznego wołania do wypisywania obiektów pośrednich, usuwania któregokolwiek ani przepisywania odwołań. Wszystko w tym artykule działa więc w warstwie opakowującej PDFiumPas na bajtach, które produkuje PDFium

Dlaczego FPDF_SaveAsCopy trzyma usunięte strony w pliku?

FPDF_SaveAsCopy trzyma obiekty sierocy, bo PDFium ładuje do dokumentu każdy żywy wpis w tabeli odwołań krzyżowych, niezależnie od tego, czy cokolwiek jeszcze na niego wskazuje. ISO 32000-1 §7.5.6 opisuje aktualizacje przyrostowe jako doklejanie nowych wersji obiektów i nowej sekcji xref na koniec pliku. Usunięcie strony edytuje tylko drzewo stron; słownik strony, jego strumień treści i fonty albo obrazy używane wyłącznie przez tę stronę są wciąż wypisane jako obiekty w użyciu. Pełny zapis nieprzyrostowy tego nie zmienia, bo z punktu widzenia tabeli obiektów te obiekty są kompletnie poprawne

Te same resztki wychodzą po scalaniu stron, spłaszczaniu formularzy zrobionym przez inne narzędzia i zapisach naprawczych uszkodzonych plików. Są niewidoczne w każdej przeglądarce i dodają nic poza bajtami. Na dokumentach skanowanych pojedynczy osierocony obraz strony potrafi ważyć kilka megabajtów

Mark-and-sweep przez SaveAsReachable

TPdf.SaveAsReachable usuwa sieroty klasycznym przebiegiem mark-and-sweep: każe PDFium zapisać nieprzyrostową kopię bieżącego dokumentu, parsuje tę kopię do grafu obiektów, zaznacza wszystko osiągalne z korzeni i zapisuje pojedynczą klasyczną rewizję zawierającą wyłącznie zaznaczone obiekty. Obiekty wewnątrz strumieni obiektów są po drodze materializowane jako zwykłe obiekty pośrednie, więc wyjście zawsze używa prostej tabeli xref

Wybór korzeni to miejsce, gdzie naiwna implementacja się wywraca. Oczywiste nasienie to Catalog wymieniony przez przyczep /Root. Stamtąd przejście dociera do drzewa stron, zasobów stron, fontów, obrazów, Form XObjects, AcroForm, drzewa struktury, konspektów, drzew nazw, treści opcjonalnej, metadanych XMP, output intentów i osadzonych plików. Ale słownik informacji o dokumencie jest wskazywany przez wpis przyczepu /Info (ISO 32000-1 §7.5.5), nie przez Catalog. Zasiej tylko z /Root, a po cichu usuniesz Title, Author i Producer każdego przetworzonego pliku. PDFiumPas zasiewa przejście i /Root, i /Info, a przebudowany przyczep niesie /Info w przód tylko, gdy ten obiekt faktycznie przeżył

Walker to przejście w głąb z jawnym stosem i zbiorem odwiedzonych, więc cykle odwołań (łącza rodziców w drzewie stron, wsteczne wskaźniki /P adnotacji) nie potrafią go zawiesić. Odwołanie do numeru obiektu już wolnego jest po prostu niepodążane, zamiast traktowane jako błąd, co trzyma przebieg pobłażliwym wobec lekko uszkodzonego wejścia

Numery obiektów są zachowywane, nie przenumerowywane

TPdf.SaveAsReachable trzyma każdy ocalały obiekt na jego oryginalnym numerze i generacji. Wymiote obiekty są zapisywane jako wolne wpisy w tabeli xref (ISO 32000-1 §7.5.4), luki między żywymi numerami są wypełniane wpisami wolnymi, a obiekt 0 pozostaje głową wolnej listy. Bo nic nie jest przenumerowywane, każde odwołanie, które przeżyło, pozostaje poprawne bez pojedynczego bajtu przepisywania, i nie ma mapowania stary-nowe do sparcia. Kosztem jest kilka nieużywanych linii xref, po 20 bajtów każda, co jest układem wartym zawarcia

uses
  PDFium, FPdfCompress;

procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Report: TPdfPruneReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    if not Pdf.SaveAsReachable(TargetPdf, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
      [Report.SourceByteCount, Report.OutputByteCount,
       Report.KeptObjectCount, Report.PrunedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

TPdfPruneReport niesie SourceByteCount (rozmiar nieprzyrostowej kopii, którą wyprodukował PDFium), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded i ErrorMessage. Przeciążenie z nazwą pliku zapisuje do pliku tymczasowego obok celu i podmienia cel tylko, gdy rewrite się udał, więc nieudany przebieg nigdy nie zostawia po sobie obciętego PDF-a

Których plików te API odmówią?

Wszystkie trzy API przepisu odmawiają dokumentów zaszyfrowanych i, domyślnie, podpisanych. Zaszyfrowane wejście kończy się Succeeded = False i komunikatem błędu zamiast wyjątku: rewrite pojedynczej rewizji nie potrafi utrzymać numerów obiektów, zakresów bajtów i per-obiektowych kluczy, od których zależy słownik szyfrowania, więc wyjście byłoby nieczytelne. Dokument z sygnaturami cyfrowymi podnosi EPdfError, chyba że przekażesz AllowSignedDocument = True, bo pełny rewrite zawsze unieważnia podpisany zakres bajtów. Ustawienie tej flagi znaczy, że przyjmujesz plik, którego sygnatury zgłoszą się jako złamane; to właściwy ruch dla kopii archiwalnej, nigdy dla umowy, którą zaraz odeslesz

Jak SaveAsDeduplicated rozstrzyga, że dwa obrazy to ten sam?

TPdf.SaveAsDeduplicated traktuje dwa zasoby jako identyczne tylko wtedy, gdy ich słowniki, całe osiągalne podgrafy i surowe bajty strumieni wszystkie się zgadzają. Najpierw puszcza to samo przejście osiągalności, potem pobiera odcisk każdego osiągalnego fontu, obrazu XObject i Form XObject, scala każdą grupę trafień na członie o najniższym numerze obiektu i przepisuje każde odwołanie na tego ocalałego. Zscalane duplikaty stają się wolnymi wpisami xref tak samo jak sieroty. To jest poprawka klasykowego zgłoszenia "logo osadzone 400 razy", produkowanego przez narzędzia mail merge, które osadzają te same zasoby na każdej stronie

Odcisk to 64-bitowy hash FNV-1a liczony rekurencyjnie. Dla każdego kandydata walker bierze ciało obiektu, podmienia każde odwołanie N G R na odcisk obiektu, na który wskazuje, i hashuje wynik. To czyni hash niezależnym od numeracji obiektów: dwie kopie tego samego fontu, których deskryptory akurat są obiektami 12 i 87, wciąż produkują tę samą wartość. Wyniki są memoizowane, więc współdzielone podobiekty są hashowane raz, a zbiór odwiedzanych zwraca stałą wartownikową dla krawędzi wstecznych, więc cykliczne grafy się kończą

Subtelny błąd siedzi w ładunku strumienia. Żeby bezpiecznie znajdować odwołania, PDFiumPas skanuje kopię każdego obiektu, w której dane strumienia, stringi i komentarze są zamazane spacjami; inaczej ciąg binarnych danych pikselowych, który akurat wygląda jak 12 0 R, zostałby potraktowany jako odwołanie. Hashuj tylko tę oczyszczoną kopię, a dwa obrazy o identycznych słownikach (te same /Width, /Height, /BitsPerComponent, /Filter, /Length), ale różnych pikselach zwiną się do jednego odcisku i drugie zdjęcie na stronie zostanie po cichu podmienione pierwszym. SaveAsDeduplicated dokleja surowe bajty między stream i endstream do hashowanych danych, więc piksele są częścią tożsamości. Kandydaci są też grupowani po kategorii przed porównaniem, więc rzadka kolizja hasha między fontem a obrazem nigdy ich nie scali

Przepisywanie odwołań używa tego samego triku z oczyszczonym skanem w odwrotnej stronę: odwołania są lokalizowane w zamazanej kopii, ale bajty są kopiowane z oryginalnego ciała, więc modyfikowana jest wyłącznie struktura słowników i tablic, a binarne ładunki przechodzą bez dotknięcia

procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
  Report: TPdfDedupReport;
begin
  if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
    [Report.MergedFontCount, Report.MergedImageCount,
     Report.MergedFormXObjectCount,
     Report.SourceByteCount, Report.OutputByteCount]));
end;

Czytaj TPdfDedupReport uważnie. Obok trzech pól Merged*Count ma KeptObjectCount i PrunedObjectCount, a w tym raporcie PrunedObjectCount liczy wyłącznie zscalane duplikaty. Nieosiągalne obiekty są nadal wyrzucane, ale nie wchodzą do tej liczby. Jeśli potrzebujesz liczby sierot do logów, weź ją z osobnego przebiegu SaveAsReachable. Drugi limit: dopasowanie jest bajt w bajt na zakodowanym strumieniu, więc to samo logo skompresowane przez dwóch różnych producentów na dwóch różnych poziomach Flate nie zostanie scalone

Ponowne sprężanie strumieni Flate na maksymalnym poziomie

TPdf.SaveAsFlateRecompressed nadyma każdy osiągalny strumień FlateDecode i spręża go z powrotem na najwyższym poziomie kompresji, zostawiając wynik tylko wtedy, gdy jest mniejszy i przechodzi twoje progi. Wielu producentów pisze Flate na szybkim domyślnym poziomie, więc strumienie treści i osadzone programy fontów często kurczą się dalej. Numery i generacje obiektów zostają na miejscu; zmienia się tylko ładunek i jego bezpośredni /Length

TPdfFlateRecompressOptions ma dwa pola i żadnego domyślnego konstruktora, więc ustaw oba. MinSavingBytes to minimalne bezwzględne oszczędności na strumień, a MinSavingRatio to ułamek oryginalnej długości (0.05 znaczy 5%); stosunek 0 wyłącza test stosunku. Przebudowany strumień jest zapisywany jako stream, CRLF, nowy ładunek, CRLF i endstream, więc wynik nie zależy od końcówek linii oryginalnego pliku

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // ignoruj malutkie zyski
  Options.MinSavingRatio := 0.05;   // i cokolwiek poniżej 5%
  if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
    [Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
     Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;

Przypadki brzegowe są zachowawcze z założenia. Strumień, którego /Length jest odwołaniem pośrednim, jest pomijany, bo rewrite musi przechować bezpośrednią liczbę całkowitą. Ładunek, który nie chce się nadąć, liczy się do FailedCount i jest kopiowany bez zmian. Strumień Flate, który nie schodzi poniżej progów, liczy się do SkippedCount. Obrazy DCT, JPX, JBIG2 i CCITT nigdy nie wchodzą na listę kandydatów, więc ten przebieg nigdy nie dotyka jakości JPEG. Do stratnych oszczędności obrazów użyj adaptacyjnego przepróbkowania obrazów w PDFiumPas przed końcowym zapisem

Jedna przenośna pułapka z pisania tego testu progów: pierwszy szkic porównywał Single(Saving)/Single(OrigLen) z Saving zadeklarowanym jako Int64. Delphi to przyjmuje, ale FPC 3.2.2 staje z Illegal type conversion: "Int64" to "Single", bo w FPC ta składnia to rzutowanie typów, a nie konwersja liczbowa. Zwykłe Saving / OrigLen to prawdziwe dzielenie w obu kompilatorach i porównuje się wprost z Single, więc rzutowania były po prostu zbędne

W jakiej kolejności puszczać te trzy API?

Puść najpierw SaveAsDeduplicated, wczytaj ponownie wyjście, a potem puść SaveAsFlateRecompressed. Deduplikacja zawiera już przebieg osiągalności, więc osobny przebieg SaveAsReachable przed nią jest redundantny; trzymaj SaveAsReachable do zleceń, gdzie chcesz wyłącznie sprzątanie sierot i dokładną liczbę przyciętych. Sprężanie na drugim miejscu znaczy, że drogie nadmuchiwanie i sprężanie biegnie raz na ocalały strumień, zamiast raz na duplikat. Przebieg ponownego sprężania przechodzi też graf od /Root i /Info jeszcze raz, co sprząta deskryptory fontów albo pliki fontów, na które wskazywał już tylko zscalony duplikat fontu

procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  TempPdf: string;
begin
  TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    MergeDuplicateResources(Pdf, TempPdf);   // przycięcie + scalenie

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // ponowne sprężenie ocalałych
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

Jeśli stosujesz też redakcję, rób to przed czymkolwiek z tego, bo fizyczne usuwanie treści to właśnie to, co rodzi nowe sieroty; stronę redakcji omawia artykuł o redakcji na poziomie operatorów i przycinaniu zasobów. I zawsze wczytuj końcowy plik jako sprawdzian zdrowia w swoim potoku: liczba stron zgodna ze źródłem to tania polisa, zanim nadpiszesz cokolwiek, na czym klientowi zależy

FAQ

Czy SaveAsReachable przenumerowuje obiekty albo zmienia odwołania?

Nie. TPdf.SaveAsReachable trzyma każdy ocalały obiekt na oryginalnym numerze i generacji i oznacza usunięte obiekty jako wolne wpisy xref, więc żadne odwołanie nie musi być przepisywane

Czy SaveAsDeduplicated może przez pomyłkę scalić dwa różne obrazy?

Nie na podstawie samych zgodnych słowników. Odcisk obejmuje surowe bajty strumienia i pełny wskazywany podgraf, a kandydaci są porównywani wyłącznie w ramach tej samej kategorii (font, obraz, Form XObject)

Czy te API utrzymają moje sygnatury cyfrowe ważnymi?

Nie. Każde API robi pełny rewrite, który łamie podpisany zakres bajtów, więc podpisane pliki są odrzucane, chyba że przekażesz AllowSignedDocument = True i przyjmiesz nieważne sygnatury w wyjściu

Wszystkie trzy API są częścią PDFium Component dla Delphi i C++Builder, obok funkcji redakcji i przepróbkowania obrazów omawianych gdzie indziej na tym blogu