Article technique

Surlignage mot à mot de la synthèse vocale (TTS) dans les visionneuses PDFium Delphi

Une fonctionnalité de lecture à voix haute a un rôle visible au-delà de la voix : au fur et à mesure que chaque mot est prononcé, elle doit illuminer ce mot sur la page et le garder en vue. Pour ce faire, vous avez besoin de la boîte englobante (bounding box) de chaque mot, indexée sur le même flux de caractères à partir duquel le moteur de synthèse vocale lit. Obtenez les boîtes mais manquez l'indexation et le surlignage dérive d'un mot ou deux derrière le son ; obtenez l'indexation mais gérez mal l'état de la page et le surlignage atterrit sur la mauvaise page. La partie vocale de ce processus, le synthétiseur lui-même, est celle qui tombe rarement en panne. SAPI signale les limites des mots au caractère près. Ce qui casse, c'est la fine couche de mappage entre un décalage de caractère (character offset) dans le tampon vocal (speech buffer) et un rectangle sur la page rendue

Le Composant PDFium fournit ce mappage pour Delphi, C++Builder et Lazarus, avec les boîtes de mots (word boxes) disponibles depuis la version 1.53 et le curseur de suivi depuis la version 1.56. La surface d'API est délibérément étroite : un appel qui renvoie les boîtes de mots pour une page, un dispositif de suivi (tracker) qui transforme un décalage de caractère en un surlignage peint, et quelques propriétés pour la couleur et le défilement automatique (auto-scroll). Aussi étroite soit-elle, l'ordre dans lequel vous appelez les choses décide si la fonctionnalité fonctionne, et la plupart des échecs ci-dessous proviennent de l'appel des bonnes fonctions dans le mauvais ordre

Les caractères ne sont pas des mots, et les moteurs TTS parlent en caractères

Un moteur de synthèse vocale consomme une chaîne plate et signale sa progression sous forme de positions de caractères dans cette chaîne. Une page PDF a des glyphes placés dans l'espace de la page, où un "mot" est un groupe heuristique d'exécutions de glyphes. Les deux systèmes de coordonnées ne partagent rien à moins que le texte que vous transmettez au synthétiseur ne soit octet par octet le texte à partir duquel les boîtes de mots ont été calculées. C'est la première règle, et elle est impitoyable. Normalisez les espaces, supprimez les traits d'union conditionnels ou "nettoyez" le texte extrait d'une autre manière avant de le prononcer, et chaque décalage en aval sera silencieusement faux. Prononcez exactement ce que vous avez extrait, ou conservez une table explicite de remappage des décalages. Il n'y a pas de troisième option qui survive à de vrais documents

La table de remappage n'est pas un cas marginal hypothétique. Au moment où votre interface utilisateur insère une annonce de page parlée ("page cinq") ou développe une abréviation pour le synthétiseur, la chaîne parlée diverge de celle extraite. Enregistrez la position et la longueur de chaque insertion, puis soustrayez l'ajustement cumulé avant chaque appel de suivi (tracking call). C'est peut-être vingt lignes de comptabilité, et c'est la différence entre un surlignage qui survit à la prochaine demande de fonctionnalité et un qui se casse la première fois que quelqu'un demande des titres parlés

Ce qu'une boîte de mot (word box) vous donne

Chaque enregistrement TPdfWordBox contient le texte du mot, son index de départ (StartIndex) et le nombre de caractères (Count) dans le texte de la page, un rectangle (Rect) dans l'espace de la page et le numéro de page basé sur 1 (Page). Le champ StartIndex est le pont entre les deux systèmes de coordonnées : c'est le même décalage que SAPI renverra au fur et à mesure qu'il lira. PageWordBoxes renvoie le tableau complet de la page active :

procedure TReaderForm.PreparePage(PageNo: Integer);
begin
  PdfView.PageNumber := PageNo;   // les boîtes de mots de la vue suivent sa page affichée

  FWords := PdfView.PageWordBoxes;
  FPageText := BuildSpeechText(FWords);   // concaténer Word.Text dans l'ordre

  if Length(FWords) = 0 then
    HandleImageOnlyPage(PageNo);          // une numérisation sans couche de texte
end;

