Artykuł techniczny

Tworzenie dostępnych czytników PDF z syntezą mowy w Delphi

Przycisk czytania na głos na etapie demonstracji powstaje w jedno popołudnie, a potem pochłania tydzień. Wersja z popołudnia wyciąga tekst ze strony, przekazuje go do SAPI i generuje dźwięk. Kolejny tydzień schodzi na to, by funkcja była użyteczna: głos nie może blokować okna, wypowiadane słowo musi podświetlać się na stronie zsynchronizowane z dźwiękiem, a klawisz spacji musi to wszystko pauzować. Ten artykuł buduje taki potok w Delphi na podstawie surowego API tekstu PDFium i Windows Speech API, oferując działający kod dla trzech elementów pomijanych w szybkiej wersji: zarządzania cyklem życia COM wykonywanym raz zamiast przy każdej wypowiedzi, rzeczywistych zdarzeń na granicach słów i matematyki współrzędnych, która przekształca pole słowa w przestrzeni PDF na prostokąt, który można narysować

Kontekst regulacyjny mieści się w jednym zdaniu: zsynchronizowane czytanie na głos to leżąca po stronie czytnika połowa tego, czego standard WCAG 2.1 wymaga od oprogramowania dokumentów, a norma ISO 14289-1 (PDF/UA) definiuje połowę dotyczącą tagowanych plików, dla których to rozwiązanie działa najlepiej. Jeśli opierasz się na komponencie PDFium Component, ten potok może wcale nie być ci potrzebny: czytnik jest wyposażony we wbudowany kursor śledzący, który mapuje przesunięcie znaku na podświetlenie narysowanego słowa w jednym wywołaniu, omówiony w artykule o podświetlaniu słowo po słowie w TTS. To, co następuje poniżej, jest przeznaczone do sytuacji, gdy jesteś właścicielem całej aplikacji czytnika i chcesz samodzielnie zbudować taki potok

Jeden wątek renderuje, jeden wątek mówi

Architektura to dwa wątki i jeden kontrakt. Wątek interfejsu użytkownika (UI) renderuje bitmapę strony, jest właścicielem stanu powiększenia i przewijania oraz maluje nakładkę podświetlenia. Dedykowany wątek mowy posiada głos SAPI i nic innego go nie dotyka. Kontrakt jest cienki: wątek mowy zgłasza postęp jako przesunięcia znaków, a wątek UI przekształca te przesunięcia w prostokąty

Większość przykładów SAPI opakowuje każdą wypowiedź w CoInitialize i CoUninitialize, a czytnik natychmiast pokazuje, dlaczego to błąd. Wywołanie Speak z SVSFlagsAsync powraca zaraz po zakolejkowaniu tekstu, więc CoUninitialize w bloku finally tej samej procedury uruchamia się, gdy głos nadal mówi, niszcząc środowisko (apartment) COM, do którego należy. W zależności od czasu operacji uzyskujesz ciszę, uciętą wypowiedź lub naruszenie ochrony dostępu kilka minut później. Prawidłowy cykl życia jest nudny: CoInitialize wywoływane raz, gdy uruchamia się wątek mowy, utworzenie głosu wewnątrz tego apartamentu i CoUninitialize na końcu, gdy wątek się kończy, po zwolnieniu obiektu głosu. Nigdy dla pojedynczej wypowiedzi

Głos potrzebuje również pętli komunikatów (message pump), która decyduje o tym, gdzie może on żyć. Obiekt automatyzacji SpVoice dostarcza swoje zdarzenia przez kolejkę komunikatów wątku, który go utworzył. Utwórz go w wątku UI, a zdarzenia dotrą, ponieważ VCL przetwarza komunikaty, ale wtedy każde powolne malowanie opóźni twoje granice słów; utwórz go w wątku roboczym bez pętli komunikatów, a zdarzenia nigdy nie nadejdą. Dedykowany wątek z własną pętlą GetMessage utrzymuje płaskie opóźnienie granic, bez względu na to, co robi interfejs użytkownika

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;

Metoda TerminatedSet wysyła komunikat WM_QUIT, aby pętla została odblokowana przy zamykaniu czytnika. Metoda SpeakPage, wywoływana z wątku interfejsu użytkownika, zapisuje tekst w polu chronionym blokadą (lock) i wysyła WM_SPEAK_PAGE, ponieważ wywołanie metody na FVoice bezpośrednio z innego wątku byłoby wywołaniem COM między apartamentami (cross-apartment) na nieprzekształconym interfejsie. Jednolinijkowe wywołanie PeekMessage przed pętlą zmusza Windows do utworzenia kolejki komunikatów wątku, domykając problem wyścigu na starcie, gdzie wczesny post z wątku UI zakończyłby się niepowodzeniem

