Artykuł techniczny

PDFium: usuwanie adnotacji z czyszczeniem OBJR i ParentTree

Usuń adnotację Link z otagowanego PDF-a edytorem działającym wyłącznie na poziomie strony, a strona renderuje się dobrze, podczas gdy drzewo struktury dalej trzyma OBJR, wpis w drzewie nadrzędnym i element Link dla adnotacji, której już nie ma. RemovePdfPageAnnotations w PDFium Component wycofuje adnotację ze strony i na żądanie usuwa razem z nią te elementy struktury

Gdzie otagowana adnotacja wpina się w drzewo struktury

Otagowana adnotacja siedzi na styku czterech obiektów i czyste usunięcie musi odwiedzić wszystkie. ISO 32000-1 §14.7.4.3 mówi, że gdy zawartością elementu struktury jest cały obiekt PDF, na przykład adnotacja, tablica /K tego elementu nazywa go słownikiem odwołania do obiektu (tabela 325): /Type /OBJR, wymagane odwołanie pośrednie /Obj wskazujące adnotację i opcjonalne /Pg nadpisujące własną stronę elementu. To pokrywa kierunek w przód, od drzewa do strony

Jak otagowana adnotacja Link łączy się z tablicą Annots strony PDF, odwołaniem obiektu OBJR, kluczem StructParent i wpisem ParentTree w PDFium Component dla Delphi
Otagowana adnotacja jest podpięta w cztery obiekty, a czyste usunięcie odwiedza odwołanie strony, OBJR, klucz StructParent i parę ParentTree

Droga wstecz to §14.7.4.4. Drzewo nadrzędne to drzewo liczb (§7.9.7) z wpisem dla każdego obiektu będącego elementem zawartości, kluczowanego liczbą całkowitą, którą ten obiekt przy sobie niesie. Adnotacja niesie /StructParent, a jej wartość w drzewie nadrzędnym to pojedyncze odwołanie pośrednie do elementu właściciela; strona, której strumień zawartości trzyma oznaczoną treść, niesie zamiast tego /StructParents, a jego wartość to tablica indeksowana MCID-ami. Tabela 326 dopuszcza najwyżej jeden z tych dwóch kluczy w dowolnym obiekcie, bo obiekt jest bądź w całości elementem zawartości, bądź pojemnikiem na oznaczoną treść. Dla hiperłączy §14.8.4.4.2 pozwala elementowi Link trzymać i widoczne słowa jako odwołania do oznaczonej treści (MCR), i OBJR wskazujący adnotację łącza. Usuń adnotację, a oto inwentaryzacja:

MiejsceCo trzymaCo robi czyste usunięcie
/Annots stronyOdwołanie pośrednie do adnotacjiUsuwa odwołanie; pozostałe zachowują swoją kolejność
/K elementu LinkOBJR z /Obj i /PgUsuwa element zawartości OBJR
Słownik adnotacjiKlucz całkowity /StructParentUsuwa klucz, bo obiekt nie jest już elementem zawartości
Liść /Nums drzewa nadrzędnegoKlucz sparowany z odwołaniem do elementu LinkUsuwa parę klucz-wartość
Korzeń drzewa struktury/ParentTreeNextKeyZostawia go w spokoju

Dlaczego łącze-widmo przeżywa swoją adnotację?

Edytor poziomu strony usuwa adnotację przez wyjęcie jej z /Annots, i stąd idzie jedną z dwóch dróg. Jeśli narzędzie dodatkowo zwalnia obiekt adnotacji, /Obj w OBJR wskazuje teraz obiekt niezdefiniowany, który §7.3.10 każe czytnikowi traktować jako null: element Link posiada element zawartości, który nie rozwiązuje się w nic. Jeśli narzędzie zostawi obiekt w pliku, OBJR nadal się rozwiązuje, ale jego /Pg nazywa stronę, której /Annots już go nie wymienia. W obu przypadkach element Link zachowuje swoje dzieci z oznaczonej treści, więc słowa „Pobierz kartę katalogową” dalej są otagowane jako łącze, a czytnik ekranowy przechodzący drzewo w kolejności czytania może odczytać łącze, którego nic na stronie nie aktywuje. Walidatory PDF/UA takie jak PAC i veraPDF, które sprawdzają zgodność z ISO 14289-1, porównują to, co wymienia strona, z tym, co głosi drzewo, więc rozjazd między nimi może tam wyjść na jaw, nawet gdy wyrenderowana strona wygląda idealnie

