Technischer Artikel

Vorlese-PDF-Viewer in Delphi mit SAPI TTS erstellen

Eine Vorlese-Schaltfläche lässt sich an einem Nachmittag demonstrieren und kostet dann eine ganze Woche. Die Nachmittagsversion extrahiert den Seitentext, übergibt ihn an SAPI und erhält Audio. Die Woche vergeht damit, die Funktion nutzbar zu machen: Die Stimme darf das Fenster nicht einfrieren, das gesprochene Wort muss im Takt zum Audio auf der Seite aufleuchten, und die Leertaste muss das Ganze pausieren können. Dieser Artikel baut diese Pipeline in Delphi gegen die rohe PDFium-Text-API und die Windows Speech API auf, mit funktionierendem Code für die drei Teile, die die schnelle Version überspringt: COM-Lebensdauer einmalig anstatt pro Äußerung, echte Wortgrenzenereignisse und die Koordinatenberechnung, die eine Wortbox im PDF-Raum in ein zu zeichnendes Rechteck verwandelt

Der regulatorische Kontext passt in einen Satz: Synchronisiertes Vorlesen ist die Viewer-seitige Hälfte dessen, was WCAG 2.1 von Dokumentensoftware verlangt, und ISO 14289-1 (PDF/UA) definiert die Hälfte der getaggten Datei, gegen die es am besten funktioniert. Wenn Sie auf dem PDFium Component aufbauen, benötigen Sie diese Pipeline möglicherweise gar nicht: Der Viewer wird mit einem integrierten Tracking-Cursor ausgeliefert, der einen Zeichen-Offset in einem einzigen Aufruf einer gezeichneten Wort-Hervorhebung zuordnet, wie im Artikel über wortweises TTS-Highlighting beschrieben. Das Folgende gilt für den Fall, dass Ihnen die gesamte Viewer-Anwendung gehört und Sie die Pipeline selbst erstellen möchten

Ein Thread rendert, ein Thread spricht

Die Architektur besteht aus zwei Threads und einem Vertrag. Der UI-Thread rendert die Seiten-Bitmap, besitzt den Zoom- und Scroll-Status und zeichnet das Hervorhebungs-Overlay. Ein dedizierter Sprach-Thread besitzt die SAPI-Stimme, und nichts anderes berührt sie. Der Vertrag ist schmal: Der Sprach-Thread meldet den Fortschritt als Zeichen-Offsets, und der UI-Thread wandelt Offsets in Rechtecke um

Die meisten SAPI-Beispiele verpacken jede Äußerung in CoInitialize und CoUninitialize, und ein Viewer zeigt sofort, warum das falsch ist. Speak mit SVSFlagsAsync kehrt zurück, sobald der Text in die Warteschlange gestellt wurde. Ein CoUninitialize im finally-Block derselben Prozedur läuft also, während die Stimme noch spricht, und zerstört das COM-Apartment, dem sie gehört. Je nach Timing erhalten Sie Stille, eine abgeschnittene Äußerung oder Minuten später eine Zugriffsverletzung. Die richtige Lebensdauer ist langweilig: CoInitialize einmal, wenn der Sprach-Thread startet, die Stimme in diesem Apartment erstellen und CoUninitialize einmal, wenn der Thread endet, nachdem die Stimme freigegeben wurde. Niemals pro Äußerung

Die Stimme benötigt außerdem eine Nachrichtenschleife, die bestimmt, wo sie leben kann. Das Automatisierungsobjekt SpVoice liefert seine Ereignisse über die Nachrichtenwarteschlange des Threads, der es erstellt hat. Erstellen Sie es im UI-Thread, kommen Ereignisse tatsächlich an, weil die VCL Nachrichten pumpt, aber jedes langsame Zeichnen verzögert dann Ihre Wortgrenzen. Erstellen Sie es in einem Worker-Thread ohne Pumpe, und die Ereignisse kommen überhaupt nie an. Ein dedizierter Thread mit seiner eigenen GetMessage-Schleife hält die Latenz der Grenzen konstant flach, unabhängig davon, was die UI gerade tut

uses
  System.Classes, System.SyncObjs, Winapi.Windows, Winapi.Messages,
  Winapi.ActiveX, SpeechLib_TLB;

const
  WM_SPEAK_PAGE = WM_APP + 1;

