Artículo técnico

Cree un lector de PDF accesible en Delphi con PDFium

Un usuario ciego abre un informe trimestral en su flamante visor Delphi, activa NVDA y oye el pie de página, luego una columna de cifras, y después el título que cualquier lector vidente habría leído primero. O no oye nada en absoluto. La página se ve perfecta en pantalla, y ahí está justamente la trampa: renderizar y leer son problemas distintos resueltos por código distinto. El orden en el que un PDF pinta sus glifos no tiene obligación alguna de coincidir con el orden en el que una persona debería oírlos, de modo que un visor construido solo sobre llamadas de renderizado produce una imagen impecable y una narración inservible. PDFium Component, el envoltorio VCL/LCL alrededor del motor PDFium para Delphi, C++Builder y Lazarus, lleva por esta razón un conjunto separado de API de lectura. Las API de dibujo no pueden recuperar un orden de lectura que nunca recibieron

Un lector accesible se sostiene o se cae por tres cosas. Tiene que extraer un orden que un lector de pantalla pueda pronunciar, mantener un cursor de palabra visible clavado en lo que la voz esté diciendo, y admitir que un documento nunca fue etiquetado en lugar de adivinar y disimular. Cada una tiene una API clara a la que recurrir y un fallo que muerde si se salta el detalle

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 superpuesto al contenido de la página. PDF/UA (ISO 14289-1) va más allá y hace ese árbol obligatorio: todo fragmento de contenido real tiene que ser alcanzable a través de él en orden de lectura, con los artefactos de página marcados como tales y omitidos. Un informe correctamente etiquetado sabe que "Resultados trimestrales" es un encabezado de segundo nivel y que la rejilla de totales es una tabla con celdas de cabecera. Un informe sin etiquetar es un montón de secuencias de glifos posicionadas que casualmente parecen un documento

ReadablePageContent recorre esa estructura cuando está presente y devuelve fragmentos etiquetados con un Kind semántico, valores como cfHeading y cfParagraph, de forma que la interfaz pueda decir "encabezado" antes de las palabras en lugar de leer una línea en negrita como texto de cuerpo corriente. Sin un árbol utilizable, la misma llamada recurre a un análisis heurístico de la maqueta: detectar columnas, agrupar líneas de base, ordenar de izquierda a derecha y de arriba abajo. Ese respaldo funciona bien para una nota de una sola columna y flaquea con un boletín, un formulario a varias columnas o cualquier cosa con una barra lateral o una cita destacada. Lo que importa es saber qué resultado ha obtenido, y la API se lo dice sin rodeos. El registro TPdfReadableContent lleva un campo Source puesto a rosStructure cuando el orden vino del árbol etiquetado, o a rosHeuristic cuando se infirió de la geometría. Muestre un orden adivinado como si estuviera verificado y habrá publicado la versión de accesibilidad de una insignia de aprobado sobre una compilación que nadie ejecutó

Un lector accesible de PDFium en Delphi toma el orden de lectura del árbol de estructura etiquetado y recurre al análisis heurístico de maqueta para PDF sin etiquetar, con el campo Source de TPdfReadableContent distinguiendo rosStructure de rosHeuristic
El árbol etiquetado anuncia encabezados y orden de filas mientras el respaldo heurístico adivina a partir de la geometría, y el campo Source mantiene separado lo verificado de lo estimado

La jugada barata al abrir el documento es leer IsTagged y llamar una vez a ValidatePdfUa, y luego cachear la respuesta. Una comprobación PDF/UA fallida no es motivo para rechazar el archivo. Es motivo para poner "orden de lectura estimado" en la barra de estado, de modo que cuando un cliente envíe una queja por una narración desordenada, el soporte ya sepa si está ante un problema de etiquetado en el archivo o ante un fallo en su código

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

Para texto a voz, ReadingUnits hace el trabajo pesado. Devuelve un array de registros TPdfReadingUnit para la página activa, cada uno con el texto que pronunciar, su rol semántico y los rectángulos que lo sitúan en la página. Hay un acompañante a nivel de documento, DocumentReadingUnits, para cuando quiera lectura continua a través de las páginas. Una unidad encaja directamente en una ranura de una cola de habla:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits actúa sobre la página activa
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // texto + semántica + rects de resalte
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Dos cosas de ese bucle son fáciles de hacer mal. Mantenga la cola por página y reconstrúyala cada vez que el usuario navegue, porque las unidades de lectura llevan rectángulos en espacio de página; una cola que sobra de la página tres pintará sus resaltes sobre la página cuatro. Y trate un array Units vacío en una página que claramente tiene contenido como su detector de páginas solo de imagen. Una página escaneada son píxeles sin capa de texto debajo, y la respuesta correcta es pronunciar un aviso ("esta página no tiene texto extraíble") en lugar de quedarse en silencio de una forma que el oyente no puede distinguir de un cuelgue

ReadingUnits de PDFium en Delphi convierte la página activa en texto, rol semántico y rectángulos en espacio de página que llenan una cola de habla de lector de pantalla, una unidad por ranura, y un array de unidades vacío señala una página escaneada para emitir un aviso hablado
Las unidades de lectura caen una por ranura en la cola de habla, y un array vacío en una página con contenido es el detector de páginas escaneadas

Un cursor de palabra que sigue a la voz