Dwie drogi zostawiające łącze-widmo w otagowanym PDF po usunięciu tylko na poziomie strony i jak czyści je PDFium Component w Delphi
Czy obiekt adnotacji zostanie zwolniony, czy zostawiony w tyle, element Link zachowuje swoje słowa, a czytniki ekranowe mogą odczytać łącze prowadzące w nikąd

Usuwanie treści ma własną wersję tego problemu. Usuń obiekty tekstowe wewnątrz /P <</MCID 4>> BDC … EMC i zostaw operatory, a strona trzyma pustą sekwencję oznaczonej treści, której MCR dalej siedzi w elemencie <P>; czytelnik dostaje akapit bez słów. Usuń BDC bez jego EMC, a strumień zawartości jest po prostu popsuty. §14.6 wymaga, aby każda sekwencja oznaczonej treści była otoczona BDC albo BMC i pasującym EMC wewnątrz jednego strumienia zawartości, a osierocony EMC paruje każdą późniejszą sekwencję na stronie z niewłaściwym otwieraczem

Usuwanie adnotacji funkcją RemovePdfPageAnnotations

RemovePdfPageAnnotations w FPdfDocumentStructure przyjmuje strumień źródłowy, indeks strony liczony od zera w kolejności drzewa stron, numery obiektów adnotacji do usunięcia, rekord TPdfAnnotationRemovalOptions i dwa parametry wyjściowe dla nowego pliku oraz TPdfAnnotationRemovalReport. Z TPdfAnnotationRemovalOptions.Default dotyka tylko strony: jedna przyrostowa rewizja niesie słownik strony ze skróconą tablicą /Annots (albo bez klucza /Annots, gdy ostatnia adnotacja znika) plus niezmieniony katalog, więc numery obiektów, identyfikator dokumentu i każdy inny klucz strony przechodzą. Obiekt adnotacji zachowuje swoje ciało, a wywołujący, który dalej trzyma jego numer, może go odczytać. Drzewo struktury zostaje takie, jakie było, co utrzymuje otagowanie nietknięte na wypadek, gdy zaraz skierujesz ten element Link na zastępczą adnotację. Wejście jest sprawdzane, zanim cokolwiek zostanie zapisane:

  • Każdy numer musi widnieć w /Annots wybranej strony i nazywać obiekt /Type /Annot; numer wymieniony dwa razy w jednym wywołaniu jest odrzucany
  • Adnotacje widget są odrzucane, bo widget należy do swojego pola formularza, a zerwanie go ze strony zostawiłoby pole, którego widget żadna strona nie wyświetla
  • Zaszyfrowane źródło jest odrzucane, bo edycja struktury potrzebuje odszyfrowanych bajtów; odszyfruj plik zawczasu
  • MaxOutputBytes, MaxWork i CancellationToken ograniczają pracę, a każde niepowodzenie zwraca False bez wyjścia i z ErrorMessage nazywającym fazę, na której stanęło

Dla dokumentu otagowanego drzewo struktury już mówi ci, które numery adnotacji mają znaczenie. Każdy element OBJR w migawce z ReadPdfDocumentStructure niesie przywołany numer obiektu, a jego numer strony pochodzi z własnego /Pg OBJR-a albo, gdy tego brak, z elementu. Poniższa procedura zbiera wszystkie adnotacje, na które wskazuje element Link na jednej stronie, i usuwa je z obiema opcjami sprzątania włączonymi. Trzymaj filtr strony: numer wzięty z innej strony odrzuca całą partię

