Ääneenlukupainikkeen demoversio syntyy iltapäivässä ja vie sitten viikon. Iltapäiväversio poimii sivun tekstin, välittää sen SAPI:lle ja tuottaa äänen. Viikko kuluu siihen, mikä tekee ominaisuudesta käyttökelpoisen: ääni ei saa jumittaa ikkunaa, puhutun sanan on korostuttava sivulla samanaikaisesti äänen kanssa ja välilyöntinäppäimen on pysäytettävä koko toiminto. Tämä artikkeli rakentaa kyseisen putken Delphissä käyttäen raakaa PDFiumin teksti-API:a ja Windowsin Speech API:a, sisältäen toimivan koodin kolmelle osalle, jotka pikaversio ohittaa: COM-elinkaari suoritetaan kerran eikä jokaista lausumaa kohden, todelliset sanarajojen tapahtumat ja koordinaattimatematiikka, joka muuttaa PDF-avaruuden sanalaatikon maalattavaksi suorakulmioksi
Sääntelykonteksti mahtuu yhteen lauseeseen: synkronoitu ääneen luku on katseluohjelman puoleinen puolisko siitä, mitä WCAG 2.1 vaatii asiakirjaohjelmistoilta, ja ISO 14289-1 (PDF/UA) määrittelee tagattujen tiedostojen puoliskon, jota vasten se toimii parhaiten. Jos rakennat PDFium Componentin päälle, et ehkä tarvitse tätä putkea lainkaan: katseluohjelma sisältää sisäänrakennetun seurantakursorin, joka yhdistää merkkisiirtymän maalattuun sanan korostukseen yhdellä kutsulla, mitä käsitellään sana sanalta -TTS-korostusartikkelissa. Seuraava koskee tilannetta, jossa omistat koko katseluohjelman sovelluksen ja haluat itse putken
Yksi säie renderöi, toinen puhuu
Arkkitehtuuri koostuu kahdesta säikeestä ja yhdestä sopimuksesta. Käyttöliittymäsäie renderöi sivun bittikartan, omistaa zoomaus- ja vieritystilan ja maalaa korostuksen peittokuvan. Erillinen puhesäie omistaa SAPI-äänen, eikä mikään muu koske siihen. Sopimus on ohut: puhesäie raportoi edistymisen merkkisiirtyminä, ja käyttöliittymäsäie muuttaa siirtymät suorakulmioiksi
Useimmat SAPI-esimerkit käärivät jokaisen lausuman CoInitialize- ja CoUninitialize-kutsuihin, ja katseluohjelma osoittaa välittömästi, miksi se on väärin. Speak parametreilla SVSFlagsAsync palautuu heti, kun teksti on jonossa, joten samassa proseduurissa oleva CoUninitialize finally-lohkossa suoritetaan äänen vielä puhuessa, purkaen sen omistavan COM-huoneiston. Ajoituksesta riippuen saat hiljaisuuden, katkaistun lausuman tai käyttöoikeusrikkomuksen (access violation) minuutteja myöhemmin. Oikea elinkaari on tylsä: CoInitialize kerran puhesäikeen käynnistyessä, äänen luominen kyseisen huoneiston sisällä ja CoUninitialize kerran säikeen poistuessa, kun ääni on vapautettu. Ei koskaan jokaista lausumaa kohden
Ääni tarvitsee myös viestipumpun (message pump), mikä ratkaisee sen olinpaikan. SpVoice-automaatio-objekti toimittaa tapahtumansa sen luoneen säikeen viestijonon kautta. Jos luot sen käyttöliittymäsäikeessä, tapahtumat saapuvat, koska VCL pumppaa viestejä, mutta jokainen hidas piirto viivästyttää sanarajojasi; jos luot sen työntekijäsäikeessä ilman pumppua, tapahtumat eivät saavu koskaan. Erillinen säie, jolla on oma GetMessage-silmukkansa, pitää rajan viiveen tasaisena riippumatta siitä, mitä käyttöliittymä tekee
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 lähettää WM_QUIT-viestin, jotta pumppu vapautuu katseluohjelman sammuessa. Käyttöliittymäsäikeestä kutsuttu SpeakPage tallentaa tekstin lukituksella suojattuun kenttään ja lähettää WM_SPEAK_PAGE-viestin, koska FVoice-metodin kutsuminen suoraan toisesta säikeestä olisi huoneistojen välinen COM-kutsu purkamattomassa (unmarshaled) rajapinnassa. Yksirivinen PeekMessage ennen silmukkaa pakottaa Windowsin luomaan säikeen viestijonon, sulkien käynnistyskilpailun (startup race), jossa varhainen viesti käyttöliittymäsäikeestä epäonnistuisi
Sanarajat saapuvat merkkisiirtyminä
Tuo Microsoft Speech Object Library kerran IDE:n tyyppikirjaston tuojan kautta, jolloin saat SpeechLib_TLB:n, jossa on TSpVoice-kääre ja sen tyypitetyt tapahtumat. Kaksi asetusta merkitsee. EventInterests tulisi kaventaa tapahtumiin, joita todella käytät, koska jokainen päälle jätetty mielenkiinnon kohde on säikeiden välistä tapahtumaliikennettä jokaisen sivun jokaiselle sanalle; SVEWordBoundary ohjaa korostusta ja SVEEndInputStream kertoo lausuman päättyneen. OnWord-käsittelijä saa CharacterPosition-arvon ja pituuden, jotka indeksoituvat täsmälleen siihen merkkijonoon, jonka välitit Speak-kutsulle — siirtymä puhepuskuriin, ei mihinkään muuhun
Tämä viimeinen lause on invariantti, josta ominaisuus riippuu: siirtymillä on merkitystä vain sitä merkkijonoa vasten, jota ääni lukee, joten puhu täsmälleen poimimasi teksti merkki merkiltä. Leikkaa tyhjeet, yhdistä rivinvaihdot tai laajenna lyhenne paremman ääntämisen vuoksi, ja jokainen korostus ensimmäisen muokkauksen jälkeen osuu yhden sanan pieleen. Jos käyttöliittymän on injektoitava puhuttua materiaalia — sivu-ilmoituksia, otsikoiden etuliitteitä — tallenna jokaisen lisäyksen sijainti ja pituus, ja vähennä kertynyt siirtymä jokaisesta siirtymäarvosta ennen sen kartoittamista
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 on tässä oikea välittäjä (marshal), ei Synchronize: käsittelijä ei saa pysäköidä puhesäiettä käyttöliittymän piirtäessä uudelleen, ja jos rajatapahtumat saapuvat nopeammin kuin näyttö piirtää, vanhentunut korostuspäivitys on harmiton, koska seuraava korvaa sen. Yhdistä OnEndStream samalla tavalla korostuksen tyhjentämiseksi ja jatkuvassa lukutilassa lataamaan seuraavan sivun teksti ja lähettämään seuraava lausuma
Merkkisiirtymistä pikseleiksi näytölle
PDFium raportoi geometrian merkkiä kohden. FPDFText_GetCharBox täyttää neljä double-arvoa järjestyksessä, joka on aiheuttanut enemmän hiljaisia bugeja kuin mikään muu teksti-API:ssa — vasen, oikea, ala, ylä, ei Windowsin vasen, ylä, oikea, ala — ja se raportoi ne sivuavaruudessa: PDF-pisteinä, 72 tuumaa kohden, origo vasemmassa alakulmassa Y:n kasvaessa ylöspäin. Sanan laatikko on sen merkkien laatikoiden unioni, ja muunnos laitteen pikseleiksi on kolmivaiheinen: siirrä sivun origon mukaan, skaalaa zoomaus kertaa näytön DPI jaettuna 72:lla ja käännä Y-akseli
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 on sivun korkeus pisteinä, joka saadaan FPDF_GetPageHeight-kutsusta, ja FPageLeft on nolla useimmille asiakirjoille, mutta tulee rajauslaatikosta (crop box), kun sivu määrittelee sellaisen, joten lue molemmat FPDF_GetPageBoundingBox-kutsusta olettamisen sijaan. Y-käännös on se, missä käsintehdyt versiot rikkoutuvat: laitteen suorakulmion yläreuna tulee PDF-laatikon yläreunasta, joka on mitattu alaspäin sivun yläreunasta. Jos teet sen väärinpäin, jokainen korostus maalautuu peilikuvana sivun väärälle puoliskolle
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;
Piirtokäsittelijä piirtää aina sivun bittikartan ensin ja korostuksen sen jälkeen, joten peittokuvan ei koskaan tarvitse tyhjentää itseään; vanhan ja uuden suorakulmion mitätöinti pitää uudelleenpiirtoalueen pienenä jopa nopeilla puhenopeuksilla. FHighlightBrush on yksikertaa yksi -kokoinen TBitmap, joka on täytetty kerran käynnistyksessä korostusvärillä — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF meripihkan värille — jonka AlphaBlend venyttää kohdesuorakulmion yli, joten mitään ei varata kehystä kohden, ja SourceConstantAlpha arvossa 96 pitää sanan luettavana sävyn läpi. Testaa väriä käännetyissä ja suuren kontrastin näyttötiloissa; peittokuva, jota heikkonäköinen käyttäjä ei näe, on olematon juuri sille henkilölle, jolle se on rakennettu
Lukujärjestys on se osa, jota teksti-API ei ratkaise
FPDFText_GetText palauttaa merkit sisältövirrasta johdetussa järjestyksessä pienen spatiaalisen siivouksen kera, ja yksipalstaiselle raportille tämä järjestys on hyvä. Sillä ei ole velvollisuutta olla oikeassa missään muualla. Kaksipalstainen uutiskirje voi lukea suoraan molempien palstojen yli, sivupalkki voi keskeyttää lauseen kesken lausekkeen ja alatunniste voi saapua keskelle sivua. Tieto, joka korjaa tämän — ISO 32000-1 §14.8:n looginen rakennepuu, jota tagatut PDF-tiedostot kantavat ja josta PDF/UA tekee pakollisen — ei ole lainkaan mukana raaoissa teksti-sivukutsuissa. Jos tarvitset rakennetietoisen järjestyksen, jossa on selkeä signaali sen alkuperästä, se on ratkaistu ongelma yhtä hyllyä ylempänä: PDFium Componentin luku-API palauttaa sisällön Source-kentän kanssa, joka on rosStructure tai rosHeuristic, ja saavutettava PDF-lukija -artikkeli käy sen läpi. Raa'alla API-tasolla puolustettava kanta on käsitellä poimintajärjestystä arviona, kertoa siitä käyttöliittymässä ja pitää yksi monipalstainen asiakirja ja yksi vain kuvia sisältävä skannaus regressiojoukossa, jotta molemmat vikatilat pysyvät näkyvillä
Itse katseluohjelman on oltava näppäimistöllä käytettävä
Puhetuloste ei vapauta katseluohjelmaa näppäimistön käytöstä; ääneenlukua todennäköisimmin käyttävät henkilöt kurottavat vähiten todennäköisesti hiireen. Anna sivupaneelille TabStop := True ja näkyvä kohdistussuorakulmio (focus rectangle), ja käsittele sitten kolme näppäintä: Välilyönti vaihtaa FVoice.Pause- ja FVoice.Resume-tilojen välillä, ja vasen ja oikea hyppäävät FVoice.Skip('Sentence', 1)-komennon kautta negatiivisella määrällä taaksepäin mennessä. SAPI:n Skip ymmärtää vain lausetason rakeisuuden, joten sanan tasolla hyppääminen tarkoittaa toiston tyhjentämistä SVSFPurgeBeforeSpeak-komennolla ja uudelleen puhumista viimeksi seuratun sanan siirtymästä — halpaa, koska korostuskoodi tallentaa jo täsmälleen tuon siirtymän. Pidä jokainen toiston ohjaus oikeana TButton-painikkeena otsikolla, jotta ruudunlukijat ilmoittavat sen
Siinä on koko putki, kokonaisuudessaan raakaa PDFiumin teksti-API:a vasten: puhesäie, joka omistaa COM:in ja äänen sovelluksen eliniän ajan, rajatapahtumat, jotka välitetään käyttöliittymään merkkisiirtyminä, ja merkkikohtaiset sivuavaruuden laatikot, jotka on muutettu yhdeksi sekoitetuksi suorakulmioksi näytöllä. Jos et mieluummin omistaisi geometriaa ja seurantaa itse, PDFium Component toimittaa sanakohtaiset laatikot, seurantakursorin, automaattisen vierityksen seurannan ja lausetason lukuyksiköt komponentin ominaisuuksina, ja sen ääneenluku-demo on tämän artikkelin putki typistettynä kouralliseen kutsuja