Resaltar un párrafo entero de golpe le resulta lento a un usuario con baja visión que sigue las palabras con la vista mientras se leen en voz alta. El resalte a nivel de palabra, el efecto karaoke, necesita dos piezas: la geometría de cada palabra, y una forma de mapear los informes de progreso del motor de TTS sobre esa geometría. PageWordBoxes le da la geometría como registros TPdfWordBox, cada uno con el texto de la palabra, su desplazamiento de carácter, su recuento de caracteres y un rectángulo en espacio de página. TrackReadingWordAt le da el mapeo. Páselo la posición de carácter que el evento de frontera de palabra de SAPI ya informa, y resuelve ese desplazamiento a un índice del array de cajas de palabra y pinta el cursor sobre la palabra correspondiente en una sola llamada

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // Las cajas de palabra de la vista vienen de la página que la vista muestra.
  // Poner solo Pdf.PageNumber no movería la vista
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt mapea el desplazamiento Y pinta el cursor de palabra
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // la frontera pasó del texto de la página
end;

El contrato es generoso en un punto e implacable en otro. La parte generosa: TrackReadingWordAt mantiene su propia caché de cajas de palabra para la página que está siguiendo, así que no hay nada que precargar, y no ocurre renderizado alguno porque las cajas de palabra salen de la capa de texto. Un servicio de habla sin ventana visible puede seguir rastreando posiciones. La parte implacable: el índice de carácter tiene que apuntar al texto que el componente extrajo, no a alguna cadena depurada que usted haya construido por su cuenta. Cuando CharIndex se pasa del final del texto de la página, la función devuelve -1 en vez de lanzar una excepción, algo que sucede continuamente cuando un motor de TTS dispara un último evento de frontera por la puntuación final. Lea -1 como "limpie el cursor", nunca como un error

Del lado de la presentación, ReadingWordColor fija el color del cursor. El ámbar por defecto aguanta sobre la mayoría de los fondos de página, pero pruébelo bajo todos los filtros de visualización que ofrezca su visor. Un cursor ámbar puede desaparecer por completo bajo inversión de color, y la inversión funcionando junto con el habla es exactamente como trabaja un usuario con baja visión, así que la única combinación que más necesita acertar es la que una demostración rápida nunca ejercita. Ponga ReadingWordFollow a True y la vista desplaza sola la palabra pronunciada hasta hacerla visible, algo de lo que no puede prescindir en una página ampliada que se sale de la pantalla. Atienda a una regla de alcance: SetReadingWord pinta solo sobre la página activa de TPdfView. Decida de antemano si el desplazamiento manual pausa el habla o si el comportamiento de seguimiento lo anula, porque no elegir ninguno deja la voz leyendo mientras el cursor se queda en algún punto fuera de la pantalla

Los eventos de frontera de palabra de SAPI en un lector PDFium de Delphi se mapean mediante TrackReadingWordAt sobre la geometría de PageWordBoxes para pintar el cursor de palabra tipo karaoke, y un retorno de -1 limpia el cursor cuando el desplazamiento se pasa del texto de la página
El desplazamiento de frontera del TTS se resuelve a una caja de palabra y pinta el cursor, y un -1 más allá del texto de la página lo limpia en lugar de lanzar una excepción

Los documentos que rompen su lector

Un puñado de formas de entrada derrotan a una implementación ingenua con la fiabilidad suficiente como para figurar como muestras permanentes en la batería de regresión, y no como fallos puntuales que se corrigen y se olvidan

  • Archivos sin etiquetar pero ricos en texto. El orden heurístico suele acertar en un informe lineal y se equivoca en cuanto entra una barra lateral o una cita destacada. Marque el orden como estimado, tanto en la interfaz como en su registro de diagnóstico, para que el fallo sea legible más adelante
  • Escaneos solo de imagen. Sin capa de texto de ningún tipo. Cácelos mediante unidades de lectura vacías y dirija al usuario a un paso de OCR previo en lugar de dejar que el lector narre una página vacía
  • Caracteres combinantes y escrituras mezcladas. Las marcas combinantes de Unicode no siempre colapsan uno a uno en palabras visuales, así que el recuento de cajas de palabra puede desviarse de lo que espere su propio tokenizador. No indexe el array de cajas de palabra con desplazamientos que haya calculado partiendo el texto usted mismo; use solo los índices que devuelve TrackReadingWordAt

Pruébelo como un auditor, no como una demostración

"Leyó mi muestra en voz alta" no demuestra nada. Un aprobado que pueda defender pasa tres archivos por la compilación terminada con NVDA conectado: un archivo con etiquetado conocido, donde los encabezados se anuncian como encabezados y una tabla se lee en orden de filas; un archivo sin etiquetar conocido, donde el indicador de orden estimado está visible; y un escaneo, donde el aviso de ausencia de texto se pronuncia de verdad. Cada uno ejercita una ruta que el caso feliz se salta

A partir de ahí, confirme que el cursor de palabra sigue clavado al doble de velocidad de habla y a la mitad, y que el desplazamiento de ReadingWordFollow no pelea con el desplazamiento propio del usuario. Después ponga el habla en marcha mientras recorre todos los filtros de color y vigile que el cursor no desaparezca nunca. El artículo sobre filtros de color para baja visión cubre esa ruta de renderizado en detalle, y el análisis a fondo del cursor de palabra hablada desmenuza la temporización del TTS

Las API de unidades de lectura y de cajas de palabra utilizadas arriba se incluyen con PDFium Component para Delphi y C++Builder (VCL) y Lazarus/FPC (LCL). La página de producto enlaza la referencia completa de la API, incluidos los diseños de registro de las unidades de lectura y las cajas de palabra que hay tras estos ejemplos