Technischer Artikel

Barrierefreien PDF-Reader in Delphi mit PDFium bauen

Richten Sie NVDA auf einen frisch gebauten Delphi-PDF-Viewer, erhalten Sie meist eines von zwei Ergebnissen: Stille oder Text in genau der Reihenfolge, in der der Content Stream ihn zufällig speichert — zuerst die Fußzeile, dann die rechte Spalte, dann die Überschrift, die die Seite visuell eröffnet. Das Rendering ist makellos; das Hörerlebnis ist nutzlos. Die Lücke entsteht, weil Rasterisierung und Lesen getrennte Pipelines sind: Die Zeichenreihenfolge innerhalb eines PDF-Content-Streams verpflichtet sich nicht dazu, der Reihenfolge zu entsprechen, in der ein Mensch den Inhalt hören sollte. PDFium Component, der VCL/LCL-Wrapper um die PDFium-Engine für Delphi, C++Builder und Lazarus, liefert genau deshalb eine eigene Familie von Lese-APIs, denn die Rendering-APIs können diese Aufgabe nicht erledigen.

Drei Probleme entscheiden, ob ein Projekt für einen barrierefreien Reader gelingt: eine sprechbare Lesereihenfolge extrahieren, einen sichtbaren Wortcursor mit der Sprachausgabe synchron halten und ehrlich degradieren, wenn das Dokument nie getaggt wurde. Für jedes davon gibt es einen konkreten API-Pfad und einen ebenso konkreten Fehlermodus, den man kennen sollte, bevor der erste Event-Handler geschrieben wird.

Lesereihenfolge lebt im Strukturbaum, nicht in der Zeichenreihenfolge

ISO 32000-1 §14.8 definiert logische Struktur als Baum von Strukturelementen über dem Seiteninhalt, und PDF/UA (ISO 14289-1) macht diesen Baum verpflichtend: Jeder echte Inhalt muss über ihn in Lesereihenfolge erreichbar sein, Artefakte ausgeschlossen. Ein korrekt getaggter Bericht weiß, dass „Quarterly Results“ eine Überschrift zweiter Ebene ist und die Summentabelle eine Tabelle mit Kopfzellen. Ein ungetaggter Bericht besteht aus positionierten Glyph-Läufen und sonst nichts.

ReadablePageContent läuft diesen Strukturbaum ab, wenn er vorhanden ist, und gibt Inhaltsfragmente mit semantischem Kind zurück — cfHeading, cfParagraph und verwandte Werte —, sodass die Reader-UI vor dem Text „Überschrift“ ansagen kann, statt eine fett gesetzte Zeile wie Fließtext vorzulesen. Wenn der Strukturbaum fehlt oder unbrauchbar ist, wechselt derselbe Aufruf auf heuristische Layoutanalyse: Spaltenerkennung, Baseline-Clustering, Links-nach-rechts-Ordnung. Die Ausgabe ist für einspaltige Dokumente oft brauchbar und für Newsletter, mehrspaltige Formulare und alles mit Seitenleisten unzuverlässig. Die entscheidende Disziplin ist, dem Benutzer mitzuteilen, in welchem Fall er sich befindet, und die API liefert diese Tatsache direkt: Der zurückgegebene TPdfReadableContent-Record trägt ein Source-Feld, das rosStructure ist, wenn die Reihenfolge aus dem getaggten Baum stammt, und rosHeuristic, wenn sie aus dem Layout geschätzt wurde. Geschätzte Reihenfolge als geprüfte Reihenfolge auszugeben ist das Barrierefreiheitsäquivalent eines grünen Hakens auf einem ungetesteten Build.

Eine praktische Klassifikation beim Öffnen einer Datei ist, IsTagged zu prüfen und einmal ValidatePdfUa auszuführen, mit gecachtem Urteil. Eine fehlgeschlagene PDF/UA-Prüfung bedeutet nicht, das Dokument abzulehnen; sie bedeutet, dass die Statusleiste „geschätzte Lesereihenfolge“ anzeigt und Ihr Supportteam genau weiß, worauf es schaut, wenn ein Kunde unsinnige Narration in einer konkreten Datei meldet.

Von der Seite zur Sprachqueue mit ReadingUnits

