Artículo técnico

Crear un lector PDF accesible en Delphi con PDFium

Apunten NVDA a un visor PDF en Delphi recién compilado y, por lo general, obtendrán uno de dos resultados: silencio, o texto leído en el orden en que el flujo de contenido decidió almacenarlo: primero el pie de página, luego la columna derecha y después el encabezado que visualmente abre la página. El renderizado es impecable; la experiencia de escucha es inútil. La brecha existe porque rasterización y lectura son canalizaciones separadas: el orden de pintado dentro de un flujo de contenido PDF no tiene obligación de coincidir con el orden que una persona debería escuchar. PDFium Component, el wrapper VCL/LCL alrededor del motor PDFium para Delphi, C++Builder y Lazarus, incluye una familia dedicada de APIs de lectura precisamente porque las APIs de renderizado no pueden hacer ese trabajo.

Tres problemas deciden si un proyecto de lector accesible tiene éxito: extraer un orden de lectura que se pueda narrar, mantener un cursor visible de palabra sincronizado con la salida de voz y degradar con honestidad cuando el documento nunca fue etiquetado. Cada uno tiene una ruta concreta de API y un modo de falla 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 estructurales superpuesto al contenido de la página, y PDF/UA (ISO 14289-1) hace obligatorio ese árbol: cada pieza de contenido real debe ser alcanzable a través de él en orden de lectura, con los artefactos excluidos. Un reporte 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 encabezado. Un reporte sin etiquetas no es más que grupos de glifos posicionados.

ReadablePageContent recorre esta estructura cuando existe y devuelve fragmentos de contenido marcados con un Kind semántico — cfHeading, cfParagraph y valores relacionados — para que la UI del lector pueda anunciar "encabezado" antes del texto en lugar de leer una línea en negritas como si fuera cuerpo normal. Cuando el árbol de estructura no existe o no se puede usar, la misma llamada cambia a análisis heurístico del diseño: detección de columnas, agrupación por línea base, ordenamiento de izquierda a derecha. El resultado suele servir para documentos de una sola columna y es poco confiable para boletines, formularios de varias columnas y cualquier cosa con barras laterales. La disciplina crucial es decirle 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 vino del árbol etiquetado y rosHeuristic cuando fue inferido desde el diseño. Presentar un orden inferido como orden verificado es el equivalente de accesibilidad de poner una marca verde sobre una compilación no probada.

Una forma práctica de clasificar un archivo al abrirlo es revisar IsTagged y ejecutar ValidatePdfUa una vez, guardando el veredicto en caché. Un fallo de 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 reporta una narración sin sentido en un archivo específico.

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

Para text-to-speech, el caballo de batalla es ReadingUnits: devuelve un arreglo de registros TPdfReadingUnit para la página activa, cada uno con el texto que se va a leer, su rol semántico y los rectángulos de resaltado que lo ubican en la página. También existe una variante de documento completo, DocumentReadingUnits, para lectura continua. Una unidad se asigna de forma natural a una entrada en una 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;

Dos detalles de este bucle merecen atención. Primero, mantengan la cola estrictamente por página y reconstruyanla al navegar: las unidades de lectura contienen rectángulos en espacio de página, por lo que una cola obsoleta pinta resaltados en la página equivocada después de que el usuario salta hacia adelante. Segundo, un arreglo Units vacío en una página que visiblemente contiene contenido es su detector de páginas solo imagen. Una página escaneada tiene píxeles pero no capa de texto, y la respuesta correcta es una advertencia hablada — "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 la voz

