Article technique

Construire 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 pied de page, puis une colonne de chiffres, puis le titre que n'importe quel lecteur voyant aurait lu en premier. Ou n'entend rien du tout. La page semble parfaite à l'écran, et c'est exactement le piège : le rendu et la lecture sont des problèmes différents résolus par des codes différents. L'ordre dans lequel un PDF peint ses glyphes n'a aucune obligation de correspondre à l'ordre dans lequel une personne devrait les entendre, de sorte qu'une visionneuse construite uniquement sur des appels de rendu produit une image impeccable et une narration inutilisable. Le Composant PDFium, l'enveloppe (wrapper) VCL/LCL autour du moteur PDFium pour Delphi, C++Builder et Lazarus, comporte un ensemble distinct d'API de lecture pour cette raison. Les API de dessin ne peuvent pas récupérer un ordre de lecture qui ne leur a jamais été donné

Un lecteur accessible tient ou tombe sur trois choses. Il doit extraire un ordre qu'un lecteur d'écran peut énoncer, maintenir un curseur de mot visible épinglé à ce que dit la voix, et admettre qu'un document n'a jamais été balisé au lieu de deviner et de faire semblant. Chacun possède une API claire à utiliser et un échec qui vous pénalise si vous omettez les détails

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

La norme ISO 32000-1 §14.8 définit la structure logique comme un arbre d'éléments superposé au contenu de la page. PDF/UA (ISO 14289-1) va plus loin et rend cet arbre obligatoire : chaque élément de contenu réel doit être accessible via cet arbre dans l'ordre de lecture, les artefacts de page (page artifacts) é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 des totaux est un tableau avec des cellules d'en-tête. Un rapport non balisé est un tas d'ensembles de glyphes positionnés qui se trouvent ressembler à un document

ReadablePageContent parcourt cette structure lorsqu'elle est présente et renvoie des fragments balisés avec un type (Kind) sémantique, des valeurs comme cfHeading et cfParagraph, de sorte que l'interface utilisateur peut dire "titre" avant les mots plutôt que de lire une ligne en gras comme du texte de corps ordinaire. Sans arbre utilisable, le même appel se rabat sur l'analyse heuristique de la disposition : détecter des colonnes, regrouper des lignes de base, ordonner de gauche à droite et de haut en bas. Cette solution de secours convient pour un mémo à une seule colonne, mais est incertaine pour une newsletter, un formulaire à plusieurs colonnes, ou tout ce qui comporte une barre latérale ou une citation en exergue (pull quote). Ce qui importe, c'est de savoir quel résultat vous avez obtenu, et l'API vous le dit d'emblée. L'enregistrement TPdfReadableContent porte un champ Source défini sur rosStructure lorsque l'ordre provient de l'arbre balisé, ou sur rosHeuristic lorsqu'il a été déduit de la géométrie. Affichez un ordre deviné comme s'il était vérifié et vous avez livré la version accessibilité d'un badge de réussite sur une compilation que personne n'a exécutée

La solution bon marché au moment de l'ouverture est de lire IsTagged et d'appeler ValidatePdfUa une fois, puis de mettre la réponse en cache. Un échec de la vérification PDF/UA n'est pas un motif pour refuser le fichier. C'est une raison pour mettre "ordre de lecture estimé" dans la barre d'état, de sorte que lorsqu'un client envoie une plainte par e-mail concernant une narration brouillée, l'assistance sait déjà s'il est confronté à un problème de balisage dans le fichier ou à un bogue dans votre code

De la page à la file d'attente vocale avec ReadingUnits

Pour la synthèse vocale (text-to-speech), ReadingUnits fait le gros du travail. Elle renvoie un tableau d'enregistrements TPdfReadingUnit pour la page active, chacun contenant le texte à énoncer, son rôle sémantique et les rectangles qui le localisent sur la page. Il existe un compagnon à l'échelle du document, DocumentReadingUnits, lorsque vous souhaitez une lecture continue sur plusieurs pages. Une unité se dépose directement dans un emplacement d'une file d'attente vocale :

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits fonctionne sur la page active
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // texte + sémantique + rectangles de mise en évidence
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Deux choses dans cette boucle sont faciles à rater. Conservez la file d'attente par page et reconstruisez-la chaque fois que l'utilisateur navigue, car les unités de lecture portent des rectangles de l'espace de page (page-space rectangles) ; une file d'attente restante de la page trois peindra ses mises en évidence sur la page quatre. Et traitez un tableau Units vide sur une page qui a clairement du contenu comme votre détecteur "image seule" (image-only). Une page numérisée est constituée de pixels sans couche de texte sous-jacente, et la bonne réponse consiste à prononcer un avertissement ("cette page n'a pas de texte extractible") plutôt que de rester silencieux d'une manière que l'auditeur ne peut pas distinguer d'un blocage

Un curseur de mot qui suit la voix

