Article technique

Créer des visionneuses PDF accessibles avec la synthèse vocale dans Delphi

Un bouton de lecture à voix haute se démontre en un après-midi, puis prend une semaine à finaliser. La version de l'après-midi extrait le texte de la page, le transmet à SAPI, et obtient de l'audio. La semaine est consacrée à ce qui rend la fonctionnalité utilisable : la voix ne doit pas geler la fenêtre, le mot prononcé doit s'allumer sur la page en synchronisation avec l'audio, et la touche Espace doit pouvoir tout mettre en pause. Cet article construit ce pipeline dans Delphi en s'appuyant sur l'API de texte brute PDFium et l'API vocale de Windows (Windows Speech API), avec du code fonctionnel pour les trois éléments que la version rapide ignore : le cycle de vie COM géré une seule fois au lieu d'une par énoncé, de véritables événements de limite de mots, et les mathématiques de coordonnées qui transforment une boîte de mots de l'espace PDF en un rectangle que vous pouvez peindre

Le contexte réglementaire tient en une phrase : la lecture à voix haute synchronisée correspond à la partie côté visionneuse de ce que les normes WCAG 2.1 exigent des logiciels documentaires, et la norme ISO 14289-1 (PDF/UA) définit la partie fichier balisé avec laquelle elle fonctionne le mieux. Si vous développez avec PDFium Component, vous n'aurez peut-être pas du tout besoin de ce pipeline : la visionneuse intègre un curseur de suivi natif qui associe un décalage de caractères à une mise en évidence de mots peints en un seul appel, ce qui est couvert dans l'article sur la mise en évidence mot par mot pour la synthèse vocale (TTS). Ce qui suit s'adresse à ceux qui possèdent l'intégralité de l'application de visionneuse et qui souhaitent créer le pipeline lui-même

Un thread effectue le rendu, un thread parle

L'architecture repose sur deux threads et un contrat. Le thread de l'interface utilisateur (UI) génère le bitmap de la page, gère l'état du zoom et du défilement, et peint la superposition de mise en évidence. Un thread vocal dédié possède la voix SAPI, et rien d'autre n'y touche. Le contrat est mince : le thread vocal signale la progression sous forme de décalages de caractères, et le thread de l'interface utilisateur transforme ces décalages en rectangles

La plupart des exemples SAPI enveloppent chaque énoncé dans CoInitialize et CoUninitialize, et une visionneuse montre immédiatement pourquoi c'est une erreur. Speak avec SVSFlagsAsync retourne dès que le texte est mis en file d'attente, de sorte qu'un CoUninitialize dans le bloc finally de la même procédure s'exécute pendant que la voix parle encore, détruisant ainsi l'appartement COM qui la possède. Selon le moment, vous obtenez un silence, un énoncé tronqué ou une violation d'accès quelques minutes plus tard. Le cycle de vie correct est ennuyeux : CoInitialize une seule fois lorsque le thread vocal démarre, créer la voix à l'intérieur de cet appartement, et CoUninitialize une seule fois lorsque le thread se termine, après que la voix a été libérée. Jamais par énoncé

La voix a également besoin d'une pompe à messages, ce qui détermine où elle peut résider. L'objet d'automatisation SpVoice transmet ses événements via la file d'attente des messages du thread qui l'a créé. Créez-la sur le thread de l'interface utilisateur et les événements arrivent, car la VCL pompe les messages, mais chaque rendu lent retarde alors vos limites de mots ; créez-la sur un thread de travail sans pompe et les événements n'arrivent jamais. Un thread dédié avec sa propre boucle GetMessage maintient la latence des limites stable, peu importe ce que fait l'interface utilisateur

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 envoie WM_QUIT pour que la pompe se débloque lors de la fermeture de la visionneuse. SpeakPage, appelée depuis le thread de l'interface utilisateur, stocke le texte dans un champ protégé par un verrou et envoie WM_SPEAK_PAGE, car appeler une méthode sur FVoice directement depuis un autre thread constituerait un appel COM inter-appartements sur une interface non sérialisée (unmarshaled). La ligne unique PeekMessage avant la boucle force Windows à créer la file d'attente de messages du thread, évitant ainsi un problème de concurrence au démarrage où un envoi prématuré depuis le thread de l'interface utilisateur échouerait

Les limites de mots arrivent sous forme de décalages de caractères

Importez la Microsoft Speech Object Library une seule fois via l'importateur de bibliothèques de types de l'IDE et vous obtiendrez SpeechLib_TLB avec le wrapper TSpVoice et ses événements typés. Deux paramètres sont importants. EventInterests doit être restreint aux événements que vous consommez réellement, car chaque intérêt laissé activé génère un trafic d'événements inter-threads pour chaque mot de chaque page ; SVEWordBoundary gère la mise en évidence et SVEEndInputStream vous indique que l'énoncé est terminé. Et le gestionnaire OnWord reçoit CharacterPosition et une longueur, qui s'indexent dans la chaîne exacte que vous avez passée à Speak — un décalage dans le tampon vocal, et dans rien d'autre

Cette dernière clause est l'invariant sur lequel repose la fonctionnalité : les décalages n'ont de sens que par rapport à la chaîne que la voix lit, alors prononcez exactement le texte que vous avez extrait, caractère par caractère. Supprimez les espaces blancs, réduisez les sauts de ligne, ou développez une abréviation pour une meilleure prononciation, et chaque mise en évidence après la première modification sera décalée d'un mot. Si l'interface utilisateur doit injecter du contenu parlé — annonces de page, préfixes de titre — enregistrez la position et la longueur de chaque insertion, et soustrayez le décalage accumulé de chaque offset avant de le mapper

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 est le marshalling approprié ici, pas Synchronize : le gestionnaire ne doit pas bloquer le thread vocal pendant que l'interface utilisateur se redessine, et si les événements de limite arrivent plus vite que l'écran ne se met à jour, une mise en évidence obsolète est inoffensive car la suivante l'écrase. Câblez OnEndStream de la même manière pour effacer la mise en évidence, et dans un mode de lecture continue, pour charger le texte de la page suivante et envoyer l'énoncé suivant

