Artículo técnico

Round trips de apariencias de anotación en Delphi y PDFium

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

Diagrama del round trip de anotaciones de PDFium Component donde añadir afPrint vía TPdf.Annotation[] y SetAnnotationData también escribe streams /R y /D vacíos vía FPDFAnnot_SetAP, convirtiendo un diccionario de apariencias limpio de PDF A en uno que veraPDF rechaza hasta que v3.121.1 reporta solo las apariencias que de verdad leyó
Leer una anotación y reescribirla sin cambios solía añadir streams de apariencia rollover y down vacíos, y eso es lo que suspende PDF/A, no el flag Print que querías añadir

¿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

El diccionario de apariencias de anotación de ISO 32000-1 con entradas normal, rollover y down: PDFium devuelve 2 bytes tanto para un stream ausente como para uno vacío existente, así que ambos se leen como sin contenido vía TPdf, mientras que solo una comprobación a nivel de bytes como TPdf.ValidatePdfA encuentra el stream vacío que PDF/A no permite
Un /R ausente cae de vuelta a /N; un /R vacío pinta un rectángulo en blanco y aun así suspende PDF/A, y vía el record ambos son indistinguibles

¿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 reporta FPDFAnnot_GetAP un stream de apariencia ausente en PDFium: el patrón de dos llamadas devuelve siempre al menos dos bytes por el terminador UTF-16, la vieja puerta que comparaba contra SizeOf(FPDF_WCHAR) pasaba con cualquier llamada y activaba todos los centinelas HasAppearance, y la puerta de v3.121.1 exige más que el terminador más un número par de bytes
Dos bytes es la cadena vacía codificada, no prueba de que exista una apariencia; el getter corregido trata todo lo que quede en o bajo la longitud del terminador como sin contenido y la reescritura se queda callada

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