Techninis straipsnis

Prieinamos PDF peržiūros programos su teksto vertimu į kalbą kūrimas „Delphi“ aplinkoje

Skaitymo balsu mygtuko demonstracija sukuriama per popietę, bet po to atima savaitę darbo. Popietinė versija išgauna puslapio tekstą, perduoda jį SAPI ir gauna garsą. Savaitė skiriama tam, kad funkcija taptų naudinga: balsas neturi užšaldyti lango, ištartas žodis turi būti paryškinamas puslapyje sinchroniškai su garsu, o tarpo klavišas turi visa tai pristabdyti. Šiame straipsnyje kuriamas šis procesas „Delphi“ aplinkoje, naudojant grynąją „PDFium“ teksto API ir „Windows“ kalbos API (Speech API), pateikiant veikiantį kodą trims dalims, kurias greitoji versija praleidžia: COM gyvavimo ciklui, atliekamam vieną kartą, o ne kiekvienai frazei, tikriems žodžių ribų įvykiams ir koordinačių matematikai, kuri paverčia PDF erdvės žodžio langelį į stačiakampį, kurį galite nupiešti

Reguliavimo kontekstas telpa į vieną sakinį: sinchronizuotas skaitymas balsu yra peržiūros programos pusės dalis to, ko WCAG 2.1 reikalauja iš dokumentų programinės įrangos, o ISO 14289-1 (PDF/UA) apibrėžia žymėtų failų (tagged-file) pusę, su kuria tai geriausiai veikia. Jei kuriate naudodami „PDFium Component“, šio proceso jums gali išvis neprireikti: peržiūros programa turi įmontuotą sekimo žymeklį, kuris vienu iškvietimu susieja simbolio poslinkį su nupieštu žodžio paryškinimu, kas aprašyta straipsnyje apie žodžių po žodžio TTS paryškinimą. Toliau pateikta informacija skirta tiems atvejams, kai jūs valdote visą peržiūros programą ir patys norite sukurti šį procesą

Viena gija atvaizduoja, kita gija kalba

Architektūrą sudaro dvi gijos ir vienas kontraktas. Vartotojo sąsajos (UI) gija atvaizduoja puslapio rastrinį paveikslėlį, valdo mastelio ir slinkties būseną bei piešia paryškinimo perdangą. Dedikuota kalbos gija valdo SAPI balsą ir niekas kitas jos neliečia. Kontraktas yra paprastas: kalbos gija praneša apie progresą kaip simbolių poslinkius, o UI gija poslinkius paverčia stačiakampiais

Daugumoje SAPI pavyzdžių kiekviena frazė apgaubiama CoInitialize ir CoUninitialize, o peržiūros programa iškart parodo, kodėl tai yra klaidinga. Speak su SVSFlagsAsync grąžina rezultatą iškart, kai tekstas įtraukiamas į eilę, todėl CoUninitialize toje pačioje procedūroje, finally bloke, įvykdomas kol balsas dar kalba, taip sugriaunant jam priklausantį COM apartamentą. Priklausomai nuo laiko, galite gauti tylą, nukirstą frazę arba prieigos pažeidimą (access violation) po kelių minučių. Teisingas gyvavimo ciklas yra nuobodus: CoInitialize iškviečiamas vieną kartą, kai kalbos gija paleidžiama, tame apartamente sukuriamas balsas, o CoUninitialize iškviečiamas vieną kartą, kai gija baigia darbą, po to, kai balsas buvo atlaisvintas. Niekada ne kiekvienai frazei

Balsui taip pat reikia pranešimų siurblio (message pump), kuris nulemia, kur jis gali gyvuoti. SpVoice automatizavimo objektas pristato savo įvykius per gijos, kuri jį sukūrė, pranešimų eilę. Sukūrus jį UI gijoje, įvykiai pasiekia tikslą, nes VCL apdoroja pranešimus, tačiau kiekvienas lėtas nupiešimas tuomet uždelsia jūsų žodžių ribas; sukūrus jį darbinėje gijoje be pranešimų siurblio, įvykiai išvis niekada nepasiekia tikslo. Dedikuota gija su savo nuosavu GetMessage ciklu išlaiko ribų delsą tolygią, nesvarbu, ką veikia UI

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 išsiunčia WM_QUIT, kad siurblys (pump) atsiblokuotų, kai peržiūros programa išjungiama. SpeakPage, iškviesta iš UI gijos, išsaugo tekstą užrakto (lock) apsaugotame lauke ir išsiunčia WM_SPEAK_PAGE, nes metodo iškvietimas objektui FVoice tiesiogiai iš kitos gijos būtų tarptinklinis COM iškvietimas neperduotai sąsajai. Vienos eilutės PeekMessage prieš ciklą priverčia „Windows“ sukurti gijos pranešimų eilę, uždarydamas paleidimo lenktynes, kai ankstyvas siuntimas iš UI gijos nepavyktų

