Artykuł techniczny

Audyt rozmiaru pliku PDF w Delphi: podział bajtów na kategorie

Aby dowiedzieć się, na co faktycznie idzie rozmiar pliku PDF, losLab PDF Library udostępnia AuditDocumentSpace, który klasyfikuje każdy obiekt pośredni do dwunastu kategorii — obrazy, programy czcionek, słowniki czcionek, strumienie treści, formularze XObject, strumienie obiektów, osadzone pliki, metadane, drzewo struktury, adnotacje, drzewo stron, inne — i raportuje liczbę obiektów, liczbę zapisanych bajtów oraz procentowy udział każdej z nich

Sytuacja, dla której to powstało, jest znajoma. 40-stronicowy raport wychodzi z Twojego generatora jako 80 MB, klient pyta dlaczego, a Ty możesz zaoferować tylko domysł. Pewnie obrazy. Może czcionki. Włączasz więc downsampling, wysyłasz, a plik ląduje na 74 MB, bo prawdziwy ciężar siedział zupełnie gdzie indziej. Nasz artykuł towarzyszący o subsetowaniu czcionek i downsamplingu obrazów opisuje, jak zmniejszyć PDF; ten opisuje krok, który powinien nastąpić najpierw, czyli zmierzenie tego, co zamierzasz zmniejszyć

Dlaczego mierzyć przed kompresją?

Ponieważ trzy standardowe przebiegi optymalizacji mają diametralnie różne zyski w zależności od pliku, a nic w samym pliku nie mówi Ci, który z nich zastosować, dopóki nie policzysz. Subsetowanie czcionek w dokumencie, którego czcionki to już 2% bajtów, to popołudnie spędzone na przesuwaniu błędu zaokrąglenia. Downsampling obrazów w pliku, którego masa to nieskompresowane strumienie treści, daje to samo rozczarowanie. Optymalizator nie jest trudną częścią — każda biblioteka go ma. Trudną częścią jest wiedza, który optymalizator skierować na dany plik, a to pytanie księgowe, nie kompresyjne. Audyt wyłapuje też przypadki, w których żaden optymalizator nie jest odpowiedzią: plik, który okazuje się w 60% osadzonymi załącznikami, nie potrzebuje lepszej kompresji, potrzebuje rozmowy o tym, czy te załączniki w ogóle powinny być w dokumencie, a plik, który jest w 30% drzewem struktury, płaci za tagowanie dostępności, co zwykle jest celowym kosztem, którego nie powinno się po cichu ucinać. Gdy bajty są już przypisane, podejmujesz decyzję produktową popartą liczbami, zamiast sięgać po najbliżej leżący przełącznik

Co zawiera raport w dwunastu kategoriach

AuditDocumentSpace zwraca uchwyt listy ciągów znaków, a nie rekord, więc raport przechodzi przez płaskie fasady DLL i COM bez zmian. Lista zawiera linię podsumowującą Total,Objects,Bytes,100.0, po której następuje dokładnie dwanaście linii Category,Objects,Bytes,Percent w ustalonej kolejności, która jest częścią kontraktu: Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other. Trzynaście linii, zawsze, nawet gdy jakaś kategoria jest pusta

var
  Lib: TPDFlib;
  ListID, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    ListID := Lib.AuditDocumentSpace;   // 0 when no document is selected
    if ListID = 0 then
      Exit;
    try
      // GetStringListItem is 1-based: items run 1..GetStringListCount
      for I := 1 to Lib.GetStringListCount(ListID) do
        Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
    finally
      Lib.ReleaseStringList(ListID);
    end;
  finally
    Lib.Free;
  end;
end;

Jeden szczegół dotyczący Delphi w tej pętli ugryzie Cię dokładnie raz. GetStringListItem używa indeksów elementów liczonych od jedynki, zgodnie z GetStringListCount, a indeks spoza zakresu zwraca pusty ciąg zamiast zgłaszać wyjątek. Napisz pętlę z przyzwyczajenia jako for I := 0 to Count - 1, a dostaniesz pustą pierwszą linię, po cichu pominiętą ostatnią linię i żadnego wyjątku, który powiedziałby Ci, że indeksowanie jest błędne. Sam raport będzie wyglądał niemal poprawnie, co jest najgorszym możliwym trybem awarii dla narzędzia diagnostycznego

