Artykuł techniczny

Rekurencja Form XObject: wykrywanie cykli PDFlibPas w Delphi

PDFlibPas rozwiązuje rekurencyjne wywołania Form XObject w strumieniach zawartości PDF w Delphi, śledząc aktywny łańcuch wywołań, a nie globalny zbiór odwiedzonych, więc TPDFlib.EnumPageContentStatesEx może przejść przez ten sam Form wywołany kilkakrotnie na jednej stronie, nie myląc uzasadnionego ponownego użycia z cyklem. Form XObject pieczątki w szablonie faktury to typowy przypadek: ten sam obiekt jest wywoływany z nagłówka, stopki i warstwy znaku wodnego na jednej stronie, i tylko łańcuch wywołań, który zapętla się sam w sobie, jest prawdziwym cyklem

ISO 32000-1 §8.10 definiuje Form XObject jako samodzielny strumień zawartości, który strona lub inny Form wywołuje operatorem Do, kompletny z własnym układem współrzędnych w /Matrix, granicą przycinania w tym układzie współrzędnych w /BBox oraz opcjonalnie własnym słownikiem zasobów. Nic w specyfikacji nie ogranicza, ile razy jeden Form może zostać wywołany ani jak głęboko Formy mogą się wzajemnie wywoływać, więc zgodny parser musi akceptować uzasadnione ponowne użycie i uzasadnione zagnieżdżenie, wciąż broniąc się przed jednym układem, który specyfikacja rzeczywiście zabrania: Formem, którego strumień zawartości, bezpośrednio lub pośrednio, wywołuje sam siebie. PDFlibPas zgłasza to rozróżnienie przez wartości TPDFlibContentFormTraversalStatus dołączone do każdego zrzutu Do, najbardziej godne uwagi ftsEnumerated dla udanego zejścia i ftsCycle dla jedynego przypadku, który faktycznie jest pętlą

Dlaczego ponowne użycie tego samego Form XObject nie wyzwala fałszywego cyklu?

Powtórzone odwołanie do Form XObject samo w sobie nie jest dowodem niczego złego. ISO 32000-1 pozwala, aby ten sam obiekt Form był wywoływany z tylu miejsc w strumieniu zawartości, ile autor zechce, co jest dokładnie sposobem, w jaki pieczątka logo, szablon papieru firmowego czy stopka z numerem strony jest ponownie wykorzystywana na stronie bez wielokrotnego duplikowania jej strumienia zawartości. Naiwną obroną przed niekontrolowaną rekurencją jest pojedynczy zbiór odwiedzonych kluczowany numerem obiektu: gdy walker po raz pierwszy widzi obiekt Form 12, oznacza 12 jako widziany i odmawia ponownego wejścia do niego gdziekolwiek indziej w drzewie. To podejście psuje się w chwili, gdy ta sama pieczątka pojawia się w dwóch niepowiązanych rogach jednej strony, ponieważ drugie, całkowicie uzasadnione wywołanie przychodzi po tym, jak numer obiektu jest już oznaczony jako widziany, i zostaje odrzucone, jakby było pętlą

PDFlibPas unika tego fałszywego pozytywu, zakresując wykrywanie cykli do bieżącego łańcucha wywołań zamiast całego dokumentu. EnumPageContentStatesEx wpycha rozwiązany strumień Form na aktywny łańcuch wywołań tuż przed zejściem do niego, a następnie zdejmuje ten sam wpis z powrotem w chwili, gdy zejście wraca, powodzeniem lub nie. Wywołanie siostrzane identycznego strumienia zaczyna się dopiero po tym, jak pierwsze zostało już zdjęte, więc łańcuch wywołań jest wolny od tego strumienia, gdy wywołanie siostrzane go sprawdza, a walker wylicza go dokładnie tak, jak zrobiłby to z każdym innym Formem. Prawdziwy cykl wygląda inaczej na tym samym łańcuchu: Form A wywołuje Form B, B jest wciąż otwarty na łańcuchu, gdy jego własna zawartość wywołuje z powrotem A, a A wciąż siedzi na łańcuchu od zewnętrznego wywołania, które jeszcze nie wróciło — to jedyny kształt, który zgłasza ftsCycle, strumień Form wciąż otwarty gdzieś wcześniej na bieżącym łańcuchu wywołań, nie tylko obecny gdzieś indziej na stronie

Jak głęboko może sięgać rekurencja Form XObject, zanim PDFlibPas ją zatrzyma?

