Tehnički članak

Round trip izgleda anotacija u Delphi-ju uz PDFium

U PDFium Component pre v3.121.1, čitanje anotacije kroz TPdf.Annotation[] i dodeljivanje zapisa nazad moglo je dodati prazne /R i /D unose u njen /AP appearance rečnik, čak i kada je original nosio samo /N. PDF/A validatori taj rečnik odbacuju. Od v3.121.1 getter prijavljuje samo appearance koji je zaista pročitao, pa nepromenjen round trip ne upisuje ništa novo. Taj kvar vredi razumeti u detalje, jer je običan okidač popravka koja je trebalo da fajl učini usklađenijim, a ne manje

Dijagram PDFium Component round trip-a anotacija gde dodavanje afPrint kroz TPdf.Annotation[] i SetAnnotationData upisuje i prazne /R i /D streamove preko FPDFAnnot_SetAP, pretvarajući čist PDF/A appearance rečnik u onaj koji veraPDF odbacuje dok v3.121.1 ne prijavi samo appearance streamove koje je zaista pročitala
Čitanje anotacije i njen nepromenjen upis nazad dodavali su prazne rollover i down appearance streamove, i baš to pada na PDF/A, a ne Print zastavica koju ste nameravali da dodate

Šta krene naopako kad anotaciju upišete nazad nepromenjenu?

Kratak odgovor: anotacija dobija appearance streamove koje nikada nije imala, i fajl koji je pre vaše izmene prolazio PDF/A validaciju posle nje pada. Tipičan scenario teče ovako. Klijentska arhiva stigne sa square i text anotacijama kojima fali Print zastavica, PDF/A zahteva da se svaka anotacija štampa, pa pređete kroz stranice, dodate afPrint i svaki zapis dodelite nazad. Ništa u tom kodu ne dira appearance streamove. Zapis iz TPdf.Annotation[] je TPdfAnnotation, a SetAnnotationData upisuje svako polje čiji je Has* sentinel postavljen, što je upravo namena parova HasContents / ContentsText. Problem je bio što je getter HasAppearanceRollover i HasAppearanceDown postavljao na True sa praznim stringovima za modove koji nisu postojali, a setter je savesno upisao dva prazna streama:

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];
          // Pre v3.121.1 ova dodela upisivala je i prazne /AP/R i
          // /AP/D streamove kad je izvorna anotacija imala samo /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 definiše appearance rečnik sa tri unosa: /N za normalni appearance, /R za rollover i /D za down. /R i /D su opciona, i kada ih nema pregledač se vraća na /N. Prazan /R stream nije odsutan, međutim. To je validan stream koji ne naslikava ništa, pa pregledač koji poštuje rollover appearance u trenutku kad pokazivač pređe preko anotacije pokazuje pravougaonik bez sadržaja. PDF/A je još stroži: ISO 19005-1 (sa Corrigendum 2) i ISO 19005-2 / 19005-3 dozvoljavaju samo /N u appearance rečniku anotacije. veraPDF round-trip fajl prijavljuje pod pravilom 6.5.3-4 za PDF/A-1 i pravilom 6.3.3-2 za PDF/A-2 i PDF/A-3, a ugrađeni TPdf.ValidatePdfA navodi ga kao pvaiAnnotationApDictViolation. Izmene koja je dodala Print zastavicu da bi zadovoljila jednu tačku standarda slomila je drugu

Appearance rečnik anotacije iz ISO 32000-1 sa normalnim, rollover i down unosima: PDFium vraća 2 bajta i za stream koji nedostaje i za postojeći prazan, pa se oboje kroz TPdf čitaju kao bez sadržaja, dok tek provera na nivou bajtova poput TPdf.ValidatePdfA pronalazi prazan stream koji PDF/A ne dozvoljava
Nedostajući /R vraća se na /N; prazan /R naslikava prazan pravougaonik i dalje pada na PDF/A, a kroz zapis ta dva su nerazdvojiva

Zašto FPDFAnnot_GetAP vraća 2 za appearance koji ne postoji?

PDFium nikada ne vraća nulu iz FPDFAnnot_GetAP, čak ni kada traženi appearance stream ne postoji. Funkcija prati uobičajeni PDFium dvopozivni obrazac: prosledite nil bafer da saznate potrebnu veličinu u bajtovima, alocirate, pa pozovete ponovo da kopirate UTF-16LE tekst. Veličina uvek uključuje UTF-16 terminator, pa stream koji nedostaje prijavljuje 2 bajta, prazan string plus njegov terminator. Getter pre v3.121.1 testirao je ByteLength >= SizeOf(FPDF_WCHAR), uslov koji prolazi svaki poziv, pa su sve tri HasAppearance* zastavice vraćale True za svaku anotaciju koja je imala bilo kakav appearance. Round trip kroz zapis tada je od FPDFAnnot_SetAP tražio da za svaki mod sačuva prazan string, i PDFium je stvorio stream da ga drži. Bez izuzetka, bez upozorenja, i vidljiva stranica izgledala je isto, pa se defekt pokazao u veraPDF fixture-u, a ne u pregledaču