Für Text-to-Speech ist ReadingUnits das Arbeitspferd: Es gibt ein Array von TPdfReadingUnit-Records für die aktive Seite zurück, jeweils mit dem zu sprechenden Text, seiner semantischen Rolle und den Hervorhebungsrechtecken, die ihn auf der Seite lokalisieren. Eine dokumentweite Variante, DocumentReadingUnits, existiert für kontinuierliches Lesen. Eine Unit passt natürlich auf einen Eintrag in einer Sprachqueue:

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;

Zwei Details in dieser Schleife lohnen Aufmerksamkeit. Erstens: Halten Sie die Queue strikt pro Seite und bauen Sie sie bei Navigation neu auf; Reading Units enthalten Rechtecke im Seitenraum, daher malt eine veraltete Queue Hervorhebungen auf die falsche Seite, nachdem der Benutzer vorwärts springt. Zweitens ist ein leeres Units-Array auf einer sichtbar gefüllten Seite Ihr Detektor für reinen Bildinhalt. Eine gescannte Seite hat Pixel, aber keine Textebene, und die richtige Reaktion ist eine gesprochene Warnung — „diese Seite enthält keinen extrahierbaren Text“ — statt Stille, die der Benutzer nicht von einem Absturz unterscheiden kann.

Ein Wortcursor, der der Stimme folgt

Blockweises Hervorheben fühlt sich für Benutzer mit Sehbehinderung, die beim Hören visuell mitlesen, träge an. Wortgenaues „Karaoke“-Hervorheben braucht zwei Zutaten: Wortgeometrie und eine Zuordnung der Fortschrittscallbacks der TTS-Engine auf diese Geometrie. PageWordBoxes liefert die Geometrie als TPdfWordBox-Records — Worttext, Zeichenoffset, Zeichenzahl und ein Rechteck im Seitenraum. TrackReadingWordAt liefert die Zuordnung: Es wandelt eine Zeichenposition, also genau das, was SAPIs Wortgrenzenbenachrichtigung liefert, in einen Index in das Word-Box-Array um und hebt im selben Aufruf das Wort hervor, das diese Position enthält.

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;

Der Vertrag ist in einer Hinsicht nachsichtig und in einer anderen streng. Nachsichtig: TrackReadingWordAt pflegt seinen eigenen Word-Box-Cache für die getrackte Seite, daher müssen Sie ihn nicht vorab füttern, und es findet kein Rendering statt, weil Word Boxes aus der Textebene der Seite stammen. Dadurch kann sogar ein Sprachdienst ohne Oberfläche Positionen verfolgen. Streng: Der Zeichenindex muss sich auf den Text beziehen, den die Komponente extrahiert hat. Die Funktion gibt außerdem -1 zurück, statt eine Exception zu werfen, wenn CharIndex hinter das Ende des Seitentextes zeigt, was regelmäßig passiert, wenn eine TTS-Engine ein letztes Boundary-Event für abschließende Interpunktion auslöst. Behandeln Sie -1 als „Cursor löschen“, nicht als Fehlerbedingung.

Auf der Anzeigeseite steuert ReadingWordColor die Cursor-Hervorhebung — die standardmäßige Bernsteinfarbe übersteht die meisten Seitenhintergründe, aber prüfen Sie sie unter jedem Anzeige-Filter, den Ihr Viewer anbietet, denn ein bernsteinfarbener Cursor kann bei Farbinversion vollständig verschwinden, und Inversion plus Sprache ist genau die Kombination, die Benutzer mit Sehbehinderung verwenden. ReadingWordFollow auf True zu setzen lässt die Ansicht automatisch zum gesprochenen Wort scrollen, was auf stark gezoomten, über mehrere Bildschirmhöhen laufenden Seiten unverzichtbar ist. Eine Geltungsbereichsregel: SetReadingWord malt nur auf der aktiven TPdfView-Seite, entscheiden Sie also, ob Benutzerscrollen die Sprache pausiert oder ob Follow-Verhalten gewinnt; nichts davon zu tun lässt Sprache gegen einen unsichtbaren Cursor laufen.

Dokumente, die sich wehren