Wykrywanie cykli i ograniczanie głębokości rozwiązują dwa różne problemy, a PDFlibPas utrzymuje je jako dwa różne wyniki TPDFlibContentFormTraversalStatus dokładnie z tego powodu. Łańcuch dwudziestu odrębnych Formów, z których każdy wywołuje następny i żaden się nie powtarza, nie jest cyklem według żadnej definicji — sprawdzenie aktywnego łańcucha nigdy nie znajduje powtórzonego strumienia — ale dwadzieścia uczciwych poziomów zagnieżdżenia to wciąż dwadzieścia poziomów parsowania, konkatenacji macierzy i rozwiązywania zasobów, które zniekształcony lub wrogi PDF mógłby dowolnie zwiększyć, gdyby nic innego tego nie zatrzymało. EnumPageContentStatesEx przyjmuje właśnie z tego powodu parametr MaxFormDepth i ogranicza dowolną podaną wartość do maksymalnie 64, niezależnie od tego, o co prosi wywołujący. Głębokość zero to szczególny przypadek wart osobnego poznania: całkowicie wyłącza rekurencję Form i odtwarza płaskie, tylko-stronowe zachowanie starszej metody EnumPageContentStates, dlatego każdy zrzut Do w tym trybie zgłasza ftsNotRequested zamiast czegokolwiek próbować

var
  Lib: TPDFlib;
  States: array of TPDFlibContentGraphicsState;
  Count, I: Integer;
begin
  Lib:= TPDFlib.Create;
  try
    if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
      Exit;
    Lib.SelectPage(1);
    Count:= Lib.EnumPageContentStatesEx(True, 8, States);  // count only
    SetLength(States, Count);
    Lib.EnumPageContentStatesEx(True, 8, States);          // fill
    for I:= 0 to Count- 1 do
      if States[I].FormTraversalStatus= ftsCycle then
        LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
  finally
    Lib.Free;
  end;
end;

Jeden podtracker na wywołanie: izolowanie stanu grafiki

Każde zejście do Form XObject dostaje własny tracker stanu grafiki zamiast dzielić ten, który już przechodzi przez stronę, ponieważ strumień zawartości Formu jest wymagany, aby zostawić stan grafiki dokładnie takim, jakim go zastał, a PDFlibPas nie może założyć, że każdy PDF, który otwiera, faktycznie honoruje ten wymóg. Tracker potomny zaczyna od zrzutu tego, jakie CTM, stan koloru i parametry tekstu były aktywne w wywołującej instrukcji Do, a następnie resetuje własny stos zapisz-i-przywróć oraz śledzenie bieżącej ścieżki do pustego, zanim wykona choć jedną instrukcję Formu. Niezbalansowane q bez pasującego Q wewnątrz niedbałego lub uszkodzonego Formu, niebędące rzadkością w PDF-ach produkowanych przez starsze narzędzia, pozostaje ograniczone wewnątrz trackera tego jednego wywołania i nigdy nie przecieka do trackera strony ani do siostrzanego wywołania tej samej pieczątki siedzącej linię dalej w strumieniu zawartości

Form /Matrix komponuje się z CTM obowiązującym przy Do w ten sam sposób, w jaki robi to operator cm, lewostronnie mnożony z bieżącą transformacją zamiast ją zastępować, a PDFlibPas celowo ponownie wykorzystuje tę jedną ścieżkę kodu zamiast utrzymywać drugą formułę, ponieważ dwie niezależne implementacje tej samej algebry macierzy to dokładnie ten rodzaj duplikacji, który po cichu rozjeżdża się po kilku rundach kompozycji skali, obrotu i pochylenia. /BBox następnie przycina we własnej przestrzeni współrzędnych Formu po tym, jak macierz już została zastosowana, a wszystkie cztery rogi tego pudełka są transformowane indywidualnie, a nie tylko przeciwległe rogi, ponieważ obrócony lub pochylony Form może inaczej zgłosić prostokąt otaczający, który pomija prawdziwą zawartość siedzącą w tym, co kiedyś było skrajnym rogiem, zanim transformacja przeniosła go gdzie indziej. Rozszerzenie pętli z poprzedniego przykładu na tej samej tablicy States odczytuje te pola bezpośrednio

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
    States[I].FormBBoxKnown then
    Writeln('Form ', States[I].XObjectResource, ' matrix ',
      States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
      ' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);

Czy dwa Formy z tą samą nazwą zasobu dzielą jedną czcionkę?

Nie. Nazwa zasobu, taka jak /F1, znaczy coś tylko względem słownika zasobów aktywnego w punkcie, w którym jest używana, a dwa różne Form XObject mogą swobodnie definiować dwie zupełnie różne czcionki pod tą samą identyczną nazwą. PDFlibPas rozwiązuje to, śledząc zakres zasobu obok każdej nazwy zasobu: gdy Form niesie własny słownik /Resources, ten słownik staje się kompletnym zakresem zasobów dla wszystkiego w jego wnętrzu, bez żadnego zapasowego odwołania na klucz do słownika strony czy wywołującego dla czegokolwiek, co własny słownik Formu akurat pomija. Tylko Form bez żadnego klucza /Resources w ogóle, wzorzec wciąż produkowany przez niektóre starsze generatory PDF, dziedziczy słownik wywołującego w całości, i to celowe wyjątkowe ustępstwo na rzecz zgodności, a nie ogólna reguła, na której warto opierać się w nowym wyjściu. Tożsamość czcionki w zrzucie TPDFlibContentGraphicsState to więc para FontResource i FontResourceScope, nie sama nazwa, przy czym FontObjectNumber jest dostępne, aby potwierdzić dokładnie, do którego obiektu pośredniego rozwiązał się dany /F1 w tym konkretnym zakresie

To samo zakresowanie stosuje się do każdego innego nazwanego zasobu, jaki może nieść Form, w tym wpisów ExtGState i zagnieżdżonych wpisów XObject, ponieważ bazowy mechanizm rozwiązywania nie traktuje czcionek specjalnie — przypadek czcionek akurat ma największe znaczenie, ponieważ niedopasowana tożsamość czcionki po cichu produkuje niewłaściwe glify zamiast oczywistej awarii. Kod ekstrakcji, który grupuje przebiegi tekstu wyłącznie po nazwie czcionki, bez grupowania też po zakresie zasobu, scali dwie wizualnie różne czcionki, które akurat dzielą nazwę, a pomyłka nie ujawni się, dopóki ktoś nie zauważy cyfr z niewłaściwego kroju siedzących wewnątrz tego, co miało czytać się jako jedna spójna czcionka

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
    RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
      States[I].FontObjectNumber, States[I].ContentDepth);