Žodžių ribos gaunamos kaip simbolių poslinkiai

Vieną kartą importuokite „Microsoft Speech Object Library“ per IDE tipų bibliotekos importuotoją ir gausite SpeechLib_TLB su TSpVoice apvalkalu ir jo tipizuotais įvykiais. Svarbūs du nustatymai. EventInterests turėtų būti susiaurintas tik iki tų įvykių, kuriuos faktiškai naudojate, nes kiekvienas paliktas įjungtas interesas reiškia tarpusavio gijų įvykių srautą kiekvienam kiekvieno puslapio žodžiui; SVEWordBoundary valdo paryškinimą, o SVEEndInputStream praneša, kad frazė baigta. O OnWord apdorojimo funkcija gauna CharacterPosition ir ilgį, kurie indeksuoja tikslią eilutę, kurią perdavėte į Speak — poslinkį kalbos buferyje, o ne kažkur kitur

Ši paskutinė sąlyga yra invariantas, kuriuo paremta ši funkcija: poslinkiai turi prasmę tik tos eilutės, kurią skaito balsas, atžvilgiu, todėl tarkite lygiai tą tekstą, kurį išgavote, simbolis po simbolio. Pašalinus tarpus, sujungus eilučių lūžius ar išplėtus trumpinį dėl gražesnio tarimo, kiekvienas paryškinimas po pirmojo pakeitimo atsidurs vienu žodžiu toliau. Jei UI turi įterpti kalbamąją medžiagą — puslapio pranešimus, antraščių priešdėlius — užfiksuokite kiekvieno įterpimo poziciją ir ilgį, ir atimkite sukauptą poslinkį iš kiekvieno poslinkio prieš atliekant susiejimą

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 čia yra tinkamas maršalizavimo būdas, o ne Synchronize: apdorojimo funkcija neturi sustabdyti kalbos gijos, kol UI persipiešia, o jei ribų įvykiai gaunami greičiau nei ekranas nupiešia, pasenęs paryškinimo atnaujinimas yra nekenksmingas, nes kitas jį perrašo. Susiekite OnEndStream tokiu pačiu būdu, kad išvalytumėte paryškinimą, o nepertraukiamo skaitymo režime — kad įkeltumėte kito puslapio tekstą ir išsiųstumėte kitą frazę

Nuo simbolių poslinkių iki pikselių ekrane

„PDFium“ praneša geometriją kiekvienam simboliui. FPDFText_GetCharBox užpildo keturis „double“ tipo kintamuosius tvarka, kuri sukėlė daugiau nepastebimų klaidų nei bet kas kitas teksto API — kairė (left), dešinė (right), apačia (bottom), viršus (top), o ne „Windows“ įprasta kairė, viršus, dešinė, apačia — ir praneša juos puslapio erdvėje: PDF taškais, 72 viename colyje, pradinė koordinatė yra apatiniame kairiajame kampe, o Y ašis auga aukštyn. Žodžio langelis yra jo simbolių langelių sąjunga, o transformacija į įrenginio pikselius susideda iš trijų žingsnių: poslinkis pagal puslapio pradinę koordinatę, mastelio keitimas padauginant iš priartinimo kart ekrano DPI padalinto iš 72, ir Y ašies apvertimas

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 yra puslapio aukštis taškais iš FPDF_GetPageHeight, o FPageLeft daugelyje dokumentų yra nulis, bet gaunamas iš apkarpymo langelio (crop box), kai puslapis jį apibrėžia, todėl nuskaitykite abu iš FPDF_GetPageBoundingBox, užuot darę prielaidas. Y ašies apvertimas yra ta vieta, kur rankiniu būdu rašytos versijos lūžta: įrenginio stačiakampio viršus gaunamas iš PDF langelio viršaus, matuojant žemyn nuo puslapio viršaus. Supainiokite tai, ir kiekvienas paryškinimas bus nupieštas veidrodiniu principu netinkamoje puslapio pusėje

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;

