Техническа статия

Изграждане на достъпни PDF визуализатори с функция за текст към реч (Text-to-Speech) в Delphi

Един бутон за четене на глас може да се демонстрира за един следобед, а след това да отнеме цяла седмица работа. Следобедната версия извлича текста от страницата, предава го на SAPI и получава аудио. Седмицата отива за това, което прави функцията използваема: гласът не трябва да замразява прозореца, изречената дума трябва да се осветява на страницата в синхрон с аудиото, а клавишът Space трябва да поставя всичко на пауза. Тази статия изгражда този конвейер в Delphi спрямо суровия текстов API на PDFium и Windows Speech API, с работещ код за трите части, които бързата версия пропуска: жизнен цикъл на COM, изпълнен веднъж вместо за всяко изказване, реални събития за граници на думи и координатна математика, която превръща кутия за дума в PDF пространството в правоъгълник, който можете да нарисувате

Нормативният контекст се събира в едно изречение: синхронизираното четене на глас е половината от страна на визуализатора от това, което WCAG 2.1 изисква от софтуера за документи, а ISO 14289-1 (PDF/UA) дефинира половината за маркирани файлове (tagged-file), с която работи най-добре. Ако надграждате върху PDFium Component, може изобщо да не се нуждаете от този конвейер: визуализаторът се доставя с вграден проследяващ курсор, който картографира отместване на знак към маркиране на нарисувана дума с едно извикване, обхванато в статията за TTS маркиране дума по дума. Това, което следва, е за случаите, когато притежавате цялото приложение за визуализация и искате самия конвейер

Една нишка изобразява, една нишка говори

Архитектурата се състои от две нишки и един договор. Нишката на потребителския интерфейс (UI) изобразява растерното изображение на страницата, притежава състоянието на мащабиране и превъртане, и рисува маркиращия слой. Специализирана нишка за реч притежава SAPI гласа и нищо друго не го докосва. Договорът е тънък: нишката за реч отчита напредъка като отмествания на знаци, а UI нишката превръща отместванията в правоъгълници

Повечето примери за SAPI обвиват всяко изказване в CoInitialize и CoUninitialize, а един визуализатор веднага показва защо това е грешно. Speak със SVSFlagsAsync се връща веднага щом текстът бъде поставен на опашка, така че CoUninitialize в блока finally на същата процедура се изпълнява, докато гласът все още говори, разрушавайки COM апартамента, който го притежава. В зависимост от времето получавате тишина, прекъснато изказване или нарушение на достъпа (access violation) минути по-късно. Правилният жизнен цикъл е скучен: CoInitialize веднъж при стартиране на нишката за реч, създаване на гласа вътре в този апартамент и CoUninitialize веднъж при излизане от нишката, след като гласът е бил освободен. Никога за всяко изказване

Гласът също така се нуждае от цикъл за съобщения (message pump), който решава къде може да живее. Обектът за автоматизация SpVoice доставя своите събития чрез опашката за съобщения на нишката, която го е създала. Ако го създадете в UI нишката, събитията пристигат, защото VCL изпомпва съобщения, но всяко бавно изобразяване след това забавя границите на думите ви; ако го създадете в работна нишка без цикъл, събитията никога не пристигат. Специализирана нишка със собствен GetMessage цикъл поддържа латентността на границите постоянна, независимо какво прави потребителският интерфейс

Delphi PDF прегледник с четене на глас с отделна SAPI нишка за синтезиран реч, обменяща офсети на словни граници с UI нишката
Говорната нишка притежава COM и SpVoice през живота на приложението и подава символните отмествания на UI нишката, която сама рисува синхронизирания highlight
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;   // чете FText под 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);   // безопасен от UI нишката
  end;

procedure TSpeechThread.Execute;
var
  Msg: TMsg;
begin
  CoInitialize(nil);                       // веднъж, когато нишката стартира
  try
    FVoice := TSpVoice.Create(nil);
    try
      FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
      FVoice.OnWord := VoiceWord;
      // Принудително създаване на опашката за съобщения на тази нишка, преди някой да публикува в нея
      PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
      while GetMessage(Msg, 0, 0, 0) do    // излиза, когато пристигне WM_QUIT
        if Msg.message = WM_SPEAK_PAGE then
          FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
        else
          DispatchMessage(Msg);            // доставя обратните извиквания на събитията на SAPI
    finally
      FVoice.Free;
    end;
  finally
    CoUninitialize;                        // веднъж, когато нишката излезе
  end;
end;

procedure TSpeechThread.TerminatedSet;
begin
  inherited;
  PostThreadMessage(ThreadID, WM_QUIT, 0, 0);   // отблокира GetMessage
end;

