Egy felolvasó gomb bemutatója elkészül egy délután alatt, majd felemészt egy hetet. A délutáni verzió kinyeri az oldal szövegét, átadja a SAPI-nak, és hangot kap. A hét arra megy el, ami a funkciót használhatóvá teszi: a hang nem fagyaszthatja le az ablakot, a kimondott szónak a hanggal szinkronban fel kell villannia az oldalon, és a Szóköz billentyűnek szüneteltetnie kell az egészet. Ez a cikk ezt a folyamatot építi fel Delphiben a nyers PDFium szöveges API és a Windows Speech API használatával, működő kóddal ahhoz a három részhez, amelyet a gyors verzió kihagy: a COM életciklus egyszeri futtatása a megnyilatkozásonkénti helyett, a valódi szóhatár-események, és a koordináta-matematika, amely egy PDF-térbeli szódobozt festhető téglalappá alakít
A szabályozási kontextus egy mondatban összefoglalható: a szinkronizált felolvasás a WCAG 2.1 által a dokumentumszoftverektől elvártak megjelenítő oldali fele, az ISO 14289-1 (PDF/UA) pedig a címkézett fájlok azon felét definiálja, amellyel a legjobban működik. Ha a PDFium Component-re építkezik, akkor lehet, hogy egyáltalán nincs szüksége erre a folyamatra: a megjelenítő beépített követőkurzorral érkezik, amely egyetlen hívással leképezi a karaktereltolást egy megfestett szókiemelésre, amiről a szavankénti TTS kiemelésről szóló cikkben olvashat bővebben. A következők arra az esetre vonatkoznak, amikor a teljes megjelenítő alkalmazás az Ön kezében van, és magát a folyamatot szeretné megvalósítani
Egy szál renderel, egy szál beszél
Az architektúra két szálat és egy szerződést takar. A felhasználói felület (UI) szála rendereli az oldal bittérképét, birtokolja a nagyítási és görgetési állapotot, és lefesti a kiemelés rátétjét. Egy dedikált beszédszál birtokolja a SAPI hangot, és semmi más nem ér hozzá. A szerződés vékony: a beszédszál karaktereltolásokként jelenti a folyamatot, a UI szál pedig az eltolásokat téglalapokká alakítja
A legtöbb SAPI példa minden egyes megnyilatkozást CoInitialize és CoUninitialize hívásokba csomagol, és egy megjelenítő azonnal megmutatja, miért helytelen ez. A Speak az SVSFlagsAsync jelzővel amint a szöveg sorba kerül, visszatér, így az ugyanazon eljárás finally blokkjában lévő CoUninitialize lefut, miközben a hang még beszél, lerombolva az azt birtokló COM apartmant. Az időzítéstől függően csendet, egy csonka megnyilatkozást, vagy percekkel később egy hozzáférési sértést kapunk. A helyes életciklus unalmas: egyszeri CoInitialize, amikor a beszédszál elindul, a hang létrehozása ezen az apartmanon belül, és egy CoUninitialize, amikor a szál kilép, miután a hang fel lett szabadítva. Soha nem megnyilatkozásonként
A hangnak szüksége van egy üzenetpumpára is, ami eldönti, hogy hol élhet. A SpVoice automatizációs objektum az őt létrehozó szál üzenetsorán keresztül kézbesíti az eseményeit. Ha a UI szálon hozza létre, az események megérkeznek, mert a VCL pumpálja az üzeneteket, de ekkor minden lassú festés késlelteti a szóhatárokat; ha egy pumpa nélküli munkaszálon hozza létre, az események soha nem érkeznek meg. Egy dedikált szál a saját GetMessage ciklusával laposan tartja a határkésleltetést, függetlenül attól, hogy a UI mit csinál
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;
A TerminatedSet egy WM_QUIT üzenetet küld, így a pumpa feloldódik a megjelenítő leállításakor. A UI szálból hívott SpeakPage a szöveget egy zárolással védett mezőben tárolja, és elküldi a WM_SPEAK_PAGE üzenetet, mert egy módszer közvetlen hívása az FVoice-on egy másik szálból egy apartmanokon átívelő COM hívás lenne egy nem marshallált interfészen. A ciklus előtti egysoros PeekMessage kikényszeríti a Windowstól, hogy létrehozza a szál üzenetsorát, lezárva a kezdeti versenyhelyzetet, ahol a UI szálról érkező korai küldés elbukna
A szóhatárok karaktereltolásokként érkeznek
Importálja a Microsoft Speech Object Library-t egyszer az IDE típuskönyvtár-importálóján keresztül, és megkapja a SpeechLib_TLB-t a TSpVoice burkolóval és annak típusos eseményeivel. Két beállítás számít. Az EventInterests-t le kell szűkíteni azokra az eseményekre, amelyeket ténylegesen fogyaszt, mivel minden bekapcsolva hagyott érdekeltség szálakon átívelő eseményforgalmat jelent az oldal minden szavára; az SVEWordBoundary vezérli a kiemelést, az SVEEndInputStream pedig jelzi a megnyilatkozás végét. Az OnWord kezelő pedig egy CharacterPosition-t és egy hosszt kap, amely pontosan abba a karakterláncba indexel, amit a Speak-nek adott át – ez egy eltolás a beszédbufferben, nem pedig bármi másban
Ez az utolsó kikötés az az invariáns, amelyen a funkció múlik: az eltolások csak azzal a karakterlánccal szemben értelmezhetők, amelyet a hang éppen felolvas, tehát pontosan azt a szöveget mondassa ki, amelyet kinyert, karakterről karakterre. Vágja le a szóközöket, vonja össze a sortöréseket, vagy bontson ki egy rövidítést a szebb kiejtés érdekében, és az első szerkesztés után minden kiemelés egy szóval elcsúszik. Ha a UI-nak beszélt anyagot kell beinjektálnia – oldalbemondásokat, címsorelőtagokat –, rögzítse minden beszúrás pozícióját és hosszát, és vonja ki a felhalmozott eltolást minden eltolásból, mielőtt leképezné azt
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;
A TThread.Queue a megfelelő marshaller itt, nem a Synchronize: a kezelő nem parkoltathatja a beszédszálat, amíg a UI újrafest, és ha a határesemények gyorsabban érkeznek, mint ahogy a képernyő rajzol, egy elavult kiemelésfrissítés ártalmatlan, mert a következő úgyis felülírja. Kösse be az OnEndStream-et ugyanezen a módon a kiemelés törléséhez, és folyamatos olvasási módban a következő oldal szövegének betöltéséhez és a következő megnyilatkozás elküldéséhez
Karaktereltolásoktól a képernyőn lévő pixelekig
A PDFium karakterenként jelenti a geometriát. Az FPDFText_GetCharBox négy double értéket tölt ki egy olyan sorrendben, amely több csendes hibát okozott, mint bármi más a szöveges API-ban – bal, jobb, alsó, felső, és nem a Windows-féle bal, felső, jobb, alsó –, és ezeket PDF-térben (oldaltérben) adja meg: PDF-pontok, 72 pont hüvelykenként, az origó a bal alsó sarokban, az Y tengely felfelé nő. Egy szó doboza a karaktereinek dobozaiból képzett unió, az eszközpixelekre történő transzformáció pedig három lépésből áll: eltolás az oldal origójával, skálázás a nagyítás és a képernyő DPI-jének 72-vel osztott szorzatával, valamint az Y tengely megfordítása
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;
Az FPageTop az oldal magassága pontokban az FPDF_GetPageHeight alapján, az FPageLeft pedig a legtöbb dokumentumnál nulla, de a vágódobozból (crop box) származik, ha az oldal definiál ilyet, ezért feltételezés helyett olvassa ki mindkettőt az FPDF_GetPageBoundingBox használatával. Az Y tengely megfordítása az a pont, ahol a saját kezűleg írt verziók elromlanak: az eszköztéglalap teteje a PDF doboz tetejéből adódik, az oldal tetejétől lefelé mérve. Ha ezt visszafelé csinálja, minden kiemelés tükrözve az oldal rossz felére lesz festve
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;
A festéskezelő minden alkalommal először az oldal bittérképét rajzolja meg, és utána a kiemelést, így a rátétnek soha nem kell törölnie önmagát; a régi és az új téglalapok érvénytelenítése kicsiben tartja az újrafestési régiót még gyors beszédsebesség mellett is. Az FHighlightBrush egy 1x1-es TBitmap, amelyet induláskor egyszer feltöltünk a kiemelés színével – FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF egy borostyánsárgához –, amelyet az AlphaBlend nyújt rá a céltéglalapra, így semmi sincs képkockánként lefoglalva, és a 96-ra állított SourceConstantAlpha olvashatóan tartja a szót az árnyalaton keresztül. Tesztelje a színt invertált és nagy kontrasztú megjelenítési módokban; egy rátét, amelyet egy gyengénlátó felhasználó nem lát, gyakorlatilag nem létezik pont annak a személynek a számára, akinek készült
Az olvasási sorrend az a rész, amit a szöveges API nem fog megoldani
Az FPDFText_GetText olyan sorrendben adja vissza a karaktereket, amely a tartalomfolyamból származik némi térbeli tisztítással, és egy egyoszlopos jelentés esetén ez a sorrend megfelelő. Nincs rá semmilyen kötelezettsége, hogy bárhol máshol helyes legyen. Egy kétoszlopos hírlevelet olvashat egyenesen mindkét oszlopon keresztül, egy oldalsáv a tagmondat közepén megszakíthat egy mondatot, egy lábléc pedig az oldal közepén is megérkezhet. Az információt, amely ezt kijavítja – az ISO 32000-1 14.8. szakaszának logikai struktúrafája, amelyet a címkézett PDF-ek hordoznak és a PDF/UA kötelezővé tesz –, a nyers szövegoldal-hívások egyáltalán nem veszik figyelembe. Ha struktúratudatos sorrendre van szüksége az eredet egyértelmű jelzésével, az egy szinttel feljebb már megoldott probléma: a PDFium Component olvasási API-ja a tartalmat egy rosStructure vagy rosHeuristic Source mezővel adja vissza, az akadálymentes PDF-olvasóról szóló cikk pedig végigvezet ezen. Nyers API szinten a védhető álláspont az, hogy a kinyerési sorrendet becslésként kezeljük, ezt mondjuk is meg a UI-n, és tartsunk egy többoszlopos dokumentumot és egy csak képeket tartalmazó szkennelt állományt a regressziós halmazban, hogy mindkét hibamód látható maradjon
Magának a megjelenítőnek is billentyűzetről kezelhetőnek kell lennie
A beszédkimenet nem mentesíti a megjelenítőt a billentyűzetes hozzáférés alól; akik a legvalószínűbben használják a felolvasást, azok nyúlnak a legkevésbé valószínűen az egérhez. Adjon az oldallapnak TabStop := True értéket és egy látható fókusztéglalapot, majd kezeljen három billentyűt: a Szóköz kapcsolja az FVoice.Pause és FVoice.Resume állapotokat, a Balra és Jobbra nyilak pedig az FVoice.Skip('Sentence', 1) metóduson keresztül ugranak (negatív számmal a visszafelé haladáshoz). A SAPI Skip metódusa csak a mondatszintű granularitást érti, így a szószintű ugrás a lejátszás SVSFPurgeBeforeSpeak segítségével történő törlését és az utoljára követett szó eltolásától való újraolvasást jelenti – ez olcsó, mivel a kiemelő kód már amúgy is pontosan ezt az eltolást tárolja. Tartson minden vezérlőgombot igazi, felirattal rendelkező TButton-ként, hogy a képernyőolvasók be tudják azokat mondani
Ez az egész folyamat, mindez a nyers PDFium szöveges API ellenében: egy beszédszál, amely birtokolja a COM-ot és a hangot az alkalmazás teljes élettartama alatt, a karaktereltolásokként a UI-hoz marshallált határesemények, és a képernyőn egyetlen kevert téglalappá alakított karakterenkénti oldaltér-dobozok. Ha inkább nem szeretné maga kezelni a geometriát és a követést, a PDFium Component szavankénti dobozokat, követőkurzort, automatikus görgetéskövetést és mondatszintű olvasási egységeket szállít komponens-tulajdonságként, és a felolvasó demója ennek a cikknek a folyamata egy maroknyi hívásra redukálva