No PDFium Component antes da v3.121.1, ler uma anotação através de TPdf.Annotation[] e atribuir o registo de volta podia acrescentar entradas /R e /D vazias ao seu dicionário de aparências /AP, mesmo quando o original só transportava /N. Os validadores PDF/A rejeitam esse dicionário. Desde a v3.121.1 que o getter só comunica uma aparência que realmente leu, por isso um round trip sem alterações não escreve nada de novo. A falha merece ser entendida em detalhe, porque o gatilho habitual é uma correção feita para tornar um ficheiro mais conforme, não menos
O que corre mal quando escreve uma anotação de volta sem alterações?
A resposta curta: a anotação ganha streams de aparência que nunca teve, e um ficheiro que passava na validação PDF/A antes da sua edição falha depois. O cenário típico decorre assim. Chega um arquivo de um cliente com anotações square e text sem a flag Print, o PDF/A exige que todas as anotações imprimam, por isso percorre as páginas, acrescenta afPrint, e atribui cada registo de volta. Nada nesse código toca nas aparências. O registo de TPdf.Annotation[] é um TPdfAnnotation, e o SetAnnotationData escreve todos os campos cuja sentinela Has* está ativa, que é exatamente como os pares HasContents / ContentsText se destinam a funcionar. O problema era que o getter punha HasAppearanceRollover e HasAppearanceDown a True com strings vazias para modos que não existiam, e o setter escrevia diligentemente dois streams vazios:
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];
// Antes da v3.121.1 esta atribuição também escrevia /AP/R e
// /AP/D vazios quando a anotação de origem só tinha /AP/N
Pdf.Annotation[I] := A;
end;
end;
end;
Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
finally
Pdf.Free;
end;
end;
A ISO 32000-1 §12.5.5 define o dicionário de aparências com três entradas: /N para a aparência normal, /R para rollover, e /D para down. /R e /D são opcionais, e quando estão ausentes um visualizador recua para /N. Um stream /R vazio não está ausente, porém. É um stream válido que não pinta nada, por isso um visualizador que respeite aparências de rollover mostra um retângulo em branco no momento em que o ponteiro passa sobre a anotação. O PDF/A é ainda mais estrito: a ISO 19005-1 (com o Corrigendum 2) e a ISO 19005-2 / 19005-3 só permitem /N num dicionário de aparências de anotação. O veraPDF reporta o ficheiro depois do round trip sob a regra 6.5.3-4 para PDF/A-1 e a regra 6.3.3-2 para PDF/A-2 e PDF/A-3, e o TPdf.ValidatePdfA incorporado lista-o como pvaiAnnotationApDictViolation. A edição que acrescentou a flag Print para satisfazer uma cláusula da norma partiu outra
Porque devolve o FPDFAnnot_GetAP 2 para uma aparência ausente?
O PDFium nunca devolve zero do FPDFAnnot_GetAP, mesmo quando o stream de aparência pedido não existe. A função segue o padrão habitual de duas chamadas do PDFium: passe um buffer nil para obter o tamanho necessário em bytes, aloque, e depois chame de novo para copiar texto UTF-16LE. O tamanho inclui sempre o terminador UTF-16, por isso um stream ausente reporta 2 bytes, uma string vazia mais o terminador dela. O getter anterior à v3.121.1 testava ByteLength >= SizeOf(FPDF_WCHAR), uma verificação que qualquer chamada passa, por isso as três flags HasAppearance* voltavam True para qualquer anotação com alguma aparência quer que fosse. Um round trip pelo registo pedia então ao FPDFAnnot_SetAP para guardar uma string vazia por cada modo, e o PDFium criava o stream para a segurar. Nenhuma exceção, nenhum aviso, e a página visível parecia idêntica, razão pela qual o defeito apareceu num fixture do veraPDF e não num visualizador
Como decide a v3.121.1 que uma aparência existe
O ReadAppearance, o helper dentro do GetPageAnnotation que preenche AppearanceNormal, AppearanceRollover e AppearanceDown, agora trata um resultado como conteúdo só quando transporta pelo menos um caráter para além do terminador. A primeira chamada tem de devolver mais do que SizeOf(FPDF_WCHAR) bytes e uma contagem par de bytes, porque um comprimento ímpar não pode ser UTF-16. A segunda chamada, que copia efetivamente o texto, é validada outra vez: um comprimento devolvido de 2 ou menos, ou um maior do que o buffer que foi alocado, repõe o HasValue a False e deixa a string vazia. Do lado da escrita nada mudou. O SetAnnotationData continua a chamar o FPDFAnnot_SetAP só para modos cuja flag HasAppearance* é True, por isso um registo lido de uma anotação que só tem /N agora escreve de volta só /N. O fixture de regressão cobre as duas direções: uma anotação square com aparência normal, lida e escrita de volta sem alterações, passa em PDF/A-1b, PDF/A-2b e PDF/A-3b, enquanto a mesma anotação com a flag Print removida falha na regra da flag esperada e em nada mais
Streams ausentes e vazios parecem idênticos, por isso o getter mantém-se conservador
A API nativa não distingue um stream de aparência ausente de um que existe mas está vazio, e o PDFium Component não finge o contrário. Ambos os casos devolvem os mesmos 2 bytes do FPDFAnnot_GetAP, por isso ambos leem como HasAppearanceRollover = False com um AppearanceRollover vazio. Isso tem duas consequências para as quais deve desenhar. Primeiro, uma sentinela False significa "não foi lido conteúdo, por isso a escrita de volta deixa este modo quieto", e não "a chave /R está ausente do dicionário". Segundo, o registo não consegue detetar um stream vazio que já esteja no ficheiro: um documento danificado por uma versão antiga ou por outra ferramenta lê como limpo, e atribuir o registo de volta nem repara nem agrava. Para encontrar esses ficheiros precisa de uma verificação ao nível do byte, que é para isso que servem o TPdf.ValidatePdfA e o fluxo de validação preflight PDF/A com o PDFium Component
Como limpar uma aparência de propósito?
Põe a sentinela explicitamente e passa uma string vazia; o setter escreve-a. Bloquear strings vazias no SetAnnotationData teria sido a correção desajeitada para este bug, mas também partiria quem limpa uma aparência deliberadamente, o mesmo contrato que o HasContents e o HasAuthor seguem para texto. Por isso a correção vive inteiramente no getter, e o setter continua a honrar o que quer que quem chama peça:
// Substituir a aparência de rollover, e depois limpá-la outra vez
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// A.HasAppearanceRollover é True e o texto faz round-trip como 'q Q'
A.HasAppearanceRollover := True; // reafirmar a intenção explicitamente
A.AppearanceRollover := ''; // escrever um stream vazio de propósito
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Lê de volta como HasAppearanceRollover = False com uma string vazia:
// um stream vazio e um ausente são indistinguíveis aqui
Tenha em mente que um /R ou /D esvaziado explicitamente ainda conta como chave extra sob as regras PDF/A citadas acima. Se o alvo é um perfil de arquivo, escrever um /N não vazio e deixar os outros dois modos intocados é a única forma que valida. Qualquer fluxo de trabalho que mova anotações entre documentos, como exportação e importação XFDF com o PDFium Component, deve seguir a mesma regra: copie os modos que a origem realmente tinha e deixe o resto das sentinelas a False
Um padrão ler-modificar-escrever que se mantém seguro para o PDF/A
Atualize para a v3.121.1 ou posterior, deixe as sentinelas de aparência exatamente como o getter as devolveu, e valide o ficheiro gravado antes de o enviar. Como um stream vazio stale lê como ausente, o passo de verificação tem de olhar para o documento serializado em vez de para o registo, e é barato o suficiente para correr depois de cada lote:
uses
PDFium, FPdfPdfa; // FPdfPdfa declara TPdfAValidationIssue
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// Valida o documento atualmente carregado no Pdf, incluindo edições
// feitas através de Pdf.Annotation[] desde que foi aberto
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
A mesma disciplina vale para qualquer painel que recoloque cores ou anote páginas para revisão, um fluxo coberto em construir um fluxo de revisão de anotações em Delphi com o PDFium Component: o registo é um instantâneo do que o motor conseguiu ler, e uma sentinela que não definiu você próprio deve viajar de volta inalterada. A API de anotações completa, o preflight PDF/A e o motor PDFium nativo viajam juntos no PDFium Component para Delphi, C++Builder e Lazarus