Artykuł techniczny

Kopiowanie obiektów PDF między dokumentami w Delphi

Scal dwa PDF ręcznie, przenieś pojedynczy obiekt strony do dokumentu docelowego, a kopia wpadnie prosto w naruszenie dostępu. PDFlibPas naprawia to w CopyForeignObject: kopiuje w głąb jeden obiekt pośredni plus całe jego domknięcie odwołań, a cykliczne odwołania wsteczne, takie jak /Parent, rozwiązuje na null zamiast się zagłębiać

Dlaczego kopiowanie jednej strony między dokumentami się wali?

Bo drzewo stron PDF jest drzewem tylko, gdy czytasz je w dół. Przejdź je tak, jak robi to rekurencyjny kopiujący, podążając za każdą wartością w każdym słowniku, a słownik strony poda ci /Parent, który wskazuje z powrotem na węzeł /Pages, z którego przyszedłeś, a ten węzeł poda ci /Kids, który wskazuje z powrotem na stronę. ISO 32000-1 §7.7.3 czyni /Parent wymaganym w każdym węźle drzewa stron poza korzeniem, więc to nie jest uszkodzony plik, który można odrzucić — to normalny kształt każdego dokumentu, jaki kiedykolwiek dostaniesz do ręki

Drugą połową problemu jest numeracja. Obiekty pośrednie identyfikuje numer obiektu lokalny dla jednego pliku (ISO 32000-1 §7.3.10), więc obiekt przeciągnięty z dokumentu A do dokumentu B musi być przenumerowany, i każde odwołanie do niego wewnątrz kopiowanego domknięcia musi być przenumerowane tak samo, bo inaczej dwa odwołania, które wskazywały na jedną wspólną czcionkę, zaczną wskazywać na dwie niezwiązane rzeczy. Ta przenumeracja to ta sama robota, którą szybkie scalanie wykonuje na poziomie bajtów, i warto czytać obie strony obok siebie: przesuwanie odwołań na poziomie bajtów dla szybkiego scalania PDF rozwiązuje to przez tłumaczenie całych plików, a kopia na poziomie obiektów musi rozwiązywać to krawędź po krawędzi

Dlaczego kopia PDF między dokumentami w Delphi wymaga ostrożności: słownik strony i jego węzeł /Pages zamykają cykl przez /Parent i /Kids, domknięcie czcionki biegnie w dół i się kończy, a PDFlibPas przemapowuje każdy numer obiektu lokalny dla pliku
Drzewo stron zamyka pętlę przez /Parent i /Kids, podczas gdy domknięcia treści się kończą, a każdy kopiowany numer obiektu trzeba przemapować po drodze

Co CopyForeignObject w PDFlibPas faktycznie kopiuje

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) klonuje jeden obiekt pośredni i wszystko, co z niego osiągalne — zagnieżdżone słowniki, tablice, łańcuchy, nazwy, liczby oraz strumienie z nietkniętymi słownikami — do aktualnie wybranego dokumentu i zwraca niezerowy uchwyt do nowego odwołania pośredniego. Numery obiektów źródłowych są przemapywane przez żywą mapę trzymaną przez czas trwania wywołania, więc obiekt osiągnięty dwukrotnie w domknięciu jest klonowany raz i współdzielony dwukrotnie. Zwraca zero, bez podnoszenia wyjątku, gdy identyfikator dokumentu źródłowego jest nieznany, gdy źródłem jest sam wybrany dokument albo gdy ObjectNumber jest mniejsze od 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile zwraca 1 przy sukcesie
    SourceDoc := Lib.SelectedDocument;   // po wczytaniu wybrany jest wczytany dokument
    Lib.SelectDocument(TargetDoc);       // kopia trafia do wybranego dokumentu
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Dwa detale gryzą ludzi przy pierwszym uruchomieniu. LoadFromFile odpowiada 1 albo 0, nie identyfikatorem dokumentu, więc uchwyt, którego potrzebujesz, bierze się z SelectedDocument tuż po wczytaniu; a kopia zawsze zapisuje do dokumentu, który SelectDocument ostatnio uczynił bieżącym, nigdy do dokumentu, z którego wczytywałeś. Wewnętrznie rekurencja niesie też twardy sufit głębokości 64, co jest asekuracją przeciw patologicznemu zagnieżdżeniu, a nie mechanizmem obsługi cykli — obsługa cykli jest osobna i zamierzona

