Articolo tecnico

Round trip degli appearance in Delphi con PDFium

In PDFium Component prima della v3.121.1, leggere una annotazione tramite TPdf.Annotation[] e riassegnare il record poteva aggiungere voci /R e /D vuote al suo dizionario di appearance /AP, anche quando l'originale portava solo /N. I validatori PDF/A rifiutano quel dizionario. Dalla v3.121.1 il getter riferisce solo un appearance che ha davvero letto, quindi un round trip invariato non scrive nulla di nuovo. Il guasto merita di essere capito in dettaglio, perché il trigger abituale è una correzione pensata per rendere un file più conforme, non meno

Diagramma del round trip delle annotazioni di PDFium Component dove aggiungere afPrint tramite TPdf.Annotation[] e SetAnnotationData scrive anche stream /R e /D vuoti via FPDFAnnot_SetAP, trasformando un dizionario di appearance pulito per PDF A in uno che veraPDF rifiuta finché la v3.121.1 non riferisce solo gli appearance davvero letti
Leggere una annotazione e riscriverla invariata aggiungeva una volta stream di appearance rollover e down vuoti, ed è quello a fallire PDF/A, non il flag Print che volevate aggiungere

Che cosa va storto quando riscrivete una annotazione invariata?

Risposta breve: la annotazione guadagna stream di appearance che non aveva, e un file che passava la validazione PDF/A prima della vostra modifica la fallisce dopo. Lo scenario tipico va così. Arriva un archivio clienti con annotazioni square e text sprovviste del flag Print, PDF/A richiede che ogni annotazione si stampi, quindi scorrete le pagine in loop, aggiungete afPrint e riassegnate ogni record. Nulla in quel codice tocca gli appearance. Il record da TPdf.Annotation[] è un TPdfAnnotation, e SetAnnotationData scrive ogni campo il cui sentinel Has* è impostato, che è esattamente come le coppie HasContents / ContentsText sono pensate per funzionare. Il problema era che il getter impostava HasAppearanceRollover e HasAppearanceDown a True con stringhe vuote per le modalità inesistenti, e il setter scriveva diligentemente due stream vuoti:

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];
          // Prima della v3.121.1 questo assegnamento scriveva anche stream /AP/R e
          // /AP/D vuoti quando l'annotazione sorgente aveva solo /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 definisce il dizionario di appearance con tre voci: /N per l'aspetto normale, /R per rollover e /D per down. /R e /D sono opzionali, e quando mancano un viewer ricade su /N. Uno stream /R vuoto non è però assente. È uno stream valido che non dipinge nulla, quindi un viewer che onora gli appearance rollover mostra un rettangolo bianco nel momento in cui il puntatore passa sopra la annotazione. PDF/A è più severo ancora: ISO 19005-1 (con Corrigendum 2) e ISO 19005-2 / 19005-3 consentono solo /N in un dizionario di appearance di annotazione. veraPDF segnala il file da round trip sotto la regola 6.5.3-4 per PDF/A-1 e la regola 6.3.3-2 per PDF/A-2 e PDF/A-3, e il TPdf.ValidatePdfA incorporato lo elenca come pvaiAnnotationApDictViolation. La modifica che aggiungeva il flag Print per soddisfare una clausola dello standard ne rompeva un'altra

Il dizionario di appearance delle annotazioni da ISO 32000-1 con voci normal, rollover e down: PDFium restituisce 2 byte sia per uno stream mancante sia per uno esistente ma vuoto, quindi entrambi si rileggono come senza contenuto tramite TPdf, mentre solo un controllo a livello di byte come TPdf.ValidatePdfA trova lo stream vuoto che PDF/A vieta
Una /R mancante ricade su /N; una /R vuota dipinge un rettangolo bianco e fallisce comunque PDF/A, e tramite il record le due sono indistinguibili

Perché FPDFAnnot_GetAP restituisce 2 per un appearance mancante?

PDFium non restituisce mai zero da FPDFAnnot_GetAP, nemmeno quando lo stream di appearance richiesto non esiste. La funzione segue il consueto schema PDFium a due chiamate: passate un buffer nil per ottenere la dimensione richiesta in byte, allocate, poi richiamate per copiare il testo UTF-16LE. La dimensione include sempre il terminatore UTF-16, quindi uno stream mancante riferisce 2 byte, una stringa vuota più il suo terminatore. Il getter prima della v3.121.1 testava ByteLength >= SizeOf(FPDF_WCHAR), un controllo che ogni chiamata supera, così tutti e tre i flag HasAppearance* tornavano True per qualunque annotazione con almeno un appearance. Un round trip tramite il record chiedeva allora a FPDFAnnot_SetAP di memorizzare una stringa vuota per ogni modalità, e PDFium creava lo stream per contenerla. Nessuna eccezione, nessun warning, e la pagina visibile appariva identica, ecco perché il difetto è emerso in una fixture veraPDF e non in un viewer

