Technical Article

Criar um Leitor de PDF Acessível em Delphi com o PDFium

Um utilizador cego abre um relatório trimestral no seu novo visualizador em Delphi, ativa o NVDA e ouve o rodapé da página, depois uma coluna de números e, finalmente, o título que qualquer leitor com visão teria lido em primeiro lugar. Ou não ouve absolutamente nada. A página parece perfeita no ecrã, e é exatamente aí que reside a armadilha: a renderização e a leitura são problemas diferentes resolvidos por códigos distintos. A ordem pela qual um PDF desenha os seus glifos não tem qualquer obrigação de corresponder à ordem pela qual uma pessoa os deve ouvir, pelo que um visualizador construído apenas com chamadas de renderização gera uma imagem perfeita, mas uma narração inutilizável. O PDFium Component (o wrapper VCL/LCL do motor PDFium para Delphi, C++Builder e Lazarus) disponibiliza APIs de leitura separadas por esta razão. As APIs de desenho não conseguem recuperar uma ordem de leitura que nunca lhes foi fornecida.

Um leitor acessível baseia-se em três aspetos essenciais. Deve extrair uma ordem que um leitor de ecrã consiga falar, manter um cursor de palavras visível sincronizado com o que a voz está a pronunciar, e reconhecer quando um documento não possui etiquetas em vez de tentar adivinhar a ordem de leitura. Cada um destes aspetos possui uma API específica e um erro comum caso os detalhes sejam ignorados.

A ordem de leitura reside na árvore de estrutura e não na ordem de desenho

A norma ISO 32000-1 §14.8 define a estrutura lógica como uma árvore de elementos disposta sobre o conteúdo da página. A norma PDF/UA (ISO 14289-1) vai mais longe e torna essa árvore obrigatória: cada elemento de conteúdo real deve ser acessível através dela na ordem de leitura correta, com os artefactos de página marcados como tal e ignorados. Um relatório corretamente etiquetado sabe que "Resultados Trimestrais" é um cabeçalho de nível dois e que a grelha de totais é uma tabela com células de cabeçalho. Um relatório sem etiquetas é apenas uma pilha de glifos posicionados que por acaso se parecem com um documento.

O ReadablePageContent percorre essa estrutura quando esta existe e devolve fragmentos etiquetados com um Kind semântico, como cfHeading e cfParagraph, para que a interface possa anunciar "cabeçalho" antes das palavras, em vez de ler uma linha a negrito como texto comum do corpo. Sem uma árvore de estrutura utilizável, a chamada reverte para uma análise de layout heurística: detetar colunas, agrupar linhas de base e ordenar da esquerda para a direita e de cima para baixo. Essa solução é aceitável para um memorando de coluna única, mas imprecisa para um boletim informativo, um formulário com várias colunas ou qualquer documento com barras laterais ou citações em destaque. O importante é saber qual o tipo de resultado obtido, e a API informa-o diretamente. O registo TPdfReadableContent contém o campo Source definido como rosStructure quando a ordem provém da árvore etiquetada, ou rosHeuristic quando foi deduzida a partir da geometria. Apresentar uma ordem baseada em estimativas como se fosse confirmada equivale a aprovar uma compilação que ninguém testou.

A abordagem mais simples no momento da abertura é ler o IsTagged e chamar o ValidatePdfUa uma vez, guardando a resposta em cache. Uma falha na verificação de PDF/UA não é motivo para recusar o ficheiro. É sim motivo para apresentar "ordem de leitura estimada" na barra de estado para que, quando um cliente enviar uma reclamação sobre a narração confusa, o suporte saiba de imediato se está perante um problema de etiquetas no ficheiro ou um bug no seu código.

Da página para a fila de fala com o ReadingUnits