Dlaczego zarezerwowanie mapowania Nil nie przerywa cyklu?

Bo Nil w tabeli mapowania znaczy naraz dwie różne rzeczy, a kod nie potrafi ich odróżnić. Oczywista obrona przed cyklem to dodanie wpisu mapy przed rekurencją w głąb obiektu, by cokolwiek zapętlającego się z powrotem znalazło wpis i stanęło. Ale wpis nie może jeszcze trzymać prawdziwego celu — cel nie istnieje, dopóki domknięcie pod nim nie zostanie zapisane — więc trzyma Nil, a wyszukanie, które ma złapać krawędź wsteczną, czyta Nil i wnioskuje, że obiekt nigdy nie był mapowany

// Zepsute: zarezerwowany cel Nil jest nieodróżnialny od "jeszcze nie zmapowany"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // zarezerwowany, nadal Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // uzupełniany dopiero przy wyjściu
end;

Prowadź to przez pętlę strony. Klon strony dochodzi do /Parent, wchodzi rekurencją w węzeł /Pages, który dochodzi do /Kids, który wraca rekurencją do strony — której zarezerwowany wpis nadal czyta Nil, więc jest klonowany drugi raz, i trzeci, a każdy poziom odkłada świeżą ramkę i świeży półzbudowany obiekt. To, co obserwujesz, nie jest też czystym przepełnieniem stosu: zewnętrzne ramki siedzą na odwołaniach, których cele nigdy nie zostały przypisane, więc pierwszy zapis przez któryś z tych slotów to naruszenie dostępu gdzieś, co nie wygląda niczym jak kopia strony, która to spowodowała

Dlaczego zarezerwowanie celu Nil w mapie nie zatrzymuje cyklu w kopii międzydokumentowej PDFlibPas: wyszukanie nie odróżnia wpisu zarezerwowanego od niezmapowanego, więc kopiujący schodzi przez coraz głębsze półzbudowane ramki, aż zapis się wywali
Ponieważ cel Nil odpowiada naraz na dwa różne pytania, krawędź wsteczna nigdy nie jest rozpoznawana, a strona jest klonowana od nowa przy każdym przejściu

Poprawka: jawny stan w toku

Naprawa polega na przestaniu przeciążać Nil i zadaniu pytania wprost. Wpis mapy, którego cel nadal nie jest przypisany, znaczy ten obiekt jest właśnie klonowany, a predykat InProgress testuje dokładnie to, zanim ruszy zwykłe wyszukanie. Gdy jest prawdą, krawędź to cykl z powrotem do przodka bieżącego klonu, i PDFlibPas emituje dla niej obiekt null, zamiast za nią podążać

// Wpis mapy z celem Nil oznacza klonowanie w toku
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... wewnątrz CloneObject, dla odwołania pośredniego:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // cykliczna krawędź wsteczna, nie rekursuj
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // wiszące odwołanie źródłowe
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // zarezerwuj, potem rekursuj
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // uzupełnij
end;
Exit(NewRef);