Come FPDFAnnot_GetAP riferisce uno stream di appearance mancante in PDFium: lo schema a due chiamate restituisce sempre almeno due byte per il terminatore UTF-16, il vecchio gate che confrontava con SizeOf(FPDF_WCHAR) passava ogni chiamata e impostava tutti i sentinel HasAppearance a true, e il gate della v3.121.1 esige più del terminatore più un numero di byte pari
Due byte sono la stringa vuota codificata, non la prova che un appearance esista; il getter corretto tratta tutto ciò che sta entro la lunghezza del terminatore come assenza di contenuto e la riscrittura resta silenziosa

Come la v3.121.1 decide che un appearance esiste

ReadAppearance, l'helper dentro GetPageAnnotation che riempie AppearanceNormal, AppearanceRollover e AppearanceDown, ora tratta un risultato come contenuto solo quando porta almeno un carattere oltre il terminatore. La prima chiamata deve restituire più di SizeOf(FPDF_WCHAR) byte e un numero di byte pari, dato che una lunghezza dispari non può essere UTF-16. La seconda chiamata, quella che copia davvero il testo, viene validata di nuovo: una lunghezza restituita di 2 o meno, o una maggiore del buffer allocato, azzera HasValue a False e lascia la stringa vuota. Sul lato scrittura nulla è cambiato. SetAnnotationData chiama FPDFAnnot_SetAP solo per le modalità il cui flag HasAppearance* è True, quindi un record letto da una annotazione che ha solo /N ora riscrive solo /N. La fixture di regressione copre entrambe le direzioni: una annotazione square con appearance normale, letta e riscritta invariata, passa PDF/A-1b, PDF/A-2b e PDF/A-3b, mentre la stessa annotazione con il flag Print rimosso fallisce sulla regola del flag atteso e su nient'altro

Stream mancanti e vuoti sembrano identici, quindi il getter resta conservativo

L'API nativa non sa dire uno stream di appearance mancante da uno che esiste ma è vuoto, e PDFium Component non finge il contrario. Entrambi i casi restituiscono gli stessi 2 byte da FPDFAnnot_GetAP, quindi entrambi si rileggono come HasAppearanceRollover = False con AppearanceRollover vuota. Ne seguono due conseguenze da tenere a mente in fase di design. Primo, un sentinel False significa "nessun contenuto è stato letto, quindi una riscrittura lascerà questa modalità in pace", non "la chiave /R è assente dal dizionario". Secondo, il record non può rilevare uno stream vuoto già presente nel file: un documento danneggiato da una build più vecchia o da un altro strumento si rilegge pulito, e riassegnare il record non lo ripara né lo peggiora. Per trovare quei file serve un controllo a livello di byte, che è ciò a cui servono TPdf.ValidatePdfA e il workflow di validazione preflight PDF/A con PDFium Component

Come si ripulisce un appearance di proposito?

Impostate il sentinel esplicitamente e passate una stringa vuota; il setter la scrive. Bloccare le stringhe vuote in SetAnnotationData sarebbe stata la correzione sterninata per questo bug, ma avrebbe rotto anche i chiamanti che ripuliscono un appearance deliberatamente, lo stesso contratto che HasContents e HasAuthor seguono per il testo. Quindi la correzione vive interamente nel getter, e il setter continua a onorare qualunque cosa chieda il chiamante:

// Sostituite l'aspetto rollover, poi ripulitelo di nuovo
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover è True e il testo fa round trip come 'q Q'
A.HasAppearanceRollover := True;   // ribadite l'intento esplicitamente
A.AppearanceRollover := '';        // scrivete uno stream vuoto di proposito
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Si rilegge come HasAppearanceRollover = False con stringa vuota:
// uno stream vuoto e uno assente sono indistinguibili qui

Tenete presente che una /R o /D svuotata esplicitamente conta comunque come chiave extra sotto le regole PDF/A citate sopra. Se il bersaglio è un profilo archivio, scrivere una /N non vuota e lasciare intatte le altre due modalità è l'unica forma che valida. Qualsiasi workflow che sposta annotazioni tra documenti, come esportare e importare XFDF con PDFium Component, dovrebbe seguire la stessa regola: copiate le modalità che la sorgente aveva davvero e lasciate a False tutti gli altri sentinel

Un pattern read-modify-write che resta a misura di PDF/A

Passate alla v3.121.1 o successiva, lasciate i sentinel di appearance esattamente come il getter li ha restituiti, e validate il file salvato prima di spedirlo. Dato che uno stream vuoto stantio si rilegge come assente, il passo di verifica deve guardare il documento serializzato anziché il record, e costa abbastanza poco da girare dopo ogni batch:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa dichiara TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Valida il documento attualmente caricato in Pdf, incluse le modifiche
  // fatte tramite Pdf.Annotation[] da quando è stato aperto
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

La stessa disciplina vale per qualsiasi pannello che ricolora o annota pagine per la revisione, un workflow trattato in costruire un workflow di revisione annotazioni in Delphi con PDFium Component: il record è uno snapshot di ciò che l'engine riusciva a leggere, e un sentinel che non avete impostato voi dovrebbe tornare indietro invariato. L'API completa delle annotazioni, il preflight PDF/A e il motore nativo PDFium viaggiano insieme in PDFium Component for Delphi, C++Builder and Lazarus