W PDFium Component sprzed v3.121.1 odczytanie adnotacji przez TPdf.Annotation[] i przypisanie rekordu z powrotem mogło dodać puste wpisy /R i /D do jej słownika appearance /AP, nawet gdy oryginał niósł tylko /N. Walidatory PDF/A odrzucają taki słownik. Od v3.121.1 getter zgłasza tylko appearance, który faktycznie odczytał, więc niezmieniony round-trip nie zapisuje niczego nowego. Tę awarię warto rozumieć dokładnie, bo typowym wyzwalaczem jest poprawka, która miała uczynić plik bardziej zgodnym ze standardem, a nie mniej
Co się psuje, gdy zapisujesz adnotację bez zmian?
Krótka odpowiedź: adnotacja zyskuje strumienie appearance, których nigdy nie miała, a plik, który przed twoją edycją przechodził walidację PDF/A, po niej jej nie przechodzi. Typowy scenariusz wygląda tak. Archiwum od klienta przychodzi z adnotacjami kwadratowymi i tekstowymi bez flagi Print, PDF/A wymaga, by każda adnotacja się drukowała, więc przechodzisz pętlą po stronach, dodajesz afPrint i przypisujesz każdy rekord z powrotem. Nic w tym kodzie nie dotyka appearance. Rekord z TPdf.Annotation[] to TPdfAnnotation, a SetAnnotationData zapisuje każde pole, którego wartownik Has* jest ustawiony — dokładnie tak mają działać pary HasContents / ContentsText. Problem w tym, że getter ustawiał HasAppearanceRollover i HasAppearanceDown na True z pustymi łańcuchami dla trybów, które nie istniały, a setter grzecznie zapisywał dwa puste strumienie:
procedure MarkAnnotationsPrintable(const FileName: string);
var
Pdf: TPdf;
PageNo, I: Integer;
A: TPdfAnnotation;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for PageNo := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := PageNo;
for I := 0 to Pdf.AnnotationCount - 1 do
begin
A := Pdf.Annotation[I];
if not (afPrint in A.Flags) then
begin
A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
// Przed v3.121.1 to przypisanie zapisywało też puste strumienie /AP/R i
// /AP/D, gdy źródłowa adnotacja miała tylko /AP/N
Pdf.Annotation[I] := A;
end;
end;
end;
Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
finally
Pdf.Free;
end;
end;
ISO 32000-1 §12.5.5 definiuje słownik appearance z trzema wpisami: /N dla appearance normalnego, /R dla rollover i /D dla down. /R i /D są opcjonalne, a gdy ich brak, przeglądarka cofa się do /N. Pusty strumień /R to jednak nie to samo co brak. To poprawny strumień, który niczego nie maluje, więc przeglądarka honoryjąca appearance rollover pokazuje pusty prostokąt, gdy tylko kursor wjedzie nad adnotację. PDF/A jest jeszcze surowszy: ISO 19005-1 (z Corrigendum 2) oraz ISO 19005-2 / 19005-3 dopuszczają w słowniku appearance adnotacji tylko /N. veraPDF raportuje plik po round-tripie pod regułą 6.5.3-4 dla PDF/A-1 i regułą 6.3.3-2 dla PDF/A-2 i PDF/A-3, a wbudowany TPdf.ValidatePdfA wypisuje go jako pvaiAnnotationApDictViolation. Edycja, która dodała flagę Print, żeby spełnić jeden paragraf standardu, złamała inny
Dlaczego FPDFAnnot_GetAP zwraca 2 dla brakującego appearance?
PDFium nigdy nie zwraca zera z FPDFAnnot_GetAP, nawet gdy żądany strumień appearance nie istnieje. Funkcja idzie za zwykłym wzorcem dwóch wywołań PDFium: podaj bufor nil, żeby dostać wymagany rozmiar w bajtach, alokuj, potem wołaj ponownie, by skopiować tekst UTF-16LE. Rozmiar zawsze zawiera terminator UTF-16, więc brakujący strumień zgłasza 2 bajty: pusty łańcuch plus jego terminator. Getter sprzed v3.121.1 testował ByteLength >= SizeOf(FPDF_WCHAR), warunek, który spełnia każde wywołanie, więc wszystkie trzy flagi HasAppearance* wracały jako True dla dowolnej adnotacji z jakimkolwiek appearance. Round-trip przez rekord prosił potem FPDFAnnot_SetAP o zapisanie pustego łańcucha dla każdego trybu, a PDFium tworzył strumień, żeby go pomieścić. Żadnego wyjątku, żadnego ostrzeżenia, a widoczna strona wyglądała identycznie — dlatego defekt wypłynął na fixturze veraPDF, a nie w przeglądarce
Jak v3.121.1 rozstrzyga, że appearance istnieje
ReadAppearance, helper wewnątrz GetPageAnnotation wypełniający AppearanceNormal, AppearanceRollover i AppearanceDown, traktuje teraz wynik jako treść tylko wtedy, gdy niesie co najmniej jeden znak ponad terminator. Pierwsze wywołanie musi zwrócić więcej niż SizeOf(FPDF_WCHAR) bajtów i parzystą liczbę bajtów, bo nieparzysta długość nie może być UTF-16. Drugie wywołanie, które faktycznie kopiuje tekst, jest walidowane ponownie: zwrócona długość 2 lub mniejsza, albo większa niż zaalokowany bufor, kasuje HasValue na False i zostawia łańcuch pusty. Po stronie zapisu nic się nie zmieniło. SetAnnotationData woła FPDFAnnot_SetAP nadal tylko dla trybów, których flaga HasAppearance* jest True, więc rekord odczytany z adnotacji mającej tylko /N zapisuje teraz z powrotem tylko /N. Fixtura regresji pokrywa obie strony: kwadratowa adnotacja z normalnym appearance, odczytana i zapisana bez zmian, przechodzi PDF/A-1b, PDF/A-2b i PDF/A-3b, a ta sama adnotacja z odebraną flagą Print wywala się na oczekiwanej regule flagi i na niczym więcej
Brakujące i puste strumienie wyglądają identycznie, więc getter zostaje konserwatywny
Natywne API nie odróżni brakującego strumienia appearance od istniejącego, ale pustego, i PDFium Component nie udaje, że jest inaczej. Oba przypadki zwracają te same 2 bajty z FPDFAnnot_GetAP, więc oba czytają się jako HasAppearanceRollover = False z pustym AppearanceRollover. To niesie dwie konsekwencje, wokół których warto projektować. Po pierwsze, wartownik False znaczy "nie odczytano treści, więc zapis zwrotny zostawi ten tryb w spokoju", a nie "klucz /R nie istnieje w słowniku". Po drugie, rekord nie wykryje pustego strumienia, który już siedzi w pliku: dokument uszkodzony przez starszy build albo inne narzędzie czyta się jako czysty, a przypisanie rekordu z powrotem go ani nie naprawia, ani nie pogarsza. Żeby znaleźć takie pliki, potrzebna jest kontrola na poziomie bajtów — do tego służą TPdf.ValidatePdfA i workflow walidacji wstępnej PDF/A z PDFium Component
Jak celowo wyczyścić appearance?
Ustawiasz wartownik jawnie i podajesz pusty łańcuch; setter go zapisuje. Zablokowanie pustych łańcuchów w SetAnnotationData byłoby toporną poprawką tego błędu, ale złamałoby też wywołujących, którzy czyszczą appearance celowo — ten sam kontrakt, którego HasContents i HasAuthor przestrzegają dla tekstu. Poprawka mieszka więc w całości w getterze, a setter wciąż spełnia to, o co prosi wywołujący:
// Zamień appearance rollover, po czym wyczyść go ponownie
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// A.HasAppearanceRollover jest True, a tekst przechodzi round-trip jako 'q Q'
A.HasAppearanceRollover := True; // jawne ponowienie intencji
A.AppearanceRollover := ''; // celowo zapisz pusty strumień
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Czyta się z powrotem jako HasAppearanceRollover = False z pustym łańcuchem:
// pusty strumień i brakujący są tu nie do odróżnienia
Miej na uwadze, że jawnie opróżnione /R albo /D nadal liczy się jako dodatkowy klucz pod przywołanymi wyżej regułami PDF/A. Jeśli celem jest profil archiwalny, zapis niepustego /N i zostawienie dwóch pozostałych trybów nietkniętych to jedyny kształt, który przechodzi walidację. Każdy workflow przenoszący adnotacje między dokumentami, jak eksport i import XFDF z PDFium Component, powinien trzymać się tej samej reguły: kopiuj tryby, które źródło faktycznie miało, a pozostałe wartowniki zostaw False
Wzorzec odczyt-modyfikacja-zapis, który zostaje bezpieczny dla PDF/A
Zaktualizuj do v3.121.1 lub nowszej, zostaw wartowniki appearance dokładnie tak, jak zwrócił je getter, i zwaliduj zapisany plik, zanim wyślesz go w świat. Ponieważ nieaktualny pusty strumień czyta się jako nieobecny, krok weryfikacji musi spojrzeć na zserializowany dokument, a nie na rekord, i jest na tyle tani, żeby odpalać go po każdej partii:
uses
PDFium, FPdfPdfa; // FPdfPdfa deklaruje TPdfAValidationIssue
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// Waliduje dokument aktualnie wczytany w Pdf, łącznie z edycjami
// zrobionymi przez Pdf.Annotation[] od momentu otwarcia
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
Ta sama dyscyplina obowiązuje każdy panel, który przekolorowuje albo adnotuje strony do przeglądu — workflow opisuje budowanie workflow przeglądu adnotacji w Delphi z PDFium Component: rekord to migawka tego, co silnik potrafił odczytać, a wartownik, którego sam nie ustawiłeś, powinien wrócić bez zmian. Pełne API adnotacji, preflight PDF/A i natywny silnik PDFium płyną razem w PDFium Component dla Delphi, C++Buildera i Lazarusa