Technický článek

Round trip appearance anotací v Delphi s PDFium

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ě

Diagram round tripu anotací v PDFium Component, kdy přidání afPrint přes TPdf.Annotation[] a SetAnnotationData zapíše taky prázdné streamy /R a /D přes FPDFAnnot_SetAP a změní čistý appearance slovník PDF A na takový, který veraPDF odmítá, dokud v3.121.1 nehlásí jen appearance, které doopravdy přečetla
Čtení anotace a její zápis zpět beze změny dřív přidal prázdné rollover a down appearance streamy, a přesně tohle selhává v PDF/A — ne flag Print, který jste chtěli přidat

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

Appearance slovník anotace z ISO 32000-1 s položkami normal, rollover a down: PDFium vrací 2 bajty pro chybějící stream i pro existující prázdný, takže oba se přes TPdf přečtou bez obsahu, zatímco jen kontrola na úrovni bajtů jako TPdf.ValidatePdfA najde prázdný stream, který PDF/A zakazuje
Chybějící /R se vrátí k /N; prázdné /R vykreslí prázdný obdélník a pořád selhává v PDF/A a přes záznam jsou oba případy nerozeznatelné

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 FPDFAnnot_GetAP hlásí chybějící appearance stream v PDFium: dvouvolací vzorec vždy vrací aspoň dva bajty za UTF-16 terminátor, stará brána porovnávající SizeOf(FPDF_WCHAR) pustila každé volání a nastavila všechny sentinely HasAppearance na true a brána v3.121.1 vyžaduje víc než terminátor plus sudý počet bajtů
Dva bajty jsou enkódovaný prázdný string, ne důkaz, že appearance existuje; opravený getter bere cokoliv do délky terminátoru jako žádný obsah a zápis zpět zůstává potichu

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