Artículo técnico

Creación de 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 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