Granice słów przychodzą jako przesunięcia znaków

Zaimportuj raz bibliotekę Microsoft Speech Object Library za pomocą importera bibliotek typów środowiska IDE, a otrzymasz plik SpeechLib_TLB z wrapperem TSpVoice i jego typowanymi zdarzeniami. Mają tu znaczenie dwa ustawienia. Wartość EventInterests powinna zostać zawężona tylko do zdarzeń, które faktycznie konsumujesz, ponieważ każde pozostawione włączone zainteresowanie oznacza ruch zdarzeń między wątkami dla każdego słowa na każdej stronie; flaga SVEWordBoundary steruje podświetleniem, a SVEEndInputStream mówi ci, że wypowiedź się skończyła. Ponadto handler OnWord otrzymuje CharacterPosition i długość, które wskazują dokładnie na ten sam łańcuch, który przekazałeś do funkcji Speak — jest to przesunięcie wewnątrz bufora mowy, a nie czegoś innego

Ostatnie zdanie to niezmiennik, na którym opiera się ta funkcja: przesunięcia mają sens tylko w odniesieniu do łańcucha, który czyta głos, więc wypowiadaj dokładnie ten tekst, który wyodrębniłeś, znak po znaku. Odetnij białe znaki, zwiń łamania linii lub rozwiń skrót dla ładniejszej wymowy, a każde podświetlenie po pierwszej edycji zostanie przesunięte o jedno słowo. Jeśli interfejs użytkownika musi wstrzyknąć materiał mówiony — powiadomienia o stronie, przedrostki nagłówków — zapisz pozycję i długość każdego wstawienia, a następnie odejmij skumulowane przesunięcie od każdego ofsetu przed jego zmapowaniem

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;

Właściwym sposobem przekazywania jest tu TThread.Queue, a nie Synchronize: obsługa zdarzenia nie może zatrzymywać wątku mowy podczas ponownego malowania interfejsu, a jeśli zdarzenia granic nadchodzą szybciej, niż ekran jest w stanie się przerysować, stara aktualizacja podświetlenia jest nieszkodliwa, ponieważ nadpisze ją kolejna. Podłącz zdarzenie OnEndStream w ten sam sposób, aby usunąć podświetlenie, a w trybie ciągłego czytania wczytać tekst kolejnej strony i wywołać kolejną wypowiedź

Od przesunięć znaków do pikseli na ekranie

PDFium zgłasza geometrię dla każdego znaku. Funkcja FPDFText_GetCharBox wypełnia cztery liczby zmiennoprzecinkowe (double) w kolejności, która spowodowała więcej cichych błędów niż cokolwiek innego w tekstowym API — lewo, prawo, dół, góra, a nie jak w systemie Windows: lewo, góra, prawo, dół — i zgłasza je w przestrzeni strony: punkty PDF, 72 na cal, ze środkiem układu współrzędnych (origin) w lewym dolnym rogu i osią Y rosnącą w górę. Pole słowa jest sumą pól jego znaków, a transformacja na piksele urządzenia to trzy kroki: przesunięcie przez początek strony, skalowanie przez powiększenie (zoom) pomnożone przez DPI ekranu na 72, a następnie odwrócenie osi 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 to wysokość strony w punktach zwracana przez FPDF_GetPageHeight, a FPageLeft wynosi zero dla większości dokumentów, ale pochodzi z crop box, gdy strona je definiuje, więc odczytuj obie z FPDF_GetPageBoundingBox zamiast zgadywać. Odbicie (flip) na osi Y to miejsce, w którym psują się autorskie wersje: góra prostokąta urządzenia pochodzi od góry pudełka (box) z pliku PDF odmierzonej w dół od szczytu strony. Jeśli zrobisz to odwrotnie, każde podświetlenie namaluje się jako odbicie lustrzane w niewłaściwej połowie strony

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;