TerminatedSet изпраща WM_QUIT, така че цикълът се отблокира, когато визуализаторът се изключи. SpeakPage, извикан от UI нишката, съхранява текста в защитено с ключалка поле и изпраща WM_SPEAK_PAGE, защото извикването на метод на FVoice директно от друга нишка би било COM извикване между апартаменти в недемаршалиран интерфейс (unmarshaled interface). Едноредовият PeekMessage преди цикъла принуждава Windows да създаде опашката за съобщения на нишката, затваряйки състезанието при стартиране, където ранно съобщение от UI нишката би се провалило

Границите на думите пристигат като отмествания на знаци

Импортирайте Microsoft Speech Object Library веднъж чрез инструмента за импортиране на библиотеки с типове на IDE и ще получите SpeechLib_TLB с обвивката TSpVoice и нейните типизирани събития. Две настройки имат значение. EventInterests трябва да бъде стеснен до събитията, които реално консумирате, защото всеки интерес, оставен включен, е трафик на събития между нишките за всяка дума от всяка страница; SVEWordBoundary управлява маркирането, а SVEEndInputStream ви казва, че изказването е завършило. А манипулаторът OnWord получава CharacterPosition и дължина, които индексират в точния низ, който сте предали на Speak — отместване в буфера за реч, а не в нещо друго

Тази последна клауза е инвариантът, на който се крепи функцията: отместванията имат смисъл само спрямо низа, който гласът чете, така че изговаряйте точно текста, който сте извлекли, знак по знак. Подрежете празните пространства, слейте прекъсванията на редове или разширете съкращение за по-хубаво произношение, и всяко маркиране след първата редакция ще се измести с една дума. Ако потребителският интерфейс трябва да инжектира говорен материал — съобщения за страницата, префикси на заглавия — запишете позицията и дължината на всяко вмъкване и извадете натрупаното изместване от всяко отместване, преди да го картографирате

SAPI офсети на словни граници, индексиращи точния текстов буфер, произнасян от Delphi PDF прегледник със синтезиран реч
Boundary събитията индексират самия буфер, подаден на Speak, затова подрязване, разширяване на съкращения или вмъкване на префикси измества всеки следващ highlight със същото количество
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
  // Изпълнява се в нишката за реч; предава отместванията към интерфейса без блокиране
  TThread.Queue(nil,
    procedure
    begin
      ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
    end);
end;

TThread.Queue е правилният маршал (marshal) тук, а не Synchronize: манипулаторът не трябва да паркира нишката за реч, докато потребителският интерфейс се прерисува, и ако събитията за граници пристигат по-бързо, отколкото екранът се изчертава, остарялата актуализация на маркирането е безобидна, защото следващата я презаписва. Свържете OnEndStream по същия начин, за да изчистите маркирането, а в режим на непрекъснато четене, за да заредите текста на следващата страница и да изпратите следващото изказване

От отмествания на знаци до пиксели на екрана

PDFium отчита геометрията за всеки знак. FPDFText_GetCharBox попълва четири числа с плаваща запетая с двойна точност (double) в ред, който е причинил повече тихи програмни грешки от всичко друго в текстовия API — ляво, дясно, долу, горе, а не Windows реда ляво, горе, дясно, долу — и ги отчита в пространството на страницата: PDF пунктове, 72 на инч, начало в долния ляв ъгъл с Y, нарастващ нагоре. Кутията на една дума е обединението от кутиите на нейните знаци, а трансформацията към пиксели на устройството се състои от три стъпки: транслиране според началото на страницата, мащабиране чрез увеличението по DPI на екрана върху 72 и обръщане на оста Y

uses
  System.Math;

type
  TPdfRectF = record
    Left, Top, Right, Bottom: Double;    // PDF точки, начало в долния ляв ъгъл
  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
    // Редът на параметрите е ляво, дясно, долу, горе - не 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 точки на инч; FZoom е коефициентът на мащабиране на прегледача
  Scale := FZoom * FScreenDpi / 72.0;
  Result.Left   := Round((W.Left  - FPageLeft) * Scale) - FScrollX;
  Result.Right  := Round((W.Right - FPageLeft) * Scale) - FScrollX;
  // PDF Y расте нагоре от долния ръб; устройственото Y расте надолу
  Result.Top    := Round((FPageTop - W.Top)    * Scale) - FScrollY;
  Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;

FPageTop е височината на страницата в пунктове от FPDF_GetPageHeight, а FPageLeft е нула за повечето документи, но идва от полето за изрязване (crop box), когато страницата дефинира такова, така че прочетете и двете от FPDF_GetPageBoundingBox, вместо да предполагате. Обръщането на Y е мястото, където ръчно написаните версии се чупят: горната част на правоъгълника на устройството идва от горната част на PDF кутията, измерена надолу от горната част на страницата. Объркате ли го, всяко маркиране ще се нарисува огледално в грешната половина на страницата

