Wyrzuć siedem stron ze 200-stronicowego podręcznika, a każda zakładka wyląduje gdzieś nie tam. Rozwiązaniem nie jest odbudowa konspektu z płaskiej listy tytułów. PDFiumPas udostępnia TPdfOutlineEditor, który wczytuje prawdziwe drzewo konspektu, pozwala przenosić elementy i przypisywać im nowe cele, a następnie uruchamia ApplyPageMap, aby przepchnąć każdą jawną destynację przez twój plan stron
Dlaczego usuwanie stron psuje każdą zakładkę?
Bo element konspektu nie przechowuje numeru strony. Przechowuje referencję do obiektu strony, a gdy obiekty stron się zmieniają, referencja wskazuje albo stronę, która się przeniosła, albo nic. ISO 32000-1 §12.3.2.2 definiuje jawną destynację jako tablicę, której pierwszy element to referencja pośrednia do słownika strony, a po nim następuje nazwa dopasowania, taka jak /Fit czy /XYZ. Usuń stronę, a zostaje wisząca referencja; przetasuj strony, a referencja jest wciąż ważna, ale opisuje już inny rozdział. PDFiumPas rozwiązuje tę tablicę z powrotem na numer strony przy wczytywaniu, więc TPdfOutlineItem.PageNumber daje indeks strony liczony od jedynki, zgodny z publicznym API TPdf, a nie numer obiektu. W tym cały sens tej abstrakcji: twoja logika remapowania działa w tym samym układzie współrzędnych co plan stron, który już zbudowałeś, dzieląc, porządkując lub składając dokument. Jeśli budujesz ten plan, ta sama konwencja liczenia od jedynki przewija się w artykule o dzieleniu dokumentów PDF na wiele plików i w artykule o impozycji n-up i porządkowaniu stron
Konspekt to dwukierunkowo powiązane drzewo, nie lista
Powodem, dla którego nie da się po prostu zserializować płaskiej tablicy tytułów, jest to, że ISO 32000-1 §12.3.3 wiąże każdy element konspektu pięcioma osobnymi odwołaniami: /Parent, /Prev, /Next, /First i /Last. Przeniesienie pojedynczego poddrzewa przepisuje więc starego rodzica, nowego rodzica, obu sąsiadów po każdej stronie cięcia i punktu wstawienia oraz wskaźnik rodzica samego przenoszonego węzła. Pomyl jedno z nich, a zgodne czytniki pokażą ucięte drzewo albo się zapętlą. PDFiumPas trzyma stan edycji jako tablicę rekordów TPdfOutlineItem przechodzoną w głąb, ze stabilnym całkowitym Id, więc poddrzewo to ciągły wycinek, a łańcuch rodzeństwa jest wyprowadzany, nigdy utrzymywany ręcznie. TPdfOutlineEditor.Move podnosi ten wycinek, wstawia go ponownie pod nowym rodzicem na żądanym indeksie rodzeństwa i przypisuje ponownie tylko korzeń bloku. Odmawia też dwóch przeniesień, które zepsułyby graf: przeniesienia elementu do jego własnego poddrzewa i podania nieistniejącego rodzica
Dlaczego /Count jest podpisany?
Bo znak niesie stan rozwinięcia, nie rozmiar. Dodatni /Count znaczy, że element jest rozwinięty, a liczba mówi, ilu potomków jest teraz widocznych; ujemny /Count znaczy, że element jest zwinięty. PDFiumPas zapisuje liczbę potomków dla każdego elementu, który ma dzieci, i neguje ją, gdy IsOpen jest False, a przy wczytywaniu odczytuje stan z powrotem jako IsOpen := HasCount and (CountValue > 0). To najczęstszy ręcznie pisany błąd w programach zapisujących konspekty: wyemitowanie niepodpisanej liczby i po cichu wymuszenie rozwinięcia całego drzewa
var
Source, Dest: TMemoryStream;
Editor: TPdfOutlineEditor;
Options: TPdfOutlineEditOptions;
Report: TPdfOutlineValidationReport;
RootId, ChapterId: Integer;
begin
Source := TMemoryStream.Create;
Dest := TMemoryStream.Create;
Editor := nil;
try
Source.LoadFromFile('handbook.pdf');
Options := TPdfOutlineEditOptions.Default; // MaxItems 100000, MaxDepth 64
if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
raise Exception.Create(Report.ErrorMessage);
RootId := Editor[0].Id;
ChapterId := Editor[2].Id;
Editor.Move(ChapterId, RootId, 1); // staje się drugim dzieckiem korzenia
Editor.SetTitle(ChapterId, 'Appendix B');
Editor.SetStyle(ChapterId, [posBold, posItalic]);
Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
Editor.SetExpanded(RootId, False); // zapisuje ujemny /Count
Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');
if not Editor.SaveIncremental(Source, Dest, Report) then
raise Exception.Create(Report.ErrorMessage);
Dest.SaveToFile('handbook-edited.pdf');
finally
Editor.Free;
Dest.Free;
Source.Free;
end;
end;
Retarget obsługuje obie postaci dozwolone przez specyfikację. Przekaż DestinationInAction jako False, a PDFiumPas zapisze bezpośrednią tablicę /Dest; przekaż True, a zapisze akcję Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, zgodnie z ISO 32000-1 §12.6.4.2. W obu przypadkach najpierw zdejmuje z elementu istniejące /Dest i /A, aby te dwie rzeczy nie mogły współistnieć i przeczyć sobie. Sufiks domyślnie wynosi /Fit i musi zaczynać się od nazwy PDF, dlatego pusty lub zniekształcony sufiks od razu zgłasza wyjątek, zamiast produkować tablicę destynacji, której żaden czytnik nie sparsuje
Jak ApplyPageMap pobiera plan stron?
ApplyPageMap przyjmuje dokładnie tę tablicę, którą twój plan stron już zwalidował: NewPageNumbers, indeksowaną starym numerem strony minus jeden, z nowym numerem strony liczonym od jedynki albo zerem, gdy ta strona nie przetrwała. Przechodzi tablicę elementów od końca, żeby usunięcie poddrzewa nigdy nie unieważniło indeksu, do którego jeszcze nie dotarło, a co zrobiło, raportuje przez RemappedDestinationCount i RemovedDanglingItemCount
var
NewPageNumbers: array of Integer;
Report: TPdfOutlineValidationReport;
I: Integer;
begin
// Jeden wpis na stronę ORYGINALNEGO dokumentu
SetLength(NewPageNumbers, OriginalPageCount);
for I := 0 to OriginalPageCount - 1 do
NewPageNumbers[I] := 0; // 0 == ta strona została porzucona
NewPageNumbers[0] := 1; // stara strona 1 -> nowa strona 1
NewPageNumbers[1] := 2;
NewPageNumbers[9] := 3; // stara strona 10 -> nowa strona 3
// True: usuwa całe wiszące poddrzewo. False: zostawia element, zdejmuje jego cel
if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
raise Exception.Create(Report.ErrorMessage);
WriteLn(Format('%d remapped, %d dangling items removed',
[Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;
Flaga DeleteDangling rozstrzyga politykę dla destynacji zmapowanej na zero, i obie gałęzie są zamierzone. Przy True PDFiumPas usuwa element i całe jego poddrzewo, bo węzeł konspektu, którego cel zniknął, zwykle nagłówekuje rozdział, który zniknął razem z nim. Przy False element przeżywa z tytułem i hierarchią nietkniętymi, ale bez /Dest i /A, czego chcesz, gdy człowiek będzie mu przypisywał nowy cel podczas przeglądu. Naprawdę zniekształcone wejście wciąż zawodzi głośno, zamiast być łatanym: ujemny wpis albo destynacja wskazująca poza koniec dostarczonej mapy zwraca False z IssueKind ustawionym na poviInvalidPageMap
Nieprzejrzyste wpisy i uczciwy kompromis
Nie każdy element konspektu ma numer strony, o którym PDFiumPas potrafi rozumować. Trzy rodzaje przechodzą nietknięte: nazwane destynacje, akcje, które nie są /S /GoTo, i nieznane klucze słowników dodane przez cokolwiek, co wyprodukowało plik. Te wczytują się z PageNumber równym zeru, trzymają swoje oryginalne bajty w elemencie i są zapisywane z powrotem dosłownie, chyba że wprost wywołasz na nich Retarget
- Nazwana destynacja to klucz do drzewa nazw dokumentu, więc jej poprawne zremapowanie oznacza rozwiązanie drzewa i przepisanie wpisu docelowego, a nie zgadywanie na poziomie konspektu
- Akcja
/URI,/Launchani JavaScript nie ma żadnej semantyki stron i nie może być po cichu zamieniana w Go-To - Klucze specyficzne dla producenta i destynacje strukturalne są zachowywane, bo porzucanie tego, czego nie rozumiesz, to sposób, w jaki obiegi tam i z powrotem gubią dane
Koszt jest realny i wart powiedzenia wprost: ApplyPageMap pomija te elementy całkowicie, więc dokument, którego zakładki w całości używają nazwanych destynacji, przejdzie przez usunięcie strony z konspektem strukturalnie poprawnym i semantycznie przeterminowanym. To zamierzony wybór — przeterminowany odnośnik, który recenzent może wychwycić, bije pewnie siebie mylnego, którego nikt nie zauważa. Jeśli triujesz przychodzące pliki przed edycją, przebieg inwentaryzacji w warsztacie przeglądu przyjęć PDF powie ci, które dokumenty wpadają do tego worka
Zapis: rewizja przyrostowa, potem niezależne ponowne wczytanie
TPdfOutlineEditor.SaveIncremental dopisuje rzadką rewizję przyrostową, zamiast przepisywać plik. Elementy, które były wczytane, zachowują swoją oryginalną referencję pośrednią do obiektu, łącznie z dokładną generacją, więc istniejące odwołania krzyżowe pozostają ważne; świeży numer losują tylko elementy dodane przez ciebie, alokowane od jednego powyżej maksymalnego numeru obiektu rewizji. Katalog jest aktualizowany w tej samej rewizji, a brakujący wpis /Outlines jest do niego dodawany, gdy źródło nie miało żadnego konspektu
To, co dzieje się po zapisie, to część warta skopiowania. PDFiumPas otwiera strumień docelowy ponownie całkowicie niezależnym edytorem i porównuje wczytane ponownie drzewo z tym w pamięci — liczbę elementów, tytuły, numery stron, sufiksy destynacji, formę akcji kontra bezpośrednia destynacja, style, stan rozwinięcia i relacje rodzicielskie. Każda niezgodność albo każde niepowodzenie wczytania czyści strumień docelowy i zwraca poviVerificationFailure, zamiast wręczać ci plik, który tylko wygląda wiarygodnie. Zaszyfrowane źródła są odrzucane od razu z poviEncryptedInput, bo nowe tytuły i destynacje tworzą treści tekstowe, których nie da się wyprodukować przez skopiowanie traileru /Encrypt do przodu
if not Editor.SaveIncremental(Source, Dest, Report) then
case Report.IssueKind of
poviEncryptedInput:
Log('Source is encrypted; outline editing needs an unprotected copy');
poviInvalidDestination:
Log(Format('Item %d %d targets a missing page',
[Report.ObjectNumber, Report.Generation]));
poviVerificationFailure:
Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
else
Log(Report.ErrorMessage);
end;
Traktuj konspekt jako to, czym jest — powiązany graf obiektów z własnymi niezmiennikami — a usuwanie stron przestaje być katastrofą zakładek, a staje się mapą stron, którą wręczasz jednemu wywołaniu metody. TPdfOutlineEditor, ApplyPageMap i zweryfikowany zapis przyrostowy są w PDFiumPas od wersji v3.98.0 dla Delphi, C++Builder i Lazarus; pełne API możesz przejrzeć i pobrać wersję próbną na stronie produktu PDFium Delphi Component