Кнопка чтения вслух демонстрируется за день, а затем съедает неделю. Дневная версия извлекает текст страницы, передает его в 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 один раз при завершении потока, после того как голос был освобожден. Никогда для каждого высказывания
Голосу также нужен цикл обработки сообщений, который решает, где он может жить. Объект автоматизации 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 поставляется с рамками для каждого слова, курсором отслеживания, автоматической прокруткой и единицами чтения на уровне предложений в качестве свойств компонента, а его демо-версия чтения вслух — это конвейер этой статьи, сведенный к горстке вызовов