Artigo Técnico

Construindo Visualizadores de PDF Acessíveis com Text-to-Speech no Delphi

Um botão de ler em voz alta pode ser demonstrado em uma tarde, mas acaba consumindo uma semana. A versão da tarde extrai o texto da página, entrega-o à SAPI e obtém áudio. A semana inteira é gasta naquilo que torna o recurso utilizável: a voz não deve congelar a janela, a palavra falada precisa acender na página no mesmo ritmo do áudio, e a tecla Espaço deve pausar tudo. Este artigo constrói esse pipeline no Delphi contra a API de texto bruta do PDFium e a Windows Speech API, com código funcional para as três partes que a versão rápida ignora: ciclo de vida COM feito uma vez em vez de a cada fala, eventos reais de limite de palavra e a matemática de coordenadas que transforma uma caixa de palavra no espaço PDF em um retângulo que você pode pintar

O contexto regulatório cabe em uma frase: a leitura em voz alta sincronizada é a metade do lado do visualizador do que o WCAG 2.1 exige dos softwares de documentos, e a ISO 14289-1 (PDF/UA) define a metade do arquivo marcado contra o qual funciona melhor. Se você estiver construindo sobre o PDFium Component, pode não precisar de forma alguma desse pipeline: o visualizador traz um cursor de rastreamento integrado que mapeia um deslocamento de caractere para um destaque de palavra pintado em uma única chamada, abordado no artigo de destaque TTS palavra por palavra. O que se segue é para quando você é dono de todo o aplicativo visualizador e deseja o próprio pipeline

Uma thread renderiza, uma thread fala

A arquitetura consiste em duas threads e um contrato. A thread de UI renderiza o bitmap da página, é dona do estado de zoom e rolagem, e pinta a sobreposição de destaque. Uma thread de fala dedicada é dona da voz da SAPI, e nada mais toca nela. O contrato é simples: a thread de fala relata o progresso como deslocamentos de caracteres, e a thread de UI transforma os deslocamentos em retângulos

A maioria dos exemplos SAPI envolve cada fala em CoInitialize e CoUninitialize, e um visualizador mostra imediatamente o porquê isso está errado. Speak com SVSFlagsAsync retorna assim que o texto é colocado na fila, de modo que um CoUninitialize no bloco finally do mesmo procedimento é executado enquanto a voz ainda está falando, derrubando o apartamento COM que é dono dele. Dependendo do timing, você obtém silêncio, uma fala truncada ou uma violação de acesso minutos depois. O ciclo de vida correto é monótono: CoInitialize uma vez quando a thread de fala iniciar, crie a voz dentro desse apartamento e CoUninitialize uma vez quando a thread sair, após a voz ter sido liberada. Nunca a cada fala

A voz também precisa de um message pump (bombeador de mensagens), que decide onde ela pode viver. O objeto de automação SpVoice entrega seus eventos através da fila de mensagens da thread que o criou. Se você o criar na thread de UI, os eventos chegam, porque a VCL bombeia mensagens, mas cada pintura lenta então atrasará os limites das suas palavras; crie-o em uma worker thread sem bomba e os eventos nunca chegarão. Uma thread dedicada com seu próprio loop GetMessage mantém a latência do limite plana, não importa o que a UI esteja fazendo

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 envia WM_QUIT para que a bomba seja desbloqueada quando o visualizador for desligado. SpeakPage, chamado a partir da thread de UI, armazena o texto em um campo protegido por bloqueio e envia WM_SPEAK_PAGE, pois chamar um método em FVoice diretamente de outra thread seria uma chamada COM entre apartamentos numa interface sem marshalling. O PeekMessage de uma linha antes do loop força o Windows a criar a fila de mensagens da thread, fechando a corrida de inicialização em que um envio antecipado da thread de UI falharia

Os limites das palavras chegam como deslocamentos de caracteres

Importe a Microsoft Speech Object Library uma vez por meio do importador de biblioteca de tipos da IDE e você obterá o SpeechLib_TLB com o wrapper TSpVoice e seus eventos tipados. Duas configurações são importantes. EventInterests deve ser reduzido aos eventos que você realmente consome, pois cada interesse deixado ativado é um tráfego de eventos entre threads para cada palavra de cada página; SVEWordBoundary aciona o destaque e SVEEndInputStream avisa que a fala terminou. E o manipulador OnWord recebe CharacterPosition e um comprimento, que indexam a string exata que você passou para o Speak — um deslocamento para o buffer de fala, e não para qualquer outra coisa

Essa última cláusula é a invariável da qual o recurso depende: os deslocamentos só fazem sentido em relação à string que a voz está lendo, portanto, fale exatamente o texto que você extraiu, caractere por caractere. Se você cortar os espaços em branco, recolher as quebras de linha ou expandir uma abreviação para obter uma pronúncia melhor, cada destaque após a primeira edição ficará uma palavra deslocado. Se a UI precisar injetar material falado — anúncios de página, prefixos de título —, registre a posição e o comprimento de cada inserção e subtraia o deslocamento acumulado de cada deslocamento (offset) antes de mapeá-lo

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 é o marshaler correto aqui, não o Synchronize: o manipulador não deve estacionar a thread de fala enquanto a UI repinta, e se os eventos de limite chegarem mais rápido do que a tela desenha, uma atualização de destaque obsoleta é inofensiva porque a próxima a substituirá. Vincule OnEndStream da mesma forma para limpar o destaque e, num modo de leitura contínua, para carregar o texto da próxima página e enviar a próxima fala

