Artykuł techniczny

Strumienie obiektów i cross-reference w PDF w Delphi

Strumienie obiektów PDF 1.5 pakują wiele małych obiektów pośrednich do jednego kontenera skompresowanego Flate, a losLab PDF Library emituje je przy pełnym zapisie za pomocą flagi PackObjectStreams. Zysk jest realny: setki słowników stron, czcionek i adnotacji, z których każdy kosztuje dziesiątki nieskompresowanych bajtów, zwijają się do garści skompresowanych blobów. Kosztem jest to, że każdy zapakowany obiekt potrzebuje teraz strumienia cross-reference, który go opisuje

Druga połowa jest tam, gdzie zawodzą pisarze plików. Zbudowanie kontenera /ObjStm to arytmetyka; nauczenie mechanizmu cross-reference wskazywania do niego to przeprojektowanie. Writer, który tworzy idealnie poprawny kontener, a potem opisuje jego elementy zwykłymi przesunięciami typu 1, wytwarza plik, który Acrobat otworzy dokładnie na tyle długo, by ogłosić go uszkodzonym. Te dwie funkcje to jedna funkcja, a ten artykuł obejmuje stronę zapisu obu, zgodnie z definicją ISO 32000-1 §7.5.7 i §7.5.8

Co faktycznie zawiera kontener ObjStm

Strumień obiektów to strumień, którego zdekodowane bajty to dwa połączone regiony, a ISO 32000-1 §7.5.7 daje słownikowi dokładnie trzy klucze istotne dla konstrukcji. /Type /ObjStm go identyfikuje, /N podaje liczbę członków, a /First podaje długość bajtową regionu nagłówka — równoważnie, przesunięcie, przy którym zaczyna się ciało. Nagłówek to oddzielone białymi znakami pary numeru obiektu i przesunięcia; ciało to elementy zserializowane jeden za drugim, przy czym każde przesunięcie mierzone jest od początku ciała, a nie od początku zdekodowanego ładunku. Odczytanie w pełni zdekodowanego kontenera czyni to oczywistym: poniżej /First wynosi 14, ponieważ trzy linie nagłówka zajmują czternaście bajtów, a obiekt 7 znajduje się 55 bajtów w ciele, ponieważ obiekt 4 zserializował się do 54 znaków plus separator

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

Dwie zasady członkostwa są absolutne i obie pochodzą bezpośrednio z §7.5.7. Obiekt strumienia nigdy nie może być członkiem, ponieważ strumień niesie surowe bajty, które musiałyby być zagnieżdżone w innym strumieniu. A członek musi być kompletną wartością obiektu, nigdy gołym odwołaniem pośrednim — skompresowany obiekt będący jedynie 5 0 R tworzy pośredniość, której czytnik nie może rozwiązać bez uprzedniej wiedzy, do czego to wskazuje. losLab PDF Library odfiltrowuje oba przypadki podczas zbierania kandydatów, wraz ze słownikiem szyfrowania i obiektem 0, a następnie pakuje to, co przetrwa, w grupach po 200 na kontener. Ten limit to decyzja związana z dostępem swobodnym, a nie ograniczenie ze specyfikacji: czytnik, który chce jednego członka, musi zdekompresować cały kontener, więc zbyt duże kontenery czynią małe wyszukiwania kosztownymi

Dlaczego członkowie ObjStm muszą używać wpisów cross-reference typu 2?

Ponieważ zapakowany obiekt nie ma przesunięcia w pliku do zapisania. ISO 32000-1 §7.5.8 odpowiada na to trzema typami wpisów w binarnym strumieniu cross-reference: typ 0 dla wolnych obiektów, typ 1 dla zwykłych obiektów w użyciu przechowywanych pod przesunięciem bajtowym, i typ 2 dla skompresowanych obiektów, których dwa pola danych trzymają numer obiektu kontenera i indeks członka w nim. Nie ma sposobu na wyrażenie zapakowanego obiektu w klasycznej tekstowej tabeli xref, co jest dokładnie powodem, dla którego PDF 1.5 wprowadził obie funkcje razem

