Artykuł techniczny

Przegląd adnotacji PDF w Delphi z PDFium Component

Adnotacja PDF to słownik doczepiony do strony, a nie znak na niej narysowany. ISO 32000-1 §12.5 definiuje mniej więcej dwa tuziny podtypów, a każdy niesie /Subtype, prostokąt we współrzędnych strony, zestaw flag i zwykle strumień wyglądu, który decyduje, co przeglądarka faktycznie maluje. Nie wszystkie podtypy znaczą to samo dla człowieka przeglądającego dokument. Highlight i pociągnięcie Ink to komentarze; Link to nawigacja; Popup to małe okienko otwierające się po kliknięciu notatki samoprzylepnej, przechowywane jako własny obiekt i wskazywane przez rodzica. Odpowiedzi to pełne adnotacje Text, które odwołują się do komentarza, na który odpowiadają, przez wpis in-reply-to. Tablica adnotacji na poziomie strony nie jest więc listą komentarzy recenzenta. To płaski worek zawierający komentarze, instalację, która je łączy, i kilka rzeczy, których żaden recenzent nie nazwałby komentarzem. Panel traktujący tę tablicę jako listę komentarzy będzie się nie zgadzał z każdą inną przeglądarką, jaką uruchamia klient

Zbudowanie przepływu przeglądu adnotacji na PDFium Component, komponencie VCL/LCL opartym na PDFium dla Delphi, C++Builder i Lazarusa, oznacza skupienie się na punktach, w których ta przepaść między surową tablicą a ludzkim widokiem sprawia kłopoty: na liczeniu, indeksowaniu, zmianie koloru oznaczeń, które silnik już zamroził, usuwaniu bez zostawiania duchów i dodawaniu własnych oznaczeń

Diagram pokazujący, jak panel przeglądu PDFium w Delphi filtruje surową tablicę adnotacji strony z komentarzami, popupami, odpowiedziami i linkami do wyselekcjonowanej listy komentarzy, którą widzi recenzent
Tablica adnotacji strony miesza komentarze z popupami, odpowiedziami, linkami i ukrytymi oznaczeniami, więc panel przeglądu potrzebuje reguły liczenia, zanim pokaże sumę

Dlaczego twój licznik nigdy nie zgadza się z panelem komentarzy Acrobata

Otwórz oznaczoną umowę w swojej przeglądarce i w Acrobacie obok siebie, a sumy rzadko będą zgodne. Acrobat pokazuje widok wyselekcjonowany: oznaczenia pogrupowane w wątki odpowiedzi, popupy zwinięte w notatki, do których należą, linki i widgety formularzy pominięte. Surowa tablica trzyma to wszystko bez rozróżnienia, więc naiwny licznik jednocześnie zawyża pod jednym względem i zaniża pod innym

Popupy nadmuchują sumę, bo każda notatka samoprzylepna przychodzi z osobnym obiektem Popup, a liczenie obu podwaja notatkę. Odpowiedzi ją zaniżają, jeśli filtrujesz po widocznych oznaczeniach, bo odpowiedź to adnotacja Text, z której nic nie jest malowane, dopóki ktoś nie rozwinie wątku, a jej odrzucenie gubi dyskusję. Flagi Hidden i NoView zdejmują adnotację z ekranu, nie zdejmując jej z tablicy, więc licznik ślepy na flagi obejmuje oznaczenia, których użytkownik nie widzi. Adnotacje Link siedzą w tej samej tablicy co komentarze i nie należą ani do licznika, ani do listy. Ustal regułę liczenia przed napisaniem pętli i zapisz tę decyzję, bo „dlaczego wasz panel pokazuje inną liczbę niż Acrobat” to pierwsze zgłoszenie, na jakie zarabia funkcja przeglądu

Zaindeksuj wszystko raz, a potem nigdy nie parsuj strony ponownie

