Artykuł techniczny

Spłaszczanie adnotacji PDF bez strumienia /AP w Delphi

HotPDF v2.743.0 spłaszcza adnotacje PDF, które nie mają strumienia wyglądu /AP, zamiast po cichu je pomijać. FlattenLoadedAnnotations kieruje teraz widget bez wyglądu przez EnsureLoadedFieldAppearanceStream i buduje Form XObject dla znaczników bez wyglądu na podstawie właściwości samej adnotacji, dzięki czemu wartości wpisane do formularza /NeedAppearances trafiają do treści strony zamiast znikać podczas spłaszczania. Awaria, która wymusiła tę zmianę, wygląda jak brak działania. Klient przysyła wypełniony formularz wniosku wydrukowany do PDF-a z przeglądarki. Ładujesz go w HotPDF, wywołujesz FlattenLoadedAnnotations, otrzymujesz 0, zapisujesz i dostarczasz dokument z pustymi polami tam, gdzie wnioskodawca wpisał nazwisko i kwotę. Nic nie zostało zgłoszone ani zapisane w logu. Wartości przez cały czas znajdowały się w pliku, w wpisie /V każdego pola, a przebieg flatten przeszedł obok nich, ponieważ żaden z tych widgetów nie miał strumienia wyglądu do zapisania

Dlaczego spłaszczanie formularza wydrukowanego z przeglądarki gubi wpisane wartości?

Bo formularz /NeedAppearances przechowuje wartość, ale nie przechowuje obrazu tej wartości. ISO 32000-1 12.7.2 pozwala formularzowi interaktywnemu ustawić /NeedAppearances true w słowniku AcroForm, co mówi viewerowi, aby przy otwieraniu zbudował wizualną powierzchnię każdego pola z /V, /DA i /Q. Producenci tanio generujący formularze — ścieżki drukowania przeglądarek, wypełniacze serwerowe i niektóre frontendy skanerów — korzystają z tej możliwości i nie zapisują w ogóle /AP. Spłaszczanie, zgodnie z algorytmem wyglądu z ISO 32000-1 12.5.5, jest zadaniem transkrypcji: pobierz normalny strumień wyglądu adnotacji, odwzoruj jego /BBox na /Rect, wywołaj go z treści strony przez operator Do, a następnie usuń adnotację. Bez strumienia źródłowego nie ma czego transkrybować. Pierwotna implementacja HotPDF z v2.386.0 traktowała taki przypadek jako "skip", co w izolacji można obronić, ale w całości jest katastrofalne: dokumenty, które najczęściej potrzebują spłaszczania, najrzadziej zawierają wyglądy. Ta sama luka pochłaniała znaczniki — a Highlight z narzędzia recenzji, Square z redakcji i podpis Ink — gdy producent liczył na to, że narysuje je viewer

Gdzie HotPDF włącza syntezę do FlattenLoadedAnnotations

Punkt zaczepienia jest celowo późny: po nieudanym wyszukiwaniu wyglądu, a nie przed nim. FlattenLoadedAnnotations nadal najpierw pyta GetLoadedAnnotationAppearanceStream o normalny wygląd, a adnotacja, która już go ma, jest zapisywana dokładnie tak jak w v2.386.0. Tylko wynik nil, dla adnotacji z nieosobliwym /Rect i bez flagi hidden, wchodzi na ścieżkę syntezy. Ta kolejność ma znaczenie: autor dokumentu, który zadał sobie trud zapisania /AP, otrzymuje z powrotem własne bajty, a nie rekonstrukcję HotPDF

Diagram przepływu decyzyjnego HotPDF: FlattenLoadedAnnotations najpierw pyta GetLoadedAnnotationAppearanceStream, adnotacja, która już niesie /AP, jest zapisywana jak wcześniej, a wynik nil przechodzi przez zabezpieczenia nieosobliwego /Rect i flagi hidden oraz rozdziela się według Subtype na EnsureLoadedFieldAppearanceStream dla widgetów albo SynthesizeMarkupAppearance dla znaczników, zanim syntetyzowany Form XObject trafi do treści strony
Ścieżka syntezy otwiera się dopiero po nieudanym zwykłym wyszukiwaniu wyglądu i po przejściu zabezpieczeń geometrii oraz widoczności, więc dokumenty niosące prawdziwe strumienie /AP zachowują bajty własnego autora
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
  if Subtype= 'Widget' then
  begin
    FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
    if FieldIdx>= 0 then
      EnsureLoadedFieldAppearanceStream(FieldIdx);
    // zapytaj ponownie: generator dołączył do widgetu /AP /N
    NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
  end
  else
    NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;

