Artigo Técnico

Construir Visualizadores de PDF Acessíveis com Texto-para-Voz em Delphi

Um botão de leitura em voz alta funciona como demonstração numa tarde e depois consome uma semana. A versão da tarde extrai o texto da página, passa-o para a SAPI e obtém áudio. A semana é dedicada ao que torna a funcionalidade utilizável: a voz não pode bloquear a janela, a palavra falada tem de se iluminar na página ao mesmo tempo que o áudio, e a tecla de Espaço tem de pausar tudo. Este artigo constrói esse pipeline em Delphi sobre a API de texto nativa do PDFium e a API de Voz do Windows, com código funcional para as três partes que a versão rápida omite: o ciclo de vida COM feito uma vez em vez de por cada frase, eventos reais de limite de palavra e a matemática de coordenadas que transforma uma caixa de palavra do espaço PDF num retângulo que pode ser desenhado

O contexto regulamentar resume-se a uma frase: a leitura em voz alta sincronizada é a metade do lado do visualizador daquilo que as normas WCAG 2.1 exigem do software de documentos, e a norma ISO 14289-1 (PDF/UA) define a metade do ficheiro com tags com o qual funciona melhor. Se está a programar com o PDFium Component, pode nem precisar deste pipeline: o visualizador inclui um cursor de rastreio integrado que mapeia um deslocamento de caráter para um destaque de palavra desenhada numa só chamada, abordado no artigo sobre destaque TTS palavra a palavra. O que se segue aplica-se quando detém a aplicação completa do visualizador e pretende o próprio pipeline

Uma thread renderiza, uma thread fala

A arquitetura consiste em duas threads e num contrato. A thread da interface de utilizador (UI) renderiza o mapa de bits da página, detém o estado do zoom e da deslocação e desenha a sobreposição de destaque. Uma thread de voz dedicada detém a voz da SAPI e nada mais lhe toca. O contrato é simples: a thread de voz relata o progresso como deslocamentos de caráter, e a thread da UI transforma os deslocamentos em retângulos

A maioria dos exemplos de SAPI envolve cada elocução em CoInitialize e CoUninitialize, e um visualizador mostra imediatamente porque isso está errado. Speak com SVSFlagsAsync regressa assim que o texto é colocado em fila, pelo que um CoUninitialize no bloco finally do mesmo procedimento é executado enquanto a voz ainda está a falar, derrubando o apartment COM a que pertence. Dependendo do tempo, obtém-se silêncio, uma elocução truncada ou uma violação de acesso minutos mais tarde. O ciclo de vida correto é aborrecido: CoInitialize uma vez quando a thread de voz se inicia, criar a voz dentro desse apartment, e CoUninitialize uma vez quando a thread termina, depois de a voz ter sido libertada. Nunca por elocução

A voz também precisa de um recetor de mensagens, o que determina onde pode residir. O objeto de automação SpVoice entrega os seus eventos através da fila de mensagens da thread que o criou. Se for criado na thread de UI, os eventos chegam, porque a VCL processa mensagens, mas cada desenho lento atrasará os seus limites de palavra; se for criado numa worker thread sem processamento de mensagens, os eventos nunca chegarão. Uma thread dedicada com o seu próprio ciclo GetMessage mantém a latência de limite plana independentemente do que a UI estiver a fazer

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 publica WM_QUIT para que o processador desbloqueie quando o visualizador é encerrado. SpeakPage, chamado a partir da thread de UI, guarda o texto num campo protegido por bloqueio e publica WM_SPEAK_PAGE, porque chamar um método no FVoice diretamente de outra thread seria uma chamada COM entre apartments numa interface não ordenada (unmarshaled). O PeekMessage de uma linha antes do ciclo obriga o Windows a criar a fila de mensagens da thread, fechando a concorrência inicial em que um post antecipado da thread da UI falharia

Os limites das palavras chegam como deslocamentos de caráter

Ao importar a Microsoft Speech Object Library uma vez através do importador de biblioteca de tipos do IDE, obtém SpeechLib_TLB com o invólucro TSpVoice e os seus eventos tipados. Duas configurações são importantes. EventInterests deve ser reduzido aos eventos que realmente consome, porque todos os interesses que ficarem ativados representam tráfego de eventos cruzados para cada palavra de cada página; SVEWordBoundary controla o destaque e SVEEndInputStream diz-lhe que a elocução terminou. E o manipulador OnWord recebe a CharacterPosition e um comprimento, que indexam a string exata que passou ao Speak — um deslocamento no buffer de voz e não em qualquer outra coisa

Esta última cláusula é a invariante de que a funcionalidade depende: os deslocamentos só fazem sentido face à string que a voz está a ler, por isso fale exatamente o texto que extraiu, caráter por caráter. Aparar espaços em branco, colapsar quebras de linha ou expandir uma abreviatura para melhorar a pronúncia faz com que cada destaque após a primeira edição fique desfasado uma palavra. Se a UI tiver de injetar material falado — anúncios de página, prefixos de cabeçalho — registe a posição e o comprimento de cada inserção, e subtraia o deslocamento acumulado a cada offset antes de o mapear

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 ordenador correto aqui, e não o Synchronize: o manipulador não deve estacionar a thread de voz enquanto a UI é repintada e, se os eventos de limite chegarem mais rápido do que o ecrã desenha, uma atualização desatualizada de destaque é inofensiva porque a seguinte sobrepõe-se-lhe. Ligue o OnEndStream da mesma forma para limpar o destaque e, num modo de leitura contínua, para carregar o texto da página seguinte e publicar a elocução seguinte

