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

Przeglądarka PDF Delphi czytająca na głos z dedykowanym wątkiem mowy SAPI wymieniającym przesunięcia granic słów z wątkiem UI
Wątek mowy posiada COM i SpVoice przez całe życie aplikacji i wręcza offsety znaków wątkowi UI, który sam maluje zsynchronizowane podświetlenie
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;   // czyta FText pod blokadą 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);   // bezpieczne dla wątku interfejsu
  end;

procedure TSpeechThread.Execute;
var
  Msg: TMsg;
begin
  CoInitialize(nil);                       // raz, gdy wątek startuje
  try
    FVoice := TSpVoice.Create(nil);
    try
      FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
      FVoice.OnWord := VoiceWord;
      // Wymuś utworzenie kolejki komunikatów tego wątku, zanim ktokolwiek do niej opublikuje
      PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
      while GetMessage(Msg, 0, 0, 0) do    // kończy działanie, gdy nadchodzi WM_QUIT
        if Msg.message = WM_SPEAK_PAGE then
          FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
        else
          DispatchMessage(Msg);            // dostarcza wywołania zwrotne zdarzeń SAPI
    finally
      FVoice.Free;
    end;
  finally
    CoUninitialize;                        // raz, gdy wątek kończy działanie
  end;
end;

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

Przesunięcia granic słów SAPI indeksujące dokładny bufor tekstu mówiony przez przeglądarkę PDF Delphi z zamianą tekstu na mowę
Zdarzenia graniczne indeksują dokładnie ten bufor, który przekazano Speak, więc przycinanie, rozwijanie skrótów lub wstrzykiwanie prefiksów przesuwa każde kolejne podświetlenie o tę samą miarę
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
  // Działa na wątku mowy; przekaż przesunięcia do UI bez blokowania
  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;    // punkty PDF, początek układu w lewym dolnym rogu
  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
    // Kolejność parametrów to lewo, prawo, dół, góra - nie kolejność Windows
    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 punkty PDF na cal; FZoom to współczynnik skali widoku
  Scale := FZoom * FScreenDpi / 72.0;
  Result.Left   := Round((W.Left  - FPageLeft) * Scale) - FScrollX;
  Result.Right  := Round((W.Right - FPageLeft) * Scale) - FScrollX;
  // Y PDF rośnie w górę od dolnej krawędzi; Y urządzenia rośnie w dół
  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

Mapowanie współrzędnych punktów PDF z FPDFText_GetCharBox na piksele urządzenia dla podświetleń mowy w przeglądarce Delphi
GetCharBox zwraca left, right, bottom, top w punktach PDF od początku w lewym dolnym rogu; przesunięcie, przeskalowanie przez zoom razy DPI przez 72 i odbicie Y ląduje prostokąt na ekranie
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);             // wymaż stare słowo
  InvalidateRect(PageBox.Handle, @FHighlightRect, False);  // narysuj nowe
end;

procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
  Blend: TBlendFunction;
begin
  PageBox.Canvas.Draw(0, 0, FPageBitmap);      // najpierw wyrenderowana strona, zawsze
  if FHighlightRect.IsEmpty then Exit;

  Blend.BlendOp := AC_SRC_OVER;
  Blend.BlendFlags := 0;
  Blend.SourceConstantAlpha := 96;             // około 38 procent nieprzezroczystości
  Blend.AlphaFormat := 0;                      // stała alfa, bez danych per-piksel
  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ń