Jedna reguła projektowa napędza wszystko, co następuje: filtrowanie po autorze, typie albo stronie nigdy nie może parsować obiektów strony od nowa. Na dokumencie o 300 stronach z gęstymi oznaczeniami ponowne parsowanie przy każdej zmianie w liście rozwijanej zamienia panel w coś, co zacina się na całe sekundy. Komponent udostępnia AnnotationCount oraz indeksowaną właściwość Annotation[], obie ograniczone do aktualnie wczytanej strony, a zwracany przez nie rekord TPdfAnnotation niesie to, czego potrzebuje widok listy: Subtype, Flags, Color, Rectangle, ContentsText, AuthorText. Właściwym ruchem jest jednokrotne przejście wszystkich stron przy otwarciu i utrzymywanie własnego płaskiego indeksu:

procedure TReviewPanel.BuildIndex;
var
  PageNo, i: Integer;
  A: TPdfAnnotation;
begin
  FItems.Clear;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    for i := 0 to Pdf.AnnotationCount - 1 do
    begin
      A := Pdf.Annotation[i];
      // Zachowaj tylko podtypy istotne dla recenzenta; zapisz parę
      // strona i indeks, bo wszystkie późniejsze edycje adresują nią
      if A.Subtype in [anText, anHighlight, anInk] then
        FItems.Add(TReviewItem.Create(PageNo, i,
          A.AuthorText, A.ContentsText, A.Rectangle, A.Color));
    end;
  end;
end;

Parą wartą podkreślenia jest (PageNo, i). Każda późniejsza modyfikacja, czy to zmiana koloru, czy usunięcie, jest adresowana numerem strony plus indeksem adnotacji, a indeks jest kruchy: usunięcie adnotacji przenumerowuje wszystko po niej na tej stronie. Zaplanuj więc przebudowę wpisów dotkniętej strony po każdym usunięciu, zamiast łatać numery indeksów w miejscu. Przebudowa kosztuje milisekundę. Nieaktualny indeks za to usuwa komentarz niewłaściwego recenzenta, a to rodzaj błędu, który podkopuje zaufanie do całej funkcji

Wątkowanie zasługuje na miejsce w indeksie, nawet jeśli twoje pierwsze wydanie tylko zlicza odpowiedzi, zamiast je pokazywać. Grupuj pozycje według ich odwołania do rodzica, póki masz stronę otwartą, żeby panel mógł później zwijać wątek tak jak Acrobat. Odtwarzanie tego grupowania leniwie podczas przewijania niweczy cały sens jednorazowego indeksowania, bo otwiera na nowo strony, za których parsowanie już zapłaciłeś. Geometria chce tej samej dyscypliny. Rectangle w każdym rekordzie jest w przestrzeni strony, a przeliczanie go na współrzędne widoku należy do jednego wspólnego helpera, a nie do rozsianych po kodzie miejsc. Panele hodują błędy współrzędnych, gdy zaznaczanie, trafianie i malowanie wymyślają każde własną matematykę powiększenia i obrotu; przepuść wszystkie trzy przez jedno przeliczenie, a wyróżnienie, jego wiersz na liście i jego cel kliknięcia zostają przypięte do tego samego tuszu

Zmiana koloru oznaczeń i weto strumienia wyglądu

Zmiana wyróżnienia z żółtego na bursztynowe brzmi jak jedna linijka i czasem nią jest. Haczykiem jest ISO 32000-1 §12.5.5. Gdy adnotacja niesie strumień wyglądu /AP, zgodna przeglądarka maluje ten gotowy strumień i traktuje wpis koloru w słowniku jako martwe metadane. Acrobat zapisuje strumienie wyglądu praktycznie dla wszystkiego, co tworzy, więc większość adnotacji przychodzących od klientów jest już w tym stanie, a kolor, który tak pewnie ustawiłeś, nigdy nie dociera do ekranu. Zmiana koloru to odczyt-modyfikacja-zapis przez właściwość Annotation[], a komponent jest wobec tego konfliktu uczciwy: gdy silnik odmawia pozwolenia, by kolor ze słownika nadpisał zapieczony wygląd, zapis zgłasza EPdfError

