Artykuł techniczny

Edycja konspektu PDF i remapowanie stron w Delphi

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

Edycja konspektu PDFiumPas w Delphi: przeniesienie rozdziału 3 z części I pod korzeń dokumentu przepisuje wskaźnik /Parent przenoszonego węzła oraz odwołania /First i rodzinne /Prev i /Next wokół cięcia i punktu wstawienia
Jedno wywołanie Move przepisuje wskaźnik rodzica podniesionego poddrzewa i odwołania rodzeństwa po obu stronach cięcia i punktu wstawienia

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

Jak PDFiumPas koduje stan rozwinięcia konspektu w Delphi: dodatni /Count znaczy, że element jest rozwinięty i liczy widocznych potomków, ujemny /Count znaczy zwinięty, a niepodpisana liczba wymusza na każdym czytniku rozwinięcie całego drzewa
Znak /Count to stan rozwinięcia, a wartość bezwzględna to liczba widocznych potomków, więc niepodpisana liczba po cichu rozwija całe drzewo
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

Jak ApplyPageMap w PDFiumPas przekierowuje zakładki PDF w Delphi: mapa stron indeksowana starym numerem minus jeden kieruje przetrwałe destynacje na ich nowe numery stron, a wpisy zmapowane na zero są albo usuwane razem z poddrzewem, albo pozbawiane celu
Mapa stron jest indeksowana starym numerem minus jeden, a wpis zerowy albo usuwa wiszące poddrzewo, albo zostawia element pozbawiony celu

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, /Launch ani 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