Kako FPDFAnnot_GetAP u PDFium-u prijavljuje appearance stream koji nedostaje: dvopozivni obrazac uvek vraća bar dva bajta za UTF-16 terminator, stara kapija koja je poredila sa SizeOf(FPDF_WCHAR) prolazila je na svakom pozivu i postavljala sve HasAppearance sentinele na true, a kapija iz v3.121.1 traži više od terminatora i paran broj bajtova
Dva bajta su enkodovan prazan string, a ne dokaz da appearance postoji; ispravljeni getter sve na ili ispod dužine terminatora tretira kao bez sadržaja i upis nazad ostaje tih

Kako v3.121.1 odlučuje da appearance postoji

ReadAppearance, helper unutar GetPageAnnotation-a koji puni AppearanceNormal, AppearanceRollover i AppearanceDown, sada rezultat tretira kao sadržaj tek kad on nosi bar jedan znak više od terminatora. Prvi poziv mora vratiti više od SizeOf(FPDF_WCHAR) bajtova i paran broj bajtova, jer neparna dužina ne može biti UTF-16. Drugi poziv, koji zaista kopira tekst, validira se ponovo: vraćena dužina od 2 ili manje, ili veća od alociranog bafera, vraća HasValue na False i ostavlja string prazan. Na strani upisa ništa se nije menjalo. SetAnnotationData i dalje zove FPDFAnnot_SetAP samo za modove čija je HasAppearance* zastavica True, pa zapis pročitan iz anotacije koja ima samo /N sada upisuje nazad samo /N. Regresioni fixture pokriva oba smera: square anotacija sa normalnim appearance-om, pročitana i upisana nazad nepromenjena, prolazi PDF/A-1b, PDF/A-2b i PDF/A-3b, dok ista anotacija sa uklonjenom Print zastavicom pada samo na pravilu očekivane zastavice i ni na čemu drugom

Streamovi koji nedostaju i prazni streamovi izgledaju isto, pa getter ostaje konzervativan

Nativni API ne ume da razlikuje appearance stream koji nedostaje od onoga koji postoji ali je prazan, i PDFium Component se ne pravi da može. Oba slučaja vraćaju ista 2 bajta iz FPDFAnnot_GetAP, pa se oboje čitaju nazad kao HasAppearanceRollover = False sa praznim AppearanceRollover. Iz toga slede dve posledice oko kojih treba da dizajnirate. Prvo, False sentinel znači „sadržaj nije pročitan, pa upis nazad ovaj mod ostavlja na miru", a ne „/R ključ nedostaje u rečniku". Drugo, zapis ne može otkriti prazan stream koji je već u fajlu: dokument oštećen starijim buildom ili drugim alatom čita se kao čist, i dodela zapisa nazad ga ni ne popravlja ni ne pogoršava. Da biste takve fajlove pronašli, treba vam provera na nivou bajtova, a za to služe TPdf.ValidatePdfA i PDF/A preflight validacioni workflow sa PDFium Component

Kako očistiti appearance namerno?

Postavite sentinel eksplicitno i prosledite prazan string; setter će ga upisati. Blokada praznih stringova u SetAnnotationData bila bi tupla popravka za ovaj bug, ali bi slomila i pozivaoce koji appearance brišu namerno, isti ugovor koji za tekst prate HasContents i HasAuthor. Pa je popravka u celini u getteru, a setter i dalje čini ono što ga pozivalac zamoli:

// Zamenite rollover appearance, pa ga ponovo očistite
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover je True i tekst prolazi round trip kao 'q Q'
A.HasAppearanceRollover := True;   // ponovo izričito potvrdite nameru
A.AppearanceRollover := '';        // namerno upišite prazan stream
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Čita se nazad kao HasAppearanceRollover = False sa praznim stringom:
// prazan stream i onaj koji nedostaje ovde se ne razlikuju

Imajte na umu da i izričito ispražnjen /R ili /D i dalje računa kao dodatni ključ pod gore navedenim PDF/A pravilima. Ako je cilj arhivski profil, upisivanje nepraznog /N uz ostala dva moda netaknuta jedini je oblik koji prolazi validaciju. Svaki workflow koji premešta anotacije između dokumenata, poput XFDF exporta i importa sa PDFium Component, trebalo bi da sledi isto pravilo: prekopirajte modove koje je izvor zaista imao i ostavite ostale sentinele na False

Obrazac čitanje-izmena-upis koji ostaje PDF/A bezbedan

Nadogradite na v3.121.1 ili noviju, ostavite appearance sentinele tačno onakve kakve ih je getter vratio, i validirajte sačuvan fajl pre nego što ga otpremite. Pošto se zaostali prazan stream čita kao odsutan, korak provere mora gledati u serijalizovan dokument, a ne u zapis, i dovoljno je jeftin da se pokreće posle svake serije:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa deklariše TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Validira dokument trenutno učitan u Pdf, uključujući izmene
  // načinjene kroz Pdf.Annotation[] od otvaranja
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

Ista disciplina važi za svaki panel koji boji ili anotira stranice radi pregleda, workflow koji pokriva građenje Delphi workflow-a za reviziju anotacija sa PDFium Component: zapis je snimak onoga što je engine mogao da pročita, i sentinel koji sami niste postavili trebalo bi da putuje nazad nepromenjen. Kompletan annotation API, PDF/A preflight i nativni PDFium engine stižu zajedno u PDFium Component za Delphi, C++Builder i Lazarus