Piešimo apdorojimo funkcija pirmiausia nupiešia puslapio rastrinį paveikslėlį, o po jo paryškinimą — kiekvieną kartą, todėl perdangai niekada nereikia pačiai savęs ištrinti; senų ir naujų stačiakampių anuliavimas išlaiko persipiešimo sritį mažą net esant dideliam kalbos greičiui. FHighlightBrush yra vienas ant vieno pikselio dydžio TBitmap, užpildytas vieną kartą paleidžiant programą paryškinimo spalva — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF gintarinei spalvai — kurį AlphaBlend ištempia virš tikslinio stačiakampio, todėl kiekvienam kadrui nieko nepriskiriama, o SourceConstantAlpha reikšmė ties 96 išlaiko žodį įskaitomą per atspalvį. Išbandykite spalvą atvirkštinio spalvų ir didelio kontrasto rodymo režimuose; perdanga, kurios silpnaregis vartotojas negali matyti, neegzistuoja būtent tam asmeniui, kuriam ji buvo sukurta

Skaitymo eiliškumas yra ta dalis, kurios teksto API neišspręs

FPDFText_GetText grąžina simbolius tvarka, išvesta iš turinio srauto su tam tikru erdviniu išvalymu, ir vieno stulpelio ataskaitai tokia tvarka yra tinkama. Ji neprivalo būti teisinga niekur kitur. Dviejų stulpelių naujienlaiškis gali būti skaitomas tiesiai per abu stulpelius, šoninė juosta gali pertraukti sakinį viduryje frazės, o poraštė gali atsirasti puslapio viduryje. Informacija, kuri tai ištaiso — ISO 32000-1 §14.8 loginės struktūros medis, kurį turi žymėti (tagged) PDF failai ir kurį PDF/UA padaro privalomu — visiškai nėra tikrinama naudojant grynus teksto puslapio iškvietimus. Jei jums reikia struktūrą atpažįstančio eiliškumo su aiškiu jo kilmės signalu, tai yra jau išspręsta problema vienu lygmeniu aukščiau: „PDFium Component“ skaitymo API grąžina turinį su Source lauku rosStructure arba rosHeuristic, o straipsnis apie prieinamą PDF skaitytuvą tai detaliai aprašo. Grynosios API lygmeniu protingiausia pozicija yra vertinti išgavimo eiliškumą kaip apytikslį įvertinimą, tai nurodyti UI ir išlaikyti vieną kelių stulpelių dokumentą ir vieną tik iš paveikslėlių sudarytą nuskenuotą dokumentą regresijos testų rinkinyje, kad abu klaidų režimai išliktų matomi

Pati peržiūros programa turi būti valdoma klaviatūra

Kalbos išvestis neatleidžia peržiūros programos nuo prieinamumo klaviatūra; žmonės, kurie labiausiai linkę naudotis skaitymu balsu, yra tie, kurie mažiausiai linkę naudoti pelę. Nustatykite puslapio skydeliui TabStop := True ir matomą fokuso stačiakampį, tada apdorokite tris klavišus: tarpas (Space) perjungia FVoice.Pause ir FVoice.Resume, o kairysis ir dešinysis (Left/Right) klavišai peršoka naudojant FVoice.Skip('Sentence', 1), su neigiamu skaičiumi grįžimui atgal. SAPI Skip supranta tik sakinio lygio granuliarumą, todėl peršokimas žodžių lygmeniu reiškia atkūrimo išvalymą naudojant SVSFPurgeBeforeSpeak ir pakartotinį kalbėjimą nuo paskutinio jūsų sekto žodžio poslinkio — tai nebrangu, nes paryškinimo kodas jau saugo būtent šį poslinkį. Išlaikykite kiekvieną atkūrimo valdiklį kaip tikrą TButton su pavadinimu, kad ekrano skaitytuvai jį praneštų

Tai visas procesas, ir visa tai atliekama naudojant grynąją „PDFium“ teksto API: kalbos gija, kuriai priklauso COM ir balsas visą programos gyvavimo laiką, ribų įvykiai, perduodami į UI kaip simbolių poslinkiai, ir kiekvieno simbolio puslapio erdvės langeliai, paverčiami vienu sumaišytos spalvos (blended) stačiakampiu ekrane. Jei verčiau nenorite patys rūpintis geometrija ir sekimu, „PDFium Component“ pateikia langelius kiekvienam žodžiui, sekimo žymeklį, automatinio slinkties sekimo funkciją ir sakinio lygio skaitymo vienetus kaip komponento savybes, o jo skaitymo balsu demonstracinė versija yra šio straipsnio procesas, sutrumpintas iki kelių iškvietimų