Tehnički članak

Round tripovi izgleda anotacija u Delphiju s PDFiumom

U PDFium Componentu prije v3.121.1 čitanje anotacije kroz TPdf.Annotation[] i vraćanje zapisa natrag moglo je dodati prazne /R i /D unose u njezin /AP rječnik izgleda, i kad je izvornik nosio samo /N. PDF/A validatori taj rječnik odbacuju. Od v3.121.1 getter javlja samo izgled koji je stvarno pročitao, pa nepromijenjen round trip ne zapisuje ništa novo. Tu grešku vrijedi razumjeti detaljno, jer uobičajeni okidač je popravak namijenjen tome da datoteku učini usklađenijom, a ne manje

Dijagram round tripa anotacija u PDFium Componentu gdje dodavanje afPrint kroz TPdf.Annotation[] i SetAnnotationData isto zapisuje prazne /R i /D streamove preko FPDFAnnot_SetAP, pretvarajući PDF A čist rječnik izgleda u onaj koji veraPDF odbacuje sve dok v3.121.1 ne javlja samo izglede koje je stvarno pročitao
Čitanje anotacije i vraćanje nepromijenjene dodavalo je prazne rollover i down appearance streamove, i to je ono što pada na PDF/A, a ne Print flag koji ste namjeravali dodati

Što krene naopako kad anotaciju vratite nepromijenjenu?

Kratki odgovor: anotacija dobiva appearance streamove koje nikad nije imala, i datoteka koja je prije Vaše izmjene prolazila PDF/A validaciju poslije pada. Tipični scenarij odvija se ovako. Arhiva kupca stiže sa square i text anotacijama kojima fali Print flag, PDF/A traži da se svaka anotacija ispisuje, pa idete u petlji po stranicama, dodate afPrint i svaki zapis vratite. Ništa u tom kodu ne dira izglede. Zapis iz TPdf.Annotation[] je TPdfAnnotation, a SetAnnotationData zapisuje svako polje čiji je Has* sentinel postavljen, što je točno način na koji parovi HasContents / ContentsText imaju raditi. Problem bio je što je getter postavljao HasAppearanceRollover i HasAppearanceDown na True s praznim stringovima za moduse koji nisu postojali, a setter ih je dužnosno zapisao kao 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];
          // Prije v3.121.1 ova je dodjela isto zapisivala 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 definira rječnik izgleda s trima unosima: /N za normalni izgled, /R za rollover i /D za down. /R i /D su neobavezni, i kad ih nema, viewer pada natrag na /N. Prazan /R stream ipak nije odsutan. To je valjan stream koji ne slika ništa, pa viewer koji poštuje rollover izglede prikaže prazan pravokut u trenutku kad pokazivač prijeđe preko anotacije. PDF/A je još stroži: ISO 19005-1 (s Corrigendum 2) i ISO 19005-2 / 19005-3 dopuštaju samo /N u rječniku izgleda anotacije. veraPDF javlja round-tripiranu datoteku 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 je kao pvaiAnnotationApDictViolation. Izmjena koja je dodala Print flag da zadovolji jednu klauzulu standarda slomila je drugu

Rječnik izgleda anotacije iz ISO 32000-1 s normal, rollover i down unosima: PDFium vraća 2 bajta za nedostajući stream i za postojeći prazan, pa se oboje čita natrag kao bez sadržaja kroz TPdf, dok ga tek provjera na razini bajtova poput TPdf.ValidatePdfA nalazi kao prazan stream koji PDF/A ne dopušta
Nedostajući /R pada na /N; prazan /R slika prazan pravokut i i dalje pada na PDF/A, a kroz zapis oboje je nerazlučivo

Zašto FPDFAnnot_GetAP vraća 2 za nedostajući izgled?

PDFium nikad ne vraća nulu iz FPDFAnnot_GetAP, čak i kad traženi appearance stream ne postoji. Funkcija slijedi uobičajeni PDFium pattern dva poziva: pošaljite nil buffer da dobijete potrebnu veličinu u bajtovima, alocirajte, pa pozovite ponovno da kopirate UTF-16LE tekst. Veličina uvijek uključuje UTF-16 terminator, pa nedostajući stream javlja 2 bajta, prazan string plus njegov terminator. Getter prije v3.121.1 testirao je ByteLength >= SizeOf(FPDF_WCHAR), provjeru koju prolazi svaki poziv, pa su sva tri HasAppearance* flaga za bilo koju anotaciju s bilo kakvim izgledom vraćala True. Round trip kroz zapis tada je od FPDFAnnot_SetAP tražio da za svaki modus spremi prazan string, a PDFium stvorio je stream da ga drži. Bez iznimke, bez upozorenja, i vidljiva stranica izgledala je identično, pa je defekt izbio u veraPDF fixtureu a ne u vieweru