Kolejność, która z tego wynika, myli niemal każdą pierwszą implementację, włącznie z naszą. Zwykłe obiekty dostają wpisy typu 1. Same kontenery /ObjStm dostają wpisy typu 1, ponieważ kontener jest zupełnie normalnym pośrednim obiektem strumienia zapisanym pod prawdziwym przesunięciem. Tylko członkowie dostają wpisy typu 2. A sam strumień cross-reference jest obiektem pośrednim w pliku, więc potrzebuje własnego wpisu typu 1 wskazującego na przesunięcie, pod którym właśnie został zapisany — to samo przesunięcie, które zapisuje startxref. Wczesna wersja naszego writer wykluczała numery obiektów kontenerów z pętli zapisu zamiast wykluczać członków, a rezultatem był plik ze strumieniem cross-reference i bez żadnych strumieni obiektów: strukturalnie spójny, semantycznie pusty, odrzucony dalej. Wartość /Size kryje pasujący błąd o jeden, ponieważ jest to najwyższy numer obiektu plus jeden, a strumień cross-reference jest przydzielony jako najwyższy numer obiektu, więc też musi być policzony

Ustalanie rozmiaru tablicy /W: dlaczego cztery bajty to za mało

Tablica /W deklaruje szerokość bajtową każdego z trzech pól, a losLab PDF Library zapisuje ją jako /W [1 Field2 Field3], przy czym pole 1 jest ustalone na jeden bajt dla kodu typu, a pole 3 ustalone na dwa bajty, co obejmuje zarówno numery generacji do 65535, jak i indeksy członków. Pole 2 jest tym, które nie może być stałe, ponieważ niesie dwie niezwiązane wielkości: we wpisie typu 1 jest to przesunięcie bajtowe ograniczone jedynie rozmiarem pliku, podczas gdy we wpisie typu 2 jest to numer obiektu kontenera, a we wpisie typu 0 jest to następny wolny obiekt w łańcuchu. Stałe czterobajtowe pole 2 działa dobrze, dopóki plik nie przekroczy 4 GB, w którym to momencie każde przesunięcie za granicą po cichu się obcina i cała tabela zamienia się w śmieci. Writer dlatego skanuje zestawioną tabelę pod kątem największej wartości, jaką kiedykolwiek będzie miał slot pola 2, włącznie z przesunięciem samego strumienia cross-reference, i poszerza pole aż do ośmiu bajtów

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

Gdy szerokości są znane, rozmiar ładunku jest znany dokładnie, więc writer wstępnie alokuje cały bufor i wypełnia go indeksem; dopisywanie wpisów bajt po bajcie do AnsiString czyni konstrukcję tabeli kwadratową, czego nikt nie zauważa na dziesięciostronicowej fakturze i wszyscy zauważają na dokumencie z dwustoma tysiącami obiektów. Dwa dalsze szczegóły utrzymują ścisłych czytników zadowolonymi. /Index deklaruje, które zakresy numerów obiektów pokrywa tabela, a dla pełnego przepisania jest to po prostu [0 N] bez luk. I każdy slot, którego writer faktycznie nie wyemitował, musi domyślnie być wolny, a nie w użyciu: obiekt 0 przewodzi łańcuchowi wolnych, każdy wolny slot łączy się z następnym, a slot, który kiedyś zawierał usunięty obiekt, zachowuje swój numer generacji zwiększony o jeden. Towarzysząca notatka o bezpieczeństwie pamięci przy parsowaniu niezaufanych plików PDF podaje ten sam argument dotyczący granic ze strony odczytu

Dlaczego strumień cross-reference nigdy nie może być zaszyfrowany?

Ponieważ czytnik musi go sparsować, zanim będzie w stanie wiedzieć, jak cokolwiek odszyfrować. Strumień cross-reference to to, co mówi czytnikowi, gdzie mieszka słownik /Encrypt; gdyby jego bajty same były zaszyfrowane, czytnik potrzebowałby klucza pliku, żeby znaleźć obiekt opisujący klucz pliku. losLab PDF Library wymusza to w jednym predykacie: ShouldCryptStreamData zwraca False zawsze, gdy słownik strumienia niesie /Type /XRef, więc wyjątek obowiązuje niezależnie od tego, która ścieżka dociera do serializera

