Article technique

Créer un lecteur PDF accessible dans Delphi avec PDFium

Un utilisateur aveugle ouvre un rapport trimestriel dans votre toute nouvelle visionneuse Delphi, active NVDA et entend le bas de page, puis une colonne de chiffres, puis le titre qu'un lecteur voyant aurait lu en premier. Ou n'entend rien du tout. La page est parfaite à l'écran, et c'est exactement le piège : le rendu et la lecture sont deux problèmes résolus par du code différent. L'ordre dans lequel un PDF dessine ses glyphes n'a aucune obligation de correspondre à l'ordre dans lequel une personne devrait les entendre, si bien qu'une visionneuse bâtie uniquement sur des appels de rendu produit une image irréprochable et une narration inutilisable. PDFium Component, l'enveloppe VCL/LCL du moteur PDFium pour Delphi, C++Builder et Lazarus, embarque pour cette raison un ensemble distinct d'API de lecture. Les API de dessin ne peuvent pas récupérer un ordre de lecture qu'on ne leur a jamais fourni.

Un lecteur accessible repose sur trois choses. Il doit extraire un ordre qu'un lecteur d'écran peut narrer, maintenir un curseur de mot visible sur ce que la voix est en train de lire, et admettre honnêtement qu'un document n'a jamais été balisé au lieu de deviner et de prétendre. Chacun de ces points a une API claire et un échec bien précis si on en omet le détail.

L'ordre de lecture vit dans l'arbre de structure, pas dans l'ordre de peinture

ISO 32000-1 §14.8 définit la structure logique comme un arbre d'éléments superposés au contenu de page. PDF/UA (ISO 14289-1) va plus loin et rend cet arbre obligatoire : chaque fragment de contenu réel doit être accessible à travers lui dans l'ordre de lecture, les artefacts de page étant marqués comme tels et ignorés. Un rapport correctement balisé sait que « Résultats trimestriels » est un titre de niveau deux et que la grille de totaux est un tableau avec des cellules d'en-tête. Un rapport non balisé est un tas de suites de glyphes positionnées qui ressemblent à un document.

ReadablePageContent parcourt cet arbre quand il est présent et renvoie des fragments étiquetés avec un Kind sémantique, des valeurs comme cfHeading et cfParagraph, afin que l'interface puisse annoncer « titre » avant les mots plutôt que de lire une ligne en gras comme du corps de texte ordinaire. Sans arbre exploitable, le même appel revient à une analyse de mise en page heuristique : détection des colonnes, regroupement des lignes de base, ordre de gauche à droite et de haut en bas. Ce repli convient à une note à une colonne et trébuche sur un journal, un formulaire multicolonne, tout ce qui comporte une barre latérale ou une citation en exergue. Ce qui compte, c'est de savoir lequel des deux résultats vous avez obtenu, et l'API le dit clairement. L'enregistrement TPdfReadableContent porte un champ Source à rosStructure quand l'ordre provient de l'arbre balisé, ou à rosHeuristic quand il a été déduit de la géométrie. Présenter un ordre deviné comme vérifié revient à apposer un badge de validation sur un build que personne n'a exécuté.

Le geste économique à l'ouverture consiste à lire IsTagged et à appeler ValidatePdfUa une fois, puis à mémoriser la réponse. Un échec à la vérification PDF/UA n'est pas une raison de refuser le fichier. C'est une raison de mettre « ordre de lecture estimé » dans la barre d'état, de sorte que quand un client signale une narration incohérente, le support sait déjà s'il s'agit d'un problème de balisage dans le fichier ou d'un bogue dans votre code.

De la page à la file de synthèse vocale avec ReadingUnits

Pour la synthèse vocale, ReadingUnits fait le gros du travail. Il renvoie un tableau d'enregistrements TPdfReadingUnit pour la page active, chacun portant le texte à lire, son rôle sémantique et les rectangles qui le localisent sur la page. Il existe un pendant pour l'ensemble du document, DocumentReadingUnits, quand vous voulez une lecture continue sur plusieurs pages. Une unité s'insère directement dans un slot de file de synthèse vocale :

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;

Deux choses dans cette boucle sont faciles à rater. Gardez la file par page et reconstruisez-la à chaque navigation, car les unités de lecture portent des rectangles en coordonnées de page ; une file restant de la page trois peindra ses surbrillances sur la page quatre. Et traitez un tableau Units vide sur une page qui contient visiblement du contenu comme votre détecteur de numérisation image seule. Une page scannée est constituée de pixels sans couche texte en dessous, et la bonne réponse est d'énoncer un avertissement (« cette page ne comporte aucun texte extractible ») plutôt que de se taire d'une façon que l'auditeur ne peut distinguer d'un blocage.

Un curseur de mot qui suit la voix

