Един бутон за четене на глас може да се демонстрира за един следобед, а след това да отнеме цяла седмица работа. Следобедната версия извлича текста от страницата, предава го на 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 цикъл поддържа латентността на границите постоянна, независимо какво прави потребителският интерфейс
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 изпраща 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 — отместване в буфера за реч, а не в нещо друго
Тази последна клауза е инвариантът, на който се крепи функцията: отместванията имат смисъл само спрямо низа, който гласът чете, така че изговаряйте точно текста, който сте извлекли, знак по знак. Подрежете празните пространства, слейте прекъсванията на редове или разширете съкращение за по-хубаво произношение, и всяко маркиране след първата редакция ще се измести с една дума. Ако потребителският интерфейс трябва да инжектира говорен материал — съобщения за страницата, префикси на заглавия — запишете позицията и дължината на всяко вмъкване и извадете натрупаното изместване от всяко отместване, преди да го картографирате
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 е правилният маршал (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 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 е височината на страницата в пунктове от FPDF_GetPageHeight, а FPageLeft е нула за повечето документи, но идва от полето за изрязване (crop box), когато страницата дефинира такова, така че прочетете и двете от FPDF_GetPageBoundingBox, вместо да предполагате. Обръщането на Y е мястото, където ръчно написаните версии се чупят: горната част на правоъгълника на устройството идва от горната част на PDF кутията, измерена надолу от горната част на страницата. Объркате ли го, всяко маркиране ще се нарисува огледално в грешната половина на страницата
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;
Манипулаторът за рисуване изобразява растерното изображение на страницата първо и маркирането след него, всеки път, така че слоят никога не трябва да изтрива себе си; инвалидирането на стария и новия правоъгълник поддържа областта за прерисуване малка дори при бързи скорости на речта. 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 доставя кутии за всяка дума, проследяващ курсор, следване с автоматично превъртане и единици за четене на ниво изречение като свойства на компонента, а неговото демо за четене на глас е конвейерът от тази статия, сведен до шепа извиквания