Dlaczego audyt używa zapisanej długości zamiast rozmiaru zdekodowanego?

Ponieważ zapisana długość jest zarazem liczbą, której potrzebujesz, i liczbą tanią do uzyskania. Każdy obiekt pośredni niesie TPDFIndObj.FLength, surową długość w bajtach, jaką obiekt zajmuje w pliku w postaci sparsowanej. Użycie jej oznacza, że obraz 900 KB w DCTDecode jest raportowany jako 900 KB — bajty, które kosztuje Cię na dysku — a nie jako 40 MB próbek RGB, do których się dekoduje. Oznacza to też, że audyt nigdy nie musi niczego dekodować: obiekty wczytywane leniwie zostają leniwe, filtry pozostają nieuruchomione, a audytowanie pliku 500 MB to przejście po nagłówkach obiektów, a nie pełny cykl dekompresji

Druga reguła jest zabezpieczeniem przed podwójnym liczeniem. Gdy obiekt żyje wewnątrz skompresowanego strumienia obiektów, na co wskazuje niezerowe FObjStrNum, jego liczba bajtów jest zapisywana jako zero. Jego przechowywanie zostało już raz opłacone przez strumień kontenera, który ISO 32000-1 §7.5.7 definiuje jako strumień /Type /ObjStm zawierający wiele obiektów w jednym ładunku skompresowanym Flate. Naliczenie każdemu członkowi jego udziału, a potem ponowne naliczenie kontenerowi, zawyżyłoby sumę powyżej rzeczywistego rozmiaru pliku. Ma to bezpośrednią konsekwencję dla tego, jak czytać wynik, co omawiamy poniżej i głębiej w naszym artykule o strumieniach obiektów i strumieniach cross-reference

Dlaczego program czcionki nie może sam się sklasyfikować?

Ponieważ plik czcionki TrueType osadzony w PDF nie ma żadnego znacznika mówiącego, czym jest. ISO 32000-1 §9.8.1 definiuje osadzony program czcionki jako wartość /FontFile, /FontFile2 lub /FontFile3 w deskryptorze czcionki, a słownik strumienia po drugiej stronie tej referencji niesie klucze /Length1 i filtry, ale nie ma ani /Type, ani /Subtype, które identyfikowałyby go jako czcionkę. Widziany osobno jest anonimowym strumieniem binarnym. Tylko deskryptor, który na niego wskazuje, wie, czym on jest. Ta sama asymetria pojawia się przy adnotacjach: §12.5.2 czyni /Type /Annot opcjonalnym w słowniku adnotacji, więc wiarygodnym sygnałem jest przynależność do tablicy /Annots strony, nie sam słownik

Klasyfikacja przebiega więc dwukrotnie. Pierwszy przebieg czyta własny /Type i /Subtype każdego obiektu i zbiera łatwe zdobycze: /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font i /Type /FontDescriptor, /Metadata, /EmbeddedFile i /Filespec, /StructTreeRoot i /StructElem, /Annot, /Page i /Pages. Wszystko inne trafia tymczasowo do Other. Drugi przebieg przechodzi po stronie odwołującej się i nadpisuje: każdy słownik strony przypisuje swój /Contents do strumieni treści, swoje wpisy /Annots do adnotacji, a swój /Thumb do obrazów, podczas gdy każdy słownik czcionki przechodzi po własnym łańcuchu deskryptora

// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
  MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
  MarkRef(Descriptor.FindValueByKeyName('FontFile'),  catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
  MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));

Czytanie raportu i wybór kolejnego kroku

