Technický článek

Vytvoření čtečky PDF s předčítáním nahlas v Delphi pomocí SAPI TTS

Tlačítko pro předčítání nahlas získáte na ukázku za odpoledne a pak vám sebere celý týden. Odpolední verze extrahuje text stránky, předá jej SAPI a získá zvuk. Ten týden spolkne to, co činí funkci použitelnou: hlas nesmí zamrazit okno, vyslovené slovo se musí na stránce rozsvítit v čase se zvukem a klávesa mezerníku to celé musí pozastavit. Tento článek staví tuto pipeline v Delphi na čistém textovém rozhraní API PDFium a Windows Speech API, s funkčním kódem pro tři části, které rychlá verze vynechává: životní cyklus COM prováděný jednou místo pro každou promluvu, skutečné události hranic slov a matematiku souřadnic, která mění rámeček slova v prostoru PDF na obdélník, který můžete vykreslit

Regulační kontext se vejde do jedné věty: synchronizované čtení nahlas je polovina čtečky, kterou požaduje WCAG 2.1 po dokumentovém softwaru, a ISO 14289-1 (PDF/UA) definuje polovinu s tagovanými soubory, proti které to funguje nejlépe. Pokud stavíte na PDFium Component, možná tuto pipeline vůbec nepotřebujete: čtečka se dodává s vestavěným sledovacím kurzorem, který v jednom volání mapuje posun znaku na obarvené zvýraznění slova, což je popsáno v článku zvýrazňování TTS slovo po slově. To, co následuje, je pro případ, kdy vlastníte celou aplikaci prohlížeče a chcete samotnou pipeline

Jedno vlákno vykresluje, jedno vlákno mluví

Architektura zahrnuje dvě vlákna a jeden kontrakt. Vlákno uživatelského rozhraní vykresluje bitmapu stránky, vlastní stav přiblížení a posouvání a obarvuje překrytí zvýraznění. Vyhrazené vlákno řeči vlastní hlas SAPI a nic jiného se jej nedotýká. Kontrakt je tenký: vlákno řeči hlásí postup jako posuny znaků a vlákno uživatelského rozhraní mění posuny na obdélníky

Většina vzorků SAPI zabalí každý výrok do CoInitialize a CoUninitialize, a čtečka okamžitě ukáže, proč je to špatně. Speak pomocí SVSFlagsAsync se vrátí hned, jakmile je text zařazen do fronty, takže CoUninitialize ve stejném bloku finally dané procedury se spustí, zatímco hlas stále mluví, a zruší COM apartment, který jej vlastní. V závislosti na načasování získáte ticho, zkrácený výrok nebo chybu narušení přístupu o několik minut později. Správný životní cyklus je nudný: CoInitialize jednou, když vlákno řeči začíná, vytvořte hlas uvnitř tohoto apartmentu a CoUninitialize jednou, když vlákno končí, poté, co byl hlas uvolněn. Nikdy per výrok

Hlas také potřebuje smyčku zpráv (message pump), což určuje, kde může žít. Objekt automatizace SpVoice doručuje své události přes frontu zpráv vlákna, které jej vytvořilo. Vytvořte jej ve vláknu uživatelského rozhraní a události skutečně dorazí, protože VCL zprávy přečerpává, ale každé pomalé překreslení pak zpozdí hranice vašich slov; vytvořte jej v pracovním vláknu bez pumpy a události nikdy nedorazí vůbec. Vyhrazené vlákno s vlastní smyčkou GetMessage udržuje latenci hranic stabilní bez ohledu na to, co uživatelské rozhraní dělá

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 odesílá WM_QUIT, takže se smyčka odblokuje, když se prohlížeč vypne. SpeakPage, volaný z vlákna uživatelského rozhraní, ukládá text do pole chráněného zámkem a odesílá WM_SPEAK_PAGE, protože volání metody na FVoice přímo z jiného vlákna by bylo volání COM napříč apartmenty na nerozbaleném rozhraní. Jednořádkový PeekMessage před smyčkou přiměje systém Windows, aby vytvořil frontu zpráv vlákna, čímž se uzavře závod při spouštění, kde by selhal předčasný příspěvek z vlákna uživatelského rozhraní

Hranice slov přicházejí jako posuny znaků

Naimportujte knihovnu Microsoft Speech Object Library jednou prostřednictvím nástroje pro import knihovny typů v IDE a získáte SpeechLib_TLB s obálkou TSpVoice a jejími typovanými událostmi. Důležitá jsou dvě nastavení. EventInterests by měly být zúženy na události, které skutečně konzumujete, protože každý zájem, který zůstane zapnutý, představuje přenos událostí napříč vlákny pro každé slovo na každé stránce; SVEWordBoundary řídí zvýraznění a SVEEndInputStream vám říká, že výrok skončil. A manipulátor OnWord obdrží CharacterPosition a délku, která odkazuje na přesný řetězec, který jste předali proceduře Speak — posun do vyrovnávací paměti řeči, nikoli do něčeho jiného

Tato poslední věta je invariant, na kterém tato funkce stojí: offsety mají smysl pouze vůči řetězci, který čte hlas, takže nechte číst přesně ten text, který jste vybrali, znak po znaku. Ořízněte prázdné znaky, sbalte zalomení řádků nebo rozbalte zkratku pro hezčí výslovnost a každé zvýraznění po první úpravě dopadne o jedno slovo vedle. Pokud musí uživatelské rozhraní vložit mluvený materiál — oznámení o stránkách, předpony nadpisů — zaznamenejte polohu a délku každého vložení a odečtěte akumulovaný posun od každého offsetu předtím, než jej namapujete

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 je zde správný maršál, nikoli Synchronize: manipulátor nesmí zaparkovat vlákno řeči, zatímco se uživatelské rozhraní překresluje, a pokud události hranic přicházejí rychleji, než se kreslí obrazovka, zastaralá aktualizace zvýraznění je neškodná, protože ta další ji přepíše. Podobně propojte OnEndStream pro vymazání zvýraznění a v režimu nepřetržitého čtení pro načtení textu další stránky a odeslání dalšího výroku