Dos deslocamentos de caráter aos píxeis no ecrã

O PDFium relata a geometria por caráter. FPDFText_GetCharBox preenche quatro variáveis duplas numa ordem que tem causado mais erros silenciosos do que qualquer outra coisa na API de texto — esquerda, direita, inferior, superior, não a ordem do Windows esquerda, superior, direita, inferior — e relata-os no espaço da página: pontos PDF, 72 por polegada, origem no canto inferior esquerdo com o Y a crescer para cima. A caixa de uma palavra é a união das caixas dos seus carateres, e a transformação para píxeis do dispositivo é feita em três passos: transladar pela origem da página, escalar pelo zoom vezes os DPI do ecrã 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 do FPDF_GetPageHeight, e FPageLeft é zero na maioria dos documentos, mas provém da caixa de recorte quando a página define uma, por isso, leia ambas do FPDF_GetPageBoundingBox em vez de assumir. A inversão de Y é onde as versões feitas à mão quebram: o topo do retângulo do dispositivo vem do topo da caixa PDF medida para baixo a partir do topo da página. Faça isto ao contrário e todos os destaques serão pintados de forma espelhada 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 desenho desenha primeiro o mapa de bits da página e depois o destaque, todas as vezes, pelo que a sobreposição nunca tem de se apagar a si própria; invalidar os retângulos antigo e novo mantém a região repintada pequena, mesmo a velocidades rápidas de voz. FHighlightBrush é um TBitmap de um por um preenchido uma vez no arranque com a cor de destaque — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF para um tom âmbar — que a AlphaBlend estende sobre o retângulo de destino, pelo que nada é alocado por frame, e um SourceConstantAlpha a 96 mantém a palavra legível através da tonalidade. Teste a cor nos modos de ecrã invertido e de alto contraste; uma sobreposição que um utilizador com baixa visão não consegue ver não existe exatamente para a pessoa para quem foi construída

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

FPDFText_GetText devolve carateres numa ordem derivada do fluxo de conteúdo com alguma limpeza espacial e, para um relatório de uma coluna, essa ordem está bem. Não tem qualquer obrigação de estar correta noutros lados. Uma newsletter com duas colunas pode ler-se a direito por ambas as colunas, uma barra lateral pode interromper uma frase a meio de uma cláusula, e um rodapé pode surgir no meio da página. A informação que corrige isto — a árvore de estrutura lógica do ISO 32000-1 §14.8, que os PDFs etiquetados transportam e o PDF/UA torna obrigatória — não é de modo algum consultada pelas chamadas em bruto da página de texto. Se precisar de ordem sensível à estrutura com um sinal explícito da sua origem, esse é um problema já resolvido uma prateleira acima: a API de leitura do PDFium Component devolve conteúdo com um campo Source de rosStructure ou rosHeuristic, e o artigo sobre o leitor de PDF acessível explica como o fazer. Ao nível da API em bruto, a posição defensável é tratar a ordem de extração como uma estimativa, dizê-lo 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 se mantenham visíveis

O próprio visualizador tem de poder ser operado por teclado

A saída de voz não desculpa o visualizador do acesso pelo teclado; as pessoas com maior probabilidade de usar a leitura em voz alta são as menos propensas a pegar num rato. Atribua ao painel da página TabStop := True e um retângulo de foco visível e depois gira três teclas: Espaço alterna FVoice.Pause e FVoice.Resume, e Esquerda e Direita saltam através de FVoice.Skip('Sentence', 1) com uma contagem negativa para recuar. O método Skip da SAPI apenas compreende a granularidade das frases, pelo que saltar ao nível da palavra significa purgar a reprodução com SVSFPurgeBeforeSpeak e voltar a falar a partir do offset da palavra que rastreou por último — barato, uma vez que o código de destaque já armazena exatamente esse offset. Mantenha cada controlo de transporte num verdadeiro TButton com uma legenda para que os leitores de ecrã o anunciem

Esse é o pipeline completo, todo ele em torno da API de texto do PDFium em bruto: uma thread de voz que detém o COM e a voz durante a vida da aplicação, eventos de limite convertidos para a UI como deslocamentos de carateres e caixas no espaço da página por caráter transformadas num retângulo sobreposto no ecrã. Se preferir não gerir a geometria e o rastreio por si mesmo, o PDFium Component inclui caixas por palavra, o cursor de rastreio, o seguimento automático por deslocamento e unidades de leitura ao nível da frase como propriedades do componente, e a sua demonstração de leitura em voz alta é o pipeline deste artigo reduzido a uma mão-cheia de chamadas