Surligner un paragraphe entier à la fois paraît trop lent pour un malvoyant qui suit les mots des yeux pendant qu'on les lit à voix haute. La surbrillance mot à mot, l'effet karaoké, nécessite deux éléments : la géométrie de chaque mot, et un moyen de projeter les rapports de progression du moteur TTS sur cette géométrie. PageWordBoxes vous donne la géométrie sous forme d'enregistrements TPdfWordBox, chacun avec le texte du mot, son offset de caractère, son nombre de caractères et un rectangle en coordonnées de page. TrackReadingWordAt vous donne la projection. Donnez-lui la position de caractère que l'événement de limite de mot de SAPI signale déjà, et il résout cet offset en un indice dans le tableau de boîtes de mots et peint le curseur sur le mot correspondant en un seul appel.

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;

Le contrat est généreux sur un point et sans pitié sur un autre. La partie généreuse : TrackReadingWordAt maintient son propre cache de boîtes de mots pour la page suivie, il n'y a donc rien à précharger, et aucun rendu ne se produit car les boîtes de mots viennent de la couche texte. Un service de synthèse vocale sans fenêtre visible peut tout de même suivre les positions. La partie sans pitié : l'index de caractère doit pointer dans le texte que le composant a extrait, pas dans une chaîne nettoyée que vous avez construite vous-même. Quand CharIndex dépasse la fin du texte de page, la fonction renvoie -1 au lieu de lever une exception, ce qui arrive systématiquement quand un moteur TTS déclenche un dernier événement de limite pour la ponctuation finale. Lisez -1 comme « effacez le curseur », jamais comme une erreur.

Côté affichage, ReadingWordColor définit la couleur du curseur. L'ambre par défaut tient sur la plupart des fonds de page, mais testez-le sous chaque filtre d'affichage que votre visionneuse propose. Un curseur ambre peut disparaître complètement sous l'inversion des couleurs, et l'inversion combinée à la synthèse vocale est exactement la façon dont travaille un malvoyant, donc la combinaison que vous devez absolument réussir est celle qu'une démonstration rapide n'exerce jamais. Mettez ReadingWordFollow à True et la vue fait défiler le mot prononcé pour le maintenir visible d'elle-même, ce qu'il est impossible de faire autrement sur une page zoomée qui déborde sur les bords. Respectez une règle de portée : SetReadingWord ne peint que sur la page du TPdfView actif. Décidez à l'avance si le défilement manuel met la synthèse en pause ou si le suivi automatique l'emporte, car ne choisir ni l'un ni l'autre laisse la voix continuer à lire pendant que le curseur reste quelque part hors de l'écran.

Les documents qui cassent votre lecteur

Quelques formes d'entrée font échouer une implémentation naïve de façon suffisamment fiable pour mériter une place permanente dans la suite de régression, et non comme des bogues ponctuels que l'on corrige et oublie.

  • Fichiers non balisés mais riches en texte. L'ordre heuristique a tendance à être correct pour un rapport linéaire et faux dès qu'une barre latérale ou une citation en exergue entre en jeu. Signalez l'ordre comme estimé, dans l'interface et dans vos journaux de diagnostic, afin que l'échec soit lisible plus tard.
  • Numérisations image seule. Aucune couche texte. Détectez-les via des unités de lecture vides et orientez l'utilisateur vers une étape OCR en amont plutôt que de laisser le lecteur narrer une page vide.
  • Caractères combinants et scripts mixtes. Les marques de combinaison Unicode ne se réduisent pas toujours à une correspondance biunivoque avec des mots visuels, si bien que le nombre de boîtes de mots peut diverger de ce que votre propre tokeniseur attend. N'indexez pas le tableau de boîtes de mots avec des offsets que vous avez calculés en découpant le texte vous-même ; utilisez uniquement les indices renvoyés par TrackReadingWordAt.

Testez comme un auditeur, pas comme une démo

« Ça a lu mon exemple à voix haute » ne prouve rien. Un test que vous pouvez défendre passe trois fichiers dans le build terminé avec NVDA attaché : un fichier connu balisé, où les titres sont annoncés comme des titres et un tableau est lu dans l'ordre des lignes ; un fichier connu non balisé, où l'indicateur d'ordre estimé est visible ; et une numérisation, où l'avertissement d'absence de texte est effectivement prononcé. Chacun exerce un chemin que le cas heureux ignore.

Ensuite, vérifiez que le curseur de mot reste bien synchronisé à double vitesse de parole et à la moitié, et que le défilement de ReadingWordFollow ne se bat pas avec le défilement de l'utilisateur. Puis lancez la synthèse tout en parcourant chaque filtre de couleur et vérifiez que le curseur ne disparaît jamais. L'article sur les filtres de couleur pour malvoyants couvre ce chemin de rendu en détail, et l'article de fond sur le curseur de lecture mot à mot décortique le timing TTS.

Les API d'unités de lecture et de boîtes de mots utilisées ci-dessus sont livrées avec PDFium Component pour Delphi et C++Builder (VCL) et Lazarus/FPC (LCL). La page produit contient la référence API complète, notamment les structures des enregistrements d'unités de lecture et de boîtes de mots derrière ces exemples.