Drei Eingabeklassen brechen naive Implementierungen oft genug, dass sie permanente Regressionsbeispiele in der Testsuite verdienen.

  • Ungetaggte, aber textreiche Dateien. Heuristische Reihenfolge ist für lineare Berichte meist richtig und für Layouts mit Seitenleisten oder Zwischenzitaten falsch. Kennzeichnen Sie die Reihenfolge in der UI und im Diagnoselog als geschätzt.
  • Reine Bildscans. Es gibt überhaupt keine Textebene. Erkennen Sie sie über leere Reading Units und führen Sie den Benutzer zu einem vorgelagerten OCR-Schritt, statt den Reader nichts sprechen zu lassen.
  • Kombinierende Zeichen und gemischte Schriften. Unicode-Kombinationszeichen bilden nicht immer eins zu eins visuelle Wörter ab, daher kann die Word-Box-Anzahl von dem abweichen, was Ihr eigener Tokenizer erwarten würde. Indizieren Sie das Word-Box-Array niemals mit Arithmetik aus eigenem Splitting; verwenden Sie ausschließlich Indizes, die TrackReadingWordAt zurückgibt.

Abnahme: testen wie ein Auditor, nicht wie eine Demo

„Es hat mein Beispiel vorgelesen“ ist keine Abnahme. Ein belastbarer Pass führt drei Dokumente mit angebundenem NVDA durch den fertigen Build: eine bekannt getaggte Datei, bei der Überschriften als Überschriften angesagt und Tabellen zeilenweise gelesen werden; eine bekannt ungetaggte Datei, bei der der Indikator für geschätzte Reihenfolge sichtbar ist; und einen Scan, bei dem die explizite Kein-Text-Warnung gesprochen wird.

Prüfen Sie dann, dass der Wortcursor bei doppelter und halber Sprechgeschwindigkeit angeheftet bleibt und dass ReadingWordFollow-Scrolling nicht gegen manuelles Scrollen arbeitet. Schalten Sie schließlich jeden Farbfilter um, während Sprache läuft, und bestätigen Sie, dass der Cursor sichtbar bleibt — der Artikel zu Low-Vision-Farbfiltern behandelt diesen Rendering-Pfad, und der Deep Dive zum Wort-Sprachcursor geht weiter in die TTS-Timing-Details.

FAQ

Benötigt der Reader zwingend ein getaggtes PDF?

Nein. ReadablePageContent und ReadingUnits fallen bei ungetaggten Dateien auf heuristische Layoutanalyse zurück, und das Source-Feld des lesbaren Inhalts teilt mit, welcher Pfad die Reihenfolge erzeugt hat. Die Pflicht liegt bei Ihrer UI: Unterscheiden Sie geprüfte Strukturbaum-Reihenfolge von geschätzter Reihenfolge, denn beide scheitern auf unterschiedliche Weise, und der Support muss wissen, um welche Art Beschwerde es geht.

Warum gibt TrackReadingWordAt mitten auf der Seite -1 zurück?

Meist bezieht sich der Zeichenindex Ihrer TTS-Engine auf Text, den Sie vor dem Queuing vorverarbeitet haben, oder er liegt auf Leerraum zwischen Wörtern. Offsets müssen in den Text zeigen, den die Komponente extrahiert hat — denselben Text, den PageWordBoxes tokenisiert hat —, nicht in eine bereinigte Kopie davon.

Kann ich Accessibility-Compliance programmatisch prüfen?

Ja — ValidatePdfUa gibt die erkannte Konformitätsstufe plus eine Menge von PDF/UA-Verstößen pro Dokument zurück, und BuildPdfPreflightReport bindet dieselbe Prüfung in einen Multi-Standard-Bericht ein. Es ist ein Detektor, kein Reparaturwerkzeug: Verwenden Sie das Urteil, um beim Öffnen Erwartungen zu setzen und eingehende Dateien zu triagieren.

Die hier gezeigten Reading-Unit- und Word-Box-APIs sind Teil der PDFium Component für Delphi und C++Builder (VCL) sowie Lazarus/FPC (LCL). Die Produktseite verlinkt die vollständige API-Referenz einschließlich der Record-Layouts für Reading Units und Word Boxes, die in den obigen Beispielen verwendet werden.