Teknisk artikel

Annotation appearance-round trips i Delphi med PDFium

I PDFium Component før v3.121.1 kunne læsning af en annotation gennem TPdf.Annotation[] og tildeling af recorden tilbage tilføje tomme /R- og /D-entries til dens /AP appearance dictionary, selv når originalen kun bar /N. PDF/A-validatorer afviser den dictionary. Siden v3.121.1 rapporterer getteren kun en appearance, den faktisk har læst, så en uændret round trip skriver intet nyt. Fejlen er værd at forstå i detaljer, for den sædvanlige udløser er en fix, der skulle gøre en fil mere compliant, ikke mindre

Diagram over PDFium Components annotation-round trip, hvor tilføjelse af afPrint gennem TPdf.Annotation[] og SetAnnotationData også skriver tomme /R- og /D-streams via FPDFAnnot_SetAP, hvilket gør en PDF/A-ren appearance dictionary til én, veraPDF afviser, indtil v3.121.1 kun rapporterer de appearances, den faktisk har læst
At læse en annotation og skrive den tilbage uændret tilføjede tidligere tomme rollover- og down-appearance-streams, og det er det, der fejler PDF/A, ikke det Print-flag, du mente at tilføje

Hvad går galt, når du skriver en annotation tilbage uændret?

Det korte svar: annotationen får appearance-streams, den aldrig har haft, og en fil, der bestod PDF/A-validering før din redigering, fejler den bagefter. Det typiske scenarie løber sådan her. Et kundearkiv ankommer med square- og text-annotationer, der mangler Print-flagget, PDF/A kræver, at hver annotation printes, så du løber siderne igennem, tilføjer afPrint og tildeler hver record tilbage. Intet i den kode rører appearances. Recorden fra TPdf.Annotation[] er en TPdfAnnotation, og SetAnnotationData skriver hvert felt, hvis Has*-sentinel er sat, hvilket netop er sådan, HasContents- / ContentsText-parrene er tænkt at virke. Problemet var, at getteren satte HasAppearanceRollover og HasAppearanceDown til True med tomme strenge for de tilstande, der ikke fandtes, og setteren skrev pligtskyldigt to tomme streams:

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];
          // Før v3.121.1 skrev denne tildeling også tomme /AP/R- og
          // /AP/D-streams, når kilde-annotationen kun havde /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 definerer appearance dictionary med tre entries: /N til normal appearance, /R til rollover og /D til down. /R og /D er valgfrie, og når de er fraværende, falder en viewer tilbage til /N. Men en tom /R-stream er ikke fraværende. Det er en gyldig stream, der maler ingenting, så en viewer, der respekterer rollover-appearances, viser en blank rektangel i det øjeblik, markøren glider over annotationen. PDF/A er endnu strengere: ISO 19005-1 (med Corrigendum 2) og ISO 19005-2 / 19005-3 tillader kun /N i en annotations appearance dictionary. veraPDF rapporterer den round-trippede fil under regel 6.5.3-4 for PDF/A-1 og regel 6.3.3-2 for PDF/A-2 og PDF/A-3, og den indbyggede TPdf.ValidatePdfA lister den som pvaiAnnotationApDictViolation. Redigeringen, der tilføjede Print-flagget for at tilfredsstille én klausul i standarden, brød en anden

Annotations appearance dictionary fra ISO 32000-1 med normal-, rollover- og down-entries: PDFium returnerer 2 bytes for en manglende stream og for en eksisterende tom, så begge læses tilbage som intet indhold gennem TPdf, mens kun et tjek på byteniveau som TPdf.ValidatePdfA finder den tomme stream, PDF/A forbyder
En manglende /R falder tilbage til /N; en tom /R maler en blank rektangel og fejler stadig PDF/A, og gennem recorden er de to uadskillelige

Hvorfor returnerer FPDFAnnot_GetAP 2 for en manglende appearance?

PDFium returnerer aldrig nul fra FPDFAnnot_GetAP, heller ikke når den ønskede appearance-stream ikke findes. Funktionen følger PDFiums sædvanlige to-kald-mønster: giv en nil-buffer for at få den krævede størrelse i bytes, allokér, og kald så igen for at kopiere UTF-16LE-tekst. Størrelsen inkluderer altid UTF-16-terminatoren, så en manglende stream rapporterer 2 bytes, en tom streng plus dens terminator. Getteren før v3.121.1 testede ByteLength >= SizeOf(FPDF_WCHAR), en kontrol, som hvert kald består, så alle tre HasAppearance*-flag kom tilbage True for enhver annotation med nogen appearance overhovedet. En round trip gennem recorden bad så FPDFAnnot_SetAP om at gemme en tom streng for hver tilstand, og PDFium oprettede streamen til at holde den. Ingen exception, ingen advarsel, og den synlige side så identisk ud, hvilket er derfor, defekten dukkede op i en veraPDF-fixture frem for i en viewer

