Artículo técnico

Construir visores de PDF accesibles con texto a voz en Delphi

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