Articol tehnic

Construirea de vizualizatoare PDF accesibile cu Text-to-Speech în Delphi

Un buton de citire cu voce tare poate fi demonstrat într-o după-amiază și apoi consumă o săptămână. Versiunea de după-amiază extrage textul paginii, îl predă către SAPI și obține sunet. Săptămâna se consumă pentru ceea ce face funcția utilizabilă: vocea nu trebuie să blocheze fereastra, cuvântul rostit trebuie să se lumineze pe pagină în timp cu sunetul, iar tasta Spațiu trebuie să pună totul pe pauză. Acest articol construiește acea conductă în Delphi folosind API-ul de text brut PDFium și Windows Speech API, cu cod funcțional pentru cele trei părți pe care versiunea rapidă le omite: ciclul de viață COM realizat o singură dată în loc de per enunț, evenimente reale de limită a cuvintelor și matematica coordonatelor care transformă o casetă de cuvânt din spațiul PDF într-un dreptunghi pe care îl poți desena

Contextul de reglementare se potrivește într-o singură propoziție: citirea cu voce tare sincronizată este jumătatea din partea vizualizatorului a ceea ce WCAG 2.1 cere de la software-ul pentru documente, iar ISO 14289-1 (PDF/UA) definește jumătatea de fișier etichetat cu care funcționează cel mai bine. Dacă dezvoltați folosind PDFium Component s-ar putea să nu aveți deloc nevoie de această conductă: vizualizatorul este livrat cu un cursor de urmărire încorporat care mapează un decalaj de caracter la o evidențiere pictată a cuvântului într-un singur apel, subiect acoperit în articolul despre evidențierea TTS cuvânt cu cuvânt. Ceea ce urmează este pentru momentul în care dețineți întreaga aplicație a vizualizatorului și doriți conducta în sine

Un fir de execuție randează, un fir de execuție vorbește

Arhitectura este formată din două fire de execuție și un contract. Firul UI randează bitmap-ul paginii, deține starea de zoom și derulare și pictează suprapunerea de evidențiere. Un fir dedicat vorbirii deține vocea SAPI și nimic altceva nu o atinge. Contractul este subțire: firul de vorbire raportează progresul sub formă de decalaje de caractere, iar firul UI transformă decalajele în dreptunghiuri

Majoritatea exemplelor SAPI înfășoară fiecare enunț în CoInitialize și CoUninitialize, iar un vizualizator arată imediat de ce acest lucru este greșit. Speak cu SVSFlagsAsync returnează de îndată ce textul este pus în coadă, așa că un CoUninitialize în blocul finally al aceleiași proceduri rulează în timp ce vocea încă vorbește, distrugând apartamentul COM care o deține. În funcție de sincronizare, obțineți tăcere, un enunț trunchiat sau o încălcare de acces câteva minute mai târziu. Ciclul de viață corect este plictisitor: CoInitialize o dată la pornirea firului de vorbire, creați vocea în interiorul acelui apartament și CoUninitialize o dată la ieșirea din fir, după ce vocea a fost eliberată. Niciodată per enunț

Vocea are nevoie și de o pompă de mesaje, care decide unde poate trăi. Obiectul de automatizare SpVoice își livrează evenimentele prin coada de mesaje a firului de execuție care l-a creat. Creați-l pe firul UI și evenimentele sosesc, deoarece VCL pompează mesajele, dar fiecare desenare lentă vă întârzie apoi limitele cuvintelor; creați-l pe un fir de lucru fără pompă și evenimentele nu sosesc deloc niciodată. Un fir de execuție dedicat, cu propria sa buclă GetMessage, menține latența limitelor constantă, indiferent de ceea ce face UI-ul

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 postează WM_QUIT astfel încât pompa să se deblocheze la închiderea vizualizatorului. SpeakPage, apelat de pe firul UI, stochează textul într-un câmp protejat cu un lacăt și postează WM_SPEAK_PAGE, deoarece apelarea unei metode pe FVoice direct de pe un alt fir de execuție ar fi un apel COM inter-apartament pe o interfață nedemarșalată. Linia unică PeekMessage înainte de buclă forțează Windows să creeze coada de mesaje a firului de execuție, închizând cursa de pornire în care o postare timpurie de pe firul UI ar eșua