Od tego miejsca dwie rodziny adnotacji rozdzielają się. Widget jest rozwiązywany z powrotem do pola właściciela przez GetLoadedFormFieldIndexForAnnotation i przekazywany do EnsureLoadedFieldAppearanceStream, generatora wyglądu pola obecnego w tej bibliotece PDF dla Delphi od v2.328.0. Ponowne użycie go zamiast pisania drugiego renderera pól jest całym sensem rozwiązania — obejmuje już fonty Type0, zawijanie wierszy, quadding, stany checkboxów i radio /AS oraz obrót /MK, czyli tę samą maszynerię, która stoi za dodawaniem pól AcroForm do już załadowanego PDF-a. Wszystko inne trafia do syntezatora znaczników. Dla wywołującego nic się nie zmienia: to samo jednoliniowe wywołanie flatten zwraca teraz niezerową liczbę dla dokumentów, które wcześniej zwracały zero

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: widgety i znaczniki bez AP są syntetyzowane, a potem zapisywane
  Flattened:= Doc.FlattenLoadedAnnotations;          // wszystkie strony, wszystkie podtypy
  // Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
  if Flattened= 0 then
    raise Exception.Create('nothing was flattened');
  Doc.SaveLoadedDocument('flattened.pdf');
finally
  Doc.Free;
end;

Dlaczego QuadPoints i InkList trafiają w złe miejsce?

Bo te współrzędne są w przestrzeni użytkownika strony, podczas gdy syntetyzowany strumień wyglądu rysuje we własnej przestrzeni /BBox, a oba początki nie są tym samym punktem. Tabela 176 ISO 32000-1 definiuje /QuadPoints dla adnotacji znaczników tekstowych w domyślnej przestrzeni użytkownika, a tabela 174 robi to samo dla końców /L adnotacji linii; /InkList podąża za tą samą konwencją. HotPDF nadaje syntetyzowanemu formularzowi /BBox o wartości [0 0 W H], którego początek leży w lewym dolnym rogu /Rect. Każdy punkt pobrany z /QuadPoints, /L albo /InkList musi więc zostać przesunięty o zanegowany lewy dolny róg /Rect, zanim zostanie zapisany do strumienia treści. Pomyłka sprawi, że zaznaczenie na linii 700 punktów wyżej na stronie zostanie narysowane 700 punktów nad własnym polem, co w praktyce oznacza brak rysunku. Poprawka to jedno odejmowanie na współrzędną i dobrze składa się z cm emitowanym później przez bake — ta macierz mapuje /BBox z powrotem na /Rect, więc oba kroki znoszą się do poprawnej geometrii absolutnej

Diagram współrzędnych HotPDF: QuadPoints, końce /L i ścieżki /InkList przychodzą w przestrzeni użytkownika strony według tabel 174 i 176 ISO 32000-1, podczas gdy budowany przez SynthesizeMarkupAppearance wygląd rysuje w /BBox, którego początek leży w lewym dolnym rogu /Rect, więc HotPDF odejmuje RL i RB od każdej współrzędnej, zanim cm emitowany przez bake zmapuje pole z powrotem
Każdy punkt pobrany z /QuadPoints, /L albo /InkList jest przesuwany o zanegowany lewy dolny róg /Rect, zanim trafi do syntetyzowanego strumienia, a cm z zapisu znosi to przesunięcie, przywracając absolutną geometrię strony
// Końce /L są w przestrzeni użytkownika strony (ISO 32000-1 tabela 174); początek
// BBox leży w lewym dolnym rogu /Rect, więc przesuń o -(RL, RB)
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
  StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
  _FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
  _FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;

Co faktycznie rysuje syntetyzowany wygląd znacznika

Syntezator znaczników odczytuje wyłącznie słownik adnotacji, dzięki czemu wynik jest przewidywalny i uczciwie pokazuje, czego nie da się wiedzieć. FreeText i Stamp rysują /Contents z użyciem fontu i koloru odczytanych z /DA, wyrównanych przez /Q, z marginesem 2 pt. Square rysuje re, a Circle obrys złożony z czterech łuków Béziera, obrysowany w /C i wypełniony przez /IC, jeśli jest obecne, szerokością z /BS /W. Line i Ink obrysowują swoje wierzchołki. Highlight wypełnia każdy quad, a Underline, StrikeOut i Squiggly rysują linię na dole quada, w jego środku albo jako zygzak o jednym punkcie. /CA poniżej 1 staje się ExtGState z wpisem ca, referencjonowanym jako /GSA gs na początku strumienia

Diagram odwzorowania HotPDF, co SynthesizeMarkupAppearance rysuje dla każdego podtypu: FreeText i Stamp renderują /Contents fontem i kolorem wyczytanym z /DA i wyrównanym przez /Q, Square i Circle obrysowują /C i wypełniają /IC szerokością z /BS, Line i Ink obrysowują swoje wierzchołki, Highlight wypełnia każdy quad, a Underline, StrikeOut i Squiggly rysują linie albo zygzak
Syntezator znaczników czyta wyłącznie słownik adnotacji i nic więcej, co utrzymuje wynik każdego podtypu przewidywalnym i uczciwie pokazuje podtypy, które słusznie pozostawia nietknięte