Para conversão de texto em voz (TTS), a propriedade ReadingUnits realiza a maior parte do trabalho. Retorna um array de registos TPdfReadingUnit para a página ativa, cada um contendo o texto a falar, a sua função semântica e os retângulos que o posicionam na página. Existe um elemento complementar para todo o documento, o DocumentReadingUnits, quando se pretende uma leitura contínua entre as páginas. Cada unidade insere-se diretamente numa fila de fala (speech queue):

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits works on the active page
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // text + semantics + highlight rects
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Dois aspetos nesse ciclo são fáceis de errar. Mantenha a fila por página e reconstrua-a sempre que o utilizador navegar, porque as unidades de leitura contêm retângulos no espaço da página; uma fila restante da página três desenhará os seus realces na página quatro. E utilize um array de Units vazio numa página que claramente tem conteúdo como um detetor de páginas que contêm apenas imagens. Uma página digitalizada contém píxeis sem qualquer camada de texto por baixo, e a resposta correta é anunciar um aviso (\"esta página não contém texto extraível\") em vez de ficar em silêncio de uma forma que o utilizador possa confundir com um bloqueio da aplicação.

Um cursor de palavras que acompanha a voz

Destacar um parágrafo inteiro de cada vez parece lento para um utilizador com baixa visão que acompanha as palavras visualmente enquanto estas são lidas. O realce palavra a palavra (efeito karaoke) necessita de dois elementos: a geometria de cada palavra e uma forma de mapear os relatórios de progresso do motor de TTS nessa geometria. A propriedade PageWordBoxes fornece a geometria como registos TPdfWordBox, cada um com o texto da palavra, o seu desvio de caracteres, a contagem de caracteres e o retângulo na página. O método TrackReadingWordAt fornece o mapeamento. Forneça o índice do caráter que o evento de limite de palavra da API de voz (SAPI) já reporta, e o método resolve esse desvio para um índice no array de caixas de palavras e desenha o cursor na palavra correspondente numa única chamada.

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // The view's word boxes come from the page the view displays.
  // Setting Pdf.PageNumber alone would not move the view
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt maps the offset AND paints the word cursor
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // boundary ran past the page text
end;

Esta lógica é flexível num aspeto e rigorosa noutro. O aspeto flexível: o TrackReadingWordAt mantém a sua própria cache de caixas de palavras para a página que está a acompanhar, pelo que não necessita de pré-carregar nada e não ocorre qualquer renderização visual, uma vez que as caixas provêm da camada de texto. Um serviço de fala sem janela visível continua a poder acompanhar as posições. O aspeto rigoroso: o índice do caráter tem de apontar para o texto que o componente extraiu, e não para uma string limpa que tenha construído por si. Quando o CharIndex ultrapassa o final do texto da página, a função retorna -1 em vez de gerar um erro, o que acontece frequentemente quando um motor de TTS dispara um último evento de limite para a pontuação final. Considere o valor -1 como indicação para \"limpar o cursor\", e nunca como um erro.

Dois aspetos merecem atenção. Do lado da exibição, o ReadingWordColor define a cor do cursor. O tom âmbar padrão funciona bem na maioria dos fundos de página, mas teste-o em todos os filtros de cores que o seu visualizador disponibilizar. Um cursor âmbar pode desaparecer por completo sob a inversão de cores. A inversão a funcionar em simultâneo com a fala é precisamente a forma como um utilizador com baixa visão trabalha, pelo que a combinação que mais necessita de acertar é a que menos se testa em demonstrações rápidas. Defina o ReadingWordFollow como True e o visualizador desloca a palavra falada para a área visível de forma automática, o que é indispensável numa página com zoom que se estende por vários ecrãs. Tenha em atenção uma regra de âmbito: o SetReadingWord apenas desenha na página ativa do TPdfView. Decida previamente se o deslocamento manual pausa a fala ou se o comportamento de acompanhamento o sobrepõe, porque não escolher nenhum deixará a voz a ler enquanto o cursor permanece oculto fora do ecrã.

Os documentos que quebram o seu leitor

Alguns tipos de documentos de entrada comprometem uma implementação básica de forma consistente, devendo fazer parte da sua suite de testes de regressão regular:

  • Ficheiros sem etiquetas mas ricos em texto. A ordem heurística tende a ser correta para um relatório linear e incorreta no momento em que surge uma barra lateral ou uma citação em destaque. Identifique a ordem como estimada, tanto na interface de utilizador como no seu registo de diagnósticos.
  • Digitalizações que contêm apenas imagens. Sem qualquer camada de texto. Identifique-as através de unidades de leitura vazias e direcione o utilizador para um passo de OCR anterior em vez de deixar o leitor narrar uma página em branco.
  • Caracteres de combinação e scripts mistos. As marcas de combinação Unicode nem sempre se agrupam numa relação de um para um em palavras visuais, pelo que a contagem de caixas de palavras pode diferir da esperada pelo seu próprio analisador. Não indexe o array de caixas de palavras com desvios calculados por si ao dividir o texto; utilize apenas os índices retornados pelo TrackReadingWordAt.

Testar como um auditor e não como uma demonstração

“O facto de ter lido a minha amostra em voz alta” não prova nada. Um teste fiável exige passar três ficheiros pela versão final com o NVDA ativo: um ficheiro etiquetado conhecido, onde os cabeçalhos são anunciados como cabeçalhos e as tabelas leem-se na ordem das linhas; um ficheiro não etiquetado conhecido, onde o indicador de ordem estimada é visível; e uma digitalização, onde o aviso de ausência de texto é efetivamente falado. Cada um destes testes valida caminhos que o caso ideal ignora.

A partir daí, confirme que o cursor da palavra permanece sincronizado com taxas de fala rápidas e lentas, e que o deslocamento automático do ReadingWordFollow não entra em conflito com o deslocamento manual efetuado pelo utilizador. Em seguida, execute a fala enquanto alterna por todos os filtros de cores e certifique-se de que o cursor nunca desaparece. O artigo sobre filtros de cores para baixa visão aborda este caminho de renderização em detalhe, e a análise sobre o cursor de fala de palavras detalha a sincronização do TTS.

As APIs de unidades de leitura e de caixas de palavras utilizadas acima são disponibilizadas com o PDFium Component para Delphi e C++Builder (VCL) e Lazarus/FPC (LCL). A página do produto contém a hiperligação para a referência completa da API, incluindo as estruturas de registo para as unidades de leitura e caixas de palavras destes exemplos.