Artículo técnico

Marcado automático de estructura para PDF accesible

Abre un informe generado en un lector de pantalla y los fallos de siempre aparecen en un minuto: «Página 3 de 40» se lee en voz alta en cada página, el logo de la empresa se anuncia como «gráfico» sin nada detrás, y una tabla sale como una tira interminable de números sin nombres de columna. PDF Library for Delphi evita los tres con marcado de estructura automático: llama a SetAutoTagMode una vez, y las llamadas de dibujo corrientes producen párrafos, titulares, figuras, tablas y adornos de página saltables mientras el documento se dibuja

El camino manual sigue existiendo. Puedes envolver cada llamada de dibujo en BeginTag y EndTag, y para documentos con estructura inusual es la herramienta correcta. Pero para un informe, una factura o un extracto corrientes, el marcado a mano hace que la accesibilidad dependa de que nadie olvide jamás una pareja, en cada camino de código que dibuje algo, durante toda la vida del producto. No apostaríamos un requisito de cumplimiento a eso

Qué activa SetAutoTagMode

SetAutoTagMode toma una máscara de bits y devuelve el modo que estaba vigente antes de la llamada. Cada bit cubre un tipo de contenido:

  • AUTOTAG_TEXT (1): el texto se convierte en párrafo, o en titular si se ha registrado uno
  • AUTOTAG_FURNITURE (2): cabeceras y pies de página repetidos y números de página se convierten en artefactos que el lector se salta
  • AUTOTAG_FIGURE (4): una imagen dibujada se convierte en figura, o en artefacto cuando la has declarado decorativa
  • AUTOTAG_TABLE (8): las tablas dibujadas llevan sus filas y celdas al árbol de estructura
  • AUTOTAG_DEFAULT (15): los cuatro, y el valor que recomendamos para cualquier código de informes nuevo

Activar el modo también marca el documento como etiquetado, y ese paso importa más de lo que parece. Bajo ISO 32000-1 §14.7.1 un lector trata un archivo como sin etiquetar salvo que el diccionario /MarkInfo del catálogo diga /Marked true. Un archivo puede llevar un árbol de estructura completo y correcto y aun así anunciarse como si no tuviera estructura alguna, porque falta la única marca que dice a la tecnología de asistencia que la busque. El árbol está ahí. Nada lo lee

El marcado de texto funciona por llamada. Cada llamada a DrawText, DrawWrappedText o DrawTextBox se convierte en un elemento, así que un párrafo dibujado línea a línea con cinco llamadas a DrawText llega al lector como cinco párrafos de una línea. Si tu código de maquetación parte las líneas por su cuenta, merece la pena cambiarlo: dale el párrafo entero a DrawWrappedText y deja que una llamada produzca un párrafo

Diagrama de las máscaras de bits de SetAutoTagMode que mapean llamadas de dibujo Delphi corrientes en un árbol de estructura PDF etiquetado, con la marca Marked señalada como obligatoria
Cada bit activo de SetAutoTagMode encamina llamadas de dibujo corrientes hacia un elemento correspondiente del árbol de estructura
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // origen arriba-izquierda, y crece hacia abajo
    Lib.SetDocumentLanguage('en-US');
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);       // antes de cualquier llamada a RegisterHeading
    Lib.AddStandardFont(4);

    Lib.SetTextSize(18);
    Lib.RegisterHeading(1, 'Annual service report');
    Lib.DrawText(72, 96, 'Annual service report');        // se marca como H1

    Lib.SetTextSize(11);
    Lib.DrawWrappedText(72, 130, 450,
      'Every unit installed before 2024 was inspected. ' +
      'Units that failed the torque check were serviced on site.');  // un P

    // ... páginas restantes ...

    // Recorre todas las páginas existentes, así que llámalo cuando las páginas estén dibujadas
    Lib.AddPageFooter('', 'Page &P of &N', 36, 1.0, 1);  // Artefacto de paginación
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

El comentario de la tercera línea está ahí por algo. RegisterHeading solo encola un nivel de titular cuando el marcado de texto ya está activado, y SetAutoTagMode borra cualquier nivel encolado cuando se ejecuta. Registra el titular primero y activa el modo después, y el título sale como un párrafo plano sin ningún error que te avise

¿Por qué un titular espera mientras se dibuja la imagen previa?

RegisterHeading fija el nivel para el siguiente texto, y solo el texto puede consumirlo. Si en medio se dibuja una imagen, la imagen se convierte en figura y el titular sigue encolado para el texto que viene después. La implementación obvia deja que cualquier etiqueta consuma el nivel encolado, y entonces la secuencia común de registrar un titular de sección, dibujar una imagen banner y dibujar el título produce una banner marcada como H2 y un título real degradado a párrafo. Un lector que navega por titulares aterriza en una imagen