type
  TSpeechThread = class(TThread)
  private
    FVoice: TSpVoice;
    FLock: TCriticalSection;
    FText: string;
    function NextUtterance: string;   // reads FText under FLock
    procedure VoiceWord(ASender: TObject; StreamNumber: Integer;
      StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
  protected
    procedure Execute; override;
    procedure TerminatedSet; override;
  public
    procedure SpeakPage(const AText: string);   // safe from the UI thread
  end;

procedure TSpeechThread.Execute;
var
  Msg: TMsg;
begin
  CoInitialize(nil);                       // once, when the thread starts
  try
    FVoice := TSpVoice.Create(nil);
    try
      FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
      FVoice.OnWord := VoiceWord;
      // Force creation of this thread's message queue before anyone posts to it
      PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
      while GetMessage(Msg, 0, 0, 0) do    // exits when WM_QUIT arrives
        if Msg.message = WM_SPEAK_PAGE then
          FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
        else
          DispatchMessage(Msg);            // delivers the SAPI event callbacks
    finally
      FVoice.Free;
    end;
  finally
    CoUninitialize;                        // once, when the thread exits
  end;
end;

procedure TSpeechThread.TerminatedSet;
begin
  inherited;
  PostThreadMessage(ThreadID, WM_QUIT, 0, 0);   // unblock GetMessage
end;

TerminatedSet postet WM_QUIT, damit die Pumpe entsperrt wird, wenn der Viewer heruntergefahren wird. SpeakPage, vom UI-Thread aufgerufen, speichert den Text in einem durch eine Sperre geschützten Feld und postet WM_SPEAK_PAGE, da der direkte Aufruf einer Methode für FVoice aus einem anderen Thread ein COM-Aufruf über Apartmentgrenzen hinweg auf einer nicht gemarshallten Schnittstelle wäre. Die einzeilige PeekMessage vor der Schleife zwingt Windows, die Nachrichtenwarteschlange des Threads zu erstellen, und schließt so das Start-Race-Condition, bei dem ein frühes Posten aus dem UI-Thread fehlschlagen würde

Wortgrenzen kommen als Zeichen-Offsets an

Importieren Sie die Microsoft Speech Object Library einmal über den Typbibliotheks-Importer der IDE, und Sie erhalten SpeechLib_TLB mit dem TSpVoice-Wrapper und dessen typisierten Ereignissen. Zwei Einstellungen sind wichtig. EventInterests sollte auf die Ereignisse eingegrenzt werden, die Sie tatsächlich konsumieren, denn jedes eingeschaltet gelassene Interesse bedeutet threadübergreifenden Ereignisverkehr für jedes Wort auf jeder Seite; SVEWordBoundary steuert die Hervorhebung und SVEEndInputStream teilt Ihnen mit, dass die Äußerung beendet ist. Und der OnWord-Handler empfängt CharacterPosition und eine Länge, die genau den String indizieren, den Sie an Speak übergeben haben – ein Offset in den Sprachpuffer, nicht in irgendetwas anderes

Dieser letzte Halbsatz ist die Invariante, von der die Funktion abhängt: Offsets sind nur für den String bedeutsam, den die Stimme vorliest, sprechen Sie also genau den Text, den Sie extrahiert haben, Zeichen für Zeichen. Trimmen Sie Leerzeichen, fassen Sie Zeilenumbrüche zusammen oder erweitern Sie eine Abkürzung für eine schönere Aussprache, und jede Hervorhebung nach der ersten Bearbeitung landet ein Wort daneben. Wenn die UI gesprochenes Material einfügen muss – Seitenansagen, Überschriften-Präfixe –, notieren Sie die Position und Länge jeder Einfügung und subtrahieren Sie die akkumulierte Verschiebung von jedem Offset, bevor Sie ihn zuordnen

procedure TSpeechThread.SpeakPage(const AText: string);
begin
  FLock.Enter;
  try
    FText := AText;
  finally
    FLock.Leave;
  end;
  PostThreadMessage(ThreadID, WM_SPEAK_PAGE, 0, 0);
end;

procedure TSpeechThread.VoiceWord(ASender: TObject; StreamNumber: Integer;
  StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
begin
  // Runs on the speech thread; hand the offsets to the UI without blocking
  TThread.Queue(nil,
    procedure
    begin
      ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
    end);
end;

TThread.Queue ist hier der richtige Marshal, nicht Synchronize: Der Handler darf den Sprach-Thread nicht parken, während die UI neu gezeichnet wird, und wenn Grenzereignisse schneller eintreffen, als der Bildschirm zeichnet, ist ein veraltetes Update der Hervorhebung harmlos, da das nächste es überschreibt. Verdrahten Sie OnEndStream auf die gleiche Weise, um die Hervorhebung zu löschen und in einem kontinuierlichen Lesemodus den Text der nächsten Seite zu laden und die nächste Äußerung zu posten

Von Zeichen-Offsets zu Pixeln auf dem Bildschirm

PDFium meldet die Geometrie pro Zeichen. FPDFText_GetCharBox füllt vier Doubles in einer Reihenfolge, die mehr stille Fehler verursacht hat als alles andere in der Text-API – links, rechts, unten, oben, nicht das Windows-übliche links, oben, rechts, unten –, und meldet sie im Seitenraum: PDF-Punkte, 72 pro Zoll, Ursprung in der unteren linken Ecke mit nach oben wachsendem Y. Die Box eines Wortes ist die Vereinigung der Boxen seiner Zeichen, und die Transformation in Gerätepixel erfolgt in drei Schritten: Verschieben um den Seitenursprung, Skalieren um Zoom mal Bildschirm-DPI geteilt durch 72 und Umkehren der Y-Achse

uses
  System.Math;

type
  TPdfRectF = record
    Left, Top, Right, Bottom: Double;    // PDF points, origin bottom-left
  end;

function TViewerForm.WordBox(CharIndex, CharCount: Integer): TPdfRectF;
var
  i, LastChar: Integer;
  L, T, R, B: Double;
begin
  Result.Left := MaxDouble;   Result.Bottom := MaxDouble;
  Result.Right := -MaxDouble; Result.Top := -MaxDouble;
  LastChar := Min(CharIndex + CharCount, FPDFText_CountChars(FTextPage)) - 1;
  for i := CharIndex to LastChar do
  begin
    // Parameter order is left, right, bottom, top - not the Windows order
    FPDFText_GetCharBox(FTextPage, i, @L, @R, @B, @T);
    Result.Left   := Min(Result.Left, L);
    Result.Right  := Max(Result.Right, R);
    Result.Bottom := Min(Result.Bottom, B);
    Result.Top    := Max(Result.Top, T);
  end;
end;

function TViewerForm.PdfToDevice(const W: TPdfRectF): TRect;
var
  Scale: Double;
begin
  // 72 PDF points per inch; FZoom is the viewer scale factor
  Scale := FZoom * FScreenDpi / 72.0;
  Result.Left   := Round((W.Left  - FPageLeft) * Scale) - FScrollX;
  Result.Right  := Round((W.Right - FPageLeft) * Scale) - FScrollX;
  // PDF Y grows upward from the bottom edge; device Y grows downward
  Result.Top    := Round((FPageTop - W.Top)    * Scale) - FScrollY;
  Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;

FPageTop ist die Seitenhöhe in Punkten von FPDF_GetPageHeight, und FPageLeft ist bei den meisten Dokumenten null, stammt aber aus der Crop-Box, wenn die Seite eine definiert. Lesen Sie also beides aus FPDF_GetPageBoundingBox, anstatt Annahmen zu treffen. Die Y-Umkehrung ist die Stelle, an der handgeschriebene Versionen kaputtgehen: Die Oberkante des Geräte-Rechtecks ergibt sich aus der Oberkante der PDF-Box, gemessen von der Oberkante der Seite nach unten. Wenn Sie dies umkehren, wird jede Hervorhebung gespiegelt in die falsche Hälfte der Seite gezeichnet

procedure TViewerForm.HighlightWordAt(CharIndex, CharCount: Integer);
var
  Old: TRect;
begin
  if CharCount <= 0 then Exit;
  Old := FHighlightRect;
  FHighlightRect := PdfToDevice(WordBox(CharIndex, CharCount));
  InvalidateRect(PageBox.Handle, @Old, False);             // erase the old word
  InvalidateRect(PageBox.Handle, @FHighlightRect, False);  // draw the new one
end;

procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
  Blend: TBlendFunction;
begin
  PageBox.Canvas.Draw(0, 0, FPageBitmap);      // rendered page first, always
  if FHighlightRect.IsEmpty then Exit;

  Blend.BlendOp := AC_SRC_OVER;
  Blend.BlendFlags := 0;
  Blend.SourceConstantAlpha := 96;             // about 38 percent opacity
  Blend.AlphaFormat := 0;                      // constant alpha, no per-pixel data
  Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
    FHighlightRect.Left, FHighlightRect.Top,
    FHighlightRect.Width, FHighlightRect.Height,
    FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;

Der Zeichen-Handler zeichnet zuerst die Seiten-Bitmap und danach die Hervorhebung, jedes Mal, sodass sich das Overlay niemals selbst löschen muss; das Ungültigmachen des alten und neuen Rechtecks hält den Bereich für das Neuzeichnen selbst bei schnellen Sprechgeschwindigkeiten klein. FHighlightBrush ist eine Eins-zu-eins-TBitmap, die beim Start einmal mit der Hervorhebungsfarbe gefüllt wird – FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF für ein Bernsteinweiß –, die AlphaBlend über das Zielrechteck streckt, sodass nichts pro Frame zugewiesen wird. Und SourceConstantAlpha auf 96 hält das Wort durch die Tönung hindurch lesbar. Testen Sie die Farbe in invertierten und kontrastreichen Anzeigemodi; ein Overlay, das ein sehbehinderter Benutzer nicht sehen kann, existiert für genau die Person nicht, für die es gebaut wurde

Die Lesereihenfolge ist der Teil, den die Text-API nicht lösen wird

FPDFText_GetText liefert Zeichen in einer aus dem Inhaltsstream abgeleiteten Reihenfolge mit etwas räumlicher Bereinigung zurück, und für einen einspaltigen Bericht ist diese Reihenfolge in Ordnung. Sie ist nicht verpflichtet, anderswo richtig zu sein. Ein zweispaltiger Newsletter kann direkt über beide Spalten hinweg gelesen werden, eine Seitenleiste kann einen Satz mitten in einem Teilsatz unterbrechen, und eine Fußzeile kann in der Mitte der Seite ankommen. Die Information, die dies behebt – der logische Strukturbaum von ISO 32000-1 §14.8, den getaggte PDFs mitführen und den PDF/UA vorschreibt –, wird von den rohen Seiten-Textaufrufen überhaupt nicht konsultiert. Wenn Sie eine strukturabhängige Reihenfolge mit einem expliziten Signal ihrer Herkunft benötigen, ist das ein gelöstes Problem ein Regal weiter oben: Die Lese-API des PDFium Components gibt Inhalte mit einem Source-Feld von rosStructure oder rosHeuristic zurück, und der Artikel zum barrierefreien PDF-Reader führt Sie dort hindurch. Auf der Ebene der rohen API besteht die vertretbare Position darin, die Extraktionsreihenfolge als Schätzung zu betrachten, dies in der Benutzeroberfläche zu sagen und ein mehrspaltiges Dokument sowie einen reinen Bild-Scan im Regression-Set zu behalten, damit beide Fehlermodi sichtbar bleiben

Der Viewer selbst muss über die Tastatur bedienbar sein

Die Sprachausgabe entbindet den Viewer nicht vom Tastaturzugriff; die Leute, die Vorlesen am ehesten nutzen, werden am seltensten zur Maus greifen. Geben Sie dem Seiten-Panel TabStop := True und ein sichtbares Fokus-Rechteck und behandeln Sie dann drei Tasten: Die Leertaste schaltet zwischen FVoice.Pause und FVoice.Resume um, und Links sowie Rechts springen durch FVoice.Skip('Sentence', 1) mit einer negativen Anzahl, um zurückzugehen. SAPI's Skip versteht nur die Satzgranularität. Das Überspringen auf Wortebene bedeutet also, die Wiedergabe mit SVSFPurgeBeforeSpeak zu löschen und ab dem Offset des zuletzt getrackten Wortes neu zu sprechen – billig, da der Hervorhebungs-Code genau diesen Offset bereits speichert. Behalten Sie für jede Transportsteuerung einen echten TButton mit einer Beschriftung (Caption) bei, damit Screenreader sie ankündigen

Das ist die gesamte Pipeline, alles davon gegen die rohe PDFium-Text-API: ein Sprach-Thread, der COM und die Stimme für die Lebensdauer der App besitzt, Grenzereignisse, die als Zeichen-Offsets an die UI gemarshallt werden, und zeichenweise Boxen im Seitenraum, die auf dem Bildschirm in ein überblendetes Rechteck verwandelt werden. Wenn Sie die Geometrie und das Tracking lieber nicht selbst besitzen möchten, bietet das PDFium Component Boxen pro Wort, den Tracking-Cursor, automatisches Scrollen (Follow) und Lese-Einheiten auf Satzebene als Komponenten-Eigenschaften an, und seine Vorlese-Demo ist die Pipeline dieses Artikels reduziert auf eine Handvoll Aufrufe