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á

Prohlížeč PDF s nahlas čtením v Delphi s vyhrazeným vláknem SAPI text-to-speech vyměňujícím offsety hranic slov s vláknem UI
Řečové vlákno vlastní COM i SpVoice po dobu života aplikace a podá posuny znaků vláknu UI, které samo maluje synchronizované zvýraznění
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;   // čte FText pod 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);   // bezpečné z vlákna UI
  end;

procedure TSpeechThread.Execute;
var
  Msg: TMsg;
begin
  CoInitialize(nil);                       // jednou, když vlákno začíná
  try
    FVoice := TSpVoice.Create(nil);
    try
      FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
      FVoice.OnWord := VoiceWord;
      // Vynutí vytvoření fronty zpráv tohoto vlákna, než do ní někdo odešle
      PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
      while GetMessage(Msg, 0, 0, 0) do    // skončí, když dorazí WM_QUIT
        if Msg.message = WM_SPEAK_PAGE then
          FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
        else
          DispatchMessage(Msg);            // doručí callbacky událostí SAPI
    finally
      FVoice.Free;
    end;
  finally
    CoUninitialize;                        // jednou, když vlákno končí
  end;
end;

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

Offsety hranic slov SAPI indexující přesný textový buffer, který pronáší prohlížeč PDF s text-to-speech v Delphi
Události hranic indexují přesně ten buffer, jenž se předal Speak, takže zkrácení, rozbalení zkratek či vpíchnutí prefixů posune každé následné zvýraznění o tutéž hodnotu
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
  // Běží na řečovém vlákně; předá offsety do UI bez blokování
  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;    // body PDF, počátek vlevo dole
  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
    // Pořadí parametrů je vlevo, vpravo, dole, nahoře - ne pořadí 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 PDF bodů na palec; FZoom je faktor měřítka prohlížeče
  Scale := FZoom * FScreenDpi / 72.0;
  Result.Left   := Round((W.Left  - FPageLeft) * Scale) - FScrollX;
  Result.Right  := Round((W.Right - FPageLeft) * Scale) - FScrollX;
  // PDF Y roste nahoru od spodního okraje; zařízení Y roste dolů
  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

Mapování souřadnic v bodech PDF z FPDFText_GetCharBox na pixely zařízení pro zvýraznění text-to-speech v prohlížeči v Delphi
GetCharBox vrací left, right, bottom, top v PDF bodech od počátku vlevo dole; přeložení, škálování zoom krát DPI děleno 72 a překlopení Y dosadí obdélník na obrazovku
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);             // smaže staré slovo
  InvalidateRect(PageBox.Handle, @FHighlightRect, False);  // vykreslí nové
end;

procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
  Blend: TBlendFunction;
begin
  PageBox.Canvas.Draw(0, 0, FPageBitmap);      // vždy nejprve vykreslená stránka
  if FHighlightRect.IsEmpty then Exit;

  Blend.BlendOp := AC_SRC_OVER;
  Blend.BlendFlags := 0;
  Blend.SourceConstantAlpha := 96;             // asi 38 procent krytí
  Blend.AlphaFormat := 0;                      // konstantní alfa, bez dat per pixel
  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í