Kako FPDFAnnot_GetAP javlja nedostajući appearance stream u PDFiumu: pattern dva poziva uvijek vraća barem dva bajta za UTF-16 terminator, stara vrata koja uspoređuju s SizeOf(FPDF_WCHAR) prolazila su na svakom pozivu i postavljala sve HasAppearance sentinele na true, a vrata v3.121.1 traže više od terminatora plus paran broj bajtova
Dva bajta kodirani su prazni string, ne dokaz da izgled postoji; popravljeni getter sve na duljini terminatora ili ispod tretira kao bez sadržaja i vraćanje natrag ostaje tiho

Kako v3.121.1 odlučuje da izgled postoji

ReadAppearance, pomoćnik unutar GetPageAnnotation koji puni AppearanceNormal, AppearanceRollover i AppearanceDown, sada rezultat tretira kao sadržaj samo kad nosi barem jedan znak više od terminatora. Prvi poziv mora vratiti više od SizeOf(FPDF_WCHAR) bajtova i paran broj bajtova, jer neparne duljine ne mogu biti UTF-16. Drugi poziv, koji stvarno kopira tekst, validira se ponovno: vraćena duljina 2 ili manje, ili veća od alociranog buffera, resetira HasValue na False i string ostaje prazan. Na strani pisanja ništa se nije promijenilo. SetAnnotationData i dalje zove FPDFAnnot_SetAP samo za moduse čiji je HasAppearance* flag True, pa zapis pročitan iz anotacije koja ima samo /N sada vraća natrag samo /N. Regresijski fixture pokriva oba smjera: square anotacija s normalnim izgledom, pročitana i vraćena nepromijenjena, prolazi PDF/A-1b, PDF/A-2b i PDF/A-3b, dok ista anotacija s uklonjenim Print flagom pada na očekivanom pravilu flaga i ni na čemu drugom

Nedostajući i prazni streamovi izgledaju isto, pa getter ostaje konzervativan

Nativni API ne može razlikovati nedostajući appearance stream od onog 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 natrag kao HasAppearanceRollover = False s praznim AppearanceRollover. To ima dvije posljedice oko kojih trebate dizajnirati. Prvo, False sentinel znači "ništa nije pročitano, pa će vraćanje natrag ostaviti ovaj modus na miru", a ne "ključ /R nema u rječniku". Drugo, zapis ne može otkriti prazan stream koji već jest u datoteci: dokument oštećen starijim buildom ili drugim alatom čita se čisto, i vraćanje zapisa natrag ni ne popravlja ni ne pogoršava. Da pronađete te datoteke treba vam provjera na razini bajtova, a za to služe TPdf.ValidatePdfA i PDF/A preflight validacijski workflow s PDFium Componentom

Kako namjerno očistiti izgled?

Postavite sentinel eksplicitno i pošaljite prazan string; setter ga zapisuje. Blokiranje praznih stringova u SetAnnotationData bio bi tup popravak za ovu grešku, ali slomio bi i pozivatelje koji izgled brišu namjerno, isti ugovor kojeg HasContents i HasAuthor prate za tekst. Popravak zato živi cijeli u getteru, a setter i dalje poštuje ono što pozivatelj traži:

// Zamijeni rollover izgled, pa ga opet očisti
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 round-tripa kao 'q Q'
A.HasAppearanceRollover := True;   // ponovno izrijčaj namjeru
A.AppearanceRollover := '';        // zapiši prazan stream namjerno
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Čita natrag kao HasAppearanceRollover = False s praznim stringom:
// prazan stream i nedostajući ovdje su nerazlučivi

Imajte na umu da eksplicitno ispražnjen /R ili /D i dalje računa kao dodatni ključ pod PDF/A pravilima citiranim gore. Ako je cilj arhivski profil, pisanje nepraznog /N i ostavljanje druga dva modusa netaknutima jedini je oblik koji validira. Svaki workflow koji premješta anotacije među dokumentima, poput XFDF izvoza i uvoza s PDFium Componentom, treba slijediti isto pravilo: kopirajte moduse koje je izvornik stvarno imao, a ostale sentinele ostavite na False

Pattern read-modify-write koji ostaje PDF/A siguran

Nadogradite na v3.121.1 ili noviji, appearance sentinele ostavite točno onakvima kakve ih je getter vratio, i validirajte spremljenu datoteku prije isporuke. Budući da zastarjeli prazan stream čita se kao odsutan, korak provjere mora gledati serializirani dokument a ne zapis, a jeftin je dovoljno da se izvodi poslije svake serije:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa deklarira TPdfAValidationIssue

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

Ista disciplina vrijedi za svaki panel koji prebojava ili anotira stranice za pregled, workflow obrađen u građenju Delphi anotacijskog workflowa za pregled s PDFium Componentom: zapis je snimak onoga što je engine mogao pročitati, i sentinel koji niste sami postavili treba putovati natrag nepromijenjen. Cijeli annotation API, PDF/A preflight i nativni PDFium engine isporučuju se zajedno u PDFium Componentu za Delphi, C++Builder i Lazarus