Кнопка читання вголос демонструється за півдня, а потім з'їдає тиждень. Версія за півдня витягує текст сторінки, передає його SAPI і отримує аудіо. Тиждень іде на те, що робить функцію зручною для використання: голос не повинен заморожувати вікно, промовлене слово має підсвічуватися на сторінці синхронно з аудіо, а клавіша Пробіл має ставити все це на паузу. У цій статті створюється цей конвеєр у Delphi за допомогою базового текстового API PDFium та Windows Speech API, з робочим кодом для трьох частин, які швидка версія пропускає: життєвий цикл COM виконується один раз замість кожного висловлювання, реальні події меж слів та координатна математика, яка перетворює рамку слова в просторі PDF на прямокутник, який можна намалювати
Нормативний контекст вміщується в одне речення: синхронізоване читання вголос — це половина того, що WCAG 2.1 вимагає від програмного забезпечення для документів з боку переглядача, а ISO 14289-1 (PDF/UA) визначає половину для тегованих файлів, з якою вона працює найкраще. Якщо ви створюєте на базі PDFium Component, можливо, вам взагалі не знадобиться цей конвеєр: переглядач постачається з вбудованим курсором відстеження, який відображає зміщення символу на намальоване виділення слова одним викликом, що описано в статті про покрокове підсвічування TTS. Те, що описано нижче, призначене для випадків, коли ви володієте всією програмою перегляду і хочете мати сам конвеєр
Один потік рендерить, один потік говорить
Архітектура складається з двох потоків і одного контракту. Потік інтерфейсу користувача рендерить растрове зображення сторінки, володіє станом масштабування та прокрутки, а також малює накладення підсвічування. Виділений потік мовлення володіє голосом SAPI, і більше ніхто до нього не торкається. Контракт є тонким: потік мовлення повідомляє про прогрес як зміщення символів, а потік інтерфейсу перетворює зміщення на прямокутники
Більшість прикладів SAPI обгортають кожне висловлювання в CoInitialize та CoUninitialize, і переглядач одразу показує, чому це неправильно. Speak з SVSFlagsAsync повертає керування, як тільки текст ставиться в чергу, тому CoUninitialize у блоці finally тієї ж процедури виконується, поки голос ще говорить, руйнуючи COM-апартамент, якому він належить. Залежно від часу ви отримаєте тишу, обрізане висловлювання або порушення доступу через кілька хвилин. Правильний життєвий цикл нудний: CoInitialize один раз, коли запускається потік мовлення, створення голосу всередині цього апартаменту і CoUninitialize один раз, коли потік завершується, після того, як голос звільнено. Ніколи не для кожного висловлювання
Голосу також потрібен насос повідомлень (message pump), який визначає, де він може жити. Об'єкт автоматизації SpVoice доставляє свої події через чергу повідомлень потоку, який його створив. Створіть його в потоці інтерфейсу, і події дійсно надходитимуть, оскільки 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, викликаний з потоку інтерфейсу, зберігає текст у полі, захищеному блокуванням, і надсилає WM_SPEAK_PAGE, оскільки виклик методу FVoice безпосередньо з іншого потоку був би міжапартаментним викликом COM на немаршалізованому інтерфейсі. Однорядковий PeekMessage перед циклом змушує Windows створити чергу повідомлень потоку, закриваючи стан гонки під час запуску, коли раннє надсилання з потоку інтерфейсу не вдалося б
Межі слів надходять як зміщення символів
Імпортуйте бібліотеку об'єктів Microsoft Speech один раз через імпортер бібліотек типів 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 тут є правильним маршалом, а не 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 і видимий прямокутник фокусу, а потім обробіть три клавіші: Пробіл перемикає FVoice.Pause та FVoice.Resume, а вліво та вправо пропускають через FVoice.Skip('Sentence', 1) з від'ємним значенням для повернення назад. Skip у SAPI розуміє лише деталізацію на рівні речень, тому пропуск на рівні слів означає очищення відтворення за допомогою SVSFPurgeBeforeSpeak і повторне мовлення зі зміщення слова, яке ви відстежували останнім — це дешево, оскільки код підсвічування вже зберігає саме це зміщення. Тримайте кожен елемент керування транспортом справжнім TButton з підписом, щоб засоби читання з екрана його озвучували
Ось і весь конвеєр, побудований на базовому текстовому API PDFium: потік мовлення, який володіє COM і голосом протягом життя програми, події меж, що передаються в інтерфейс як зміщення символів, і рамки для кожного символу в просторі сторінки, перетворені на один змішаний прямокутник на екрані. Якщо ви не хочете самостійно займатися геометрією та відстеженням, PDFium Component постачає рамки для слів, курсор відстеження, автоматичну прокрутку та одиниці читання на рівні речень як властивості компонента, а його демо-версія читання вголос — це конвеєр із цієї статті, зведений до кількох викликів