El resaltado por bloques se siente lento para usuarios con baja visión que siguen visualmente mientras escuchan. El resaltado palabra por palabra, tipo karaoke, necesita dos ingredientes: geometría de palabras y una asignación desde las devoluciones de progreso del motor TTS hacia esa geometría. PageWordBoxes proporciona la geometría como registros TPdfWordBox: texto de la palabra, desplazamiento de carácter, conteo de caracteres y un rectángulo en espacio de página. TrackReadingWordAt proporciona la asignación: 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 del arreglo de word boxes, y resalta la palabra que lo contiene en la misma llamada.

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 tolerante en un aspecto y estricto en otro. Tolerante: TrackReadingWordAt mantiene su propia caché de word boxes para la página rastreada, así que no tienen que precargarla; y no interviene ningún renderizado, porque las word boxes se derivan de la capa de texto de la página, lo que significa que incluso un servicio de voz sin interfaz puede rastrear posiciones. Estricto: el índice de carácter debe referirse al texto que extrajo 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 con frecuencia cuando un motor TTS emite un evento final de límite para puntuación de cierre. Traten -1 como "limpiar el cursor", no como una condición de error.

En el lado visual, ReadingWordColor controla el resaltado del cursor: el ámbar predeterminado sobrevive a la mayoría de los fondos de página, pero verifíquenlo bajo cada filtro de visualización que ofrezca su visor, porque un cursor ámbar puede desaparecer por completo bajo inversión de color, y la inversión junto con voz es precisamente la combinación que usan los 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 con zoom y varias pantallas. Una regla de alcance: SetReadingWord pinta solo en la página activa de TPdfView, así que decidan si el desplazamiento del usuario pausa la voz o si gana el seguimiento automático; no hacer ninguna de las dos cosas deja la voz corriendo contra un cursor invisible.

Documentos que se resisten

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

  • Archivos sin etiquetas pero con mucho texto. El orden heurístico suele ser correcto para reportes lineales y erróneo para diseños con barras laterales o citas destacadas. Etiqueten el orden como estimado en la UI y en su registro de diagnóstico.
  • Escaneos solo imagen. No tienen capa de texto. Detéctenlos mediante unidades de lectura vacías y dirijan 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 asignan uno a uno con palabras visuales, por lo que el conteo de word boxes puede diferir de lo que predeciría su propio tokenizador. Nunca indexen el arreglo de word boxes con aritmética derivada de su propia separación; usen solo índices devueltos por TrackReadingWordAt.

Aceptación: prueben como auditores, no como una demo

"Leyó mi muestra en voz alta" no es aceptación. Una aprobación defendible ejecuta tres documentos en la compilación final con NVDA conectado: un archivo etiquetado conocido (encabezados anunciados como encabezados, tabla leída por filas), un archivo sin etiquetas conocido (indicador de orden estimado visible) y un escaneo (advertencia explícita de ausencia de texto hablada).

Después verifiquen que el cursor de palabra permanezca unido al texto a doble velocidad y a media velocidad de voz, y que el desplazamiento de ReadingWordFollow no pelee con el desplazamiento manual. Por último, activen cada filtro de color mientras la voz corre y confirmen que el cursor siga visible: el artículo sobre filtros de color para baja visión cubre esa ruta de renderizado, y la guía profunda del cursor de voz por palabra entra con más detalle en la sincronización de TTS.

Preguntas frecuentes

¿El lector requiere un PDF etiquetado para funcionar?

No. ReadablePageContent y ReadingUnits recurren al análisis heurístico de diseño en archivos sin etiquetas, y el campo Source del contenido legible indica qué ruta produjo el orden. La carga queda en su UI: distingan el orden verificado del árbol de estructura del orden estimado, porque los dos fallan de maneras diferentes y soporte necesita saber de cuál se trata la queja.

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

Por lo general, el índice de carácter de su motor TTS se refiere a texto que preprocesaron antes de encolarlo, o cayó sobre espacio en blanco entre palabras. Los desplazamientos deben apuntar al texto que extrajo el componente, el mismo texto que tokenizó PageWordBoxes, no a una copia limpiada.

¿Puedo revisar el cumplimiento de accesibilidad mediante programación?

Sí: ValidatePdfUa devuelve el nivel de conformidad detectado más un conjunto de infracciones PDF/UA por documento, y BuildPdfPreflightReport integra la misma revisión en un reporte multiestándar. Es un detector, no una herramienta de reparación: usen el veredicto para fijar expectativas al abrir y para clasificar archivos entrantes.

Las APIs de unidades de lectura y word boxes mostradas aquí son 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 para las unidades de lectura y word boxes usados en los ejemplos anteriores.