Artículo técnico

Crear un lector PDF accesible en Delphi con PDFium

Apunta NVDA a un visor PDF en Delphi recién creado y normalmente obtendrás uno de estos dos resultados: silencio, o texto leído en el orden en que el flujo de contenido lo guarda por casualidad: primero el pie de página, después la columna de la derecha y luego el encabezado que visualmente abre la página. El renderizado es impecable; la experiencia de escucha no sirve. La brecha existe porque la rasterización y la lectura son procesos distintos: el orden de pintado dentro de un flujo de contenido PDF no tiene ninguna obligación de coincidir con el orden en que una persona debería escucharlo. PDFium Component, el envoltorio VCL/LCL del motor PDFium para Delphi, C++Builder y Lazarus, incluye una familia específica de APIs de lectura precisamente porque las APIs de renderizado no pueden hacer este trabajo.

Tres problemas deciden si un proyecto de lector accesible sale adelante: extraer un orden de lectura que pueda narrarse, mantener un cursor visual de palabra sincronizado con la salida de voz y degradar de forma honesta cuando el documento nunca fue etiquetado. Cada uno tiene una ruta de API concreta y un modo de fallo igualmente concreto que conviene conocer antes de escribir el primer manejador de eventos.

El orden de lectura vive en el árbol de estructura, no en el orden de pintado

ISO 32000-1 §14.8 define la estructura lógica como un árbol de elementos de estructura superpuesto al contenido de la página, y PDF/UA (ISO 14289-1) convierte ese árbol en obligatorio: todo contenido real debe ser accesible a través de él en orden de lectura, dejando fuera los artefactos. Un informe correctamente etiquetado sabe que "Quarterly Results" es un encabezado de nivel dos y que la cuadrícula de totales es una tabla con celdas de cabecera. Un informe sin etiquetas no es más que secuencias de glifos colocadas en coordenadas.

ReadablePageContent recorre esa estructura cuando existe y devuelve fragmentos de contenido marcados con un Kind semántico — cfHeading, cfParagraph y valores relacionados — para que la interfaz del lector pueda anunciar "encabezado" antes del texto en lugar de leer una línea en negrita como si fuese cuerpo normal. Cuando el árbol de estructura falta o no es utilizable, la misma llamada cambia a análisis heurístico de composición: detección de columnas, agrupación por líneas de base y ordenación de izquierda a derecha. El resultado suele ser aceptable en documentos de una sola columna y poco fiable en boletines, formularios multicolumna y cualquier cosa con barras laterales. La disciplina clave es decir al usuario en qué caso está, y la API entrega ese dato directamente: el registro TPdfReadableContent devuelto lleva un campo Source que vale rosStructure cuando el orden procede del árbol etiquetado y rosHeuristic cuando se ha inferido desde la maqueta. Presentar un orden estimado como si estuviese verificado es, en accesibilidad, el equivalente a poner un visto bueno verde a una compilación no probada.

Una forma práctica de clasificar un fichero al abrirlo es comprobar IsTagged y ejecutar ValidatePdfUa una vez, guardando el veredicto en caché. Un fallo en la comprobación PDF/UA no significa rechazar el documento; significa que la barra de estado muestra "orden de lectura estimado", y que el equipo de soporte sabe exactamente qué está viendo cuando un cliente informa de una narración absurda en un fichero concreto.

De la página a la cola de voz con ReadingUnits

Para texto a voz, la pieza de trabajo es ReadingUnits: devuelve una matriz de registros TPdfReadingUnit para la página activa, cada uno con el texto que se debe leer, su rol semántico y los rectángulos de resaltado que lo localizan en la página. Existe una variante para todo el documento, DocumentReadingUnits, pensada para lectura continua. Una unidad encaja de forma natural con una entrada en la cola de voz:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits works on the active page
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // text + semantics + highlight rects
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Hay dos detalles de ese bucle que merecen atención. Primero, mantén la cola estrictamente por página y reconstrúyela al navegar: las unidades de lectura contienen rectángulos en espacio de página, así que una cola obsoleta pinta resaltados en la página incorrecta después de que el usuario salte hacia delante. Segundo, una matriz Units vacía en una página que visiblemente contiene contenido es tu detector de página basada solo en imagen. Una página escaneada tiene píxeles pero no capa de texto, y la respuesta correcta es un aviso hablado — "esta página no contiene texto extraíble" — en lugar de un silencio que el usuario no puede distinguir de un bloqueo.

Un cursor de palabra que sigue a la voz

El resaltado a nivel de bloque se percibe lento para usuarios con baja visión que siguen el texto visualmente mientras escuchan. El resaltado palabra a palabra, tipo karaoke, necesita dos ingredientes: la geometría de las palabras y una correspondencia entre las notificaciones de progreso del motor TTS y esa geometría. PageWordBoxes proporciona la geometría como registros TPdfWordBox: texto de la palabra, desplazamiento de caracteres, número de caracteres y rectángulo en espacio de página. TrackReadingWordAt proporciona la correspondencia: convierte una posición de carácter, que es exactamente lo que entrega la notificación de límite de palabra de SAPI, en un índice dentro de la matriz de cajas de palabra, y resalta en la misma llamada la palabra que la contiene.

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // The view's word boxes come from the page the view displays —
  // setting Pdf.PageNumber alone would not move the view
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt maps the offset AND paints the word cursor
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // boundary ran past the page text
end;

