Teknisk artikel

Annotation appearances på rundtur i Delphi med PDFium

I PDFium Component före v3.121.1 kunde läsning av en annotation genom TPdf.Annotation[] och tilldelning av posten tillbaka lägga till tomma /R- och /D-poster i dess /AP-appearance-ordlista, även när originalet bar bara /N. PDF/A-validatorer avvisar den ordlistan. Sedan v3.121.1 rapporterar gettern bara en appearance den faktiskt läst, så en oförändrad rundtur skriver ingenting nytt. Felet är värt att förstå i detalj, för den vanliga utlösaren är en fix som var tänkt att få filen att bättre leva upp till standarden, inte tvärtom

Diagram över PDFium Components annotationsrundtur där tillägg av afPrint genom TPdf.Annotation[] och SetAnnotationData också skriver tomma /R- och /D-strömmar via FPDFAnnot_SetAP, vilket förvandlar en ren PDF A-appearance-ordlista till en som veraPDF avvisar tills v3.121.1 bara rapporterar de appearances den faktiskt läst
Att läsa en annotation och skriva tillbaka den oförändrad brukade lägga till tomma rollover- och down-appearance-strömmar, och det är det som faller på PDF/A, inte Print-flaggan du menade att lägga till

Vad går fel när du skriver tillbaka en annotation oförändrad?

Det korta svaret: annotationen får appearance-strömmar den aldrig haft, och en fil som klarade PDF/A-valideringen före din redigering faller på den efteråt. Det typiska scenariot går till så här. Ett kundarkiv anländer med square- och text-annotationer som saknar Print-flaggan, PDF/A kräver att varje annotation skrivs ut, så du loopar över sidorna, lägger till afPrint och tilldelar varje post tillbaka. Ingenting i den koden rör appearances. Posten från TPdf.Annotation[] är en TPdfAnnotation, och SetAnnotationData skriver varje fält vars Has*-sentinel är satt, vilket är exakt hur paren HasContents / ContentsText är tänkta att fungera. Problemet var att gettern satte HasAppearanceRollover och HasAppearanceDown till True med tomma strängar för lägen som inte fanns, och settern plikttrogen skrev två tomma strömmar:

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öre v3.121.1 skrev denna tilldelning också tomma /AP/R- och
          // /AP/D-strömmar när källannotationen bara hade /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 definierar appearance-ordlistan med tre poster: /N för normal appearance, /R för rollover och /D för down. /R och /D är valfria, och när de saknas faller en viewer tillbaka på /N. En tom /R-ström är dock inte frånvarande. Den är en giltig ström som ritar ingenting, så en viewer som hedrar rollover-appearances visar en tom rektangel i samma stund som pekaren rör sig över annotationen. PDF/A är strängare: ISO 19005-1 (med Corrigendum 2) och ISO 19005-2 / 19005-3 tillåter bara /N i en annotations appearance-ordlista. veraPDF rapporterar den rundturade filen under regel 6.5.3-4 för PDF/A-1 och regel 6.3.3-2 för PDF/A-2 och PDF/A-3, och inbyggda TPdf.ValidatePdfA listar den som pvaiAnnotationApDictViolation. Redigeringen som lade till Print-flaggan för att tillfredsställa en klausul i standarden bröt en annan

Annotations appearance-ordlistan från ISO 32000-1 med normal-, rollover- och down-poster: PDFium returnerar 2 byte för en saknad ström och för en befintlig tom, så båda läses tillbaka som inget innehåll genom TPdf, medan bara en kontroll på bytenivå som TPdf.ValidatePdfA hittar den tomma ström som PDF/A förbjuder
En saknad /R faller tillbaka på /N; en tom /R ritar en tom rektangel och faller ändå på PDF/A, och genom posten är de två otskiljbara

Varför returnerar FPDFAnnot_GetAP 2 för en saknad appearance?

PDFium returnerar aldrig noll från FPDFAnnot_GetAP, inte ens när den efterfrågade appearance-strömmen inte finns. Funktionen följer det vanliga PDFium-mönstret med två anrop: skicka en nil-buffert för att få den erforderliga storleken i byte, allokera, anropa sedan igen för att kopiera UTF-16LE-text. Storleken inkluderar alltid UTF-16-terminatorn, så en saknad ström rapporterar 2 byte, en tom sträng plus dess terminator. Gettern före v3.121.1 testade ByteLength >= SizeOf(FPDF_WCHAR), en kontroll som varje anrop klarar, så alla tre HasAppearance*-flaggor kom tillbaka True för varje annotation med någon appearance alls. En rundtur genom posten bad sedan FPDFAnnot_SetAP lagra en tom sträng för varje läge, och PDFium skapade strömmen för att hysa den. Inget undantag, ingen varning, och den synliga sidan såg identisk ut, vilket är därför felet dök upp i en veraPDF-fixture i stället för i en viewer