Odczyt FormTraversalStatus we własnym potoku

FormTraversalStatus sam z siebie zamienia każdy zrzut Do w mały raport diagnostyczny, a potok, który go ignoruje, wyrzuca dokładnie tę informację, która wyjaśniłaby niekompletną ekstrakcję. ftsNotApplicable oznacza, że instrukcja nigdy nie była rozwiązanym wywołaniem Formu; ftsNotRequested oznacza, że rekurencja była wyłączona dla tego wywołania; ftsEnumerated oznacza, że Form został pomyślnie sparsowany i przejrzany; ftsDepthLimit i ftsCycle oznaczają dwa sposoby, w jakie zejście zostaje celowo skrócone; a ftsMalformed obejmuje wszystko inne, co zatrzymało przejście — nierozwiązywalne odwołanie do strumienia, /Matrix lub /BBox, który nie sparsował się poprawnie, albo wyjątek zgłoszony podczas wykonywania własnej zawartości Formu. Ten ostatni przypadek ma znaczenie operacyjne, ponieważ nieudane zagnieżdżone przejście wycofuje jakiekolwiek częściowe wyjście, które już wygenerowało dla tej gałęzi, więc wywołujący nigdy nie musi zgadywać, czy Form był naprawdę pusty, czy po prostu wybuchł dwie instrukcje w swój strumień zawartości

var
  Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
  Status: TPDFlibContentFormTraversalStatus;
begin
  for Status:= Low(Tally) to High(Tally) do
    Tally[Status]:= 0;
  for I:= 0 to Count- 1 do
    Inc(Tally[States[I].FormTraversalStatus]);
  if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
    FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;

Granice, koszty i gdzie to pasuje

Strumień zawartości Formu jest dekodowany i parsowany dokładnie raz na wywołanie enumeracji, bez względu na to, ile razy Form zostanie wywołany, ponieważ PDFlibPas buforuje sparsowaną listę instrukcji względem bazowego obiektu strumienia, zamiast parsować ją ponownie przy każdym wywołaniu siostrzanym — trójrożna pieczątka z przykładu otwierającego jest dekodowana raz i przechodzona trzy razy, nie dekodowana trzy razy. To, co faktycznie jest odbudowywane przy każdym pojedynczym wywołaniu, to wszystko, co uzasadnienie różni się między jednym miejscem wywołania a następnym: tracker potomny, skonkatenowane CTM, przecięte przycinanie i zakres zasobu. To księgowanie CTM i przycinania na wywołanie to ta sama maszyneria stojąca za trackerem stanu CTM i przycinania strumienia zawartości PDFlibPas, wart przeczytania obok tego artykułu przy każdym przejściu przez strumień zawartości wykraczającym poza samą rekurencję Form

Dwa ograniczenia warto ustawić jako oczekiwania, zanim to API trafi do większego potoku. Sufit głębokości 64 poziomów to nie pokrętło strojenia dla uzasadnienie głębokich dokumentów, ponieważ prawdziwe faktury, wyciągi i szablony raportów zasadniczo nigdy nie zagnieżdżają Formów głębiej niż trzy czy cztery poziomy — dokument, który faktycznie trafia na ftsDepthLimit, znacznie bardziej prawdopodobnie jest zniekształcony lub wrogi niż niezwykle rozbudowany, i warto go zalogować jako sygnał jakości danych, a nie po cichu ponowić z większą liczbą. EnumPageContentStatesEx to też API analizy strony odczytu: zgłasza, co robi strumień zawartości, nie czy Form w ogóle powinien być widoczny, co jest osobnym pytaniem, na które odpowiada stan widoczności grup zawartości opcjonalnej, gdy Form pieczątki lub znaku wodnego siedzi za warstwą, którą przeglądarka mogła przełączyć na wyłączoną. Wykrywanie cykli po łańcuchu wywołań, izolacja na wywołanie i zakresowanie zasobów razem stanowią jeden zakątek powierzchni inspekcji strumienia zawartości w komponencie PDFlibPas dla Delphi i C++Buildera