Съпоставяне на PDF точковите координати от FPDFText_GetCharBox с устройствени пиксели за открояване при синтезиран реч в Delphi прегледник
GetCharBox връща left, right, bottom, top в PDF точки от началото долу вляво; превеждане, мащабиране с zoom по DPI над 72 и обръщане на Y каца правоъгълника на екрана
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);             // изтрива старата дума
  InvalidateRect(PageBox.Handle, @FHighlightRect, False);  // рисува новата
end;

procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
  Blend: TBlendFunction;
begin
  PageBox.Canvas.Draw(0, 0, FPageBitmap);      // рендираната страница първо, винаги
  if FHighlightRect.IsEmpty then Exit;

  Blend.BlendOp := AC_SRC_OVER;
  Blend.BlendFlags := 0;
  Blend.SourceConstantAlpha := 96;             // около 38 процента непрозрачност
  Blend.AlphaFormat := 0;                      // постоянно алфа, без данни за пиксел
  Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
    FHighlightRect.Left, FHighlightRect.Top,
    FHighlightRect.Width, FHighlightRect.Height,
    FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;

Манипулаторът за рисуване изобразява растерното изображение на страницата първо и маркирането след него, всеки път, така че слоят никога не трябва да изтрива себе си; инвалидирането на стария и новия правоъгълник поддържа областта за прерисуване малка дори при бързи скорости на речта. FHighlightBrush е TBitmap с размер едно на едно, запълнен веднъж при стартиране с цвета за маркиране — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF за кехлибарен цвят — който AlphaBlend разтяга върху целевия правоъгълник, така че нищо не се заделя за всеки кадър, а SourceConstantAlpha на 96 запазва думата четлива през оцветяването. Тествайте цвета при инвертирани и висококонтрастни режими на дисплея; слой, който потребител с нарушено зрение не може да види, не съществува за точно този човек, за когото е създаден

Редът на четене е частта, която текстовият API няма да реши

FPDFText_GetText връща знаци в ред, извлечен от потока на съдържанието с известно пространствено почистване, и за отчет с една колона този ред е наред. Той няма задължение да е правилен където и да било другаде. Бюлетин с две колони може да се чете направо през двете колони, странична лента може да прекъсне изречение по средата на клаузата, а долен колонтитул може да пристигне в средата на страницата. Информацията, която поправя това — дървото на логическата структура на ISO 32000-1 §14.8, което маркираните PDF файлове носят и което PDF/UA прави задължително — изобщо не се консултира от извикванията към суровата текстова страница. Ако имате нужда от ред, съобразен със структурата, с ясен сигнал за неговия произход, това е решен проблем на едно ниво по-нагоре: API за четене на PDFium Component връща съдържание с поле Source със стойност rosStructure или rosHeuristic, а статията за достъпен PDF четец го разглежда подробно. На ниво суров API, защитимата позиция е да третирате реда на извличане като приблизителна оценка, да го кажете в потребителския интерфейс и да запазите един документ с много колони и едно сканиране само с изображения в регресионния набор, така че и двата режима на отказ да останат видими

Самият визуализатор трябва да може да се управлява от клавиатурата

Извеждането на реч не извинява визуализатора от достъп чрез клавиатура; хората, които е най-вероятно да използват четене на глас, са най-малко склонни да посегнат към мишката. Задайте на панела на страницата TabStop := True и видим правоъгълник за фокус, след това обработвайте три клавиша: Space превключва FVoice.Pause и FVoice.Resume, а лявата и дясната стрелка прескачат чрез FVoice.Skip('Sentence', 1) с отрицателен брой за връщане назад. Skip на SAPI разбира само гранулиране на ниво изречение, така че прескачането на ниво дума означава изчистване на възпроизвеждането със SVSFPurgeBeforeSpeak и повторно изговаряне от отместването на думата, която последно сте проследили — което е евтино, тъй като кодът за маркиране вече съхранява точно това отместване. Поддържайте всяка контрола за придвижване като истински TButton с надпис, така че екранните четци да го обявяват

Това е целият конвейер, изцяло срещу суровия текстов API на PDFium: нишка за реч, която притежава COM и гласа за целия живот на приложението, събития за граници, маршалирани към потребителския интерфейс като отмествания на знаци, и кутии за всеки знак в пространството на страницата, превърнати в един смесен правоъгълник на екрана. Ако предпочитате да не притежавате геометрията и проследяването сами, PDFium Component доставя кутии за всяка дума, проследяващ курсор, следване с автоматично превъртане и единици за четене на ниво изречение като свойства на компонента, а неговото демо за четене на глас е конвейерът от тази статия, сведен до шепа извиквания