To jest bezpieczne do uogólnienia wyłącznie dzięki jednemu faktowi strukturalnemu o PDF: cykle w grafie obiektów pojawiają się na odnośnikach wstecznych, nie na krawędziach treści. /Parent w drzewie stron i /Prev w łańcuchu konspektu wskazują w górę albo w tył na coś już odwiedzonego; domknięcie czcionki, XObject obrazu albo XObject formularza biegnie w dół i się kończy. Kopia deskryptora czcionki, przestrzeni barw albo słownika cieniowania jest więc nietknięta podstawieniem null — nic w tych domknięciach nigdy nie trafia w InProgress. Koszt, powiedziany wprost, jest taki, że cykliczna krawędź nie przeżywa kopii. Słownik strony sklonowany tą drogą przychodzi z /Parent jako obiektem null, co ISO 32000-1 §7.3.9 czyni równoważnym brakującemu wpisowi, więc skopiowana strona jest poprawnym obiektem, który nie należy do żadnego drzewa stron, dopóki sam nie podepniesz go do docelowego węzła /Pages i nie poprawisz /Count. Skopiowana pozycja konspektu traci /Prev tak samo i wymaga przebudowy łańcucha rodzeństwa. To uczciwy handel: CopyForeignObject daje ci poprawne domknięcie i zostawia strukturalne przepinanie rodzica wywołującemu, co jest tą samą granicą, w której pracuje zastępowanie stron z zachowaniem numerów obiektów

Poprawka w CopyForeignObject w PDFlibPas dla Delphi: jawny test InProgress uruchamia się przed wyszukaniem w mapie, cykliczna krawędź wsteczna staje się obiektem null, a wywołujący podepina później skopiowaną stronę do docelowego drzewa stron
Jawny stan w toku zastępuje przeciążony Nil, więc krawędź wsteczna rozwiązuje się na null, a wywołującemu zostaje jedna naprawa strukturalna do wykonania

Dlaczego wpis mapy musi zostać zarezerwowany przed NewObjRef

Oczywista alternatywa ominęłaby cały taniec ze stanem w toku: alokuj najpierw pusty obiekt-powłokę, zarejestruj jego prawdziwy numer w mapie, a potem wypełnij powłokę, gdy dzieci zostaną sklonowane. Tutaj to nie działa, bo TPDFIndObj.Obj jest tylko do odczytu i jego zawartości nie da się wymienić po konstrukcji — nie ma powłoki do wypełnienia. Numer i zawartość rozstrzyga razem NewObjRef, co znaczy, że wpis mapy musi powstać przed wywołaniem rekurencyjnym i zostać uzupełniony po nim, a odstęp między tymi dwoma momentami to dokładnie to, co InProgress musi pokrywać. Konsekwencja warta znajomości, zanim zrobisz diff wyjścia: ponieważ NewObjRef działa po zapisaniu domknięcia dziecka, numeracja w celu wychodzi oddolnie, a numery obiektów nie będą lustrzanym odbiciem kolejności źródłowej. Format pliku nic sobie z tego nie robi, ale porównanie bajtowe z ręcznie zbudowanym oczekiwaniem już tak. Jeśli przebieg zostawi obiekty, które postanowiłeś nigdzie nie podepinać, są to obiekty bez odwołań, a nie uszkodzone, a zbieranie mark-and-sweep nieosiągalnych obiektów PDF to narzędzie, które sprząta je przed zapisem

Regresja pokrywająca to wymaga jednego detalu, który zaskakuje ludzi piszących testy pod TPDFlib: konstruktor już trzyma dokument domyślny, więc DocumentCount startuje od 1 i fixture z dwoma dokumentami musi asertować >= 2, a nie = 2. Obok udanej kopii test przypina trzy odrzucenia — nieznany identyfikator źródła, wybrany dokument jako własne źródło i numer obiektu zero — wszystkie zwracające 0 zamiast podnosić wyjątek, bo pętla scalania to złe miejsce na odkrycie, że klauzula strażnicza rzuca

Gdzie to pasuje w potoku scalania

Kopiowanie na poziomie obiektów to prymityw, po który sięgasz, gdy scalanie całych plików jest zbyt grube: wyjęcie jednego programu czcionki z szablonu, przyciągnięcie pojedynczego XObject formularza do dokumentu stemplującego albo przeniesienie adnotacji z jej strumieniami wyglądu między plikami bez wlokienia reszty strony. PDFlibPas wystawia to jako pojedyncze wywołanie na wczytanych dokumentach, a jak to siedzi obok reszty niskopoziomowego API obiektów, zobaczysz w dokumentacji PDFlibPas Delphi PDF Library