Limitele cuvintelor sosesc sub formă de decalaje de caractere

Importați Biblioteca de Obiecte Microsoft Speech o dată prin importatorul de biblioteci de tipuri al IDE-ului și veți obține SpeechLib_TLB cu wrapper-ul TSpVoice și evenimentele sale tipizate. Două setări contează. EventInterests ar trebui redus la evenimentele pe care le consumați efectiv, deoarece fiecare interes lăsat activat înseamnă trafic de evenimente între fire pentru fiecare cuvânt de pe fiecare pagină; SVEWordBoundary conduce evidențierea și SVEEndInputStream vă spune că enunțul s-a terminat. Iar manipulatorul OnWord primește CharacterPosition și o lungime, care indexează șirul exact pe care l-ați transmis către Speak — un decalaj în bufferul de vorbire, nu în altceva

Acea ultimă clauză este invariantul de care depinde funcția: decalajele sunt semnificative doar în raport cu șirul pe care îl citește vocea, deci rostiți exact textul extras, caracter cu caracter. Tăiați spațiile goale, restrângeți sfârșiturile de linie sau extindeți o abreviere pentru o pronunție mai frumoasă, iar fiecare evidențiere după prima modificare va ajunge decalată cu un cuvânt. Dacă UI-ul trebuie să injecteze material vorbit — anunțuri de pagină, prefixe de antet — înregistrați poziția și lungimea fiecărei inserții și scădeți deplasarea acumulată din fiecare decalaj înainte de a-l mapa

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 este modalitatea corectă de marșalare aici, nu Synchronize: manipulatorul nu trebuie să parcheze firul de vorbire în timp ce UI-ul redesenează, iar dacă evenimentele de limită sosesc mai repede decât se desenează pe ecran, o actualizare învechită a evidențierii este inofensivă, deoarece următoarea o va suprascrie. Conectați OnEndStream în același mod pentru a șterge evidențierea și, într-un mod de citire continuă, pentru a încărca textul paginii următoare și a posta următorul enunț

De la decalaje de caractere la pixeli pe ecran

PDFium raportează geometria per caracter. FPDFText_GetCharBox umple patru valori duble într-o ordine care a cauzat mai multe bug-uri silențioase decât orice altceva în API-ul de text — stânga, dreapta, jos, sus, nu stânga, sus, dreapta, jos ca în Windows — și le raportează în spațiul paginii: puncte PDF, 72 la un inch, originea în colțul din stânga jos, cu Y crescând în sus. Caseta unui cuvânt este reuniunea casetelor caracterelor sale, iar transformarea în pixeli ai dispozitivului are trei pași: translatare cu originea paginii, scalare cu zoom-ul înmulțit cu DPI-ul ecranului supra 72 și inversarea axei Y

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 este înălțimea paginii în puncte din FPDF_GetPageHeight, iar FPageLeft este zero pentru majoritatea documentelor, dar provine din caseta de decupare când pagina definește una, deci citiți-le pe ambele din FPDF_GetPageBoundingBox mai degrabă decât să presupuneți. Inversarea pe Y este locul unde versiunile manuale eșuează: partea de sus a dreptunghiului dispozitivului provine din partea de sus a casetei PDF măsurată în jos de la partea de sus a paginii. Dacă o calculați invers, fiecare evidențiere se va picta în oglindă în jumătatea greșită a paginii

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;

