Odborný článok

Round tripy vzhľadov anotácií v Delphi s PDFium

V PDFium Component pred v3.121.1 mohlo prečítanie anotácie cez TPdf.Annotation[] a priradenie recordu späť pridať do jej slovníka vzhľadov /AP prázdne položky /R a /D, aj keď pôvodný niesol len /N. Validátory PDF/A taký slovník odmietajú. Od v3.121.1 getter hlási len vzhľad, ktorý naozaj prečítal, takže nezmenený round trip nezapisuje nič nové. Toto zlyhanie stojí za detailné pochopenie, pretože obvyklý spúšťač je oprava, ktorá má súbor spraviť viac súladným, nie menej

Diagram round tripu anotácie v PDFium Component, kde pridanie afPrint cez TPdf.Annotation[] a SetAnnotationData zapisuje aj prázdne streamy /R a /D cez FPDFAnnot_SetAP a mení čistý slovník vzhľadov PDF A na taký, ktorý veraPDF odmieta, kým v3.121.1 nehlási len vzhľady, ktoré naozaj prečítala
Prečítanie anotácie a zápis späť bez zmeny pridával prázdne streamy vzhľadov rollover a down a presne toto padá na PDF/A, nie flag Print, ktorý ste chceli pridať

Čo sa pokazí, keď anotáciu zapíšete späť nezmenenú?

Krátka odpoveď: anotácia získa streamy vzhľadov, ktoré nikdy nemala, a súbor, ktorý pred vašou úpravou prešiel validáciou PDF/A, ju po nej neprejde. Typický scenár beží takto. Zákaznícky archív príde so štvorcovými a textovými anotáciami bez flagu Print, PDF/A vyžaduje, aby tlačila každá anotácia, takže prejdete stránky, pridáte afPrint a každý record priradíte späť. Nič v tom kóde sa nedotýka vzhľadov. Record z TPdf.Annotation[] je TPdfAnnotation a SetAnnotationData zapisuje každé pole, ktorého sentinel Has* je nastavený, presne takto majú fungovať páry HasContents / ContentsText. Problém bol v tom, že getter nastavil HasAppearanceRollover a HasAppearanceDown na True s prázdnymi reťazcami pre režimy, ktoré neexistovali, a setter ich poslušne zapísal ako dva prázdne 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];
          // Pred v3.121.1 toto priradenie zapisovalo aj prázdne /AP/R a
          // /AP/D streamy, keď zdrojová anotácia mala len /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 slovník vzhľadov s troma položkami: /N pre normálny vzhľad, /R pre rollover a /D pre down. /R a /D sú voliteľné a keď chýbajú, viewer prepadne na /N. Prázdny stream /R ale nie je neprítomný. Je to platný stream, ktorý nič nenamaľuje, takže viewer rešpektujúci vzhľady rollover ukáže prázdny obdĺžnik v momente, keď sa kurzor nad anotáciou pohne. PDF/A je ešte prísnejšie: ISO 19005-1 (s Corrigendum 2) a ISO 19005-2 / 19005-3 pripúšťajú v slovníku vzhľadov anotácie len /N. veraPDF hlási round-tripovaný súbor pod pravidlom 6.5.3-4 pre PDF/A-1 a pravidlom 6.3.3-2 pre PDF/A-2 a PDF/A-3 a vstavané TPdf.ValidatePdfA ho vypíše ako pvaiAnnotationApDictViolation. Úprava, ktorá pridala flag Print kvôli jednej klauzule štandardu, pokorila inú

Slovník vzhľadov anotácie z ISO 32000-1 s položkami normal, rollover a down: PDFium vracia 2 bajty pre chýbajúci stream aj pre existujúci prázdny, takže oba sa cez TPdf načítajú ako bez obsahu, kým len kontrola na úrovni bajtov ako TPdf.ValidatePdfA nájde prázdny stream, ktorý PDF/A zakazuje
Chýbajúce /R prepadne na /N; prázdne /R namaľuje prázdny obdĺžnik a aj tak padne na PDF/A a cez record sú tie dva nerozlíšiteľné

Prečo vracia FPDFAnnot_GetAP 2 pre chýbajúci vzhľad?

PDFium nikdy nevracia z FPDFAnnot_GetAP nulu, aj keď požadovaný stream vzhľadu neexistuje. Funkcia nasleduje obvyklý PDFium dvojfázový vzor: pošlite nil buffer, aby ste dostali potrebnú veľkosť v bajtoch, alokujte a zavolajte znova na skopírovanie UTF-16LE textu. Veľkosť vždy zahŕňa terminátor UTF-16, takže chýbajúci stream hlási 2 bajty, prázdny reťazec plus terminátor. Getter pred v3.121.1 testoval ByteLength >= SizeOf(FPDF_WCHAR), kontrolu, ktorou prejde každé volanie, takže všetky tri flagy HasAppearance* sa vrátili True pre akúkoľvek anotáciu s akýmkoľvek vzhľadom. Round trip cez record potom požiadal FPDFAnnot_SetAP o uloženie prázdneho reťazca pre každý režim a PDFium vytvoril stream, ktorý ho má niesť. Žiadna výnimka, žiadne varovanie a viditeľná stránka vyzerala rovnako, preto sa defekt objavil vo fixtúre pre veraPDF, nie vo vieweri

