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

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