Artículo técnico

Resaltado TTS palabra por palabra en visores PDFium para Delphi

La primera demostración de nuestra función de lectura en voz alta para una aplicación de alfabetización funcionó bien durante dos párrafos. Después la página llegó a una capitular, la voz dijo "Capítulo" mientras el resaltado seguía en la línea anterior, y al final de la página el cursor iba tres palabras detrás del audio. La voz nunca fue el problema: SAPI reportaba los límites de palabra con precisión. El problema estaba en la capa que mapea los desplazamientos de caracteres del búfer de voz con rectángulos sobre una página PDF renderizada, y en esa capa vive o muere cualquier resaltador estilo karaoke. PDFium Component (cuadros de palabra desde v1.53, el rastreador y el cursor de resaltado desde v1.56) entrega ese mapeo para Delphi, C++Builder y Lazarus como una API pequeña y deliberada: cuadros de palabra, un rastreador de desplazamiento a palabra y un cursor de resaltado con auto-scroll. Usada en el orden correcto es robusta; usada en el orden incorrecto produce exactamente el desfase que mostramos en la demo.

Los caracteres no son palabras, y los motores TTS hablan en caracteres

Un motor de voz consume una cadena plana y reporta el avance como posiciones de caracteres dentro de esa cadena. Una página PDF, mientras tanto, tiene glifos colocados en el espacio de página, donde una "palabra" es un grupo heurístico de corridas de glifos. Los dos sistemas de coordenadas no comparten nada a menos que el texto que entregan al sintetizador sea byte por byte el texto con el que se calcularon los cuadros de palabra. Esa es la primera regla, y no perdona: si normalizan espacios, eliminan guiones blandos o "limpian" de cualquier otra forma el texto extraído antes de leerlo, todos los desplazamientos posteriores quedan silenciosamente mal. Lean exactamente lo que extrajeron, o mantengan una tabla explícita de remapeo de desplazamientos; no hay una tercera opción que sobreviva a documentos reales.

La opción de remapeo no es hipotética. Si la interfaz inserta anuncios hablados de página ("página cinco") o expande abreviaturas para el sintetizador, registren la posición y longitud de cada inserción, y resten el ajuste acumulado antes de cada llamada de rastreo. Son veinte líneas de contabilidad, y marcan la diferencia entre un resaltado que sobrevive al crecimiento de la función y uno que se rompe la primera vez que producto pide encabezados hablados.

Qué ofrece un cuadro de palabra

Cada registro TPdfWordBox contiene el texto de la palabra, su StartIndex y Count de caracteres dentro del texto de la página, un Rect en espacio de página y el número de Page basado en 1. PageWordBoxes devuelve el arreglo completo para la página activa:

procedure TReaderForm.PreparePage(PageNo: Integer);
begin
  PdfView.PageNumber := PageNo;   // the view's word boxes track its displayed page

  FWords := PdfView.PageWordBoxes;
  FPageText := BuildSpeechText(FWords);   // concatenate Word.Text in order

  if Length(FWords) = 0 then
    HandleImageOnlyPage(PageNo);          // a scan with no text layer
end;

El comentario sobre el orden es crítico: el PageWordBoxes del visor tokeniza la capa de texto de la página que el visor muestra en ese momento, así que primero naveguen el visor y después extraigan; no se requiere renderizar, solo tener abierto el documento. (El componente de documento ofrece su propio PageWordBoxes asociado a Pdf.PageNumber para uso sin interfaz.) Un resultado vacío en una página que visiblemente tiene contenido significa una digitalización solo de imagen; envíenla a OCR o sáltenla de forma audible ("la página 4 no contiene texto legible") en vez de dejar que la voz se quede callada sin explicación.

Conectar los límites de palabra de SAPI al rastreador

TrackReadingWordAt, en el visor, es la bisagra de toda la función: recibe un número de página y un índice de carácter, encuentra el cuadro de palabra que contiene ese carácter, pinta el cursor de lectura encima y devuelve el índice de palabra, o −1. La notificación de límite de palabra de SAPI entrega exactamente la posición de carácter que necesitan:

procedure TReaderForm.OnSpeechWordBoundary(StreamPos: Integer);
var
  WordIdx: Integer;
begin
  // Maps the offset to a word box and moves the highlight in one call
  WordIdx := PdfView.TrackReadingWordAt(FPageNo, StreamPos);
  if WordIdx < 0 then
    Exit;                     // boundary fell outside any word: keep last highlight
end;

Dos detalles defensivos. TrackReadingWordAt mantiene su propia caché de cuadros de palabra para la página rastreada (se reconstruye automáticamente cuando cambia la página), así que el costo por límite permanece estable; y no hace una verificación de límites generosa: un índice igual o superior al conteo de caracteres de la página devuelve −1 en vez de ajustarse a la última palabra. Traten −1 como "mantener el resaltado anterior", nunca como un error, porque las corridas de puntuación y los espacios entre palabras generan legítimamente límites que no pertenecen a ninguna palabra. Si registran cada −1 se van a ahogar en ruido; cuéntenlos por página y revisen las páginas donde la proporción se dispara, porque eso suele señalar una discrepancia de normalización de texto respecto de la primera regla.

