Un botón de lectura en voz alta se demuestra en una tarde y luego consume una semana. La versión de la tarde extrae el texto de la página, se lo entrega a SAPI y obtiene audio. La semana se invierte en lo que hace que la función sea utilizable: la voz no debe congelar la ventana, la palabra hablada tiene que iluminarse en la página al mismo tiempo que el audio, y la tecla Espacio tiene que pausar todo. Este artículo construye esa tubería en Delphi contra la API de texto de PDFium en bruto y la API de voz de Windows, con código funcional para las tres piezas que la versión rápida omite: el ciclo de vida COM realizado una vez en lugar de por locución, los eventos reales de límite de palabra y las matemáticas de coordenadas que convierten el cuadro de una palabra en espacio PDF en un rectángulo que puedes dibujar
El contexto regulatorio cabe en una oración: la lectura en voz alta sincronizada es la mitad del lado del visor de lo que WCAG 2.1 le pide al software de documentos, y la norma ISO 14289-1 (PDF/UA) define la mitad del archivo etiquetado con la que mejor funciona. Si estás desarrollando sobre PDFium Component, es posible que no necesites esta tubería en absoluto: el visor incluye un cursor de seguimiento integrado que mapea el desplazamiento de un carácter a un resaltado de palabra dibujado en una sola llamada, cubierto en el artículo sobre resaltado de texto a voz palabra por palabra. Lo que sigue es para cuando eres el propietario de toda la aplicación del visor y deseas la tubería por ti mismo
Un hilo renderiza, un hilo habla
La arquitectura consta de dos hilos y un contrato. El hilo de la interfaz de usuario renderiza el mapa de bits de la página, es dueño del estado de zoom y desplazamiento (scroll), y dibuja la superposición de resaltado. Un hilo de voz dedicado es dueño de la voz SAPI y nada más lo toca. El contrato es sencillo: el hilo de voz informa el progreso como desplazamientos de caracteres (offsets), y el hilo de la interfaz de usuario convierte esos desplazamientos en rectángulos
La mayoría de los ejemplos de SAPI envuelven cada locución en CoInitialize y CoUninitialize, y un visor demuestra de inmediato por qué eso está mal. Speak con SVSFlagsAsync retorna tan pronto como se encola el texto, por lo que un CoUninitialize en el bloque finally del mismo procedimiento se ejecuta mientras la voz todavía está hablando, derribando el "apartment" COM que lo posee. Dependiendo de los tiempos, obtienes silencio, una locución truncada o una violación de acceso minutos después. El ciclo de vida correcto es aburrido: CoInitialize una vez cuando se inicia el hilo de voz, creas la voz dentro de ese "apartment", y CoUninitialize una vez cuando el hilo termina, después de que la voz se ha liberado. Nunca por locución
La voz también necesita un bucle de mensajes (message pump), lo cual decide dónde puede vivir. El objeto de automatización SpVoice entrega sus eventos a través de la cola de mensajes del hilo que lo creó. Si lo creas en el hilo de la interfaz de usuario, los eventos llegan, porque la VCL procesa los mensajes, pero cada dibujado lento retrasa los límites de las palabras; si lo creas en un hilo de trabajo sin bucle de mensajes, los eventos nunca llegan. Un hilo dedicado con su propio bucle GetMessage mantiene plana la latencia de los límites sin importar lo que esté haciendo la interfaz de usuario
uses
System.Classes, System.SyncObjs, Winapi.Windows, Winapi.Messages,
Winapi.ActiveX, SpeechLib_TLB;
const
WM_SPEAK_PAGE = WM_APP + 1;
type
TSpeechThread = class(TThread)
private
FVoice: TSpVoice;
FLock: TCriticalSection;
FText: string;
function NextUtterance: string; // reads FText under FLock
procedure VoiceWord(ASender: TObject; StreamNumber: Integer;
StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
protected
procedure Execute; override;
procedure TerminatedSet; override;
public
procedure SpeakPage(const AText: string); // safe from the UI thread
end;
procedure TSpeechThread.Execute;
var
Msg: TMsg;
begin
CoInitialize(nil); // once, when the thread starts
try
FVoice := TSpVoice.Create(nil);
try
FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
FVoice.OnWord := VoiceWord;
// Force creation of this thread's message queue before anyone posts to it
PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
while GetMessage(Msg, 0, 0, 0) do // exits when WM_QUIT arrives
if Msg.message = WM_SPEAK_PAGE then
FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
else
DispatchMessage(Msg); // delivers the SAPI event callbacks
finally
FVoice.Free;
end;
finally
CoUninitialize; // once, when the thread exits
end;
end;
procedure TSpeechThread.TerminatedSet;
begin
inherited;
PostThreadMessage(ThreadID, WM_QUIT, 0, 0); // unblock GetMessage
end;
TerminatedSet envía un mensaje WM_QUIT para que el bucle de mensajes se desbloquee cuando el visor se apaga. SpeakPage, llamado desde el hilo de la interfaz de usuario, almacena el texto en un campo protegido por un bloqueo y envía WM_SPEAK_PAGE, porque llamar un método en FVoice directamente desde otro hilo sería una llamada COM entre "apartments" en una interfaz no serializada (unmarshaled). La única línea de PeekMessage antes del bucle fuerza a Windows a crear la cola de mensajes del hilo, cerrando la condición de carrera de inicio en la que fallaría un mensaje enviado tempranamente por la interfaz de usuario
Los límites de palabras llegan como desplazamientos de caracteres
Importa la biblioteca de objetos de Microsoft Speech (Microsoft Speech Object Library) una vez a través del importador de bibliotecas de tipos del IDE y obtendrás SpeechLib_TLB con el contenedor TSpVoice y sus eventos tipados. Dos configuraciones son importantes. EventInterests debe reducirse a los eventos que realmente consumes, porque cada interés que dejas encendido genera tráfico de eventos entre hilos para cada palabra de cada página; SVEWordBoundary impulsa el resaltado y SVEEndInputStream te indica que la locución finalizó. Además, el manejador OnWord recibe CharacterPosition y una longitud, que indexan exactamente en la cadena que le pasaste a Speak: un desplazamiento hacia el búfer de voz, no hacia ninguna otra cosa
Esa última cláusula es la invariante de la que depende la función: los desplazamientos solo tienen sentido con respecto a la cadena que la voz está leyendo, por lo que debes hablar exactamente el texto que extrajiste, carácter por carácter. Si recortas los espacios en blanco, colapsas los saltos de línea o expandes una abreviatura para lograr una pronunciación más agradable, cada resaltado posterior a la primera edición quedará desfasado una palabra. Si la interfaz de usuario debe inyectar material hablado (anuncios de página, prefijos de encabezado), registra la posición y la longitud de cada inserción, y resta el desplazamiento acumulado de cada "offset" antes de mapearlo
procedure TSpeechThread.SpeakPage(const AText: string);
begin
FLock.Enter;
try
FText := AText;
finally
FLock.Leave;
end;
PostThreadMessage(ThreadID, WM_SPEAK_PAGE, 0, 0);
end;
procedure TSpeechThread.VoiceWord(ASender: TObject; StreamNumber: Integer;
StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
begin
// Runs on the speech thread; hand the offsets to the UI without blocking
TThread.Queue(nil,
procedure
begin
ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
end);
end;
TThread.Queue es la forma correcta de serializar aquí, no Synchronize: el manejador no debe detener el hilo de voz mientras la interfaz de usuario se vuelve a dibujar, y si los eventos de límite llegan más rápido de lo que se dibuja en la pantalla, una actualización de resaltado obsoleta es inofensiva porque la siguiente la sobrescribe. Conecta OnEndStream de la misma manera para borrar el resaltado y, en un modo de lectura continua, para cargar el texto de la página siguiente y publicar la próxima locución
De desplazamientos de caracteres a píxeles en la pantalla
PDFium informa la geometría por carácter. FPDFText_GetCharBox llena cuatro números dobles en un orden que ha causado más errores silenciosos que cualquier otra cosa en la API de texto (izquierda, derecha, abajo, arriba, no el clásico de Windows: izquierda, arriba, derecha, abajo) y los informa en espacio de página: puntos PDF, 72 por pulgada, origen en la esquina inferior izquierda con la Y creciendo hacia arriba. El cuadro de una palabra es la unión de los cuadros de sus caracteres, y la transformación a píxeles del dispositivo consta de tres pasos: trasladar por el origen de la página, escalar por el zoom multiplicado por los DPI de la pantalla sobre 72, y voltear el eje Y
uses
System.Math;
type
TPdfRectF = record
Left, Top, Right, Bottom: Double; // PDF points, origin bottom-left
end;
function TViewerForm.WordBox(CharIndex, CharCount: Integer): TPdfRectF;
var
i, LastChar: Integer;
L, T, R, B: Double;
begin
Result.Left := MaxDouble; Result.Bottom := MaxDouble;
Result.Right := -MaxDouble; Result.Top := -MaxDouble;
LastChar := Min(CharIndex + CharCount, FPDFText_CountChars(FTextPage)) - 1;
for i := CharIndex to LastChar do
begin
// Parameter order is left, right, bottom, top - not the Windows order
FPDFText_GetCharBox(FTextPage, i, @L, @R, @B, @T);
Result.Left := Min(Result.Left, L);
Result.Right := Max(Result.Right, R);
Result.Bottom := Min(Result.Bottom, B);
Result.Top := Max(Result.Top, T);
end;
end;
function TViewerForm.PdfToDevice(const W: TPdfRectF): TRect;
var
Scale: Double;
begin
// 72 PDF points per inch; FZoom is the viewer scale factor
Scale := FZoom * FScreenDpi / 72.0;
Result.Left := Round((W.Left - FPageLeft) * Scale) - FScrollX;
Result.Right := Round((W.Right - FPageLeft) * Scale) - FScrollX;
// PDF Y grows upward from the bottom edge; device Y grows downward
Result.Top := Round((FPageTop - W.Top) * Scale) - FScrollY;
Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;
FPageTop es la altura de la página en puntos de FPDF_GetPageHeight, y FPageLeft es cero para la mayoría de los documentos pero proviene de la caja de recorte (crop box) cuando la página define una, así que lee ambos de FPDF_GetPageBoundingBox en lugar de asumir. El giro de la Y es donde las versiones hechas a mano se rompen: la parte superior del rectángulo del dispositivo proviene de la parte superior de la caja de PDF medida hacia abajo desde la parte superior de la página. Si lo haces al revés, cada resaltado se pinta reflejado en la mitad equivocada de la página
procedure TViewerForm.HighlightWordAt(CharIndex, CharCount: Integer);
var
Old: TRect;
begin
if CharCount <= 0 then Exit;
Old := FHighlightRect;
FHighlightRect := PdfToDevice(WordBox(CharIndex, CharCount));
InvalidateRect(PageBox.Handle, @Old, False); // erase the old word
InvalidateRect(PageBox.Handle, @FHighlightRect, False); // draw the new one
end;
procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
Blend: TBlendFunction;
begin
PageBox.Canvas.Draw(0, 0, FPageBitmap); // rendered page first, always
if FHighlightRect.IsEmpty then Exit;
Blend.BlendOp := AC_SRC_OVER;
Blend.BlendFlags := 0;
Blend.SourceConstantAlpha := 96; // about 38 percent opacity
Blend.AlphaFormat := 0; // constant alpha, no per-pixel data
Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
FHighlightRect.Left, FHighlightRect.Top,
FHighlightRect.Width, FHighlightRect.Height,
FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;
El manejador de pintura dibuja el mapa de bits de la página primero y el resaltado después, en cada momento, por lo que la superposición nunca tiene que borrarse a sí misma; la invalidación de los rectángulos antiguo y nuevo mantiene la región de redibujado pequeña incluso a velocidades de voz rápidas. FHighlightBrush es un TBitmap de uno por uno rellenado una vez al inicio con el color de resaltado (FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF para un ámbar) que AlphaBlend estira sobre el rectángulo de destino, por lo que no se asigna nada por fotograma, y SourceConstantAlpha en 96 mantiene la palabra legible a través del tinte. Prueba el color bajo modos de pantalla invertida y de alto contraste; una superposición que un usuario con baja visión no puede ver, sencillamente no existe para la persona exacta para la que fue creada
El orden de lectura es la parte que la API de texto no resolverá
FPDFText_GetText devuelve los caracteres en un orden derivado del flujo de contenido con cierta limpieza espacial, y para un reporte de una sola columna, ese orden está bien. No tiene obligación de ser correcto en ningún otro lugar. Un boletín de dos columnas puede leerse directamente a través de ambas columnas, una barra lateral puede interrumpir una oración a mitad de la cláusula, y un pie de página puede llegar al medio de la página. La información que soluciona esto —el árbol de estructura lógica de ISO 32000-1 §14.8, que los archivos PDF etiquetados incluyen y que PDF/UA hace obligatorio— no es consultada en absoluto por las llamadas a páginas de texto sin procesar. Si necesitas un orden consciente de la estructura con una señal explícita de su origen, es un problema que ya está resuelto un nivel más arriba: la API de lectura de PDFium Component devuelve contenido con un campo Source de rosStructure o rosHeuristic, y el artículo sobre el lector de PDF accesible lo detalla. A nivel de la API sin procesar, la postura defendible es tratar el orden de extracción como una estimación, indicarlo en la interfaz de usuario, y mantener un documento de varias columnas y un escaneo de solo imagen en el conjunto de pruebas de regresión para que ambos modos de falla permanezcan visibles
El visor en sí tiene que ser operable por teclado
La salida de voz no excusa al visor de tener acceso por teclado; las personas con más probabilidades de usar la lectura en voz alta son las que tienen menos probabilidades de buscar un ratón. Dale al panel de la página TabStop := True y un rectángulo de enfoque visible, luego maneja tres teclas: Espacio alterna entre FVoice.Pause y FVoice.Resume, y Izquierda y Derecha saltan a través de FVoice.Skip('Sentence', 1) con un recuento negativo para retroceder. El Skip de SAPI solo entiende la granularidad de la oración, por lo que el salto a nivel de palabra significa purgar la reproducción con SVSFPurgeBeforeSpeak y volver a hablar desde el desplazamiento de la palabra que rastreaste por última vez (algo barato, ya que el código de resaltado ya está almacenando exactamente ese "offset"). Mantén cada control de transporte como un TButton real con una etiqueta para que los lectores de pantalla lo anuncien
Esa es toda la tubería, todo contra la API de texto de PDFium en bruto: un hilo de voz que es dueño de COM y de la voz durante la vida útil de la aplicación, eventos de límite serializados hacia la interfaz de usuario como desplazamientos de caracteres, y cuadros de espacio de página por carácter convertidos en un rectángulo superpuesto en la pantalla. Si prefieres no encargarte de la geometría y el seguimiento por ti mismo, PDFium Component incluye cuadros por palabra, el cursor de seguimiento, desplazamiento automático, y unidades de lectura a nivel de oración como propiedades del componente, y su demostración de lectura en voz alta es la tubería de este artículo reducida a unas cuantas llamadas