Dos deslocamentos de caracteres aos pixels na tela

O PDFium relata a geometria por caractere. FPDFText_GetCharBox preenche quatro doubles numa ordem que causou mais bugs silenciosos do que qualquer outra coisa na API de texto — esquerda, direita, inferior, superior, e não a ordem esquerda, superior, direita, inferior do Windows — e os relata no espaço da página: pontos PDF, 72 por polegada, origem no canto inferior esquerdo com o Y crescendo para cima. A caixa de uma palavra é a união das caixas de seus caracteres, e a transformação para pixels de dispositivo ocorre em três etapas: transladar pela origem da página, escalar multiplicando o zoom pelos DPIs da tela sobre 72 e inverter o eixo 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 é a altura da página em pontos a partir de FPDF_GetPageHeight, e FPageLeft é zero para a maioria dos documentos, mas vem da caixa de corte (crop box) quando a página define uma; portanto, leia ambos a partir de FPDF_GetPageBoundingBox em vez de assumir. A inversão do Y é onde as versões feitas à mão quebram: o topo do retângulo do dispositivo vem do topo da caixa PDF, medido para baixo a partir do topo da página. Se você fizer o contrário, cada destaque será pintado espelhado na metade errada da 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;

O manipulador de pintura desenha o bitmap da página primeiro e o destaque depois, todas as vezes, de modo que a sobreposição nunca precisa se apagar; invalidar os retângulos antigo e novo mantém a região de repintura pequena, mesmo em taxas de fala rápidas. O FHighlightBrush é um TBitmap um por um, preenchido uma vez na inicialização com a cor de destaque — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF para um âmbar — que o AlphaBlend estica sobre o retângulo de destino, portanto, nada é alocado por quadro, e o SourceConstantAlpha em 96 mantém a palavra legível através do tom. Teste a cor nos modos de exibição invertido e de alto contraste; uma sobreposição que um usuário com baixa visão não consegue enxergar não existe exatamente para a pessoa para quem ela foi feita

A ordem de leitura é a parte que a API de texto não resolverá

FPDFText_GetText devolve os caracteres em uma ordem derivada do fluxo de conteúdo com alguma limpeza espacial e, para um relatório de coluna única, essa ordem é boa. Ele não tem obrigação de estar certo em nenhum outro lugar. Um boletim informativo de duas colunas pode ser lido diretamente através de ambas as colunas, uma barra lateral pode interromper uma frase no meio da oração, e um rodapé pode chegar no meio da página. A informação que corrige isso — a árvore de estrutura lógica da ISO 32000-1 §14.8, que os PDFs com tags carregam e o PDF/UA torna obrigatória — não é consultada de modo algum pelas chamadas brutas de página de texto. Se você precisa de uma ordem ciente da estrutura com um sinal explícito da sua origem, esse é um problema resolvido uma prateleira acima: a API de leitura do PDFium Component retorna o conteúdo com um campo Source (Fonte) como rosStructure ou rosHeuristic, e o artigo de leitor de PDF acessível analisa o assunto passo a passo. No nível bruto da API, a posição defensável é tratar a ordem de extração como uma estimativa, informar isso na UI, e manter um documento multicolunas e uma digitalização apenas de imagem no conjunto de regressão para que ambos os modos de falha continuem visíveis

O visualizador em si precisa ser operável por teclado

A saída de fala não isenta o visualizador do acesso via teclado; as pessoas com maior probabilidade de usar a leitura em voz alta são as com menor probabilidade de usar um mouse. Atribua ao painel da página um TabStop := True e um retângulo de foco visível, depois manipule três teclas: Espaço alterna FVoice.Pause e FVoice.Resume, e as setas Esquerda e Direita avançam ou retrocedem através de FVoice.Skip('Sentence', 1) com uma contagem negativa para voltar. O Skip da SAPI entende apenas a granularidade da frase, de modo que pular no nível da palavra significa limpar a reprodução com SVSFPurgeBeforeSpeak e falar novamente a partir do deslocamento da palavra que você rastreou por último — o que é barato, visto que o código de destaque já está armazenando exatamente esse deslocamento. Mantenha cada controle de transporte como um TButton real com uma legenda para que os leitores de tela o anunciem

Esse é o pipeline inteiro, todo ele contra a API de texto bruta do PDFium: uma thread de fala que é dona do COM e da voz durante toda a vida útil do aplicativo, eventos de limite convertidos (marshaled) para a UI como deslocamentos de caracteres, e caixas no espaço de página por caractere transformadas num único retângulo mesclado na tela. Se você prefere não ser o dono da geometria e do rastreamento, o PDFium Component fornece caixas por palavra, o cursor de rastreamento, seguimento de rolagem automática (auto-scroll follow) e unidades de leitura no nível de frases como propriedades do componente, e a sua demonstração de leitura em voz alta é o pipeline deste artigo reduzido a um punhado de chamadas