uses
  System.SysUtils, System.Classes, System.IOUtils,
  FPdfCompress, FPdfDocumentStructure;

function RemoveTaggedLinks(const InName, OutName: string;
  PageIndex: Integer): TPdfAnnotationRemovalReport;
var
  Source: TMemoryStream;
  Pages: TPdfSparsePageDictionaries;
  ReadOptions: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  ReadReport: TPdfStructureEditReport;
  Item: TPdfDocumentStructureContentItem;
  Targets: array of Integer;
  Options: TPdfAnnotationRemovalOptions;
  Report: TPdfAnnotationRemovalReport;
  Output: TBytes;
  N, I, J: Integer;
  Known: Boolean;
begin
  Report := Default(TPdfAnnotationRemovalReport);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(InName);
    if not ReadPdfSparsePageDictionaries(Source, Pages) or
       (PageIndex < 0) or (PageIndex > High(Pages)) then
      raise Exception.Create('Page index is outside the page tree');
    ReadOptions := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, ReadOptions, Snapshot, ReadReport) then
      raise Exception.Create(ReadReport.ErrorMessage);

    // Każda adnotacja, na którą wskazuje element Link na tej stronie
    for N := 0 to High(Snapshot.Nodes) do
      if Snapshot.Nodes[N].RoleName = 'Link' then
        for I := 0 to High(Snapshot.Nodes[N].ContentItems) do
        begin
          Item := Snapshot.Nodes[N].ContentItems[I];
          if (Item.Kind <> psckObjectReference) or
             (Item.PageObjectNumber <> Pages[PageIndex].ObjectNumber) then
            Continue;
          Known := False;
          for J := 0 to High(Targets) do
            if Targets[J] = Item.ReferencedObjectNumber then Known := True;
          if not Known then
          begin
            SetLength(Targets, Length(Targets) + 1);
            Targets[High(Targets)] := Item.ReferencedObjectNumber;
          end;
        end;
    if Length(Targets) = 0 then
      Exit(Report);

    Options := TPdfAnnotationRemovalOptions.Default;
    Options.RemoveOwnedStructurePeers := True;    // elementy OBJR i wpisy ParentTree
    Options.RemoveEmptiedStructureNodes := True;  // elementy opróżnione do zera
    if not RemovePdfPageAnnotations(Source, PageIndex, Targets, Options,
      Output, Report) then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
    Result := Report;
  finally
    Source.Free;
  end;
end;

Co się dzieje z kluczem ParentTree

Z ustawionym RemoveOwnedStructurePeers każdy OBJR, którego /Obj jest usuniętą adnotacją na tej stronie, odchodzi ze swojego elementu przez edytor struktury, co trzyma odwrócony indeks w kroku. Para klucz-wartość zapisana pod /StructParent adnotacji jest wyciągana z jej liścia /Nums, a sam klucz /StructParent jest usuwany ze słownika adnotacji, bo tabela 326 żąda go tylko na obiektach będących strukturalnymi elementami zawartości. /ParentTreeNextKey nie jest obniżane. §14.7.4.4 wymaga jedynie, aby był większy od każdego klucza w użyciu, czego usunięcie nie potrafi złamać, a klucze drzewa liczb nie muszą być ciągłe, więc writer trzymający się /ParentTreeNextKey nigdy ponownie nie wydaje uwolnionego klucza. Każda ocalała adnotacja zachowuje swój numer obiektu, pozycję w /Annots i klucz w drzewie nadrzędnym; funkcja odczytuje z powrotem zapisaną przez siebie stronę i odrzuca wynik, gdy któraś ocalała zniknęła albo zmieniła miejsce. Oczekiwane: na stronie z jednym otagowanym łączem Report.RemovedAnnotations i Report.RemovedObjectReferences mają obie wartość 1, a Report.SourceSha256 i Report.OutputSha256 odnotowują, które bajty weszły, a które wyszły

Kiedy opróżniony element też powinien odejść?