Kodowanie tekstu jest wybierane z wpisu /DR /Font AcroForm o nazwie wskazanej przez /DA. Jeśli /Subtype tego fontu to Type0, HotPDF zapisuje łańcuch jako szesnastkowy literał UTF-16BE ze znacznikiem kolejności bajtów FEFF; w przeciwnym razie zapisuje escapowany literał, w którym nawiasy i odwrotne ukośniki są escapowane, a bajty powyżej 126 zapisywane oktalnie. Operator Tf z /DA jest emitowany przed BT, co jest legalne, bo stan tekstu trwa poza granicą obiektu tekstowego, i pozwala uniknąć rozbierania łańcucha /DA. Warto jasno nazwać dwa ograniczenia. Szerokość linii używana do zawijania i quadding jest szacowana heurystyką pół em / pełne em zamiast prawdziwymi metrykami fontu, więc wyrównanie w foncie proporcjonalnym jest bliskie, ale nie dokładne. Podtyp, dla którego nie da się niczego syntetyzować — Popup, Link albo Stamp, którego jedyną treścią jest nazwa ikony — daje nil i pozostaje nietknięty, dokładnie jak wcześniej

Tymczasowa zamiana /Annots, która karze za pomocne porządki

FlattenOneWidget, ścieżka pojedynczego widgetu używana przez FlattenLoadedFormFields, jest pułapką aliasowania, którą musi respektować każda zmiana we wspólnej pętli flatten. Tymczasowo zastępuje wartość /Annots strony tablicą z jednym elementem, aby ogólny przebieg flatten działał na jednym widgecie, a następnie przywraca pierwotny wskaźnik PHPDFDictionaryItem w bloku finally. Przywrócenie zapisuje do slotu słownika przechwyconego przed wywołaniem

DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
  Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
  DictItem^.Value:= Item;   // wiszący, jeśli wewnętrzna pętla zwolniła ten element
  TemporaryAnnots.Free;
end;

Dodaj rozsądnie wyglądające porządki do wspólnej pętli wewnętrznej — DeleteValue('Annots') po opróżnieniu tablicy, aby zachowana strona nie miała kosmetycznej pustej tablicy — a to wywołanie zwolni właśnie element słownika wskazywany przez DictItem. finally zapisze wtedy przez wiszący wskaźnik, a proces zakończy się komunikatem "Invalid pointer operation". Dwa istniejące testy wykryły to natychmiast, co jest jedynym powodem, dla którego trafiło do przypisu, a nie do zgłoszenia wsparcia. Zasada jest szersza: przed dodaniem porządków do wspólnej pętli sprawdź w wywołujących kontrakty aliasowania albo zamiany. Pozostawiona pusta tablica /Annots jest kosmetyczną skazą i nie warto za nią płacić utratą gwarancji czasu życia wskaźnika

Co pozostaje niespłaszczone i ile kosztuje flatten

Ukryte adnotacje są wykluczone celowo. Adnotacja, której liczba całkowita /F ma ustawiony bit na pozycji 2, jest ukryta zgodnie z ISO 32000-1 12.5.3, a gdy nie ma również /AP, pojawia się realna pokusa, aby zsyntetyzować wygląd i zapisać go jak resztę. Byłby to błąd o konsekwencjach bezpieczeństwa: zapisanie niewidocznej notatki w treści strony uczyniłoby ją widoczną dla każdego, kto otworzy plik. HotPDF pozostawia takie adnotacje dokładnie tam, gdzie były, i nie zlicza ich w wartości zwrotnej. Równie jasno trzeba mówić użytkownikom o cenie adnotacji, które zostaną zapisane. Spłaszczanie jest nieodwracalne — adnotacja jest usuwana z tablicy /Annots strony, a jej wygląd staje się teraz treścią strony, więc nie ma już edycji wartości pola, wątku komentarzy, przełączania stanu /AS ani sposobu odzyskania danych strukturalnych poza oryginalnym plikiem. Spłaszczaj kopię, zachowaj oryginał i sięgaj po niego dopiero wtedy, gdy dokument przestaje być formularzem, a staje się rekordem. Jeśli problem dotyczy XFA, a nie braku wyglądu, zacznij od osobnej ścieżki spłaszczania XFA do AcroForm w HotPDF, a jeśli dopiero budujesz formularz, notatki o podłączaniu akcji pól AcroForm i walidacji opisują stronę zapisu

Jedna uwaga dotycząca weryfikacji oszczędzi ci popołudnia. ExtractLoadedPageGlyphs nie schodzi do Form XObject, a zapisany wygląd znajduje się właśnie wewnątrz niego — strumień treści strony zawiera tylko sekwencję q ... cm /FlatAn<n> Do Q. Ekstrakcja glifów ze spłaszczonej strony nie zwróci więc niczego i jest to poprawne zachowanie, a nie utrata zapisu. Weryfikuj albo na poziomie bajtów, sprawdzając nazwę zasobu /FlatAn, wywołanie Do i /Subtype /Form, albo przez pipeline renderowania, który rozwija XObjecty

Spłaszczanie adnotacji wygląda jak trzy linie transkrypcji dopóty, dopóki nie spotkasz dokumentów, które ludzie naprawdę generują. Jeśli pracujesz z wypełnionymi formularzami, znacznikami recenzji albo wynikiem archiwalnym w Delphi lub C++Builder, warto przeczytać, jak komponent PDF HotPDF dla Delphi obsługuje załadowane dokumenty po stronie AcroForms i adnotacji, zanim zbudujesz własny generator wyglądu na jego bazie