Technischer Artikel

Barrierefreien PDF-Reader in Delphi mit PDFium bauen

Ein blinder Benutzer öffnet einen Quartalsbericht in Ihrem glänzend neuen Delphi-Viewer, schaltet NVDA ein und hört zuerst die Seitenfußzeile, dann eine Zahlenspalte, dann den Titel, den jeder sehende Leser zuerst gelesen hätte. Oder hört gar nichts. Die Seite sieht auf dem Bildschirm perfekt aus, und genau darin liegt die Falle: Rendering und Lesen sind verschiedene Probleme, die von verschiedenem Code gelöst werden. Die Reihenfolge, in der ein PDF seine Glyphen zeichnet, muss nicht mit der Reihenfolge übereinstimmen, in der ein Mensch sie hören sollte, weshalb ein Viewer, der nur auf Rendering-Aufrufen aufbaut, ein tadelloses Bild und eine unbrauchbare Vorleseausgabe produziert. Die PDFium-Komponente, der VCL/LCL-Wrapper um die PDFium-Engine für Delphi, C++Builder und Lazarus, führt deshalb einen eigenen Satz an Lese-APIs mit sich. Die Zeichen-APIs können keine Lesereihenfolge rekonstruieren, die ihnen nie mitgegeben wurde

Ein barrierefreier Reader steht und fällt mit drei Dingen. Er muss eine Reihenfolge extrahieren, die ein Screenreader sprechen kann, einen sichtbaren Wort-Cursor auf dem halten, was die Stimme gerade sagt, und einräumen, wenn ein Dokument nie getaggt wurde, statt zu raten und so zu tun, als ob. Für jedes davon gibt es eine klar zuständige API und einen Fehler, der zuschlägt, wenn man das Detail auslässt

Die Lesereihenfolge lebt im Strukturbaum, nicht in der Zeichnungsreihenfolge

ISO 32000-1 §14.8 definiert die logische Struktur als Baum aus Elementen, der über dem Seiteninhalt liegt. PDF/UA (ISO 14289-1) geht weiter und macht diesen Baum zur Pflicht: Jedes echte Inhaltselement muss in Lesereihenfolge über ihn erreichbar sein, und Seiten-Artefakte müssen als solche markiert und übersprungen werden. Ein korrekt getaggter Bericht weiß, dass „Quartalsergebnisse“ eine Überschrift der Ebene zwei ist und dass das Summenraster eine Tabelle mit Kopfzellen ist. Ein ungetaggter Bericht ist ein Haufen positionierter Glyphenläufe, die zufällig wie ein Dokument aussehen

ReadablePageContent durchläuft diese Struktur, wenn sie vorhanden ist, und liefert Fragmente zurück, die mit einer semantischen Kind-Kennzeichnung versehen sind, mit Werten wie cfHeading und cfParagraph, sodass die UI „Überschrift“ sagen kann, bevor die Wörter kommen, statt eine fette Zeile als normalen Fließtext vorzulesen. Ohne nutzbaren Baum fällt derselbe Aufruf auf eine heuristische Layoutanalyse zurück: Spalten erkennen, Grundlinien clustern, von links nach rechts und von oben nach unten ordnen. Dieser Fallback ist für ein einspaltiges Memo in Ordnung und wackelig für einen Newsletter, ein mehrspaltiges Formular, alles mit Seitenleiste oder Zitatkasten. Entscheidend ist, zu wissen, welches Ergebnis man erhalten hat, und die API sagt es unverblümt. Der Record TPdfReadableContent trägt ein Source-Feld, das auf rosStructure steht, wenn die Reihenfolge aus dem getaggten Baum stammt, oder auf rosHeuristic, wenn sie aus der Geometrie erschlossen wurde. Zeigen Sie eine geratene Reihenfolge, als wäre sie verifiziert, haben Sie die Barrierefreiheits-Version eines Erfolgs-Badges an einem Build ausgeliefert, den niemand je ausgeführt hat

Ein barrierefreier PDFium-Reader in Delphi nimmt die Lesereihenfolge aus dem getaggten Strukturbaum und fällt für ungetaggte PDFs auf heuristische Layoutanalyse zurück; das TPdfReadableContent-Source-Feld unterscheidet rosStructure von rosHeuristic
Der getaggte Baum kündigt Überschriften und Zeilenreihenfolge an, während der heuristische Fallback aus Geometrie rät, und das Source-Feld hält verifiziert von geschätzt getrennt

Der günstige Griff beim Öffnen besteht darin, IsTagged zu lesen und ValidatePdfUa einmal aufzurufen und die Antwort dann zwischenzuspeichern. Eine fehlgeschlagene PDF/UA-Prüfung ist kein Grund, die Datei abzuweisen. Sie ist ein Grund, „geschätzte Lesereihenfolge“ in die Statusleiste zu schreiben, damit der Support bei einer Kundenbeschwerde über verstümmelte Vorleseausgabe bereits weiß, ob er es mit einem Tagging-Problem der Datei oder mit einem Bug in Ihrem Code zu tun hat

