V PDFium Component před v3.121.1 mohlo čtení anotace přes TPdf.Annotation[] a přiřazení záznamu zpět přidat do jejího appearance slovníku /AP prázdné položky /R a /D, i když originál nesl jen /N. Validátory PDF/A takový slovník odmítnou. Od v3.121.1 getter hlásí jen vzhled, který doopravdy přečetl, takže nezměněný round trip nezapisuje nic nového. Tohle selhání stojí za podrobné porozumění, protože obvyklou spouštěčkou je oprava mířená na to, aby byl soubor více v souladu se standardem, ne méně
Co se pokazí, když anotaci zapíšete zpět beze změny?
Stručná odpověď: anotace získá appearance streamy, které nikdy neměla, a soubor, který prošel validací PDF/A před vaší editací, ji po ní neprojde. Typický scénář vypadá takhle. Zákaznický archiv dorazí s čtvercovými a textovými anotacemi bez flagu Print, PDF/A vyžaduje, aby se každá anotace tiskla, takže projdete stránky, přidáte afPrint a přiřadíte každý záznam zpět. Tenhle kód se appearance nedotkne ničím. Záznam z TPdf.Annotation[] je TPdfAnnotation a SetAnnotationData zapisuje každé pole, jehož sentinel Has* je nastavený — přesně takhle mají páry HasContents / ContentsText fungovat. Problém byl, že getter nastavoval HasAppearanceRollover a HasAppearanceDown na True s prázdnými stringy pro módy, které neexistovaly, a setter je pilně zapsal jako dva prázdné streamy:
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];
// Před v3.121.1 tohle přiřazení zapsalo taky prázdné /AP/R a
// /AP/D streamy, když zdrojová anotace měla jen /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 definuje appearance slovník se třemi položkami: /N pro normální vzhled, /R pro rollover a /D pro down. /R a /D jsou volitelné a když chybí, viewer se vrátí k /N. Prázdný stream /R ale přítomný není. Je to validní stream, který nic nevykresluje, takže viewer, který rollover appearance respektuje, ukáže prázdný obdélník v momentě, kdy se ukazatel přes anotaci pohne. PDF/A je přísnější: ISO 19005-1 (s Corrigendum 2) a ISO 19005-2 / 19005-3 dovolují v appearance slovníku anotace jen /N. veraPDF nahlásí round-tripovaný soubor pod pravidlem 6.5.3-4 pro PDF/A-1 a pravidlem 6.3.3-2 pro PDF/A-2 a PDF/A-3 a vestavěné TPdf.ValidatePdfA ho vypíše jako pvaiAnnotationApDictViolation. Editace, která přidala flag Print, aby uspokojila jednu klauzuli standardu, rozbila druhou
Proč vrací FPDFAnnot_GetAP 2 pro chybějící appearance?
PDFium nikdy nevrací z FPDFAnnot_GetAP nulu, ani když požadovaný appearance stream neexistuje. Funkce jde obvyklou cestou dvou volání PDFium: pošlete nil buffer, abyste dostali potřebnou velikost v bajtech, alokujete a zavoláte znovu pro kopii textu UTF-16LE. Velikost vždy zahrnuje UTF-16 terminátor, takže chybějící stream hlásí 2 bajty, prázdný string plus terminátor. Getter před v3.121.1 testoval ByteLength >= SizeOf(FPDF_WCHAR), podmínku, kterou projde každé volání, takže všechny tři flagy HasAppearance* se pro jakoukoli anotaci s jakýmkoli appearance vracely jako True. Round trip přes záznam pak žádal FPDFAnnot_SetAP, aby pro každý mód uložil prázdný string, a PDFium vytvořil stream, který ho uchová. Žádná výjimka, žádné varování a viditelná stránka vypadala stejně, proto se defekt vynořil ve fixture pro veraPDF, ne ve vieweru
Jak v3.121.1 rozhoduje, že appearance existuje
ReadAppearance, pomocník uvnitř GetPageAnnotation, který plní AppearanceNormal, AppearanceRollover a AppearanceDown, teď bere výsledek jako obsah jen tehdy, když nese aspoň jeden znak nad rámec terminátoru. První volání musí vrátit víc než SizeOf(FPDF_WCHAR) bajtů a sudý počet bajtů, protože lichá délka nemůže být UTF-16. Druhé volání, které text doopravdy kopíruje, se validuje znovu: vrácená délka 2 a méně, nebo delší než alokovaný buffer, resetuje HasValue na False a nechá string prázdný. Na straně zápisu se nic nezměnilo. SetAnnotationData stále volá FPDFAnnot_SetAP jen pro módy s flagem HasAppearance* nastaveným na True, takže záznam načtený z anotace, která má jen /N, teď zapíše zpět jen /N. Regresní fixture pokrývá oba směry: čtvercová anotace s normálním vzhledem, přečtená a zapsaná zpět beze změny, projde PDF/A-1b, PDF/A-2b i PDF/A-3b, zatímco tatáž anotace s odebraným flagem Print selže na očekávaném pravidle flagu a na ničem jiném
Chybějící a prázdné streamy vypadají stejně, takže getter zůstává konzervativní
Nativní API nerozliší chybějící appearance stream od streamu, který existuje, ale je prázdný, a PDFium Component se jinak netváří. Oba případy vracejí ze FPDFAnnot_GetAP stejných 2 bajtů, takže oba se čtou zpět jako HasAppearanceRollover = False s prázdným AppearanceRollover. To má dva důsledky, kolem kterých máte designovat. Za prvé, sentinel False znamená „nebyl přečten žádný obsah, takže zápis zpět tenhle mód nechá na pokoji", ne „klíč /R ve slovníku chybí". Za druhé, záznam nedokáže odhalit prázdný stream, který už v souboru je: dokument poškozený starším buildem nebo jiným nástrojem se přečte čistě a přiřazení záznamu zpět ho neopraví ani nezhorší. Na nalezení takových souborů potřebujete kontrolu na úrovni bajtů, k čemuž slouží TPdf.ValidatePdfA a PDF/A předběžná validace v Delphi s PDFium VCL
Jak appearance záměrně vymazat?
Sentinel nastavíte explicitně a pošlete prázdný string; setter ho zapíše. Zablokovat prázdné stringy v SetAnnotationData by byla hrubá oprava tohohle bugu, ale rozbila by i volající, kteří appearance mažou záměrně — týž kontrakt, jaký pro text následují HasContents a HasAuthor. Takže oprava bydlí celá v getteru a setter dál plní, o co volající požádá:
// Nahradit rollover appearance, pak ho zase vymazat
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// A.HasAppearanceRollover je True a text projde round tripe jako 'q Q'
A.HasAppearanceRollover := True; // záměr vynovit explicitně
A.AppearanceRollover := ''; // zapsat prázdný stream záměrně
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Přečte se zpět jako HasAppearanceRollover = False s prázdným stringem:
// prázdný stream a chybějící se tady nedají rozlišit
Mějte na paměti, že explicitně vyprázdněné /R nebo /D se pořád počítá jako extra klíč pod výše citovanými pravidly PDF/A. Pokud je cílem archivní profil, jediný tvar, který validuje, je zapsat neprázdné /N a ostatní dva módy nechat nedotčené. Jakýkoli workflow, který přenáší anotace mezi dokumenty, jako Export a import XFDF v Delphi s PDFium Component, by měl následovat totéž pravidlo: zkopírujte módy, které zdroj doopravdy měl, a nechte ostatní sentinely na False
Vzor čti-uprav-zapiš, který zůstává v bezpečí pro PDF/A
Přejděte na v3.121.1 nebo novější, nechte appearance sentinely přesně tak, jak je getter vrátil, a před odesláním soubor zvalidujte. Protože zastaralý prázdný stream se čte jako absentující, musí se ověřovací krok podívat na serializovaný dokument místo na záznam, a je dost levný na to, aby běžel po každé dávce:
uses
PDFium, FPdfPdfa; // FPdfPdfa deklaruje TPdfAValidationIssue
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// Validuje dokument právě načtený v Pdf, včetně editací
// provedených přes Pdf.Annotation[] od jeho otevření
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
Stejná disciplína platí pro jakýkoli panel, který pro kontrolu přebarvuje nebo anotuje stránky — workflow popsaný v Kontrola anotací PDF v Delphi pomocí komponenty PDFium: záznam je snapshot toho, co engine dokázal přečíst, a sentinel, který jste sami nenastavili, by měl cestovat zpět nezměněný. Kompletní anotační API, PDF/A preflight a nativní engine PDFium shipují dohromady v PDFium Component pro Delphi, C++Builder a Lazarus