Hvordan FPDFAnnot_GetAP rapporterer en manglende appearance-stream i PDFium: to-kald-mønsteret returnerer altid mindst to bytes til UTF-16-terminatoren, den gamle gate, der sammenlignede med SizeOf(FPDF_WCHAR), bestod hvert kald og satte alle HasAppearance-sentinel true, og v3.121.1-gaten kræver mere end terminatoren plus et lige antal bytes
To bytes er den encodede tomme streng, ikke bevis på, at en appearance findes; den rettede getter behandler alt på eller under terminatorlængden som intet indhold, og write-back forbliver stille

Hvordan v3.121.1 afgør, at en appearance findes

ReadAppearance, hjælperen inde i GetPageAnnotation, der udfylder AppearanceNormal, AppearanceRollover og AppearanceDown, behandler nu et resultat som indhold kun, når det bærer mindst ét tegn ud over terminatoren. Første kald skal returnere mere end SizeOf(FPDF_WCHAR) bytes og et lige antal bytes, da en ulige længde ikke kan være UTF-16. Andet kald, som faktisk kopierer teksten, valideres igen: en returneret længde på 2 eller derunder, eller én større end den allokerede buffer, nulstiller HasValue til False og efterlader strengen tom. På skrivesiden er intet ændret. SetAnnotationData kalder stadig FPDFAnnot_SetAP kun for de tilstande, hvis HasAppearance*-flag er True, så en record læst fra en annotation med kun /N skriver nu tilbage kun /N. Regression-fixturen dækker begge retninger: en square-annotation med en normal appearance, læst og skrevet tilbage uændret, består PDF/A-1b, PDF/A-2b og PDF/A-3b, mens samme annotation med sit Print-flag fjernet fejler på den forventede flagregel og intet andet

Manglende og tomme streams ser ens ud, så getteren forbliver konservativ

Den native API kan ikke skelne en manglende appearance-stream fra én, der findes men er tom, og PDFium Component lader som om, det var ellers. Begge tilfælde returnerer de samme 2 bytes fra FPDFAnnot_GetAP, så begge læses tilbage som HasAppearanceRollover = False med en tom AppearanceRollover. Det har to konsekvenser, du bør designe omkring. For det første betyder en False-sentinel "intet indhold blev læst, så en write-back lader denne tilstand i fred", ikke "/R-nøglen er fraværende fra dictionary". For det andet kan recorden ikke detektere en tom stream, der allerede ligger i filen: et dokument beskadiget af en ældre build eller et andet værktøj læses tilbage rent, og at tildele recorden tilbage reparerer hverken eller forværrer det. For at finde de filer skal du have et tjek på byteniveau, og dét er TPdf.ValidatePdfA og PDF/A preflight validation workflow with PDFium Component til

Hvordan rydder du en appearance med vilje?

Du sætter sentinel-eksplicit og giver en tom streng; setteren skriver den. At blokere tomme strenge i SetAnnotationData ville have været den naive fix for denne bug, men den ville også brække kaldere, der rydder en appearance bevidst, samme kontrakt som HasContents og HasAuthor følger for tekst. Så fixet bor helt i getteren, og setteren fortsætter med at adlyde, hvad end kalderen beder om:

// Erstat rollover-appearance, og ryd den så igen
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover er True, og teksten round-tripper som 'q Q'
A.HasAppearanceRollover := True;   // gentag hensigten eksplicit
A.AppearanceRollover := '';        // skriv bevidst en tom stream
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Læser tilbage som HasAppearanceRollover = False med en tom streng:
// en tom stream og en manglende er uadskillelige her

Husk, at en eksplicit tømt /R eller /D stadig tæller som en ekstra nøgle under de PDF/A-regler, der er citeret ovenfor. Er målet en arkivprofil, er en ikke-tom /N og de to andre tilstande urørte den eneste form, der validerer. Enhver workflow, der flytter annotationer mellem dokumenter, som XFDF export and import with PDFium Component, bør følge samme regel: kopiér de tilstande, kilden faktisk havde, og lad resten af sentinelene være False

Et read-modify-write-mønster, der forbliver PDF/A-sikker

Opgradér til v3.121.1 eller senere, lad appearance-sentinelene stå præcis, som getteren returnerede dem, og validér den gemte fil, før du skiber den. Fordi en forældet tom stream læses tilbage som fraværende, skal verifikationstrinnet se på det serialiserede dokument frem for på recorden, og det er billigt nok til at køre efter hver batch:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa deklarerer TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Validerer dokumentet aktuelt indlæst i Pdf, inklusive ændringer
  // lavet gennem Pdf.Annotation[], siden det blev åbnet
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

Samme disciplin gælder ethvert panel, der farvelægger eller annoterer sider til review, en workflow dækket i building a Delphi annotation review workflow with PDFium Component: recorden er et snapshot af, hvad motoren kunne læse, og en sentinel, du ikke selv har sat, bør rejse tilbage uændret. Den fulde annotation-API, PDF/A preflight og den native PDFium-motor skibes sammen i PDFium Component for Delphi, C++Builder and Lazarus