Kontener /ObjStm otrzymuje przeciwne traktowanie, a asymetria jest zamierzona. Kontener jest szyfrowany w całości, kluczowany na podstawie własnego numeru obiektu, dokładnie jak każdy inny strumień. Jego członkowie nie są szyfrowani indywidualnie — są zapakowani w swojej odszyfrowanej postaci jawnej, a pojedyncze przejście po zestawionym kontenerze obejmuje je wszystkie, wraz z ciągami znaków. Podwójne szyfrowanie członków wytwarza plik, który deszyfruje się do szyfrogramu, a ponieważ zewnętrzna warstwa się powodzi, awaria wychodzi na jaw jako błąd parsowania głęboko w grafie obiektów, a nie jako błąd uwierzytelniania. Jeden obiekt pozostaje wtedy całkowicie poza schematem: w zaszyfrowanym dokumencie katalog jest przechowywany jako bezpośredni obiekt typu 1 i nigdy nie jest pakowany, ponieważ zapakowanie go zmusiłoby loader do zdekompresowania i odszyfrowania strumienia obiektów, aby dotrzeć do korzenia dokumentu, zanim kontekst deszyfrowania, który pomaga ustanowić ten korzeń, zostanie w pełni zbudowany

Włączanie pakowania z Delphi

Publiczny przełącznik to PackObjectStreams, wystawiony jako pole na TPDFlibSaveOptions, jako samodzielny setter SetPackObjectStreams i jako właściwość na obiekcie dokumentu. Domyślnie jest włączony i automatycznie bramkowany wersją: writer pakuje tylko wtedy, gdy dokument jest już w wersji PDF 1.5 lub nowszej, i wywołuje wewnętrzną straż minimalnej wersji, więc zapakowany dokument zostaje podniesiony do 1.5, a nie błędnie oznaczony. Po zapisie GetLastSaveUsedObjectStreams raportuje, czy brama faktycznie się otworzyła, co jest asercją, jakiej chcesz w teście regresyjnym, zamiast porównania rozmiaru bajtów

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

Kolejność ma znaczenie między pakowaniem a garbage collection. Analiza osiągalności musi zostać uruchomiona jako pierwsza, ponieważ członek, który przetrwa do kontenera, ciągnie za sobą kontener — jeśli żywy obiekt jest zapakowany, numer jego kontenera jest osiągalny z definicji, a zamiecenie kontenera pozostawia członka bez sposobu na zlokalizowanie. Uruchomienie kolektora jako pierwszego oznacza też, że martwe obiekty nigdy w ogóle nie wchodzą do kontenera, co jest źródłem kumulującego się zysku rozmiaru. Pakowanie uzupełnia inne dźwignie rozmiaru, zamiast je zastępować; przegląd optymalizacji rozmiaru pliku PDF i subsettingu czcionek obejmuje dźwignie działające na ładunkach strumieni, podczas gdy strumienie obiektów działają na strukturze

Granice, które warto znać przed włączeniem

Zapisy przyrostowe nigdy nie pakują. Aktualizacja przyrostowa dopisuje nowe obiekty i nową sekcję cross-reference, pozostawiając wcześniejsze wersje fizycznie nietknięte, więc przepakowanie istniejących obiektów do świeżych kontenerów osierociłoby wpisy typu 1, do których nadal odwołuje się poprzednia wersja; losLab PDF Library wyłącza pakowanie, gdy tylko aktywny jest tryb append, a artykuł o aktualizacjach przyrostowych i strumieniowaniu w trybie append obejmuje tę ścieżkę w pełni. Dokumenty poniżej PDF 1.5 bezwarunkowo zachowują tekstową tabelę cross-reference: konsument 1.4 nie ma pojęcia, co znaczy /ObjStm, a ciche promowanie dokumentu, ponieważ writer preferował mniejszy plik, byłoby złym kompromisem do zrobienia w imieniu wywołującego. Jeden opcjonalny klucz, którego celowo nie emitujemy, to /Extends, który ISO 32000-1 §7.5.7 definiuje tak, aby kontener mógł nazwać poprzednika, a czytniki mogły traktować łańcuch kontenerów jako grupę logiczną. Jest naprawdę opcjonalny, każdy zapisywany przez nas kontener jest samowystarczalny i niezależnie dekodowalny, a pominięcie go usuwa z writer całą klasę błędów cykli i wiszących odwołań — chociaż czytniki oczywiście nadal muszą honorować /Extends, gdy napotkają go w plikach od innych producentów

Pakowanie strumieni obiektów i wyjście strumienia cross-reference są dostarczane jako część losLab PDF Library dla Delphi i C++Builder, obok garbage collectora i optymalizatora strumieni treści, z którymi się komponują; strona produktu zawiera pełne odniesienie do opcji zapisu