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 a tiempo con el audio, y la tecla Espacio tiene que pausar todo el proceso. Este artículo construye esa canalización en Delphi utilizando la API de texto sin formato de PDFium y la API de Windows Speech, con código funcional para las tres partes que la versión rápida omite: el ciclo de vida de COM realizado una vez en lugar de por locución, eventos reales de límites de palabra y las matemáticas de coordenadas que convierten un cuadro de palabra del espacio de PDF en un rectángulo que se puede pintar
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 exige 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á desarrollando sobre PDFium Component, es posible que no necesite esta canalización en absoluto: el visor incluye un cursor de seguimiento incorporado que mapea el desplazamiento de un carácter a un resaltado de palabra pintado en una sola llamada, lo cual se cubre en el artículo sobre el resaltado palabra por palabra con TTS. Lo que sigue es para cuando usted es el propietario de toda la aplicación del visor y desea la canalización en sí
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, posee el estado de zoom y desplazamiento, y pinta la superposición de resaltado. Un hilo de voz dedicado posee la voz de SAPI, y nada más la toca. El contrato es mínimo: el hilo de voz informa el progreso como desplazamientos de caracteres, y el hilo de la interfaz de usuario convierte los desplazamientos en rectángulos
La mayoría de los ejemplos de SAPI envuelven cada locución en CoInitialize y CoUninitialize, y un visor muestra inmediatamente por qué eso es incorrecto. Speak con SVSFlagsAsync regresa tan pronto como el texto se pone en cola, por lo que un CoUninitialize en el bloque finally del mismo procedimiento se ejecuta mientras la voz aún está hablando, derribando el apartamento COM que la posee. Dependiendo del momento, se obtiene silencio, una locución truncada o una infracción de acceso minutos más tarde. El ciclo de vida correcto es aburrido: llame a CoInitialize una vez cuando se inicia el hilo de voz, cree la voz dentro de ese apartamento y llame a CoUninitialize una vez cuando el hilo salga, después de que la voz haya sido liberada. Nunca por locución
La voz también necesita un bucle de mensajes (message pump), lo que decide dónde puede residir. El objeto de automatización SpVoice entrega sus eventos a través de la cola de mensajes del hilo que lo creó. Créelo en el hilo de la interfaz de usuario y los eventos llegarán, porque la VCL procesa los mensajes, pero cada repintado lento retrasará los límites de las palabras; créelo en un hilo de trabajo sin bucle y los eventos nunca llegarán. 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 WM_QUIT para que el bucle 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 (lock) y envía WM_SPEAK_PAGE, porque llamar a un método en FVoice directamente desde otro hilo sería una llamada COM entre apartamentos en una interfaz no calculada (unmarshaled). La línea única de PeekMessage antes del bucle obliga a Windows a crear la cola de mensajes del hilo, cerrando la condición de carrera de inicio en la que un envío temprano desde el hilo de la interfaz de usuario fallaría
Los límites de las palabras llegan como desplazamientos de caracteres
Importe 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á SpeechLib_TLB con el contenedor TSpVoice y sus eventos tipados. Dos configuraciones son importantes. EventInterests debe reducirse a los eventos que realmente consume, porque cada interés que se deja activado es tráfico de eventos entre hilos por cada palabra de cada página; SVEWordBoundary impulsa el resaltado y SVEEndInputStream le dice que la locución ha terminado. Y el manejador OnWord recibe CharacterPosition y una longitud, que indexan en la cadena exacta que le pasó a Speak: un desplazamiento en el búfer de voz, no en ninguna otra cosa
Esa última cláusula es la invariante de la que depende la función: los desplazamientos solo tienen sentido frente a la cadena que la voz está leyendo, así que hable exactamente el texto que extrajo, carácter por carácter. Recorte los espacios en blanco, colapse los saltos de línea o expanda una abreviatura para lograr una mejor pronunciación, y 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), registre la posición y longitud de cada inserción, y reste el desplazamiento acumulado de cada desplazamiento 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 el mecanismo de organización correcto aquí, no Synchronize: el manejador no debe estacionar el hilo de voz mientras la interfaz de usuario se repinta, y si los eventos de límite llegan más rápido de lo que la pantalla los dibuja, una actualización de resaltado obsoleta es inofensiva porque la siguiente la sobrescribirá. Conecte OnEndStream de la misma manera para borrar el resaltado y, en un modo de lectura continua, cargar el texto de la siguiente página y enviar la siguiente locución
De desplazamientos de caracteres a píxeles en la pantalla
PDFium informa la geometría por carácter. FPDFText_GetCharBox llena cuatro dobles (doubles) en un orden que ha causado más errores silenciosos que cualquier otra cosa en la API de texto: izquierda, derecha, inferior, superior, no el orden de Windows de izquierda, superior, derecha, inferior; y los informa en el espacio de la 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 entre 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 lea ambas de FPDF_GetPageBoundingBox en lugar de suponer. El giro de la Y es donde se rompen las versiones creadas manualmente: la parte superior del rectángulo del dispositivo proviene de la parte superior del cuadro del PDF medido hacia abajo desde la parte superior de la página. Hágalo al revés y cada resaltado se pintará 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 (paint handler) dibuja el mapa de bits de la página primero y el resaltado después, en cada ocasión, 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 repintado pequeña, incluso a velocidades de habla rápidas. FHighlightBrush es un TBitmap de uno por uno que se llena una vez en el inicio con el color de resaltado (FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF para un tono ámbar) que AlphaBlend estira sobre el rectángulo objetivo, de modo que no se asigna nada por cuadro, y SourceConstantAlpha en 96 mantiene la palabra legible a través del tinte. Pruebe el color bajo modos de visualización invertidos y de alto contraste; una superposición que un usuario con baja visión no puede ver no existe precisamente para la persona para la que fue construida
El orden de lectura es la parte que la API de texto no resolverá
FPDFText_GetText devuelve caracteres en un orden derivado del flujo de contenido con un poco de limpieza espacial, y para un informe de una sola columna, ese orden está bien. Pero no tiene ninguna obligación de ser correcto en ningún otro caso. 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 una cláusula, y un pie de página puede llegar a la mitad 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 de página de texto sin formato. Si necesita un orden consciente de la estructura con una señal explícita de su origen, eso es un problema resuelto un nivel 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 explica. En el nivel de API sin formato (raw API), la posición defendible es tratar el orden de extracción como una estimación, indicarlo así en la interfaz de usuario y mantener un documento de varias columnas y un escaneo solo de imágenes en el conjunto de regresión, para que ambos modos de falla permanezcan visibles
El visor en sí debe ser operable mediante teclado
La salida de voz no excusa al visor de requerir acceso por teclado; las personas que tienen más probabilidades de usar la lectura en voz alta son las que tienen menos probabilidades de buscar un ratón. Dele al panel de la página TabStop := True y un rectángulo de enfoque visible, luego maneje tres teclas: Espacio (Space) alterna entre FVoice.Pause y FVoice.Resume, y las teclas Izquierda y Derecha saltan usando FVoice.Skip('Sentence', 1), con un conteo negativo para retroceder. El Skip de SAPI solo entiende la granularidad de 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 última palabra que rastreó (algo económico, ya que el código de resaltado ya está almacenando exactamente ese desplazamiento). Mantenga cada control de transporte como un TButton real con una leyenda (caption) para que los lectores de pantalla lo anuncien
Esa es toda la canalización, todo esto con base en la API de texto sin formato de PDFium: un hilo de voz que es el propietario de COM y de la voz durante la vida de la aplicación, eventos de límite organizados (marshaled) a la interfaz de usuario como desplazamientos de caracteres, y cuadros de espacio de página por carácter convertidos en un solo rectángulo combinado en pantalla. Si prefiere no hacerse cargo de la geometría y del seguimiento usted mismo, PDFium Component incluye cuadros por palabra, el cursor de seguimiento, seguimiento de 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 precisamente la canalización de este artículo reducida a un puñado de llamadas