RemoveEmptiedStructureNodes usuwa elementy, które usunięcie OBJR zostawiło bez elementów zawartości i bez elementów potomnych, najgłębsze najpierw. Działa w turach: każda tura odczytuje strukturę ponownie, usuwa elementy, które są już puste, i dodaje ich rodziców do kandydatów następnej tury, więc łańcuch owijek zapada się od dołu w górę, podczas gdy korzeń drzewa struktury zawsze zostaje. Element Annot tagujący żółtą karteczę zwykle nie trzyma niczego poza OBJR-em (§14.8.4.4.3 na to pozwala), więc opróżnia się i odchodzi. Element Link jest inny. Nadal posiada odwołania do oznaczonej treści dla swoich widocznych słów, więc nie jest pusty i przechodzi — jako Link bez adnotacji. RemovePdfPageAnnotations nie przemianowuje go, bo to, czy te słowa powinny zostać łączem, jest pytaniem redakcyjnym, a nie strukturalnym. Przy czystym usunięciu włącz obie opcje, a potem zdegraduj każdy element Link zostawiony bez OBJR do Span: technologie wspomagające czytają wtedy te słowa jako zwykły tekst, zamiast serwować łącze prowadzące w nikąd

Drzewo decyzyjne dla opróżnionych elementów struktury: element Annot zapada się tura po turze, element Link przechodzi, a PDFium Component może go zdegradować do Span
Opróżnione owijki zapadają się od najgłębszych, podczas gdy korzeń zostaje, a Link zostawiony bez swojego OBJR-a można zdegradować do Span albo zachować dla zastępczej adnotacji
function DemoteUnlinkedLinks(const Data: TBytes; out Fixed: TBytes): Integer;
var
  Source: TBytesStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  N: Integer;
begin
  Result := 0;
  Fixed := Data;
  Source := TBytesStream.Create(Data);
  try
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);
    for N := 0 to High(Snapshot.Nodes) do
      if (Snapshot.Nodes[N].RoleName = 'Link') and
         (Snapshot.Nodes[N].ObjectReferenceCount = 0) and
         (Length(Snapshot.Nodes[N].ContentItems) > 0) then
      begin
        SetLength(Ops, Length(Ops) + 1);
        Ops[High(Ops)] := Default(TPdfStructureEditOperation);
        Ops[High(Ops)].Kind := psekSetRole;
        Ops[High(Ops)].NodeID := Snapshot.Nodes[N].NodeID;
        Ops[High(Ops)].Value := 'Span';
      end;
    if Length(Ops) = 0 then Exit;
    // Skrót migawki strzeże przed zmianą bajtów w międzyczasie
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

Przy połówce zamiany trzymaj domyślne. Opróżniony element zostawiony na miejscu dalej niesie swoją rolę, tytuł, tekst alternatywny i identyfikator elementu, a jeśli późniejszy krok przypnie do niego nową adnotację operacją psekAddObjectReference, która daje adnotacji świeży klucz /StructParent i przesuwa /ParentTreeNextKey według tych samych reguł, które rejestrują nowe elementy MCR i OBJR w drzewie nadrzędnym, element zachowuje swoje miejsce w kolejności czytania. Usunięcie i odtworzenie tego elementu wyrzuciłoby te wartości i dało mu nowy numer obiektu

Usuwanie otagowanej treści: własność biegnie od BDC do swojego EMC