Dos cosas más se deducen de cómo funciona la cola. El nivel de titular se pega al texto que venga después, sea cual sea el string que hayas pasado a RegisterHeading: si tu maquetación dibuja un pequeño antetítulo «Sección 3» sobre el título, el antetítulo se convierte en el titular. Y los niveles más profundos que seis se escriben como H6, porque un lector no conoce nada más allá. El argumento Title sigue haciendo su propio trabajo, alimentando el esquema que DrawTOC convierte luego en una tabla de contenido

Las imágenes reciben el mismo tratamiento de «se consume en un elemento». RegisterFigure suministra la descripción que llevará la siguiente imagen, y RegisterDecoration declara la siguiente imagen como una regla, un borde o un fondo sin significado, que luego se escribe como artefacto Layout (ISO 32000-1 §14.8.2.2). Cada declaración la consume exactamente una imagen, así que una imagen posterior nunca hereda una descripción escrita para una anterior. Un texto alternativo pegado a la imagen equivocada es peor que ninguno, porque el lector afirma algo falso con total seguridad

No intentamos adivinar deliberadamente qué imágenes son decorativas. Una imagen es o bien contenido que el lector debe describir o bien contenido que debe saltarse, y solo el código llamador sabe cuál de las dos. El caso a evitar es el tercero: una imagen dibujada sin ninguna declaración se convierte en una figura sin descripción, lo que suspende PDF/UA y hace que el lector de pantalla se detenga a decir «gráfico» y luego nada. Si haces grep a tu código de informes buscando DrawImage, cada resultado debería tener un RegisterFigure o un RegisterDecoration justo encima

Diagrama de PDF Library for Delphi que muestra un titular PDF registrado esperando mientras tanto una imagen interpuesta, con RegisterFigure y RegisterDecoration consumiéndose cada uno exactamente en una imagen
RegisterHeading espera al siguiente texto sin importar qué más se dibuje mientras tanto
Lib.RegisterHeading(2, 'Gearbox inspection');   // encolado para el siguiente texto

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);       // carga y selecciona la imagen
Lib.DrawImage(72, 160, 360, 270);             // se convierte en una Figure marcada

Lib.RegisterDecoration;                       // meaningless rule
Lib.AddImageFromFile('divider.png', 0);
Lib.DrawImage(72, 450, 360, 4);               // dibujada dentro de un artefacto de layout

Lib.DrawText(72, 480, 'Gearbox inspection');  // solo ahora aparece el H2

La descripción merece más cuidado que cualquier otro string del documento. Un lector no vidente la recibe en lugar de la imagen, y eso es todo lo que recibe. «Gráfico» no le dice nada; «Ingresos trimestrales por región, con la región este en lo más alto en el tercer trimestre» le dice lo que un lector vidente saca de la imagen en dos segundos

Tablas: las filas de cabecera se declaran, jamás se adivinan

Con el bit de tabla activado, DrawTableRows escribe la tabla, sus filas y sus celdas en el árbol de estructura, así que un lector puede saber en qué columna está un valor en lugar de leer la rejilla como texto sin relación. SetTableHeaderRowCount dice cuántas filas iniciales son cabeceras. Esas filas se escriben como celdas TH con ámbito de columna, que es lo que permite a un lector anunciar «Par» antes del valor al que el usuario se ha movido

El número de filas de cabecera es cero por defecto, y eso fue una decisión. La primera fila de una tabla suele ser la cabecera, así que adivinar acertaría la mayoría de las veces. Pero el coste del fallo en la adivinanza está descompensado: una fila de datos tomada por cabecera se lee en voz alta delante de cada valor de su columna, lo que confunde mucho más que una tabla sin cabeceras en absoluto. Una línea de código tuya es más barata que ese modo de fallo

Declarar filas de cabecera y repetirlas en cada página son decisiones separadas. SetTableHeaderRowCount es semántico y no cambia nada en la página. La repetición es maquetación, y pertenece al argumento RepeatHeaderRows de DrawTaggedTableRows. Si las dos fueran un solo ajuste, pedir etiquetas de cabecera correctas también empezaría a dibujar bandas de cabecera repetidas que nunca pediste, y una maquetación revisada página a página se movería. Las filas pasadas como RepeatHeaderRows también se marcan como cabeceras, así que no tienes que decir lo mismo dos veces