Des décalages de caractères aux pixels à l'écran

PDFium rapporte la géométrie par caractère. FPDFText_GetCharBox remplit quatre variables de type double dans un ordre qui a causé plus de bugs silencieux que n'importe quoi d'autre dans l'API de texte — gauche, droite, bas, haut (left, right, bottom, top), et non pas l'ordre Windows gauche, haut, droite, bas — et il les rapporte dans l'espace de la page : points PDF, 72 par pouce, origine dans le coin inférieur gauche avec l'axe Y augmentant vers le haut. La boîte d'un mot est l'union des boîtes de ses caractères, et la transformation vers les pixels de l'appareil se fait en trois étapes : translation par l'origine de la page, mise à l'échelle par le zoom multiplié par le DPI de l'écran divisé par 72, et inversion de l'axe 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 est la hauteur de la page en points obtenue via FPDF_GetPageHeight, et FPageLeft est à zéro pour la plupart des documents mais provient de la zone de recadrage (crop box) lorsque la page en définit une, il vaut donc mieux lire les deux avec FPDF_GetPageBoundingBox plutôt que de supposer. L'inversion de l'axe Y est là où les versions créées manuellement échouent : le haut du rectangle de l'appareil provient du haut de la boîte PDF mesuré vers le bas à partir du haut de la page. Si vous vous trompez de sens, chaque mise en évidence sera dessinée en miroir dans la mauvaise moitié de la page

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;

Le gestionnaire de rendu dessine le bitmap de la page en premier et la mise en évidence ensuite, à chaque fois, de sorte que la superposition n'a jamais à s'effacer d'elle-même ; invalider l'ancien et le nouveau rectangle permet de maintenir une petite zone de rafraîchissement même avec un débit de parole rapide. FHighlightBrush est un TBitmap de un par un rempli une seule fois au démarrage avec la couleur de mise en évidence — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF pour un ambre — que AlphaBlend étire sur le rectangle cible, ainsi rien n'est alloué par image, et SourceConstantAlpha à 96 garde le mot lisible à travers la teinte. Testez la couleur dans les modes d'affichage inversé et à contraste élevé ; une superposition qu'un utilisateur malvoyant ne peut pas voir est inutile pour la personne même pour laquelle elle a été conçue

L'ordre de lecture est la partie que l'API de texte ne résoudra pas

FPDFText_GetText renvoie les caractères dans un ordre dérivé du flux de contenu avec un peu de nettoyage spatial, et pour un rapport à colonne unique, cet ordre convient très bien. Il n'a cependant aucune obligation d'être correct ailleurs. Une newsletter à deux colonnes peut être lue de bout en bout à travers les deux colonnes, une barre latérale peut interrompre une phrase au milieu d'une proposition, et un pied de page peut arriver en plein milieu de la page. L'information qui corrige cela — l'arborescence de structure logique de l'ISO 32000-1 §14.8, que les PDF balisés transportent et que PDF/UA rend obligatoire — n'est pas du tout consultée par les appels de la page de texte brute. Si vous avez besoin d'un ordre conscient de la structure avec un signal explicite de son origine, c'est un problème résolu à un niveau supérieur : l'API de lecture de PDFium Component renvoie le contenu avec un champ Source défini sur rosStructure ou rosHeuristic, et l'article sur la visionneuse PDF accessible explique la démarche. Au niveau de l'API brute, la position défendable est de traiter l'ordre d'extraction comme une estimation, de l'indiquer dans l'interface utilisateur, et de conserver un document multi-colonnes et un scan contenant uniquement des images dans l'ensemble de régression afin que les deux modes d'échec restent visibles

La visionneuse elle-même doit être utilisable au clavier

La sortie vocale ne dispense pas la visionneuse d'un accès au clavier ; les personnes les plus susceptibles d'utiliser la lecture à voix haute sont celles qui ont le moins de chances d'utiliser une souris. Donnez au panneau de la page TabStop := True et un rectangle de focus visible, puis gérez trois touches : Espace bascule entre FVoice.Pause et FVoice.Resume, et les flèches Gauche et Droite sautent via FVoice.Skip('Sentence', 1) avec un compte négatif pour revenir en arrière. La fonction Skip de SAPI ne comprend que la granularité des phrases, donc le saut au niveau du mot signifie purger la lecture avec SVSFPurgeBeforeSpeak et relancer la voix à partir du décalage du dernier mot suivi — ce qui est peu coûteux, puisque le code de mise en évidence stocke déjà exactement ce décalage. Gardez chaque contrôle de transport comme un vrai TButton avec une légende pour que les lecteurs d'écran l'annoncent

C'est tout le pipeline, entièrement construit sur l'API de texte brute de PDFium : un thread vocal qui gère COM et la voix pour toute la durée de vie de l'application, des événements de limite transférés à l'interface utilisateur sous forme de décalages de caractères, et des boîtes par caractère dans l'espace de la page transformées en un seul rectangle fondu à l'écran. Si vous préférez ne pas gérer vous-même la géométrie et le suivi, PDFium Component intègre des boîtes par mot, un curseur de suivi, un défilement automatique et des unités de lecture au niveau de la phrase sous forme de propriétés de composant, et sa démo de lecture à voix haute n'est autre que le pipeline de cet article réduit à une poignée d'appels