Manipulatorul de desenare pictează mai întâi bitmap-ul paginii și evidențierea după el, de fiecare dată, astfel încât suprapunerea nu trebuie niciodată să se șteargă singură; invalidarea dreptunghiurilor vechi și noi menține regiunea de redesenare mică chiar și la viteze rapide de vorbire. FHighlightBrush este un TBitmap unu-pe-unu umplut o singură dată la pornire cu culoarea de evidențiere — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF pentru chihlimbar — pe care AlphaBlend îl întinde peste dreptunghiul țintă, deci nimic nu este alocat per cadru, iar SourceConstantAlpha la 96 menține cuvântul lizibil prin nuanță. Testați culoarea în modurile de afișare inversate și cu contrast ridicat; o suprapunere pe care un utilizator cu vedere slabă nu o poate vedea nu există exact pentru persoana pentru care a fost construită

Ordinea de citire este partea pe care API-ul de text nu o va rezolva

FPDFText_GetText returnează caractere într-o ordine derivată din fluxul de conținut cu o anumită curățare spațială, iar pentru un raport pe o singură coloană, acea ordine este în regulă. Nu are nicio obligație să fie corectă în altă parte. Un buletin informativ pe două coloane poate fi citit direct peste ambele coloane, o bară laterală poate întrerupe o propoziție la mijlocul clauzei, iar un subsol poate apărea în mijlocul paginii. Informațiile care remediază acest lucru — arborele de structură logică din ISO 32000-1 §14.8, pe care PDF-urile etichetate îl conțin și PDF/UA îl face obligatoriu — nu sunt consultate deloc de apelurile brute ale paginii de text. Dacă aveți nevoie de o ordine conștientă de structură, cu un semnal explicit al originii sale, aceasta este o problemă rezolvată la un nivel superior: API-ul de citire al Componentei PDFium returnează conținut cu un câmp Source având valoarea rosStructure sau rosHeuristic, iar articolul despre cititorul PDF accesibil detaliază acest lucru. La nivelul brut al API-ului, poziția care poate fi apărată este să tratați ordinea de extracție ca pe o estimare, să o specificați în UI și să păstrați un document cu mai multe coloane și o scanare doar cu imagini în setul de regresie, astfel încât ambele moduri de eșec să rămână vizibile

Vizualizatorul însuși trebuie să poată fi operat de la tastatură

Ieșirea vocală nu scutește vizualizatorul de accesul de la tastatură; persoanele cu cea mai mare probabilitate să folosească citirea cu voce tare sunt cele mai puțin predispuse să recurgă la un mouse. Acordați panoului paginii TabStop := True și un dreptunghi de focalizare vizibil, apoi manipulați trei taste: Spațiu comută FVoice.Pause și FVoice.Resume, iar Stânga și Dreapta sar peste conținut cu FVoice.Skip('Sentence', 1), cu un număr negativ pentru a merge înapoi. Skip din SAPI înțelege doar granularitatea la nivel de propoziție, deci sărirea la nivel de cuvânt înseamnă curățarea redării cu SVSFPurgeBeforeSpeak și reluarea vorbirii de la decalajul ultimului cuvânt pe care l-ați urmărit — o operațiune ieftină, deoarece codul de evidențiere stochează deja exact acel decalaj. Păstrați fiecare control de transport ca un TButton real cu o legendă, astfel încât cititoarele de ecran să-l anunțe

Aceasta este întreaga conductă, totul realizat folosind API-ul de text brut PDFium: un fir de execuție pentru vorbire care deține COM și vocea pe toată durata de viață a aplicației, evenimente de limită transmise către UI sub formă de decalaje de caractere și casete de spațiu per pagină pentru fiecare caracter transformate într-un dreptunghi amestecat pe ecran. Dacă preferați să nu vă ocupați singur de geometrie și urmărire, Componenta PDFium oferă casete pe cuvânt, cursorul de urmărire, derularea automată și unități de citire la nivel de propoziție ca proprietăți ale componentei, iar demonstrația sa de citire cu voce tare este conducta din acest articol redusă la o mână de apeluri