Diagrama de PDF Library for Delphi de celdas de cabecera de tabla que llevan ámbito de columna en el árbol de estructura mientras RepeatHeaderRows mantiene la repetición en saltos de página como una decisión solo de maquetación
Las filas de cabecera viven una vez en el árbol de estructura, con el ámbito de columna pegado a cada celda TH
var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // la fila 1 es la banda de cabecera
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... rellenar las filas de datos ...
  // Dibuja las filas 1..40 en una banda de 600pt, repitiendo una fila de cabecera por página
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

Secciones marcadas a mano dentro de un documento automático

El marcado automático se aparta mientras una etiqueta que abriste a mano sigue abierta. Eso permite que tu código describa parte de un documento por sí mismo y deje el resto a la librería sin que ambos se aniden entre sí, que es el arreglo que quieren la mayoría de los documentos reales. La portada y el bloque de firma tienen estructura que solo tú entiendes. Las doscientas páginas de cuerpo entre ellas no

Tres reglas mantienen la salida válida con la mezcla que uses. Nada se marca dentro de un artefacto, porque la secuencia de contenido marcado de un artefacto no puede llevar además un elemento de estructura, y un archivo que lo intenta suspende la validación PDF/UA. El texto vacío no abre ningún elemento, así que un DrawText extraviado con un string vacío no puede dejar atrás un elemento que un lector anuncie como blanco. Y una llamada de la librería que dibuja por dentro a través de otras llamadas de dibujo abre una etiqueta en el nivel exterior, así que sus piezas nunca acaban anidadas dentro de un segundo elemento propio

Después de guardar, lee el árbol antes de fiarte de él. La API de lectura es 1-based, cosa que nos pilló: nuestra primera comprobación bucleaba de 0 a StructTreeItemCount - 1, obtenía un registro vacío al principio, nunca llegaba al último elemento, y durante un tiempo estuvimos convencidos de que un párrafo había desaparecido. No había desaparecido nada. El bucle estaba simplemente desplazado uno en ambos extremos

var
  I: Integer;
begin
  // Una cita destacada marcada a mano; el marcado automático no interviene
  Lib.BeginTag('BlockQuote', '', '');
  Lib.DrawWrappedText(72, 520, 450,
    'All readings were taken with the unit at operating temperature.');
  Lib.EndTag;
  Lib.SaveToFile('report.pdf');

  // Lee el árbol de vuelta: los índices van de 1 a StructTreeItemCount
  if Lib.LoadFromFile('report.pdf', '') = 1 then
    for I := 1 to Lib.StructTreeItemCount do
      Writeln(Lib.GetStructTreeItemText(I, 2), '  ',    // tipo resuelto
              Lib.GetStructTreeItemText(I, 4));         // /Alt, si lo hay
end;

Un listado como este es una comprobación de cordura rápida, no un veredicto. Lo que quieres ver es un H1 antes del primer párrafo, cada figura con su descripción, y ninguna figura con un /Alt vacío

Cuando el orden de dibujo y el orden de lectura se separan

El marcado automático registra los elementos en el orden en que se dibuja el contenido. Si tu código de maquetación dibuja la barra lateral antes del cuerpo, ese es el orden que guarda el árbol, y ese es el orden en que lee un lector de pantalla. Para documentos donde el orden visual y el orden de lectura difieren de verdad, o que necesitan otros tipos de estructura o idioma por elemento, la API de marcado manual encaja mejor, y el recorrido de PDF etiquetado y estructura de accesibilidad cubre roles, ámbitos y enlaces de cabeceras en detalle

Para páginas que no dibujaste tú, como páginas importadas sin contenido marcado, AutoTagPages funciona a posteriori: toma el tamaño de texto mediano a través de las páginas que recibe como texto de cuerpo, trata las tiras marcadamente mayores como titulares, y marca el resto del pintado como artefactos. Es la herramienta correcta para contenido que no puedes regenerar. Para cualquier cosa que dibuje tu propio código, preferimos marcar en el momento del dibujo, porque ahí se le dice a la librería qué es un titular en lugar de adivinarlo por el tamaño de fuente

Cuando el documento esté terminado, pásalo por un verificador en lugar de asumir: las notas sobre preflight PDF/A y PDF/UA muestran cómo obtener un veredicto sobre la estructura que produciste, y el artículo sobre exportación de informes desde datasets muestra dónde se sitúan estas llamadas en un motor de informes que construye su maquetación desde los datos

Como PDF Library for Delphi está escrito en Pascal nativo para Delphi, C++Builder y Lazarus, sin runtime PDF externo que distribuir, la salida etiquetada sale del mismo código que dibuja la página. La referencia completa de la API y la lista de plataformas están en la página de producto de PDF Library for Delphi