Mettre en surbrillance tout un paragraphe à la fois semble lent à un utilisateur malvoyant qui suit les mots des yeux pendant qu'ils sont lus à voix haute. La mise en surbrillance au niveau du mot, l'effet karaoké, nécessite deux éléments : la géométrie de chaque mot, et un moyen de faire correspondre les rapports d'avancement du moteur TTS sur cette géométrie. PageWordBoxes vous donne la géométrie sous forme d'enregistrements TPdfWordBox, chacun contenant le texte du mot, son décalage de caractère, son nombre de caractères et un rectangle de l'espace de page. TrackReadingWordAt vous donne le mappage. Fournissez-lui la position de caractère que l'événement de limite de mot de SAPI signale déjà, et elle résout ce décalage en un index dans le tableau de boîtes de mots (word-box array) et peint le curseur sur le mot correspondant en un seul appel

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // Les boîtes de mots de la vue proviennent de la page que la vue affiche.
  // Définir Pdf.PageNumber seul ne déplacerait pas la vue
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt cartographie le décalage ET peint le curseur de mot
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // la limite a dépassé le texte de la page
end;

Le contrat est généreux sur un point et impitoyable sur un autre. La partie généreuse : TrackReadingWordAt conserve son propre cache de boîtes de mots pour la page qu'elle suit, il n'y a donc rien à précharger, et aucun rendu ne se produit du tout car les boîtes de mots proviennent de la couche de texte. Un service vocal sans tête (headless) sans fenêtre visible peut toujours suivre les positions. La partie impitoyable : l'index de caractère doit pointer vers le texte extrait par le composant, et non vers une chaîne nettoyée que vous avez construite vous-même. Lorsque CharIndex dépasse la fin du texte de la page, la fonction renvoie -1 au lieu de lever une exception, ce qui arrive tout le temps lorsqu'un moteur TTS déclenche un dernier événement de limite pour la ponctuation de fin. Lisez -1 comme "effacer le curseur", jamais comme une erreur

Côté affichage, ReadingWordColor définit la couleur du curseur. L'ambre par défaut tient le coup sur la plupart des arrière-plans de page, mais testez-le sous chaque filtre d'affichage proposé par votre visionneuse. Un curseur ambre peut disparaître complètement sous l'inversion des couleurs, et l'inversion fonctionnant en même temps que la synthèse vocale est exactement la façon dont un utilisateur malvoyant travaille, la combinaison que vous devez absolument réussir est donc celle qu'une démonstration rapide ne met jamais à l'épreuve. Définissez ReadingWordFollow sur True et la vue fera défiler le mot prononcé pour qu'il soit visible d'elle-même, ce dont vous ne pouvez pas vous passer sur une page zoomée qui déborde sur plusieurs écrans. Faites attention à une règle de portée : SetReadingWord ne peint que sur la page TPdfView active. Décidez d'emblée si le défilement manuel met en pause la parole ou si le comportement de suivi la remplace, car ne rien choisir laisse la voix lire pendant que le curseur se trouve quelque part hors de l'écran

Les documents qui cassent votre lecteur

Une poignée de formes d'entrée mettent en échec une implémentation naïve de manière suffisamment fiable pour qu'elles aient leur place en tant qu'échantillons permanents dans la suite de régression, et non en tant que bogues isolés que l'on corrige et que l'on oublie

  • Fichiers non balisés mais riches en texte. L'ordre heuristique tend à être correct pour un rapport linéaire et faux dès qu'une barre latérale ou une citation en exergue intervient. Signalez l'ordre comme estimé, à la fois dans l'interface utilisateur et dans votre journal de diagnostic, afin que l'échec soit lisible par la suite
  • Numérisations "image seule" (Image-only scans). Aucune couche de texte. Attrapez-les via des unités de lecture vides et dirigez l'utilisateur vers une étape de reconnaissance optique de caractères (OCR) en amont au lieu de laisser le lecteur narrer une page vide
  • Combinaison de caractères et de scripts mixtes. Les marques de combinaison Unicode ne se réduisent pas toujours une pour une en mots visuels, de sorte que le nombre de boîtes de mots peut s'écarter de ce que votre propre tokenizer attend. N'indexez pas le tableau de boîtes de mots avec des décalages que vous avez calculés en divisant le texte vous-même ; n'utilisez que les index renvoyés par TrackReadingWordAt

Testez-le comme un auditeur, pas comme une démonstration

"Il a lu mon échantillon à voix haute" ne prouve rien. Un test que vous pouvez défendre passe trois fichiers dans la compilation terminée avec NVDA attaché : un fichier connu comme balisé, où les titres sont annoncés comme tels et où un tableau est lu dans l'ordre des lignes ; un fichier connu comme non balisé, où l'indicateur d'ordre estimé est visible ; et une numérisation, où l'avertissement d'absence de texte est réellement prononcé. Chacun exerce un chemin que le cas idéal (happy case) ignore

À partir de là, confirmez que le curseur de mot reste verrouillé au double de la vitesse de la parole et à la moitié, et que le défilement ReadingWordFollow ne lutte pas contre le propre défilement de l'utilisateur. Ensuite, exécutez la synthèse vocale tout en parcourant chaque filtre de couleur et observez que le curseur ne disparaît jamais. L'article sur le filtre de couleur pour basse vision couvre ce chemin de rendu en détail, et l'exploration approfondie du curseur vocal de mot décortique le minutage TTS

Les API d'unité de lecture et de boîte de mots utilisées ci-dessus sont livrées avec le Composant PDFium pour Delphi et C++Builder (VCL) et Lazarus/FPC (LCL). La page du produit propose un lien vers la référence complète de l'API, y compris les dispositions d'enregistrement pour les unités de lecture et les boîtes de mots qui sous-tendent ces exemples