En PDFium Component antes de la v3.121.1, leer una anotación vía TPdf.Annotation[] y asignar el registro de vuelta podía agregar entradas /R y /D vacías a su diccionario de appearances /AP, aun cuando el original llevaba solo /N. Los validadores PDF/A rechazan ese diccionario. Desde la v3.121.1 el getter solo reporta un appearance que de verdad leyó, así que un round trip sin cambios no escribe nada nuevo. La falla vale la pena entenderla en detalle, porque el disparador usual es un fix pensado para hacer el archivo más conforme, no menos
¿Qué se rompe cuando escribe una anotación de vuelta sin cambios?
La respuesta corta: la anotación gana streams de appearance que nunca tuvo, y un archivo que pasaba la validación PDF/A antes de su edición la falla después. El escenario típico va así. Llega un archivo de un cliente con anotaciones square y text que les falta el flag Print, PDF/A exige que toda anotación se imprima, así que usted recorre las páginas, agrega afPrint, y asigna cada registro de vuelta. Nada en ese código toca appearances. El registro de TPdf.Annotation[] es un TPdfAnnotation, y SetAnnotationData escribe todo campo cuyo centinela Has* esté fijado, que es exactamente como están pensados los pares HasContents / ContentsText. El problema era que el getter fijaba HasAppearanceRollover y HasAppearanceDown en True con strings vacías para los modos que no existían, y el setter, diligente, escribía dos streams vacíos:
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 de la v3.121.1 esta asignación también escribía /AP/R y
// streams /AP/D cuando la anotación fuente solo tenía /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 define el diccionario de appearances con tres entradas: /N para el appearance normal, /R para rollover, y /D para down. /R y /D son opcionales, y cuando están ausentes el visor cae a /N. Un stream /R vacío no está ausente, eso sí. Es un stream válido que no pinta nada, así que un visor que honra los appearances de rollover muestra un rectángulo en blanco apenas el puntero pasa sobre la anotación. PDF/A es más estricto todavía: ISO 19005-1 (con Corrigendum 2) e ISO 19005-2 / 19005-3 permiten solo /N en el diccionario de appearances de una anotación. veraPDF reporta el archivo de round-trip bajo la regla 6.5.3-4 para PDF/A-1 y la 6.3.3-2 para PDF/A-2 y PDF/A-3, y el TPdf.ValidatePdfA incorporado lo lista como pvaiAnnotationApDictViolation. La edición que agregó el flag Print para satisfacer una cláusula del estándar rompió otra
¿Por qué FPDFAnnot_GetAP devuelve 2 para un appearance ausente?
PDFium nunca devuelve cero de FPDFAnnot_GetAP, ni siquiera cuando el stream de appearance pedido no existe. La función sigue el patrón usual de dos llamadas de PDFium: pasar un buffer nil para obtener el tamaño requerido en bytes, asignar, y llamar de nuevo para copiar el texto UTF-16LE. El tamaño siempre incluye el terminador UTF-16, así que un stream ausente reporta 2 bytes, un string vacío más su terminador. El getter previo a la v3.121.1 testeaba ByteLength >= SizeOf(FPDF_WCHAR), un chequeo que toda llamada pasa, así que los tres flags HasAppearance* volvían True para cualquier anotación que tuviera algún appearance. Un round trip por el registro entonces le pedía a FPDFAnnot_SetAP guardar un string vacío para cada modo, y PDFium creaba el stream para contenerlo. Sin excepción, sin aviso, y la página visible se veía idéntica, que es por qué el defecto salió a la luz en un fixture de veraPDF y no en un visor
Cómo decide v3.121.1 que un appearance existe
ReadAppearance, el helper dentro de GetPageAnnotation que llena AppearanceNormal, AppearanceRollover y AppearanceDown, ahora trata un resultado como contenido solo cuando trae al menos un carácter más allá del terminador. La primera llamada debe devolver más de SizeOf(FPDF_WCHAR) bytes y un conteo de bytes par, porque un largo impar no puede ser UTF-16. La segunda llamada, la que de verdad copia el texto, se valida otra vez: un largo devuelto de 2 o menos, o uno mayor que el buffer asignado, reponen HasValue en False y dejan el string vacío. Del lado de la escritura no cambió nada. SetAnnotationData sigue llamando FPDFAnnot_SetAP solo para los modos cuyo flag HasAppearance* es True, así que un registro leído de una anotación que solo tiene /N ahora reescribe solo /N. El fixture de regresión cubre ambas direcciones: una anotación square con appearance normal, leída y reescrita sin cambios, pasa PDF/A-1b, PDF/A-2b y PDF/A-3b, mientras que la misma anotación con su flag Print removido falla en la regla del flag esperado y en nada más
Streams ausentes y vacíos se ven idénticos, así que el getter se queda conservador
El API nativo no puede distinguir un stream de appearance ausente de uno que existe pero está vacío, y PDFium Component no finge lo contrario. Ambos casos devuelven los mismos 2 bytes de FPDFAnnot_GetAP, así que ambos se leen como HasAppearanceRollover = False con un AppearanceRollover vacío. Eso tiene dos consecuencias para las que conviene diseñar. Primero, un centinela en False significa "no se leyó contenido, así que una reescritura dejará este modo tranquilo", no "la clave /R está ausente del diccionario". Segundo, el registro no puede detectar un stream vacío que ya está en el archivo: un documento dañado por un build viejo o por otra herramienta se lee limpio, y asignar el registro de vuelta ni lo repara ni lo empeora. Para encontrar esos archivos hace falta un chequeo a nivel de bytes, que es para lo que están TPdf.ValidatePdfA y el flujo de validación preflight PDF/A con PDFium Component
¿Cómo se limpia un appearance a propósito?
Usted fija el centinela explícitamente y pasa un string vacío; el setter lo escribe. Bloquear los strings vacíos en SetAnnotationData habría sido el fix contundente para este bug, pero también rompería a los llamadores que limpian un appearance deliberadamente, el mismo contrato que HasContents y HasAuthor siguen para texto. Así que el fix vive por completo en el getter, y el setter sigue honrando lo que el llamador pida:
// Reemplace el appearance de rollover, después límpielo otra vez
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// A.HasAppearanceRollover es True y el texto hace round-trip como 'q Q'
A.HasAppearanceRollover := True; // reafirme la intención explícitamente
A.AppearanceRollover := ''; // escriba un stream vacío a propósito
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Se lee de vuelta como HasAppearanceRollover = False con string vacío:
// un stream vacío y uno ausente son indistinguibles aquí
Tenga presente que un /R o /D vaciado explícitamente sigue contando como clave extra bajo las reglas PDF/A citadas arriba. Si el objetivo es un perfil de archivo, escribir un /N no vacío y dejar los otros dos modos intactos es la única forma que valida. Cualquier flujo que mueva anotaciones entre documentos, como exportar e importar XFDF con PDFium Component, debería seguir la misma regla: copie los modos que la fuente de verdad tenía y deje el resto de los centinelas en False
Un patrón leer-modificar-escribir que se mantiene a salvo de PDF/A
Actualice a la v3.121.1 o posterior, deje los centinelas de appearance exactamente como el getter los devolvió, y valide el archivo guardado antes de despacharlo. Como un stream vacío remanente se lee como ausente, el paso de verificación tiene que mirar el documento serializado y no el registro, y es lo bastante barato para correrlo tras cada lote:
uses
PDFium, FPdfPdfa; // FPdfPdfa declara TPdfAValidationIssue
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// Valida el documento cargado actualmente en Pdf, incluidas las ediciones
// hechas vía Pdf.Annotation[] desde que se abrió
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
La misma disciplina aplica a cualquier panel que recolorea o anota páginas para revisión, un flujo que cubre construir un flujo de revisión de anotaciones en Delphi con PDFium Component: el registro es una foto de lo que el motor pudo leer, y un centinela que usted no fijó debería viajar de vuelta sin cambios. El API completo de anotaciones, el preflight PDF/A y el motor nativo PDFium vienen juntos en PDFium Component for Delphi, C++Builder and Lazarus