Uma funcionalidade de leitura em voz alta tem uma tarefa visível além da voz: à medida que cada palavra é falada, deve destacá-la na página e mantê-la visível. Para tal, necessita do retângulo envolvente (bounding box) de cada palavra, indexado ao mesmo fluxo de caracteres que o motor de voz está a ler. Se obtiver as caixas de palavras mas falhar na indexação, o destaque ficará atrasado uma ou duas palavras em relação ao áudio; se acertar na indexação mas gerir incorretamente o estado da página, o destaque aparecerá numa página totalmente errada. A parte complexa disto, o sintetizador em si, raramente falha. O SAPI reporta os limites das palavras ao nível do carácter. O que falha é a fina camada de mapeamento entre o desvio de caracteres (character offset) no buffer de voz e um retângulo na página renderizada.
O PDFium Component disponibiliza esse mapeamento para Delphi, C++Builder e Lazarus, com as caixas de palavras disponíveis desde a v1.53 e o cursor de acompanhamento desde a v1.56. A interface é deliberadamente simples: uma chamada que devolve as caixas de palavras de uma página, um cursor de acompanhamento que transforma um desvio de caracteres num destaque desenhado e algumas propriedades para cor e deslocamento automático. Apesar da simplicidade, a ordem em que executa as chamadas decide se a funcionalidade funciona, e a maioria das falhas decorre de chamar as funções corretas na sequência incorreta.
Caracteres não são palavras, e os motores de TTS falam em caracteres
Um motor de voz consome uma string plana e reporta o progresso como posições de caracteres dentro dessa string. Uma página PDF contém glifos dispostos no espaço da página, onde uma "palavra" constitui um agrupamento heurístico de traçados de glifos. Os dois sistemas de coordenadas não partilham nada, a menos que o texto fornecido ao sintetizador seja idêntico byte a byte ao texto a partir do qual as caixas de palavras foram calculadas. Esta é a regra número um, e não perdoa. Normalize espaços em branco, remova hífenes opcionais ou limpe o texto extraído antes de o falar, e cada desvio posterior estará silenciosamente incorreto. Fale exatamente o que extraiu ou mantenha uma tabela de remapeamento de desvios explícita. Não existe uma terceira opção viável para documentos reais.
A tabela de remapeamento não é um caso limite hipotético. No momento em que a interface de utilizador insere um anúncio de página falada ("página cinco") ou expande uma abreviatura para o sintetizador, a string falada diverge da extraída. Registe a posição e o comprimento de cada inserção e, em seguida, subtraia o ajuste acumulado antes de cada chamada de acompanhamento. São cerca de vinte linhas de código e representa a diferença entre um destaque que sobrevive à próxima atualização e outro que falha na primeira vez que o utilizador solicita a leitura de títulos.
O que uma caixa de palavra lhe oferece
Cada registo TPdfWordBox transporta o texto da palavra, o seu StartIndex e a contagem de caracteres (Count) dentro do texto da página, um retângulo Rect em espaço de página e o número da página (Page, indexado a 1). O campo StartIndex é a ponte entre os dois sistemas de coordenadas: trata-se do mesmo desvio que o SAPI devolverá à medida que lê. A função PageWordBoxes devolve o array completo para a página activa:
procedure TReaderForm.PreparePage(PageNo: Integer);
begin
PdfView.PageNumber := PageNo; // the view's word boxes track its displayed page
FWords := PdfView.PageWordBoxes;
FPageText := BuildSpeechText(FWords); // concatenate Word.Text in order
if Length(FWords) = 0 then
HandleImageOnlyPage(PageNo); // a scan with no text layer
end;
A observação sobre a ordenação é fundamental. A função PageWordBoxes do visualizador divide em tokens a camada de texto da página que o visualizador apresenta no momento, pelo que deve navegar na vista primeiro e extrair depois; não é necessária renderização, apenas um documento aberto. (O componente de documento, TPdf, expõe a sua própria propriedade PageWordBoxes associada a Pdf.PageNumber para utilização sem interface gráfica. Os dois números de página são independentes, o que constitui outra armadilha.) Um resultado vazio numa página que contém conteúdo visível significa uma digitalização puramente gráfica. Encaminhe-a para OCR ou, pelo menos, emita um aviso por voz ("a página 4 não contém texto legível"), em vez de permitir que a voz pare sem explicação.
Associar os limites de palavras do SAPI ao localizador
A função TrackReadingWordAt, no visualizador, é o ponto fulcral de toda a funcionalidade. Indique-lhe um número de página e um índice de caracteres; a função localiza a caixa de palavra correspondente a esse carácter, desenha o cursor de leitura sobre ela e devolve o índice da palavra, ou −1 se o índice cair entre palavras. A notificação de limite de palavra do SAPI fornece exatamente a posição do carácter pretendida:
procedure TReaderForm.OnSpeechWordBoundary(StreamPos: Integer);
var
WordIdx: Integer;
begin
// Maps the offset to a word box and moves the highlight in one call
WordIdx := PdfView.TrackReadingWordAt(FPageNo, StreamPos);
if WordIdx < 0 then
Exit; // boundary fell outside any word: keep last highlight
end;
Dois detalhes defensivos justificam a sua implementação aqui. Primeiro, a função TrackReadingWordAt mantém a sua própria cache de caixas de palavras para a página acompanhada, reconstruída automaticamente quando a página muda, pelo que o custo por limite permanece plano independentemente da velocidade de receção dos limites. Segundo, a função não valida limites de forma permissiva. Um índice igual ou superior à contagem de caracteres da página devolve −1 em vez de se ajustar à palavra final. Trate −1 como "manter o destaque anterior" e nunca como um erro, porque sequências de pontuação e espaços em branco entre palavras produzem limites que não pertencem a nenhuma palavra. Registar cada −1 gerará ruído excessivo. Em vez disso, contabilize-os por página e analise detalhadamente qualquer página onde o rácio dispare, pois isso normalmente indica uma divergência de normalização de texto em relação à regra número um.
O cursor em si: cor, acompanhamento e limpeza
A função SetReadingWord desenha o destaque diretamente quando possui a caixa de palavra, ReadingWordColor define o seu estilo e ReadingWordFollow := True desloca a vista o suficiente para manter a palavra falada visível. Esta última propriedade é essencial. Uma deslocação personalizada do tipo "centrar a palavra atual" faz com que a página sofra solavancos a cada quebra de linha, levando leitores sensíveis ao movimento a desativar a funcionalidade em poucos segundos. O destaque é desenhado apenas na página apresentada no TPdfView ativo, pelo que a leitura de múltiplas páginas tem de avançar o PageNumber em sincronia com a fala, executando o passo de preparação para a nova página antes que o primeiro evento de limite da mesma seja recebido. Caso contrário, os primeiros destaques em cada página apontarão para coordenadas desatualizadas.
procedure TReaderForm.StopReading;
begin
FVoice.Stop; // halt SAPI playback first
PdfView.ClearReadingWord; // then remove the highlight; a stale cursor reads as a bug
end;
A simetria no encerramento é o que garante a correção do destaque. Todos os caminhos de pausa, paragem e mudança de página devem terminar em ClearReadingWord. Deixe isto de fora e um retângulo âmbar permanecerá numa página parada, parecendo um defeito visual, o que será reportado pelos testadores como erro, mesmo que nada esteja de facto danificado.
A velocidade da fala sobrecarrega este fluxo de trabalho mais do que o tamanho do documento. A 300 palavras por minuto, os eventos de limite chegam a cada 200 ms, e nas velocidades mais rápidas do SAPI chegam mais depressa do que o olho humano acompanha confortavelmente. A resposta correta consiste em aglutinar os eventos e não em colocá-los em fila. Se um novo limite chegar enquanto uma atualização de destaque estiver pendente, descarte o desatualizado e desenhe o mais recente. Um cursor que visite cada palavra por ordem mas sofra um atraso de meio segundo parecerá incorreto; um que ocasionalmente ignore uma palavra mantendo a sincronia com a voz funcionará perfeitamente.
Casos limite que separam as demonstrações dos produtos reais
Algumas categorias de documentos expõem os limites da implementação. Os caracteres combinados (combining characters) são os mais subtis: sequências Unicode como uma letra base mais um diacrítico combinado podem ocupar mais índices de caracteres do que a palavra visual sugere, pelo que qualquer aritmética de desvio que assuma um índice por glifo sofrerá um desfasamento gradual. Este é o argumento mais forte para permitir que a função TrackReadingWordAt faça a gestão do mapeamento em vez de calcular os números das palavras manualmente. A hifenização é mais comum: uma palavra dividida por uma quebra de linha torna-se duas caixas, e se a falar como um único token, o evento de limite para a sua segunda metade resolver-se-á na primeira caixa. Isto costuma ser aceitável, mas constitui uma decisão de design a tomar de forma consciente. A marcação (tagging) altera a própria ordem de leitura. Quando um documento transporta etiquetas de estrutura corretas (o âmbito da norma ISO 14289, PDF/UA), a sequência de palavras segue a estrutura lógica; sem elas, baseia-se em heurísticas de paginação, e uma página de duas colunas sem marcação pode ser lida horizontalmente cruzando ambas as colunas. As páginas rodadas são o último caso comum: o Rect de cada palavra delimita-a corretamente em espaço de página, mas uma política de acompanhamento da área de visualização configurada para fluxo horizontal desloca-se de forma brusca quando o texto corre verticalmente, pelo que deve manter pelo menos um documento rodado no seu conjunto de testes. Para o processamento da ordem de leitura, unidades de frase via ReadingUnits e a acessibilidade geral, consulte o artigo sobre como construir um leitor de PDF acessível em Delphi.
Uma limitação da plataforma molda a implementação. O SAPI é exclusivo do Windows. A API de caixas de palavras e de acompanhamento é idêntica sob Lazarus e FPC, mas as versões para Linux e macOS necessitam de um sintetizador diferente associado aos mesmos eventos de limites; essa configuração é abordada no artigo sobre a execução do visualizador sob Lazarus e FPC. O custo do destaque também interage com a cache de renderização à medida que a velocidade da fala aumenta, e a aritmética de limites descrita no artigo sobre cache de renderização e desempenho de zoom aplica-se aqui sem alterações.
Quando o destaque de palavra única é a granularidade incorreta
O acompanhamento palavra a palavra nem sempre é o que o leitor deseja. Em velocidades de fala elevadas, a oscilação do cursor palavra a palavra torna-se ruído visual, e alguns ouvintes acompanham uma frase de forma mais confortável do que uma oscilação contínua de palavras individuais. Para esses casos, o componente expõe uma unidade mais abrangente. A propriedade ReadingUnits devolve unidades ao nível de frases e blocos, cada uma com os seus próprios retângulos de destaque, e desenha-as com SetReadingHighlight em vez de SetReadingWord. A ligação estrutural é idêntica: um desvio de limite continua a determinar qual a unidade que se ilumina, mas a unidade destacada abrange uma oração ou uma linha em vez de um único token. Os leitores mais lentos e a reprodução de alta velocidade tendem a preferir esta opção, e nada impede que disponibilize ambos os modos sob uma configuração configurável.
As versões mínimas merecem ser confirmadas antes de implementar o desenvolvimento: as caixas de palavras exigem o PDFium Component v1.53 ou posterior, e o cursor de acompanhamento exige a v1.56. A API de leitura completa, as unidades de frase e uma demonstração funcional de leitura em voz alta encontram-se na página do produto para o PDFium Component.