Le commentaire sur l'ordre est porteur. PageWordBoxes de la visionneuse convertit en jetons (tokenizes) la couche de texte de la page actuellement affichée par la vue, alors naviguez d'abord dans la vue et extrayez ensuite ; aucun rendu n'est requis, seul un document ouvert. (Le composant de document, TPdf, expose son propre PageWordBoxes basé sur Pdf.PageNumber pour une utilisation sans interface (headless). Les deux numéros de page sont indépendants, ce qui constitue son propre piège). Un résultat vide sur une page qui comporte visiblement du contenu signifie une numérisation composée uniquement d'images (image-only scan). Dirigez-la vers l'OCR, ou du moins annoncez-la ("la page 4 ne contient aucun texte lisible"), plutôt que de laisser la voix se taire sans explication

Câblage des limites de mots SAPI au tracker

TrackReadingWordAt, sur la visionneuse, est la charnière de toute la fonctionnalité. Donnez-lui un numéro de page et un index de caractère ; il trouve la boîte de mot contenant ce caractère, y peint le curseur de lecture et renvoie l'index du mot, ou −1 lorsque l'index tombe entre les mots. La notification de limite de mot de SAPI fournit exactement la position du caractère qu'il souhaite :

procedure TReaderForm.OnSpeechWordBoundary(StreamPos: Integer);
var
  WordIdx: Integer;
begin
  // Mappe le décalage sur une boîte de mot et déplace la surbrillance en un seul appel
  WordIdx := PdfView.TrackReadingWordAt(FPageNo, StreamPos);
  if WordIdx < 0 then
    Exit;                     // la limite se situe en dehors de tout mot : conserver le dernier surlignage
end;

Deux détails défensifs gagnent leur place ici. Tout d'abord, TrackReadingWordAt conserve son propre cache de boîtes de mots pour la page suivie, reconstruit automatiquement lorsque la page change, de sorte que le coût par limite reste plat, quelle que soit la vitesse à laquelle les limites arrivent. Deuxièmement, il ne vérifie pas généreusement les limites. Un index égal ou supérieur au nombre de caractères de la page renvoie −1 plutôt que de se caler sur le dernier mot. Traitez −1 comme "conserver le surlignage précédent", jamais comme une erreur, car les séries de ponctuation et les espaces entre les mots produisent légitimement des limites qui n'appartiennent à aucun mot. Enregistrer chaque −1 vous submergera. Comptez-les plutôt par page, et examinez attentivement toute page où le ratio grimpe en flèche, car cela signifie généralement une inadéquation de normalisation de texte qui renvoie à la règle numéro un

Le curseur lui-même : couleur, suivi et nettoyage

SetReadingWord peint directement le surlignage lorsque vous tenez vous-même la boîte du mot, ReadingWordColor la stylise, et ReadingWordFollow := True fait défiler la vue juste assez pour garder le mot prononcé visible. Cette dernière propriété gagne sa place. Un défilement "centrer le mot actuel" codé à la main fait vaciller la page à chaque saut de ligne, et les lecteurs sensibles au mouvement désactiveront l'ensemble de la fonctionnalité en moins d'une minute. Le surlignage ne s'affiche que sur la page actuellement affichée dans le TPdfView actif, la lecture de plusieurs pages doit donc faire avancer PageNumber en rythme avec la parole, puis réexécuter l'étape de préparation (prepare step) pour la nouvelle page avant que son premier événement de limite n'arrive. Omettez cela et les premiers surlignages de chaque page pointeront vers des coordonnées obsolètes (stale coordinates)

procedure TReaderForm.StopReading;
begin
  FVoice.Stop;                // arrêter la lecture SAPI en premier
  PdfView.ClearReadingWord;   // puis supprimer le surlignage ; un curseur obsolète est perçu comme un bogue
end;

La symétrie à l'arrêt est ce qui maintient le surlignage honnête. Chaque chemin de pause, d'arrêt et de rotation de page doit se terminer par ClearReadingWord. Omettez-le et un rectangle ambre repose sur une page arrêtée, ressemblant exactement à un défaut, ce qui est le genre de chose que chaque testeur signalera même si rien n'est réellement cassé