Von der Seite zur Sprach-Warteschlange mit ReadingUnits

Für die Sprachausgabe erledigt ReadingUnits die Schwerstarbeit. Es liefert ein Array aus TPdfReadingUnit-Records für die aktive Seite, von denen jeder den vorzulesenden Text, seine semantische Rolle und die Rechtecke hält, die ihn auf der Seite verorten. Ein dokumentweiter Gefährte, DocumentReadingUnits, existiert, wenn Sie durchgehendes Lesen über Seitengrenzen hinweg wollen. Eine Einheit passt direkt in einen Slot einer Sprach-Warteschlange:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits arbeitet auf der aktiven Seite
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // Text + Semantik + Hervorhebungsrechtecke
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Zwei Dinge in dieser Schleife geraten leicht falsch. Halten Sie die Warteschlange pro Seite und bauen Sie sie bei jeder Navigation des Benutzers neu auf, denn Leseeinheiten tragen seitenbezogene Rechtecke; eine von Seite drei übrig gebliebene Warteschlange malt ihre Hervorhebungen auf Seite vier. Und behandeln Sie ein leeres Units-Array auf einer Seite, die erkennbar Inhalt hat, als Ihren Nur-Bild-Detektor. Eine gescannte Seite besteht aus Pixeln ohne Textebene darunter, und die richtige Antwort ist, eine Warnung vorzulesen („diese Seite hat keinen extrahierbaren Text“), statt stumm zu fallen, sodass der Hörer es nicht von einem Aufhänger unterscheiden kann

PDFium ReadingUnits in Delphi machen aus der aktiven Seite Text, semantische Rolle und Seitenraum-Rechtecke, die die Sprachwarteschlange eines Screenreaders füllen, eine Einheit pro Slot; ein leeres units-Array markiert eine gescannte Seite für eine gesprochene Warnung
Lese-Einheiten fallen pro Slot eine in die Sprachwarteschlange, und ein leeres Array auf einer inhaltsreichen Seite ist der Detektor für gescannte Seiten

Ein Wort-Cursor, der der Stimme folgt

Ganze Absätze auf einmal hervorzuheben wirkt träge für einen sehbehinderten Benutzer, der die Wörter mit den Augen verfolgt, während sie vorgelesen werden. Hervorhebung auf Wortebene, der Karaoke-Effekt, braucht zwei Dinge: die Geometrie jedes Wortes und eine Möglichkeit, die Fortschrittsmeldungen der TTS-Engine auf diese Geometrie abzubilden. PageWordBoxes liefert die Geometrie als TPdfWordBox-Records, jeder mit dem Worttext, seinem Zeichen-Offset, seiner Zeichenanzahl und einem seitenbezogenen Rechteck. TrackReadingWordAt liefert die Abbildung. Übergeben Sie ihm die Zeichenposition, die das Wortgrenz-Ereignis von SAPI bereits meldet, und es löst diesen Offset zu einem Index im Wortfeld-Array auf und zeichnet den Cursor in einem einzigen Aufruf auf das passende Wort

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // Die Wortfelder der Ansicht stammen von der Seite, die die Ansicht anzeigt.
  // Pdf.PageNumber allein zu setzen würde die Ansicht nicht bewegen
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt bildet den Offset ab UND zeichnet den Wort-Cursor
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // die Grenze lief über den Seitentext hinaus
end;

Der Vertrag ist in einem Punkt großzügig und in einem anderen unerbittlich. Der großzügige Teil: TrackReadingWordAt hält seinen eigenen Wortfeld-Cache für die Seite, die es verfolgt, es gibt also nichts vorzuladen, und es findet gar kein Rendering statt, weil die Wortfelder aus der Textebene stammen. Ein Headless-Sprachdienst ohne sichtbares Fenster kann Positionen dennoch verfolgen. Der unerbittliche Teil: Der Zeichenindex muss in den Text zeigen, den die Komponente extrahiert hat, nicht in irgendeinen bereinigten String, den Sie selbst gebaut haben. Wenn CharIndex über das Ende des Seitentexts hinausläuft, gibt die Funktion -1 zurück, statt eine Exception auszulösen, was ständig passiert, wenn eine TTS-Engine ein letztes Grenz-Ereignis für abschließende Satzzeichen feuert. Lesen Sie -1 als „Cursor löschen“, nie als Fehler