Od posunů znaků k pixelům na obrazovce

PDFium hlásí geometrii pro každý znak. FPDFText_GetCharBox vyplňuje čtyři doubles (desetinná čísla s dvojitou přesností) v pořadí, které způsobilo více skrytých chyb než cokoli jiného v API pro text — vlevo, vpravo, dole, nahoře, ne v pořadí Windows vlevo, nahoře, vpravo, dole — a hlásí je v prostoru stránky: body PDF, 72 na palec, počátek v levém dolním rohu s rostoucím Y směrem nahoru. Rámeček slova je sjednocením rámečků jeho znaků a transformace na pixely zařízení má tři kroky: posun o počátek stránky, změna měřítka násobením přiblížení DPI obrazovky lomeno 72 a překlopení osy 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 je výška stránky v bodech z FPDF_GetPageHeight a FPageLeft je pro většinu dokumentů nula, ale pochází z rámečku oříznutí (crop box), když jej stránka definuje, takže si raději přečtěte obojí z FPDF_GetPageBoundingBox, než abyste předpokládali. Překlopení osy Y je místo, kde se ručně psané verze rozbíjejí: horní okraj obdélníku zařízení pochází z horního okraje rámečku PDF měřeného dolů od horního okraje stránky. Pokud to uděláte obráceně, každé zvýraznění se vykreslí zrcadlově do špatné poloviny stránky

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;

Manipulátor kreslení vždy nejprve nakreslí bitmapu stránky a po ní zvýraznění, a to pokaždé, takže se překrytí nikdy nemusí samo mazat; zneplatnění starých a nových obdélníků udržuje oblast překreslení malou i při vysoké rychlosti řeči. FHighlightBrush je TBitmap jedna ku jedné vyplněná při spuštění barvou zvýraznění — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF pro jantarovou — kterou AlphaBlend roztáhne přes cílový obdélník, takže na jeden snímek se nic nepřiděluje, a hodnota SourceConstantAlpha na 96 udržuje slovo čitelné skrz tónování. Vyzkoušejte barvu v invertovaných a vysoce kontrastních režimech zobrazení; překrytí, které uživatel se slabým zrakem nevidí, neexistuje přesně pro osobu, pro kterou bylo vytvořeno

Pořadí čtení je to, co API textu nevyřeší

FPDFText_GetText vrací znaky v pořadí odvozeném z toku obsahu s určitým prostorovým vyčištěním a pro jednosloupcovou zprávu je toto pořadí v pořádku. Nemá to povinnost být správně nikde jinde. Dvousloupcový zpravodaj se může číst rovnou napříč oběma sloupci, postranní panel může přerušit větu uprostřed, a zápatí se může objevit uprostřed stránky. Informace, které to opravují — logický strom struktury ISO 32000-1 §14.8, který tagovaná PDF nesou a u PDF/UA je povinný — raw volání textové stránky vůbec nekonzultuje. Pokud potřebujete strukturovaně uvědomělé pořadí s výslovným signálem jeho původu, je to vyřešený problém o úroveň výš: API pro čtení v PDFium Component vrací obsah s polem Source nastaveným na rosStructure nebo rosHeuristic, a článek o přístupné čtečce PDF to podrobně probírá. Na úrovni raw API je udržitelnou pozicí považovat pořadí extrakce za odhad, říct to v uživatelském rozhraní a udržovat jeden vícesloupcový dokument a jeden sken pouze s obrázky v regresní sadě, aby byly obě poruchové varianty stále viditelné

Samotný prohlížeč musí být ovladatelný z klávesnice

Hlasový výstup neomlouvá prohlížeč z přístupu přes klávesnici; lidé, kteří s největší pravděpodobností budou používat předčítání nahlas, jsou ti nejméně pravděpodobní, že sáhnou po myši. Dejte panelu stránky hodnotu TabStop := True a viditelný obdélník fokusu a poté obsluhujte tři klávesy: Mezerník přepíná FVoice.Pause a FVoice.Resume, a šipky doleva a doprava přeskakují pomocí FVoice.Skip('Sentence', 1), se záporným počtem pro posun zpět. Funkce Skip (přeskočit) v SAPI rozumí pouze granularitě na úrovni vět, takže přeskakování na úrovni slov znamená vyčištění přehrávání pomocí SVSFPurgeBeforeSpeak a opětovné přečtení z pozice slova, které jste naposledy sledovali — je to levné, protože kód pro zvýraznění už si ukládá přesně tuto pozici (offset). Udržujte každý ovládací prvek přesunu jako skutečný TButton s titulkem, aby jej čtečky obrazovky mohly oznámit

To je celá pipeline, kompletně na čistém rozhraní API PDFium pro text: vlákno řeči, které vlastní COM a hlas po celou dobu životnosti aplikace, události hranic zprostředkované do uživatelského rozhraní jako posuny znaků a obdélníky v prostoru stránky pro každý znak převedené do jednoho prolnutého obdélníku na obrazovce. Pokud byste raději neměli na starosti geometrii a sledování sami, knihovna PDFium Component poskytuje rámečky pro jednotlivá slova, sledovací kurzor, automatické posouvání za kurzorem a jednotky čtení na úrovni vět jako vlastnosti komponenty, a její ukázka předčítání je pipeline z tohoto článku redukovaná na hrstku volání