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