El contrato es flexible en un punto y estricto en otro. Flexible: TrackReadingWordAt mantiene su propia caché de cajas de palabra para la página seguida, así que no tienes que precargarla; además, no interviene el renderizado, porque las cajas de palabra se derivan de la capa de texto de la página, lo que permite seguir posiciones incluso en un servicio de voz sin interfaz. Estricto: el índice de carácter debe referirse al texto que ha extraído el componente. La función también devuelve -1 en lugar de lanzar una excepción cuando CharIndex apunta más allá del final del texto de la página, algo que ocurre a menudo cuando un motor TTS emite un último evento de límite para la puntuación final. Trata -1 como "limpiar el cursor", no como una condición de error.

En la parte visual, ReadingWordColor controla el resaltado del cursor; el ámbar predeterminado aguanta la mayoría de fondos de página, pero conviene comprobarlo con todos los filtros de visualización que ofrezca el visor, porque un cursor ámbar puede desaparecer por completo con la inversión de color, y la combinación de inversión más voz es precisamente la que usan usuarios con baja visión. Establecer ReadingWordFollow en True hace que la vista desplace automáticamente la palabra hablada hasta hacerla visible, algo esencial en páginas ampliadas a varias pantallas. Una regla de ámbito: SetReadingWord pinta solo en la página activa de TPdfView, así que decide si el desplazamiento manual del usuario pausa la voz o si gana el seguimiento automático; no hacer ninguna de las dos cosas deja la voz avanzando contra un cursor invisible.

Documentos que se resisten

Tres clases de entrada rompen implementaciones ingenuas con suficiente frecuencia como para merecer muestras de regresión permanentes en la batería de pruebas.

  • Ficheros sin etiquetas pero ricos en texto. El orden heurístico suele acertar en informes lineales y fallar en maquetaciones con barras laterales o entradillas. Etiqueta el orden como estimado en la interfaz y en el registro de diagnóstico.
  • Escaneos solo imagen. No hay capa de texto en absoluto. Detéctalos mediante unidades de lectura vacías y conduce al usuario hacia un paso OCR previo en lugar de dejar que el lector no diga nada.
  • Caracteres combinados y escrituras mixtas. Las marcas combinadas Unicode no siempre se corresponden una a una con palabras visuales, así que el número de cajas de palabra puede diferir de lo que prediría tu propio tokenizador. Nunca indexes la matriz de cajas de palabra con aritmética derivada de tu propia segmentación; usa solo índices devueltos por TrackReadingWordAt.

Aceptación: prueba como un auditor, no como una demo

"Lee mi muestra en voz alta" no es una aceptación. Una validación defendible pasa tres documentos por la compilación final con NVDA conectado: un fichero conocido y etiquetado (encabezados anunciados como encabezados, tabla leída por filas), un fichero conocido y sin etiquetas (indicador de orden estimado visible) y un escaneo (aviso explícito de ausencia de texto leído en voz alta).

Después comprueba que el cursor de palabra permanece unido al texto al doble y a la mitad de velocidad de habla, y que el desplazamiento de ReadingWordFollow no pelea con el desplazamiento manual. Por último, activa todos los filtros de color mientras la voz se reproduce y confirma que el cursor sigue visible; el artículo sobre filtros de color para baja visión cubre esa ruta de renderizado, y el análisis en profundidad del cursor de palabra y voz entra más en los detalles de temporización TTS.

FAQ

¿El lector necesita un PDF etiquetado para funcionar?

No. ReadablePageContent y ReadingUnits recurren al análisis heurístico de composición en ficheros sin etiquetas, y el campo Source del contenido legible indica qué ruta produjo el orden. La carga recae en tu interfaz: distingue el orden verificado por árbol de estructura del orden estimado, porque fallan de formas distintas y soporte necesita saber de cuál trata la incidencia.

¿Por qué TrackReadingWordAt devuelve -1 en mitad de una página?

Normalmente el índice de carácter de tu motor TTS se refiere a texto que preprocesaste antes de encolarlo, o ha caído en un espacio entre palabras. Los desplazamientos deben apuntar al texto que extrajo el componente — el mismo texto que tokenizó PageWordBoxes — y no a una copia saneada.

¿Puedo comprobar el cumplimiento de accesibilidad por programa?

Sí: ValidatePdfUa devuelve el nivel de conformidad detectado junto con un conjunto de infracciones PDF/UA por documento, y BuildPdfPreflightReport incorpora la misma comprobación en un informe multiestándar. Es un detector, no una herramienta de reparación: usa el veredicto para fijar expectativas al abrir el fichero y para clasificar los documentos entrantes.

Las APIs de unidades de lectura y cajas de palabra que se muestran aquí forman parte de PDFium Component para Delphi y C++Builder (VCL) y Lazarus/FPC (LCL). La página del producto enlaza la referencia completa de API, incluidos los diseños de registros de unidades de lectura y cajas de palabra usados en los ejemplos anteriores.