El cursor: color, seguimiento y limpieza

SetReadingWord pinta el resaltado directamente cuando ustedes ya tienen el cuadro de palabra, ReadingWordColor define su estilo y ReadingWordFollow := True desplaza la vista apenas lo necesario para mantener visible la palabra hablada. Esa última propiedad importa más de lo que parece: un desplazamiento casero que "centra la palabra actual" hace que la página salte en cada cambio de línea, y los lectores sensibles al movimiento desactivarán la función en menos de un minuto. El resaltado se dibuja solo en la página que se muestra actualmente en el TPdfView activo, así que la lectura de varias páginas debe avanzar PageNumber al mismo ritmo que la voz, y repetir el paso de preparación para la nueva página antes de que llegue su primer evento de límite, de modo que el texto hablado y los desplazamientos queden alineados con la página nueva.

procedure TReaderForm.StopReading;
begin
  FVoice.Stop;                // halt SAPI playback first
  PdfView.ClearReadingWord;   // then remove the highlight; a stale cursor reads as a bug
end;

La simetría importa al cerrar: toda ruta de pausa, detención y cambio de página debe terminar en ClearReadingWord. El "bug" más reportado en nuestra beta fue un rectángulo ámbar que quedaba en una página pausada; no dañaba nada, pero todos los evaluadores lo reportaron.

La velocidad de lectura estresa esta canalización más que el tamaño del documento. A 300 palabras por minuto, los eventos de límite llegan cada 200 ms; a las velocidades máximas de SAPI, más rápido de lo que el ojo puede seguir cómodamente. Fusionen en vez de encolar: si llega un límite nuevo mientras una actualización de resaltado aún está pendiente, descarten la vieja. Un cursor que visita cada palabra en orden pero medio segundo tarde se siente roto; uno que a veces salta una palabra pero se mantiene sincronizado no.

Casos límite que separan las demos de los productos

Se repiten tres categorías. Caracteres combinantes: las secuencias Unicode, como letras base con diacríticos combinantes, pueden ocupar más índices de carácter de lo que sugiere la palabra visual, así que la aritmética de desplazamientos que asume un índice por glifo visible se desfasa; una razón más para dejar que TrackReadingWordAt haga el mapeo en vez de calcular números de palabra por cuenta propia. Guionado: una palabra cortada en un salto de línea se convierte en dos cuadros; si la leen como un solo token, el evento de límite de su segunda mitad resuelve al primer cuadro, lo cual puede ser aceptable, pero decídanlo a propósito. Y documentos etiquetados frente a no etiquetados: la secuencia de palabras sigue la estructura lógica del documento cuando existe etiquetado correcto (territorio de ISO 14289, PDF/UA) y recurre a heurísticas de diseño en caso contrario, así que una página no etiquetada a dos columnas puede leerse de corrido a través de ambas columnas. Las páginas rotadas agregan un cuarto caso: el Rect de cada palabra sigue delimitándola correctamente en espacio de página, pero una política de seguimiento de viewport ajustada para flujo horizontal se desplaza de forma brusca cuando el texto corre en vertical, así que mantengan al menos un documento rotado en el conjunto de regresión. Para manejo del orden de lectura, unidades a nivel de oración mediante ReadingUnits y la pila asistiva más amplia, vean cómo construir un lector PDF accesible en Delphi.

Una nota de plataforma: SAPI solo existe en Windows. La API de cuadros de palabra y rastreo es idéntica bajo Lazarus/FPC, pero las compilaciones para Linux y macOS necesitan otro sintetizador detrás de los mismos eventos de límite; las diferencias de configuración se cubren en ejecutar el visor con Lazarus y FPC. El costo de renderizar el resaltado también interactúa con la caché de páginas a velocidades de voz altas; la aritmética de presupuesto de caché de renderizado y rendimiento de zoom aplica aquí sin cambios.

Preguntas frecuentes

¿Por qué TrackReadingWordAt siempre devuelve −1?

Normalmente por una de tres causas: el número de página pasado está fuera de rango o el documento no está activo, el texto entregado al motor TTS difiere del texto extraído de la página y por eso los desplazamientos no se alinean, o el índice de carácter pertenece a un espacio entre palabras. Revísenlas en ese orden.

¿Por qué el resaltado deja de actualizarse después de cambiar de página?

El cursor de lectura se dibuja solo en la página actual de la vista activa. Avancen PageNumber y vuelvan a obtener PageWordBoxes para el texto hablado antes de reanudar, de modo que los desplazamientos de límite se refieran a la página que ahora está en pantalla.

¿Puedo resaltar oraciones completas en vez de palabras individuales?

Sí: ReadingUnits devuelve unidades a nivel de oración y bloque con sus propios rectángulos de resaltado (píntenlos con SetReadingHighlight), lo que se adapta a oyentes más lentos y reduce el cambio visual a velocidades de voz altas.

Los requisitos de versión (v1.53 o posterior para cuadros de palabra, v1.56 para el cursor de rastreo), la API completa de lectura y una demo funcional de lectura en voz alta están en la página del producto: PDFium Component.