Handler malowania rysuje bitmapę strony jako pierwszą, a po niej za każdym razem rysuje podświetlenie, więc nakładka nigdy nie musi się sama usuwać; unieważnienie starego i nowego prostokąta utrzymuje mały obszar przemalowywania nawet przy wysokich prędkościach mowy. FHighlightBrush to jedno- na jednopikselowa TBitmap wypełniona jednorazowo przy uruchomieniu kolorem podświetlenia — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF dla koloru bursztynowego — którą AlphaBlend rozciąga na docelowym prostokącie, więc nic nie jest alokowane na klatkę, a SourceConstantAlpha ustawione na 96 utrzymuje czytelność słowa pomimo jego zabarwienia. Przetestuj kolor pod kątem odwróconych trybów wyświetlania i trybów wysokiego kontrastu; nakładka, której niedowidzący użytkownik nie jest w stanie dostrzec, po prostu nie istnieje dla osoby, z myślą o której została stworzona

Kolejność czytania to część, której tekstowe API nie rozwiąże

Metoda FPDFText_GetText zwraca znaki w kolejności pochodzącej ze strumienia zawartości z niewielkim czyszczeniem przestrzennym (spatial cleanup) i w przypadku jednokolumnowego raportu taka kolejność jest w porządku. Jednak poza tym nie ma ona żadnego obowiązku bycia poprawną. Dwukolumnowy biuletyn może być odczytywany po prostej przez obie kolumny w poprzek, pasek boczny może przerwać zdanie w połowie klauzuli, a stopka może pojawić się na środku strony. Informacja, która to naprawia — drzewo struktury logicznej z normy ISO 32000-1 §14.8, które zawierają tagowane pliki PDF i którego wymaga PDF/UA — nie jest w ogóle sprawdzana przez surowe wywołania tekstowe. Jeśli potrzebujesz kolejności uwzględniającej strukturę z wyraźnym sygnałem jej pochodzenia, to ten problem został rozwiązany poziom wyżej: interfejs API czytnika z komponentu PDFium Component zwraca zawartość z polem Source ustawionym na rosStructure lub rosHeuristic, co demonstruje artykuł o dostępnym czytniku PDF. Na poziomie czystego interfejsu API pozycją wartą obrony jest traktowanie kolejności wyodrębniania jako wartości szacunkowej, poinformowanie o tym w interfejsie użytkownika i trzymanie jednego dokumentu wielokolumnowego oraz jednego skanu opartego tylko na obrazach w swoim zestawie regresji tak, aby oba rodzaje błędów pozostawały widoczne

Sam czytnik musi być obsługiwany z użyciem klawiatury

Generowanie mowy nie zwalnia czytnika z obowiązku obsługi klawiatury; osoby najbardziej skłonne do korzystania z czytania na głos rzadko sięgają po mysz. Ustaw dla panelu strony właściwość TabStop := True oraz upewnij się, że otrzymuje on widoczny prostokąt fokusu, a następnie obsłuż trzy klawisze: spacja będzie przełączać FVoice.Pause i FVoice.Resume, z kolei strzałki w lewo i w prawo przeskoczą o jedną pozycję przez FVoice.Skip('Sentence', 1) z ujemną wartością do cofania się. Z kolei funkcja Skip w SAPI rozumie tylko szczegółowość na poziomie zdań, więc skoki na poziomie pojedynczych słów oznaczają usuwanie odtwarzania za pomocą SVSFPurgeBeforeSpeak i ponowne czytanie od ofsetu słowa, które ostatnio prześledziłeś — jest to tanie rozwiązanie, ponieważ kod obsługujący podświetlenie przechowuje już to przesunięcie w pamięci. Zachowaj wszystkie elementy sterujące transportem jako prawdziwy TButton z etykietą tak, aby czytniki ekranowe informowały o jego zawartości

To już cały potok przetwarzania i wszystko bazuje na surowym tekstowym API biblioteki PDFium: wątek mowy będący właścicielem COM i obiektu głosu przez cały okres życia aplikacji, zdarzenia graniczne przesyłane do interfejsu użytkownika w postaci przesunięć znaków, a na sam koniec znaki pojedynczych pól w przestrzeni strony scalane w pojedynczy nałożony (blended) prostokąt na ekranie. Jeśli wolisz nie zajmować się własnoręcznym obsługiwaniem geometrii i śledzeniem jej na ekranie, komponent PDFium Component dostarcza pola pojedynczych słów, śledzący kursor tekstu, samo-podążający auto-scroll oraz narzędzia do czytania zdań (sentence-level reading units) jako wbudowane właściwości, a dołączone do niego demo czytania na głos to po prostu opisany tutaj potok zredukowany do zaledwie kilku wywołań