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; // lê FText sob 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); // seguro a partir da thread de UI
end;
procedure TSpeechThread.Execute;
var
Msg: TMsg;
begin
CoInitialize(nil); // uma vez, quando a thread inicia
try
FVoice := TSpVoice.Create(nil);
try
FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
FVoice.OnWord := VoiceWord;
// Força a criação da fila de mensagens desta thread antes que alguém envie para ela
PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
while GetMessage(Msg, 0, 0, 0) do // sai quando WM_QUIT chega
if Msg.message = WM_SPEAK_PAGE then
FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
else
DispatchMessage(Msg); // entrega os callbacks de evento do SAPI
finally
FVoice.Free;
end;
finally
CoUninitialize; // uma vez, quando a thread termina
end;
end;
procedure TSpeechThread.TerminatedSet;
begin
inherited;
PostThreadMessage(ThreadID, WM_QUIT, 0, 0); // desbloqueia o 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
// Roda na thread de fala; entrega os deslocamentos à UI sem bloquear
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; // pontos PDF, origem no canto inferior esquerdo
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
// A ordem dos parâmetros é esquerda, direita, inferior, superior - não a ordem do Windows
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 pontos PDF por polegada; FZoom é o fator de escala do visualizador
Scale := FZoom * FScreenDpi / 72.0;
Result.Left := Round((W.Left - FPageLeft) * Scale) - FScrollX;
Result.Right := Round((W.Right - FPageLeft) * Scale) - FScrollX;
// O Y do PDF cresce para cima a partir da borda inferior; o Y do dispositivo cresce para baixo
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); // apaga a palavra antiga
InvalidateRect(PageBox.Handle, @FHighlightRect, False); // desenha a nova
end;
procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
Blend: TBlendFunction;
begin
PageBox.Canvas.Draw(0, 0, FPageBitmap); // a página renderizada sempre primeiro
if FHighlightRect.IsEmpty then Exit;
Blend.BlendOp := AC_SRC_OVER;
Blend.BlendFlags := 0;
Blend.SourceConstantAlpha := 96; // cerca de 38 por cento de opacidade
Blend.AlphaFormat := 0; // alfa constante, sem dados por pixel
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