Diagram ścieżki odczyt-modyfikacja-zapis przy zmianie koloru w komponencie PDFium dla Delphi, gdzie zapieczony strumień wyglądu wetuje kolor ze słownika i zgłasza EPdfError
Gdy adnotacja niesie gotowy strumień /AP, silnik odmawia przyjęcia koloru ze słownika i zgłasza EPdfError, więc panel zmienia kolor własnej nakładki albo oznacza wiersz jako zablokowany wyglądem
A := Pdf.Annotation[Item.Index];
A.HasColor := True;
A.Color := $0000B0FF;       // bursztyn
A.ColorAlpha := 160;
try
  Pdf.Annotation[Item.Index] := A;
except
  on EPdfError do
  begin
    // Adnotacja ma własny, wcześniej wyrenderowany strumień /AP; sam
    // kolor ze słownika nie zmieni tego, co malują przeglądarki
    Item.AppearanceLocked := True;
    StatusBar.SimpleText := 'Color is fixed by the annotation appearance';
  end;
end;

Przechwytuj ten wyjątek za każdym razem i traktuj go jako informację, a nie porażkę. Pomiń to zabezpieczenie, a twój panel radośnie pokaże bursztyn na własnej liście, podczas gdy strona dalej maluje żółć; użytkownik zgłosi to tygodnie później jako „wasza przeglądarka ignoruje moje zmiany”, a ty spędzisz popołudnie, bezskutecznie próbując odtworzyć to na pliku, który akurat nie ma strumienia wyglądu. Gdy już wiesz, że wygląd jest zablokowany, masz dwie uczciwe odpowiedzi: zmień kolor własnej nakładki zaznaczenia zamiast adnotacji, żeby recenzent widział przynajmniej wyróżnienie, które wybrał, albo oznacz wiersz jako zablokowany wyglądem, żeby nikt nie oczekiwał, że zmiana się utrzyma

Usuwanie adnotacji bez zostawiania duchów

DeleteAnnotation usuwa obiekt z drzewa adnotacji bieżącej strony, ale zostawia w spokoju buforowany raster strony. Namaluj natychmiast po tym wywołaniu, a usunięte wyróżnienie wciąż jest na ekranie, siedząc w bitmapie, która nie odpowiada już modelowi dokumentu za nią. Naprawą jest traktowanie ponownego renderowania jako części usunięcia, a nie kroku, o którym wywołujący może zapomnieć:

Diagram trzykrokowego cyklu usuwania w PDFium dla Delphi, który usuwa adnotację, renderuje stronę ponownie z reAnnotations i przebudowuje indeks strony
Usunięcie dotyka wyłącznie drzewa adnotacji, więc panel musi wyrenderować ponownie z reAnnotations i przebudować wpisy strony, zanim wyświetlanie i indeks znów będą uczciwe
Pdf.PageNumber := Item.PageNo;
Pdf.DeleteAnnotation(Item.Index);   // zgłasza EPdfError przy niepowodzeniu
Bmp := Pdf.RenderPage(0, 0, ViewWidth, ViewHeight, ro0, [reAnnotations]);
try
  PaintPageBitmap(Bmp);
finally
  Bmp.Free;  // RenderPage przekazuje własność bitmapy wywołującemu
end;
RebuildPageEntries(Item.PageNo);  // indeksy po Item.Index przesunięte

Dwa szczegóły w tym bloku łatwo pomylić. Opcja reAnnotations musi być obecna, inaczej nowy raster upuszcza każdą pozostałą adnotację i strona wygląda, jakbyś wymazał cały zestaw komentarzy zamiast jednego oznaczenia. A Bmp.Free nie jest opcjonalne: przeciążenie RenderPage w postaci funkcji przekazuje własność bitmapy wywołującemu, więc brak zwolnienia wycieka pełnostronicowy raster przy każdym pojedynczym usunięciu, co recenzent przechodzący przez długi dokument zamieni w realną presję na pamięć w ciągu kilku minut