Adnotacje nie są obiektami strony, więc tekst, ścieżki i obrazy idą przez inne API: wsadowy edytor obiektów, PlanStyledPdfObjectEdits i ExecuteStyledPdfObjectEdits w FPdfStyledEdit, którego TPdfObjectEditOptions niesie RemoveOwnedStructurePeers i RemoveEmptiedStructureNodes w tym samym znaczeniu. Otagowana sekwencja oznaczonej treści znika razem ze swoim wpisem struktury tylko wtedy, gdy każdy obiekt między jej BDC a pasującym EMC jest usuwany. Wtedy oba operatory odchodzą ze strumienia zawartości, MCR odchodzi ze swojego elementu, a slot tego MCID-a w tablicy ParentTree strony jest ustawiany na null, zamiast być zasuniętym, bo każdy inny MCID na stronie indeksuje w tę samą tablicę. Sekwencja, która owija też przetrwałą treść, zostaje razem ze swoim MCR-em. Strona, która ponownie używa usuniętego MCID-a dla przetrwałej treści, jest odrzucana wprost, bo MCID-y są ograniczone do strumienia zawartości strony, a wyrzucenie wpisu struktury osierociłoby przetrwałego. Przy opcji zostawionej jako False partia zachowuje zamiast tego pary BDC i EMC na granicy usuwania, więc drzewo struktury dalej się rozwiązuje i nic w nim się nie zmienia

Własność trzeba mierzyć od BDC do końca jego pasującego EMC. Nasza pierwsza wersja mierzyła ją po zakresie bajtów samego operatora BDC, bo tam sparsowany znacznik odnotowuje swoją pozycję, i nie znajdowała żadnych objętych obiektów. Pokrewny błąd to oznaczenie BDC do usunięcia i zgubienie jego partnera, co zapisuje dokładnie niezbalansowany strumień opisany wyżej, więc edytor zawsze usuwa obie połówki pary razem. Poniższa procedura usuwa każdy obiekt najwyższego poziomu niosący jeden MCID. ExecuteStyledPdfObjectEdits przeładowuje Pdf wynikiem, chyba że ustawiono EditOptions.PreviewOnly, a Report.RemovedStructurePeers i Report.RemovedStructureNodes liczą, co odeszło z drzewa; funkcja zwraca ten raport

uses
  System.SysUtils, PDFium, FPdfStyledEdit;

function DeleteTaggedRun(Pdf: TPdf; PageIndex, Mcid: Integer): TPdfObjectEditReport;
var
  Ops: TPdfObjectEditOperations;
  NoSemanticEdits: TPdfObjectSemanticEdits;
  Options: TPdfObjectEditOptions;
  Plan: TPdfObjectEditPlan;
  Preview: TBytes;
  Report: TPdfObjectEditReport;
  I: Integer;
begin
  Pdf.PageNumber := PageIndex + 1;          // PageNumber liczy od 1
  for I := 0 to Pdf.ObjectCount - 1 do
    if Pdf.PageObjectMarkedContentID(I) = Mcid then
    begin
      SetLength(Ops, Length(Ops) + 1);
      Ops[High(Ops)] := Default(TPdfObjectEditOperation);
      Ops[High(Ops)].Kind := poekRemove;
      Ops[High(Ops)].ObjectIndex := I;
    end;
  if Length(Ops) = 0 then
    raise Exception.CreateFmt('No object on page %d carries MCID %d',
      [PageIndex + 1, Mcid]);
  NoSemanticEdits := nil;
  Options := TPdfObjectEditOptions.Default;
  Options.RemoveOwnedStructurePeers := True;
  Options.RemoveEmptiedStructureNodes := True;
  Plan := PlanStyledPdfObjectEdits(Pdf, PageIndex, Ops, NoSemanticEdits, Options);
  if not ExecuteStyledPdfObjectEdits(Pdf, Plan, Options, Preview, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Result := Report;
end;

Usunięta adnotacja po każdym z tych wywołań dalej zajmuje swój obiekt w pliku, bo wycofane zostaje tylko odwołanie ze strony. Gdy OBJR-a już nie ma, nic w grafie katalogu do niego nie sięga, więc TPdf.SaveAsReachable może wymieść ten obiekt-sierotę z przepisanej kopii; przy opcjach domyślnych OBJR trzyma adnotację w zasięgu i zamiatanie zostawia ją też. Przeprowadzaj usuwanie z obiema opcjami struktury włączonymi zawsze, gdy usunięcie jest ostateczne, a strona, drzewo struktury i drzewo nadrzędne w pliku, który zapisuje z powrotem PDFium Component dla Delphi, opisują wszystkie ten sam zestaw adnotacji