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