Dodawanie oznaczeń recenzenta z własnego interfejsu

Tworzenie adnotacji idzie przez CreateAnnotation, które przyjmuje wypełniony rekord TPdfAnnotation (podtyp, prostokąt, kolor, treść, autor) i doczepia go do bieżącej strony. Notatka samoprzylepna, podtyp anText, to łatwy przypadek: ustaw pozycję, treść i autora i gotowe. Adnotacje Ink są tym, na czym ludzie się łapią. Prostokąt w rekordzie tylko ogranicza rysunek; same pociągnięcia to tablice punktów, które trzeba doczepić osobno przez wywołanie pociągnięć silnika, FPDFAnnot_AddInkStroke karmione danymi FS_POINTF, przechwytywanymi z myszy albo pióra po jednym pociągnięciu naraz. Zbuduj adnotację Ink z samego prostokąta i niczego więcej, a dostaniesz pusty bazgroł renderujący się jako puste miejsce, co wygląda na błąd silnika, a naprawdę jest niedokończoną adnotacją

Rozstrzygnij przy tej samej okazji politykę autorstwa. Każde oznaczenie tworzone przez twój interfejs powinno nieść spójny AuthorText, bo filtr recenzentów, który zbudujesz w przyszłym miesiącu, będzie tyle wart, ile nazwy stemplowane dziś na komentarzach. Pustych albo niespójnych ciągów autora nie da się naprawić wstecznie bez ponownego otwarcia każdego pliku

Wyprowadzanie przeglądu poza przeglądarkę

Dane przeglądu zarabiają na siebie, gdy mogą opuścić przeglądarkę: jako podsumowanie, które kierownik projektu czyta bez otwierania pliku, albo jako CSV zasilający arkusz śledzenia. Eksportuj z indeksu, który już zbudowałeś, nigdy ze świeżego parsowania, i wybierz stabilny sposób odwoływania się do każdego oznaczenia. Numer strony w parze z prostokątem adnotacji przeżywa obiegi, których nie przeżywa indeks tablicowy, bo następne usunięcie po cichu przenumerowuje indeksy i twój CSV zaczyna wskazywać niewłaściwe komentarze

Wiersz wart zachowania niesie stronę, podtyp, autora, znacznik czasu utworzenia, gdy plik go zapisuje, treść oraz kolumnę statusu, która należy do ciebie, a nie taką, którą dostarcza PDF. Ten sam przebieg indeksujący przydaje się wcześniej, przy przyjmowaniu, gdy dokument przychodzi spoza zespołu i chcesz wiedzieć, co w nim jest, zanim ktokolwiek go przejrzy. Artykuł o warsztacie przyjmowania PDF przechodzi przez tę segregację, a nawigacja po polach formularza omawia problem lustrzany: przeglądanie dokumentów zbudowanych do zbierania danych, a nie komentarzy

Jeden przypadek, którego tablica ci nie pokaże

Jeden tryb awarii zasługuje na oznaczenie, bo wygląda jak defekt w twoim kodzie, a nim nie jest. Klient zgłasza widoczne wyróżnienia na całej stronie, a twój panel nie wypisuje niczego i AnnotationCount wraca z zerem. Zwykłym wyjaśnieniem jest to, że oznaczenia zostały gdzieś wcześniej spłaszczone. Spłaszczanie zapieka wyglądy adnotacji w zwykłą treść strony, więc wyróżnienia stają się częścią grafiki strony i całkowicie przestają istnieć jako obiekty adnotacji. Nie zostaje nic, co API adnotacji mogłoby wyliczyć, przekolorować albo usunąć. Gdy widzisz namalowane oznaczenia przy zerowym liczniku, przestań szukać błędu w swojej pętli wyliczającej i zapytaj, jak powstał ten plik

Użyta tutaj powierzchnia adnotacji, od wyliczania i tworzenia po zmianę koloru, usuwanie i opcje renderowania, które utrzymują uczciwość wyświetlania, jest częścią PDFium Component dla Delphi, C++Builder i Lazarus/FPC