No PDFium Component antes da v3.121.1, ler uma anotação por TPdf.Annotation[] e atribuir o registro de volta podia adicionar entradas /R e /D vazias ao dicionário de appearance /AP dela, mesmo quando o original carregava só /N. Validadores de PDF/A rejeitam esse dicionário. Desde a v3.121.1 o getter só reporta uma appearance que ele de fato leu, então um round trip sem mudanças não grava nada novo. A falha vale a pena entender em detalhe, porque o gatilho habitual é um fix pensado para tornar um arquivo mais conforme, não menos
O que dá errado quando você grava uma anotação de volta sem mudanças?
A resposta curta: a anotação ganha streams de appearance que nunca teve, e um arquivo que passava na validação PDF/A antes da sua edição passa a falhar depois. O cenário típico rola assim. Um acervo de cliente chega com anotações square e text sem a flag Print, o PDF/A exige que toda anotação imprima, então você percorre as páginas, adiciona afPrint e atribui cada registro de volta. Nada nesse código toca appearances. O registro de TPdf.Annotation[] é um TPdfAnnotation, e o SetAnnotationData grava todo campo cujo sentinel Has* está setado, que é exatamente como os pares HasContents / ContentsText funcionam por design. O problema era que o getter setava HasAppearanceRollover e HasAppearanceDown como True com strings vazias para modos que não existiam, e o setter obedientemente gravava 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 gravava /AP/R e
// /AP/D vazios quando a anotação de origem tinha só /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 appearance com três entradas: /N para a appearance normal, /R para rollover e /D para down. /R e /D são opcionais, e quando estão ausentes o viewer volta para a /N. Um stream /R vazio não está ausente, porém. É um stream válido que não pinta nada, então um viewer que respeita appearances de rollover mostra um retângulo em branco no instante em que o ponteiro passa sobre a anotação. O PDF/A é mais estrito ainda: a ISO 19005-1 (com o Corrigendum 2) e as ISO 19005-2 / 19005-3 só permitem /N num dicionário de appearance de anotação. O veraPDF reporta o arquivo pós 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 embutido o lista como pvaiAnnotationApDictViolation. A edição que adicionou a flag Print para satisfazer uma cláusula do padrão quebrou outra
Por que o FPDFAnnot_GetAP devolve 2 para uma appearance ausente?
O PDFium nunca devolve zero do FPDFAnnot_GetAP, mesmo quando o stream de appearance pedido não existe. A função segue o padrão usual de duas chamadas do PDFium: passe um buffer nil para obter o tamanho necessário em bytes, aloque, depois chame de novo para copiar o texto UTF-16LE. O tamanho sempre inclui o terminador UTF-16, então 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 checagem que toda chamada passa, então as três flags HasAppearance* voltavam True para qualquer anotação com qualquer appearance. Um round trip pelo registro então pedia ao FPDFAnnot_SetAP para guardar uma string vazia em cada modo, e o PDFium criava o stream para acomodá-la. Sem exceção, sem aviso, e a página visível ficava idêntica, razão pela qual o defeito apareceu num fixture do veraPDF e não num viewer
Como a v3.121.1 decide que uma appearance existe
O ReadAppearance, o helper dentro do GetPageAnnotation que preenche AppearanceNormal, AppearanceRollover e AppearanceDown, agora trata um resultado como conteúdo só quando ele carrega ao menos um caractere além do terminador. A primeira chamada precisa devolver mais que SizeOf(FPDF_WCHAR) bytes e uma contagem par de bytes, já que um comprimento ímpar não pode ser UTF-16. A segunda chamada, que de fato copia o texto, é validada de novo: um comprimento devolvido de 2 ou menos, ou um maior que o buffer alocado, reseta HasValue para False e deixa a string vazia. No lado da escrita nada mudou. O SetAnnotationData continua chamando o FPDFAnnot_SetAP só para modos cuja flag HasAppearance* é True, então um registro lido de uma anotação que tem só /N agora grava de volta só a /N. O fixture de regressão cobre as duas direções: uma anotação square com appearance normal, lida e gravada de volta sem mudanças, 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 de flag esperada e em nada mais
Streams ausentes e vazios parecem idênticos, então o getter segue conservador
A API nativa não distingue um stream de appearance ausente de um que existe mas está vazio, e o PDFium Component não finge o contrário. Os dois casos devolvem os mesmos 2 bytes do FPDFAnnot_GetAP, então ambos leem como HasAppearanceRollover = False com um AppearanceRollover vazio. Isso tem duas consequências para levar em conta no design. Primeiro, um sentinel False significa "nenhum conteúdo foi lido, então uma regravação deixará esse modo em paz", não "a chave /R está ausente do dicionário". Segundo, o registro não detecta um stream vazio que já está no arquivo: um documento danificado por um build antigo ou por outra ferramenta lê limpo, e atribuir o registro de volta nem repara nem piora. Para achar esses arquivos você precisa de uma checagem a nível de byte, que é para isso que existem o TPdf.ValidatePdfA e o workflow de validação preflight PDF/A com PDFium Component
Como você limpa uma appearance de propósito?
Você seta o sentinel explicitamente e passa uma string vazia; o setter grava. Barrar strings vazias no SetAnnotationData teria sido o fix grosseiro para esse bug, mas também quebraria chamadores que limpam uma appearance deliberadamente, o mesmo contrato que HasContents e HasAuthor seguem para texto. Então o fix vive inteiramente no getter, e o setter continua honrando o que o chamador pedir:
// Substitua a appearance de rollover, depois limpe de novo
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; // reafirme a intenção explicitamente
A.AppearanceRollover := ''; // grave um stream vazio de propósito
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Lê de volta como HasAppearanceRollover = False com string vazia:
// um stream vazio e um ausente são indistinguíveis aqui
Tenha em mente que uma /R ou /D esvaziada explicitamente ainda conta como chave extra sob as regras de PDF/A citadas acima. Se o alvo é um perfil de arquivamento, gravar uma /N não vazia e deixar os outros dois modos intocados é a única forma que valida. Qualquer workflow que mova anotações entre documentos, como export e import de XFDF com PDFium Component, deve seguir a mesma regra: copie os modos que a origem de fato tinha e deixe o resto dos sentinels False
Um padrão ler-modificar-gravar que se mantém seguro no PDF/A
Atualize para a v3.121.1 ou posterior, deixe os sentinels de appearance exatamente como o getter os devolveu, e valide o arquivo salvo antes de despachá-lo. Como um stream vazio remanescente lê como ausente, o passo de verificação precisa olhar o documento serializado em vez do registro, e é barato o bastante para rodar depois de cada lote:
uses
PDFium, FPdfPdfa; // FPdfPdfa declara TPdfAValidationIssue
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// Valida o documento atualmente carregado em Pdf, incluindo edições
// feitas por Pdf.Annotation[] desde a abertura
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
A mesma disciplina vale para qualquer painel que recolore ou anota páginas para revisão, um workflow coberto em construir um workflow de revisão de anotações em Delphi com PDFium Component: o registro é um retrato do que o motor conseguia ler, e um sentinel que você não setou deve viajar de volta inalterado. A API de anotações completa, o preflight PDF/A e o motor PDFium nativo vão juntos no PDFium Component para Delphi, C++Builder e Lazarus