Tekninen artikkeli

Saavutettavien PDF-katseluohjelmien rakentaminen tekstistä puheeksi -ominaisuudella Delphissä

Ää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

Delphi ääneen lukema -PDF-katselin, jossa on omistettu SAPI teksti puheeksi -säie, joka vaihtaa sanarajasiirtymiä käyttöliittymäsäikeen kanssa
Puhesäie omistaa COM:n ja SpVoice:n sovelluksen eliniän ajan ja luovuttaa merkkioffsetit UI-säikeelle, joka yksin maalaa synkronoidun korostuksen
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;   // lukee FText-muuttujan FLock-sulun alla
    procedure VoiceWord(ASender: TObject; StreamNumber: Integer;
      StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
  protected
    procedure Execute; override;
    procedure TerminatedSet; override;
  public
    procedure SpeakPage(const AText: string);   // turvallinen käyttöliittymäsäikeestä käsin
  end;

procedure TSpeechThread.Execute;
var
  Msg: TMsg;
begin
  CoInitialize(nil);                       // kerran, kun säie käynnistyy
  try
    FVoice := TSpVoice.Create(nil);
    try
      FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
      FVoice.OnWord := VoiceWord;
      // Pakota tämän säikeen viestijonon luominen ennen kuin kukaan lähettää siihen
      PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
      while GetMessage(Msg, 0, 0, 0) do    // päättyy, kun WM_QUIT saapuu
        if Msg.message = WM_SPEAK_PAGE then
          FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
        else
          DispatchMessage(Msg);            // toimittaa SAPI-tapahtumakutsut
    finally
      FVoice.Free;
    end;
  finally
    CoUninitialize;                        // kerran, kun säie poistuu
  end;
end;

procedure TSpeechThread.TerminatedSet;
begin
  inherited;
  PostThreadMessage(ThreadID, WM_QUIT, 0, 0);   // vapauta 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

SAPI-sanarajasiirtymät indeksoivat täsmällisen tekstipuskurin, jonka Delphi teksti puheeksi -PDF-katselin puhuu
Rajatapahtumat indeksoivat täsmälleen Speak:lle annetun puskurin, joten typistaminen, lyhenteiden laajentaminen tai etuliitteiden lisääminen siirtää jokaista myöhempää korostusta saman verran
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
  // Suoritetaan puhesäikeessä; luovuta siirtymät käyttöliittymälle lohkomatta
  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-pisteitä, origo vasemmassa alakulmassa
  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
    // Parametrijärjestys on vasen, oikea, ala, ylä - ei Windows-järjestys
    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-pistettä tuumassa; FZoom on katseluohjelman skaalaustekijä
  Scale := FZoom * FScreenDpi / 72.0;
  Result.Left   := Round((W.Left  - FPageLeft) * Scale) - FScrollX;
  Result.Right  := Round((W.Right - FPageLeft) * Scale) - FScrollX;
  // PDF:n Y kasvaa ylöspäin alareunasta; laitteen Y kasvaa alaspäin
  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

FPDFText_GetCharBox-PDF-pistekoordinaattien kuvaaminen laitepikseleiksi teksti puheeksi -korostuksia varten Delphi-katselimessa
GetCharBox palauttaa vasemman, oikean, alareunan ja yläreunan PDF-pisteinä vasemman alakulman origosta; siirtämällä, skaalaamalla zoom kertaa DPI jaettuna 72:lla ja kääntämällä Y:n suorakulmio laskeutuu näytölle
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);             // pyyhi vanha sana
  InvalidateRect(PageBox.Handle, @FHighlightRect, False);  // piirrä uusi
end;

procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
  Blend: TBlendFunction;
begin
  PageBox.Canvas.Draw(0, 0, FPageBitmap);      // renderöi sivu ensin, aina
  if FHighlightRect.IsEmpty then Exit;

  Blend.BlendOp := AC_SRC_OVER;
  Blend.BlendFlags := 0;
  Blend.SourceConstantAlpha := 96;             // noin 38 prosentin läpikuultavuus
  Blend.AlphaFormat := 0;                      // vakio alfa, ei pikselikohtaista dataa
  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