Czytaj najpierw udziały procentowe, liczby obiektów w drugiej kolejności, a każdą dużą rozbieżność między nimi traktuj jako sygnał. Nowoczesny PDF umieszcza większość swoich małych słowników wewnątrz strumieni obiektów, więc Page tree i Structure tree rutynowo pokazują dziesiątki obiektów przy niemal zerowej liczbie bajtów — ich rzeczywisty koszt został wliczony do linii Object streams. Jeśli sama linia Object streams jest duża, plik jest gęsty od struktury przypominającej metadane, a nie od treści, i dźwignią jest przycinanie obiektów, a nie ich kompresja. Strumienie wyglądu adnotacji zachowują się podobnie: niosą /Subtype /Form, więc mocno ostemplowany dokument pokazuje swój ciężar pod Form XObjects, podczas gdy linia Annotations pozostaje mała

function CategoryShare(Lib: TPDFlib; ListID: Integer;
  const Category: string): Double;
var
  I: Integer;
  Parts: TArray<string>;
  Inv: TFormatSettings;
begin
  Result := 0;
  Inv := FormatSettings;
  Inv.DecimalSeparator := '.';   // the report is locale-independent
  for I := 2 to Lib.GetStringListCount(ListID) do   // line 1 is Total
  begin
    Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
    if (Length(Parts) = 4) and SameText(Parts[0], Category) then
      Exit(StrToFloatDef(Parts[3], 0, Inv));
  end;
end;

Dwa fakty dotyczące formatowania mają znaczenie, jeśli parsujesz procenty zamiast je tylko wyświetlać. Separator dziesiętny to zawsze dosłowna kropka, niezależnie od locale maszyny, więc parsowanie z użyciem otaczających FormatSettings na stacji roboczej z ustawieniami niemieckimi czy francuskimi zawiedzie albo, co gorsza, odczyta błędnie. A końcowe zera są przycinane, więc kategoria zawierająca dokładnie 40% bajtów wypisuje się jako 40, nie 40.0 — nigdy nie zakładaj stałej liczby miejsc po przecinku. Mając udział w ręku, dalsze kroki są mechaniczne: dominujący udział Images wskazuje na DownsampleImages, dominujący udział Font programs na SubsetEmbeddedFonts, a masywne Content streams na CompressContent

Czego audyt celowo nie mówi

Suma jest sumą po obiektach pośrednich, a plik PDF to nieco więcej niż jego obiekty. Nagłówek pliku, trailer, biały znak między obiektami i klasyczna tablica cross-reference nie są obiektami pośrednimi, więc te bajty nie są nigdzie przypisane, a suma audytu wypada nieco poniżej rozmiaru na dysku. Strumień cross-reference jest inny — to prawdziwy obiekt z /Type /XRef, więc w nowoczesnym pliku te bajty się pojawiają, w kategorii Other. Żadne z tych zachowań nie jest wadą, ale jeśli uzgadniasz wynik audytu z liczbą bajtów z systemu plików, to stąd bierze się rozbieżność

Warto jasno wskazać jeszcze dwie granice. Po pierwsze, liczby opisują plik, który został wczytany, nie taki, który jest tworzony: dla obiektów zbudowanych w pamięci, które nie mają jeszcze zapisanej długości, rozmiar spada do zserializowanego wyniku z nominalnym marginesem na słownik strumienia, co jest szacunkiem docelowego zapisu, a nie pomiarem. Audytuj po zapisie i ponownym wczytaniu, jeśli chcesz dokładnych liczb. Po drugie, gruba linia Other to obserwacja, nie zgłoszenie błędu — zwykle oznacza osierocone obiekty, do których nic już się nie odwołuje, co jest zadaniem dla garbage collection typu mark-and-sweep, a nie dla żadnego przebiegu kompresji

Użyty w ten sposób audyt zmienia kształt rozmowy. Zamiast zgadywać przy raporcie 80 MB, otwierasz go, wykonujesz jedno wywołanie i czytasz, że obrazy to 8%, programy czcionek to 61%, a dokument osadza dziewięć pełnych programów czcionek dla stylu firmowego używającego trzech krojów. To odpowiedź, którą da się naprawić, z liczbą przy niej. AuditDocumentSpace, razem z przebiegami optymalizacji, do których kieruje, jest dostarczany w losLab PDF Library dla Delphi i C++Builder, gdzie strony dokumentacji opisują pełną listę kategorii i otaczające ją API listy ciągów znaków