Le débit de la parole sollicite ce pipeline (pipeline) plus durement que la taille du document. À 300 mots par minute, les événements de limite arrivent toutes les 200 ms, et aux débits SAPI les plus rapides, ils arrivent plus vite que l'œil ne peut confortablement les suivre. La bonne réponse est de fusionner (coalesce), et non de mettre en file d'attente (queue). Si une nouvelle limite arrive alors qu'une mise à jour de surlignage est toujours en attente, supprimez l'obsolète et peignez la dernière. Un curseur qui visite chaque mot dans l'ordre mais qui a une demi-seconde de retard semble cassé ; celui qui saute occasionnellement un mot tout en restant synchronisé avec la voix ne l'est pas

Cas marginaux qui séparent les démos des produits

Quelques catégories de documents exposent les failles (seams). Les caractères de combinaison sont les plus subtils : les séquences Unicode telles qu'une lettre de base plus un diacritique de combinaison peuvent occuper plus d'index de caractères que ne le suggère le mot visuel, de sorte que toute arithmétique de décalage qui suppose un index par glyphe dérive lentement. C'est l'argument le plus fort pour laisser TrackReadingWordAt posséder le mappage plutôt que de calculer les numéros de mots à la main. La césure est plus banale mais plus courante : un mot coupé sur un saut de ligne devient deux boîtes, et si vous le prononcez comme un seul jeton, l'événement de limite de sa deuxième moitié se résout sur la première boîte. C'est généralement bien, mais c'est une décision, alors prenez-la délibérément au lieu de la découvrir. Le balisage modifie l'ordre de lecture lui-même. Lorsqu'un document contient des balises de structure appropriées (le territoire de la norme ISO 14289, PDF/UA), le séquençage des mots suit la structure logique ; sans elles, il se rabat sur des heuristiques de mise en page, et une page non balisée à deux colonnes peut se lire directement à travers les deux colonnes. Les pages pivotées (rotated pages) sont le dernier cas courant : le Rect de chaque mot le délimite toujours correctement dans l'espace de la page, mais une politique de suivi de la fenêtre (viewport-follow policy) réglée pour un flux horizontal défile de manière discordante lorsque le texte est vertical, alors gardez au moins un document pivoté dans l'ensemble de régression (regression set). Pour la gestion de l'ordre de lecture, les unités au niveau de la phrase via ReadingUnits et la pile d'assistance (assistive stack) plus large, voir la construction d'un lecteur PDF accessible dans Delphi

Une contrainte de plate-forme façonne le déploiement. SAPI est exclusif à Windows. La boîte de mots et l'API de suivi sont identiques octet par octet sous Lazarus et FPC, mais les compilations Linux et macOS ont besoin d'un synthétiseur différent câblé derrière les mêmes événements de limite ; cette configuration est couverte dans l'exécution de la visionneuse sous Lazarus et FPC. Le coût du surlignage interagit également avec votre cache de pages une fois que le débit de parole augmente, et l'arithmétique du budget dans le cache de rendu et les performances de zoom s'applique ici sans modification

Lorsque le surlignage d'un seul mot n'est pas la bonne granularité

Le karaoké au niveau du mot n'est pas toujours ce que souhaite un lecteur. À des débits de parole élevés, le scintillement du curseur mot à mot devient son propre bruit visuel, et certains auditeurs suivent une phrase plus confortablement qu'un stroboscope de mots individuels. Pour ce cas, le composant expose une unité plus grossière. ReadingUnits renvoie des unités au niveau de la phrase et du bloc, chacune avec ses propres rectangles de surlignage, et vous les peignez avec SetReadingHighlight au lieu de SetReadingWord. Le câblage a la même forme : un décalage de limite détermine toujours quelle unité s'allume, mais l'unité que vous surlignez couvre une proposition ou une ligne plutôt qu'un seul jeton. Les lecteurs plus lents et la lecture à haut débit ont tendance à le préférer, et rien ne vous empêche de proposer les deux modes derrière un paramètre

Il est utile de fixer les limites de version avant de construire sur cette base : les boîtes de mots nécessitent la version 1.53 du Composant PDFium ou ultérieure, et le curseur de suivi nécessite la version 1.56. L'API de lecture complète, les unités au niveau de la phrase et une démonstration fonctionnelle de lecture à voix haute se trouvent sur la page produit du Composant PDFium