En PDFium Component antes de v3.121.1, leer una anotación vía TPdf.Annotation[] y asignar el record de vuelta podía añadir entradas /R y /D vacías a su diccionario de apariencias /AP, incluso cuando el original solo llevaba /N. Los validadores PDF/A rechazan ese diccionario. Desde v3.121.1 el getter solo reporta una apariencia que de verdad haya leído, así que un round trip sin cambios no escribe nada nuevo. El fallo merece entenderse en detalle, porque el disparador habitual es un fix pensado para hacer un archivo más conforme, no menos
¿Qué sale mal cuando reescribes una anotación sin cambios?
La respuesta corta: la anotación gana streams de apariencia que nunca tuvo, y un archivo que pasaba la validación PDF/A antes de tu edición la suspende después. El escenario típico va así. Llega un archivo de cliente con anotaciones square y text sin el flag Print, PDF/A exige que toda anotación se imprima, así que recorres las páginas, añades afPrint y reasignas cada record. Nada en ese código toca apariencias. El record de TPdf.Annotation[] es un TPdfAnnotation, y SetAnnotationData escribe todo campo cuyo centinela Has* esté activado, que es exactamente como funcionan las parejas HasContents / ContentsText. El problema era que el getter ponía HasAppearanceRollover y HasAppearanceDown a True con cadenas vacías para los modos que no existían, y el setter obedientemente 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 v3.121.1 esta asignación también escribía streams /AP/R y
// /AP/D vacíos cuando la anotación origen 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 apariencias con tres entradas: /N para la apariencia normal, /R para rollover y /D para down. /R y /D son opcionales, y cuando faltan el visor cae de vuelta a /N. Un stream /R vacío no es una ausencia, eso sí. Es un stream válido que no pinta nada, así que un visor que honra las apariencias de rollover muestra un rectángulo en blanco en cuanto 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 solo permiten /N en un diccionario de apariencias de anotación. veraPDF reporta el archivo tras el 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 integrado lo lista como pvaiAnnotationApDictViolation. La edición que añadió el flag Print para satisfacer una cláusula del estándar rompió otra
¿Por qué FPDFAnnot_GetAP devuelve 2 para una apariencia ausente?
PDFium nunca devuelve cero de FPDFAnnot_GetAP, ni siquiera cuando el stream de apariencia pedido no existe. La función sigue el patrón habitual de PDFium de dos llamadas: pasas un buffer nil para obtener el tamaño necesario en bytes, reservas, y llamas 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, una cadena vacía más su terminador. El getter anterior a v3.121.1 comprobaba ByteLength >= SizeOf(FPDF_WCHAR), una condición que toda llamada pasa, así que los tres flags HasAppearance* volvían True para cualquier anotación con cualquier apariencia. Un round trip vía el record le pedía entonces a FPDFAnnot_SetAP que guardara una cadena vacía por cada modo, y PDFium creaba el stream para contenerla. Sin excepción, sin aviso, y la página visible se veía idéntica, razón por la que el defecto salió a la luz en un fixture de veraPDF y no en un visor
Cómo decide v3.121.1 que una apariencia existe
ReadAppearance, el helper dentro de GetPageAnnotation que rellena AppearanceNormal, AppearanceRollover y AppearanceDown, ahora solo trata un resultado como contenido 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 número par de bytes, porque una longitud impar no puede ser UTF-16. La segunda llamada, la que copia de verdad el texto, se valida otra vez: una longitud devuelta de 2 o menos, o una mayor que el buffer reservado, reinicia HasValue a False y deja la cadena vacía. En el lado de escritura no cambió nada. SetAnnotationData sigue llamando a FPDFAnnot_SetAP solo para los modos cuyo flag HasAppearance* esté a True, así que un record leído de una anotación que solo tiene /N ahora reescribe solo /N. El fixture de regresión cubre ambos sentidos: una anotación square con apariencia normal, leída y reescrita sin cambios, pasa PDF/A-1b, PDF/A-2b y PDF/A-3b, mientras la misma anotación con su flag Print quitado suspende en la regla del flag esperado y en nada más
Streams ausentes y vacíos se ven iguales, así que el getter se queda conservador
La API nativa no puede distinguir un stream de apariencia 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. Primera, un centinela False significa «no se leyó contenido, así que una reescritura dejará este modo en paz», no «la clave /R está ausente del diccionario». Segunda, el record no puede detectar un stream vacío que ya está en el archivo: un documento dañado por un build antiguo o por otra herramienta se lee limpio, y reasignar el record ni lo repara ni lo empeora. Para encontrar esos archivos necesitas una comprobación 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 borras una apariencia a propósito?
Activas el centinela explícitamente y pasas una cadena vacía; el setter la escribe. Bloquear cadenas vacías en SetAnnotationData habría sido el fix contundente para este bug, pero también rompería a quienes borran una apariencia 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 invocador pida:
// Sustituye la apariencia rollover y luego bórrala otra vez
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// A.HasAppearanceRollover está a True y el texto hace round trip como 'q Q'
A.HasAppearanceRollover := True; // reafirma la intención explícitamente
A.AppearanceRollover := ''; // escribe un stream vacío a propósito
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Se lee como HasAppearanceRollover = False con una cadena vacía:
// un stream vacío y uno ausente son indistinguibles aquí
Ten 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 la exportación e importación XFDF con PDFium Component, debería seguir la misma regla: copia los modos que el origen realmente tenía y deja los demás centinelas a False
Un patrón leer-modificar-escribir que se mantiene a salvo con PDF/A
Actualiza a v3.121.1 o posterior, deja los centinelas de apariencia exactamente como los devolvió el getter, y valida el archivo guardado antes de enviarlo. Como un stream vacío obsoleto se lee como ausente, el paso de verificación tiene que mirar el documento serializado y no el record, y es lo bastante barato para ejecutarlo 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 vale para cualquier panel que recoloree o anote páginas para revisión, un flujo que cubre montar un flujo de revisión de anotaciones en Delphi con PDFium Component: el record es una instantánea de lo que el motor pudo leer, y un centinela que no pusiste tú debe viajar de vuelta sin cambios. La API completa de anotaciones, el preflight PDF/A y el motor nativo PDFium se envían juntos en PDFium Component para Delphi, C++Builder y Lazarus