Ako hlási FPDFAnnot_GetAP chýbajúci stream vzhľadu v PDFium: dvojfázový vzor vždy vracia aspoň dva bajty pre terminátor UTF-16, stará brána porovnávajúca s SizeOf(FPDF_WCHAR) prepustila každé volanie a nastavila všetky sentinely HasAppearance na true a brána v3.121.1 vyžaduje viac než terminátor plus párny počet bajtov
Dva bajty sú zakódovaný prázdny reťazec, nie dôkaz, že vzhľad existuje; opravený getter berie všetko na dĺžke terminátoru a menej ako bez obsahu a zápis späť ostáva tichý

Ako v3.121.1 rozhoduje, že vzhľad existuje

ReadAppearance, helper vnútri GetPageAnnotation, ktorý plní AppearanceNormal, AppearanceRollover a AppearanceDown, teraz berie výsledok ako obsah len vtedy, keď nesie aspoň jeden znak nad rámec terminátoru. Prvé volanie musí vrátiť viac než SizeOf(FPDF_WCHAR) bajtov a párny počet bajtov, lebo nepárna dĺžka nemôže byť UTF-16. Druhé volanie, ktoré text naozaj kopíruje, sa validuje znova: vrátená dĺžka 2 a menej alebo väčšia než alokovaný buffer resetuje HasValue na False a nechá reťazec prázdny. Na strane zápisu sa nezmenilo nič. SetAnnotationData stále volá FPDFAnnot_SetAP len pre režimy s flagom HasAppearance* True, takže record prečítaný z anotácie, ktorá má len /N, teraz zapisuje späť len /N. Regresná fixtúra pokrýva oba smery: štvorcová anotácia s normálnym vzhľadom, prečítaná a zapísaná späť bez zmeny, prejde PDF/A-1b, PDF/A-2b aj PDF/A-3b, zatiaľ čo tá istá anotácia s odobratým flagom Print padne na očakávanom pravidle flagu a na ničom inom

Chýbajúce a prázdne streamy vyzerajú rovnako, preto getter ostáva konzervatívny

Natívne API nedokáže odlíšiť chýbajúci stream vzhľadu od takého, ktorý existuje, ale je prázdny, a PDFium Component netvrdí inak. Oba prípady vrátia z FPDFAnnot_GetAP tých istých 2 bajtov, takže oba sa načítajú ako HasAppearanceRollover = False s prázdnym AppearanceRollover. Z toho vyplievajú dva dôsledky, na ktoré si máte návrh pripraviť. Po prvé, sentinel False znamená "nebol prečítaný žiadny obsah, takže zápis späť nechá tento režim na pokoji", nie "kľúč /R chýba v slovníku". Po druhé, record nedokáže odhaliť prázdny stream, ktorý už v súbore je: dokument poškodený starším buildom alebo iným nástrojom sa načíta čistý a priradenie recordu späť ho ani nevylieči, ani nezhorší. Na ich nájdenie potrebujete kontrolu na úrovni bajtov, na čo sú TPdf.ValidatePdfA a workflow PDF/A preflight validácie s PDFium Component

Ako vymazať vzhľad zámerne?

Nastavíte sentinel explicitne a podáte prázdny reťazec; setter ho zapíše. Blokovať prázdne reťazce v SetAnnotationData by bola tupá oprava tohto bugu, ale rozbila by aj volajúcich, ktorí vzhľad mažú zámerne, tú istú zmluvu, ktorú pri texte dodržiavajú HasContents a HasAuthor. Oprava preto žije celá v getteri a setter naďalej plní, o čo volajúci poprosí:

// Nahraďte rollover vzhľad a potom ho znovu zmažte
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 prejde round tripom ako 'q Q'
A.HasAppearanceRollover := True;   // výslovne potvrďte úmysel
A.AppearanceRollover := '';        // zámerne zapíšte prázdny stream
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Načíta sa ako HasAppearanceRollover = False s prázdnym reťazcom:
// prázdny stream a chýbajúci sú tu nerozlíšiteľné

Majte na pamäti, že výslovne vyprázdnené /R alebo /D sa pod pravidlami PDF/A citovanými vyššie stále počíta ako extra kľúč. Ak je cieľom archívny profil, zápis neprázdneho /N a nechanie ostatných dvoch režimov na pokoji je jediný tvar, ktorý prejde validáciou. Každý workflow presúvajúci anotácie medzi dokumentmi, ako export a import XFDF s PDFium Component, by mal nasledovať to isté pravidlo: kopírujte režimy, ktoré zdroj naozaj mal, a ostatné sentinely nechajte False

Vzor read-modify-write, ktorý ostáva v bezpečí PDF/A

Prejdite na v3.121.1 alebo novšiu, nechajte sentinely vzhľadov presne tak, ako ich getter vrátil, a pred odoslaním validujte uložený súbor. Keďže zastaralý prázdny stream sa načíta ako neprítomný, overovací krok sa musí pozrieť na serializovaný dokument, nie na record, a je dost lacný na to, aby bežal po každej dávke:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa deklaruje TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Validuje dokument práve načítaný v Pdf, vrátane úprav
  // urobených cez Pdf.Annotation[] od jeho otvorenia
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

Tá istá disciplína platí pre každý panel, ktorý farbí alebo anotuje strany na revíziu, workflow popísaný v stavbe Delphi workflow na revíziu anotácií s PDFium Component: record je snímka toho, čo engine dokázal prečítať, a sentinel, ktorý ste si nenastavili sami, má cestovať späť nezmenený. Plné anotačné API, PDF/A preflight aj natívny engine PDFium idú spolu v PDFium Component pre Delphi, C++Builder a Lazarus