Hur FPDFAnnot_GetAP rapporterar en saknad appearance-ström i PDFium: tvåanropsmönstret returnerar alltid minst två byte för UTF-16-terminatorn, den gamla gränsen som jämförde mot SizeOf(FPDF_WCHAR) klarade varje anrop och satte alla HasAppearance-sentinels till true, och v3.121.1-gränsen kräver mer än terminatorn plus ett jämnt byteantal
Två byte är den kodade tomma strängen, inte bevis för att en appearance finns; den fixade gettern behandlar allt på eller under terminatormängden som inget innehåll och tillbakaskrivningen förblir tyst

Hur v3.121.1 avgör att en appearance finns

ReadAppearance, hjälparen inne i GetPageAnnotation som fyller AppearanceNormal, AppearanceRollover och AppearanceDown, behandlar nu ett resultat som innehåll bara när det bär minst ett tecken utöver terminatorn. Det första anropet måste returnera mer än SizeOf(FPDF_WCHAR) byte och ett jämnt byteantal, eftersom en udda längd inte kan vara UTF-16. Det andra anropet, som faktiskt kopierar texten, valideras igen: en returnerad längd på 2 eller mindre, eller en större än den allokerade bufferten, återställer HasValue till False och lämnar strängen tom. På skrivsidan ändrades ingenting. SetAnnotationData anropar fortfarande FPDFAnnot_SetAP bara för lägen vars HasAppearance*-flagga är True, så en post läst från en annotation som bara har /N skriver nu tillbaka bara /N. Regressionfixturen täcker båda riktningarna: en square-annotation med en normal appearance, läst och skriven tillbaka oförändrad, klarar PDF/A-1b, PDF/A-2b och PDF/A-3b, medan samma annotation utan sin Print-flagga faller på den förväntade flaggregeln och inget annat

Saknade och tomma strömmar ser identiska ut, så gettern förblir konservativ

Det nativa API:t kan inte skilja en saknad appearance-ström från en som finns men är tom, och PDFium Component låtsas inte annat. Båda fallen returnerar samma 2 byte från FPDFAnnot_GetAP, så båda läses tillbaka som HasAppearanceRollover = False med en tom AppearanceRollover. Det har två konsekvenser du bör designa kring. För det första betyder en False-sentinel "inget innehåll lästes, så en tillbakaskrivning lämnar det här läget orört", inte "nyckeln /R saknas i ordlistan". För det andra kan posten inte upptäcka en tom ström som redan finns i filen: ett dokument skadat av ett äldre bygge eller av annat verktyg läses tillbaka rent, och att tilldela posten tillbaka varken reparerar eller förvärrar det. För att hitta de filerna behöver du en kontroll på bytenivå, vilket är vad TPdf.ValidatePdfA och PDF/A-preflight-valideringsflödet med PDFium Component är till för

Hur rensar du en appearance medvetet?

Du sätter sentineln explicit och skickar en tom sträng; settern skriver den. Att blockera tomma strängar i SetAnnotationData hade varit den grova fixen för den här buggen, men den skulle också bryta anropare som rensar en appearance med flit, samma kontrakt som HasContents och HasAuthor följer för text. Så fixen bor helt i gettern, och settern fortsätter hedra vad anroparen ber om:

// Ersätt rollover-appearancen, rensa den sedan igen
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover är True och texten klarar rundturen som 'q Q'
A.HasAppearanceRollover := True;   // hävda avsikten explicit igen
A.AppearanceRollover := '';        // skriv en tom ström med flit
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Läses tillbaka som HasAppearanceRollover = False med en tom sträng:
// en tom ström och en saknad är otskiljbara här

Kom ihåg att en explicit tömd /R eller /D fortfarande räknas som en extra nyckel under de PDF/A-regler som citerats ovan. Om målet är en arkivprofil är det enda utformning som validerar att skriva en icke-tom /N och lämna de två andra lägena orörda. Varenda flöde som flyttar annotationer mellan dokument, som XFDF-export och -import med PDFium Component, bör följa samma regel: kopiera de lägen källan faktiskt hade och lämna resten av sentinelna False

Ett read-modify-write-mönster som förblir PDF/A-säkert

Uppgradera till v3.121.1 eller senare, lämna appearance-sentinelsna exakt som gettern returnerade dem, och validera den sparade filen innan du skickar den. Eftersom en kvarlämnad tom ström läses tillbaka som frånvarande måste verifieringssteget titta på det serialiserade dokumentet i stället för på posten, och det är billigt nog att köra efter varje batch:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa deklarerar TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Validerar dokumentet som för närvarande är inläst i Pdf, inklusive ändringar
  // gjorda genom Pdf.Annotation[] sedan det öppnades
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

Samma disciplin gäller alla paneler som färgar om eller annoterar sidor för granskning, ett flöde som tas upp i att bygga ett Delphi-granskningsflöde för annotationer med PDFium Component: posten är en ögonblicksbild av vad motorn kunde läsa, och en sentinel du inte själv satt bör färdas tillbaka oförändrad. Det fullständiga annotations-API:t, PDF/A-preflight och den nativa PDFium-motorn skeppas tillsammans i PDFium Component för Delphi, C++Builder och Lazarus