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

Создание доступных средств просмотра PDF с функцией преобразования текста в речь в Delphi

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

PDF-просмотрщик Delphi с чтением вслух и выделенным потоком синтеза речи SAPI, обменивающимся смещениями границ слов с потоком UI
Речевой поток владеет COM и SpVoice всю жизнь приложения и передаёт символьные смещения потоку UI, который один рисует синхронизированную подсветку
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, вызываемый из потока пользовательского интерфейса, сохраняет текст в защищенном блокировкой поле и публикует WM_SPEAK_PAGE, потому что вызов метода у FVoice напрямую из другого потока был бы межапартаментным вызовом COM на немаршалированном интерфейсе. Однострочный PeekMessage перед циклом заставляет Windows создать очередь сообщений потока, закрывая состояние гонки при запуске, когда ранняя публикация из потока пользовательского интерфейса потерпела бы неудачу

Границы слов поступают как смещения символов

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

Это последнее предложение является инвариантом, на котором держится функция: смещения имеют смысл только относительно строки, которую читает голос, поэтому произносите именно тот текст, который вы извлекли, символ за символом. Обрежьте пробелы, сверните разрывы строк или разверните аббревиатуру для лучшего произношения, и каждая подсветка после первого изменения сместится на одно слово. Если пользовательский интерфейс должен внедрять озвучиваемый материал — объявления страниц, префиксы заголовков — запишите позицию и длину каждой вставки и вычитайте накопленный сдвиг из каждого смещения перед его сопоставлением

Смещения границ слов SAPI, индексирующие точный текстовый буфер, который произносит PDF-просмотрщик Delphi с синтезом речи
События границ индексируют тот самый буфер, переданный в 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
  // Выполняется в потоке речи; передавайте смещения в UI, не блокируя его
  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, начало координат снизу слева
  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
    // Порядок параметров: left, right, bottom, top — не порядок 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;
  // Y в PDF растёт вверх от нижней кромки; устройственное 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 и видимый прямоугольник фокуса, затем обработайте три клавиши: Пробел переключает FVoice.Pause и FVoice.Resume, а влево и вправо пропускают через FVoice.Skip('Sentence', 1) с отрицательным счетчиком для возврата назад. Метод Skip в SAPI понимает только уровень предложений, поэтому пропуск на уровне слов означает очистку воспроизведения с помощью SVSFPurgeBeforeSpeak и повторное произнесение со смещения слова, которое вы отслеживали последним — это дешево, так как код подсветки уже сохраняет именно это смещение. Держите каждый элемент управления воспроизведением как настоящий TButton с заголовком, чтобы программы чтения с экрана озвучивали его

Это весь конвейер, весь он работает с сырым текстовым API PDFium: речевой поток, который владеет COM и голосом в течение жизни приложения, события границ, передаваемые в пользовательский интерфейс как смещения символов, и посимвольные рамки в пространстве страницы, превращенные в один смешанный прямоугольник на экране. Если вы не хотите сами владеть геометрией и отслеживанием, PDFium Component поставляется с рамками для каждого слова, курсором отслеживания, автоматической прокруткой и единицами чтения на уровне предложений в качестве свойств компонента, а его демо-версия чтения вслух — это конвейер этой статьи, сведенный к горстке вызовов