Auf der Anzeigeseite setzt ReadingWordColor die Cursorfarbe. Das voreingestellte Bernstein hält auf den meisten Seitenhintergründen stand, aber testen Sie es unter jedem Anzeigefilter, den Ihr Viewer anbietet. Ein Bernstein-Cursor kann unter Farbinversion vollständig verschwinden, und Inversion parallel zur Sprachausgabe ist genau die Weise, wie ein sehbehinderter Benutzer arbeitet, weshalb die eine Kombination, die Sie am dringendsten richtig machen müssen, die ist, die eine schnelle Demo nie ausprobiert. Setzen Sie ReadingWordFollow auf True, und die Ansicht scrollt das gesprochene Wort von selbst in Sicht, worauf Sie bei einer gezoomten Seite, die über Bildschirme hinausreicht, nicht verzichten können. Beachten Sie eine Geltungsbereichs-Regel: SetReadingWord zeichnet nur auf der aktiven TPdfView-Seite. Entscheiden Sie vorab, ob manuelles Scrollen die Sprachausgabe pausiert oder das Folgeverhalten es übersteuert, denn wer sich für keins von beidem entscheidet, lässt die Stimme weiterlesen, während der Cursor irgendwo außerhalb des Bildschirms sitzt

SAPI-Wortgrenzenereignisse in einem Delphi-PDFium-Reader mappen über TrackReadingWordAt auf die PageWordBoxes-Geometrie, um den Karaoke-Wortcursor zu malen; eine -1-Rückgabe löscht den Cursor, wenn der Offset über den Seitentext hinausläuft
Der TTS-Grenzen-Offset löst sich zu einer Wortbox auf und malt den Cursor, und ein -1 hinter dem Seitentext löscht ihn, statt eine Exception zu werfen

Die Dokumente, die Ihren Reader brechen

Eine Handvoll Eingabeformen bringen eine naive Implementierung zuverlässig genug zu Fall, dass sie als dauerhafte Proben in die Regressionssuite gehören und nicht als einmalige Bugs, die man behebt und vergisst

  • Ungetaggte, aber textreiche Dateien. Die heuristische Reihenfolge ist für einen linearen Bericht meist richtig und in dem Moment falsch, in dem eine Seitenleiste oder ein Zitatkasten auftaucht. Kennzeichnen Sie die Reihenfolge als geschätzt, sowohl in der UI als auch im Diagnoselog, damit der Fehlschlag später lesbar ist
  • Reine Bildscans. Überhaupt keine Textebene. Fangen Sie sie über leere Leseeinheiten und verweisen Sie den Benutzer auf einen vorgelagerten OCR-Schritt, statt den Reader eine leere Seite vorlesen zu lassen
  • Kombinierende Zeichen und gemischte Schriften. Unicode-Kombinationszeichen fallen nicht immer eins-zu-eins zu visuellen Wörtern zusammen, sodass die Anzahl der Wortfelder von dem abweichen kann, was Ihr eigener Tokenizer erwartet. Indizieren Sie das Wortfeld-Array nicht mit Offsets, die Sie durch selbst berechnetes Aufteilen des Texts gewonnen haben; verwenden Sie nur die Indizes, die TrackReadingWordAt zurückgibt

Testen Sie wie ein Prüfer, nicht wie eine Demo

„Es hat meine Beispieldatei vorgelesen“ beweist nichts. Ein Bestehen, das Sie verteidigen können, führt drei Dateien durch den fertigen Build mit laufendem NVDA: eine als getaggt bekannte Datei, bei der Überschriften als Überschriften angesagt und eine Tabelle in Zeilenreihenfolge gelesen wird; eine als ungetaggt bekannte Datei, bei der der Indikator für die geschätzte Reihenfolge sichtbar ist; und einen Scan, bei dem die Warnung ohne Text tatsächlich ausgesprochen wird. Jede davon übt einen Pfad, den der Happy Case auslässt

Bestätigen Sie von dort, dass der Wort-Cursor bei doppelter und bei halber Sprechrate eingerastet bleibt und dass das Scrollen von ReadingWordFollow nicht mit dem Scrollen des Benutzers ringt. Lassen Sie dann die Sprachausgabe laufen, während Sie jeden Farbfilter durchschalten, und beobachten Sie, dass der Cursor nie verschwindet. Der Artikel zu Low-Vision-Farbfiltern in Delphi mit PDFium behandelt diesen Rendering-Pfad im Detail, und die Vertiefung zur Wort-für-Wort-TTS-Hervorhebung in Delphi-Viewern nimmt das TTS-Timing auseinander

Die oben verwendeten APIs für Leseeinheiten und Wortfelder werden mit PDFium Component für Delphi und C++Builder (VCL) sowie Lazarus/FPC (LCL) ausgeliefert. Die Produktseite verlinkt die vollständige API-Referenz, einschließlich der Record-Layouts für Leseeinheiten und Wortfelder hinter diesen Beispielen