技術記事

DelphiとSAPI TTSを使用して読み上げ可能なPDFビューアを構築する

読み上げボタンは午後のデモで完成したように見えても、その後1週間を費やすことになります。午後に完成するバージョンは、ページのテキストを抽出し、SAPIに渡して音声を取得します。1週間は、この機能を実際に使えるものにするために費やされます。つまり、音声がウィンドウをフリーズさせてはならず、読み上げられている単語が音声に合わせてページ上でハイライト表示され、スペースキーで全体を一時停止できなければなりません。この記事では、純粋なPDFiumのテキストAPIとWindows Speech APIを使用して、Delphiでそのパイプラインを構築します。クイックバージョンでは省略される3つの要素(発話ごとではなく1回だけ行われるCOMのライフサイクル管理、実際の単語境界イベント、PDF空間の単語ボックスを描画可能な矩形に変換する座標計算)についての動作するコードが含まれています

規制の背景は一言で説明できます。同期された読み上げは、WCAG 2.1がドキュメントソフトウェアに要求するビューア側の半分であり、ISO 14289-1 (PDF/UA) はそれが最適に機能するためのタグ付きファイル側の半分を定義しています。PDFium Component上で構築している場合、このパイプラインはまったく必要ないかもしれません。ビューアには、文字オフセットを1回の呼び出しで描画された単語のハイライトにマッピングする追跡カーソルが組み込まれており、これについては単語ごとのTTSハイライトに関する記事で説明されています。以下の内容は、ビューアアプリケーション全体を所有し、パイプラインそのものを必要としている場合のためのものです

1つのスレッドが描画し、もう1つのスレッドが読み上げる

アーキテクチャは2つのスレッドと1つのコントラクトで構成されます。UIスレッドはページビットマップを描画し、ズームとスクロールの状態を保持し、ハイライトオーバーレイをペイントします。専用の音声スレッドがSAPIの音声を保持し、他の何もそれに触れることはありません。コントラクトはシンプルです。音声スレッドは進行状況を文字オフセットとして報告し、UIスレッドはそのオフセットを矩形に変換します

ほとんどのSAPIサンプルでは、すべての発話をCoInitializeCoUninitializeでラップしていますが、ビューアではなぜそれが間違っているかがすぐにわかります。SVSFlagsAsyncを指定したSpeakは、テキストがキューに入れられるとすぐに戻るため、同じプロシージャのfinallyブロック内のCoUninitializeは音声の読み上げ中に実行され、それを所有するCOMアパートメントを破棄してしまいます。タイミングによっては、無音になったり、発話が途切れたり、数分後にアクセス違反が発生したりします。正しいライフサイクルは退屈なものです。音声スレッドが開始されたときにCoInitializeを1回呼び出し、そのアパートメント内に音声を作成し、スレッドが終了するとき(音声が解放された後)にCoUninitializeを1回呼び出します。発話ごとに呼び出すことは決してありません

音声にはメッセージポンプも必要であり、これによってそれがどこに配置されるかが決まります。SpVoiceオートメーションオブジェクトは、それを作成したスレッドのメッセージキューを通じてイベントを配信します。UIスレッドで作成すると、VCLがメッセージをポンプするためイベントは届きますが、描画が遅れるたびに単語の境界も遅延します。メッセージポンプのないワーカースレッドで作成すると、イベントはまったく届きません。独自のGetMessageループを持つ専用スレッドを使用すると、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;   // 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;

TerminatedSetWM_QUITをポストし、ビューアのシャットダウン時にポンプのブロックを解除します。UIスレッドから呼び出されるSpeakPageは、ロックで保護されたフィールドにテキストを保存し、WM_SPEAK_PAGEをポストします。これは、別のスレッドからFVoiceのメソッドを直接呼び出すと、マーシャリングされていないインターフェイス上のクロスアパートメントCOM呼び出しになるためです。ループの前の1行のPeekMessageは、Windowsにスレッドのメッセージキューの作成を強制し、UIスレッドからの初期のポストが失敗する起動時の競合を解消します

単語の境界は文字オフセットとして到着する

IDEのタイプライブラリインポーターを使用してMicrosoft Speech Object Libraryを1回インポートすると、TSpVoiceラッパーとその型付きイベントを含むSpeechLib_TLBが得られます。2つの設定が重要です。有効にしたままの各関心事項は、すべてのページのすべての単語に対するクロススレッドのイベントトラフィックになるため、EventInterestsは実際に消費するイベントに絞り込む必要があります。SVEWordBoundaryはハイライトを駆動し、SVEEndInputStreamは発話の終了を通知します。そして、OnWordハンドラーはCharacterPositionと長さをパラメーターとして受け取ります。これらはSpeakに渡した正確な文字列のインデックスです。つまり、他の何かのオフセットではなく、音声バッファ内のオフセットです

この最後の句が、この機能が依存する不変条件です。オフセットは音声が読み上げている文字列に対してのみ意味を持つため、抽出したテキストを一文字ずつ正確に読み上げさせる必要があります。読みを良くするために空白をトリミングしたり、改行を折りたたんだり、略語を展開したりすると、最初の編集以降のすべてのハイライトが1単語分ずれてしまいます。UIでページの案内や見出しのプレフィックスなどの話し言葉を挿入する必要がある場合は、各挿入の位置と長さを記録し、マッピングする前にすべてのオフセットから累積されたシフト量を差し引いてください

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;

ここでの正しいマーシャリングはSynchronizeではなくTThread.Queueです。ハンドラーはUIの再描画中に音声スレッドを停止させてはならず、画面の描画よりも速く境界イベントが到着した場合でも、古いハイライトの更新は次の更新によって上書きされるため無害です。同じ方法でOnEndStreamを接続してハイライトをクリアし、連続読み上げモードの場合は、次のページのテキストを読み込んで次の発話をポストします

文字オフセットから画面上のピクセルへ

PDFiumは文字ごとのジオメトリを報告します。FPDFText_GetCharBoxは4つのdouble値を埋めますが、その順序はテキストAPIの他の何よりも多くのサイレントバグを引き起こしてきました。Windowsの左、上、右、下ではなく、左、右、下、上という順序です。また、それらをページ空間で報告します。つまり、1インチあたり72のPDFポイント、原点は左下隅、Yは上に向かって増加します。単語のボックスはその文字のボックスの和集合であり、デバイスピクセルへの変換は3つのステップで行われます。ページ原点での平行移動、ズームに画面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;

FPageTopFPDF_GetPageHeightからのポイント単位のページ高さであり、FPageLeftはほとんどのドキュメントでゼロですが、ページで定義されている場合はクロップボックスから取得されます。そのため、推測せずに両方を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は、起動時にハイライトカラー(アンバーの場合はFHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF)で1回だけ塗りつぶされる1x1のTBitmapであり、それをAlphaBlendが対象の矩形上に引き伸ばします。したがってフレームごとに何も割り当てられず、SourceConstantAlphaを96に設定することで、色合いを通して単語を読みやすく保ちます。色を反転モードやハイコントラスト表示モードでテストしてください。ロービジョンのユーザーが見えないオーバーレイは、まさにその機能を必要としている人にとって存在しないも同然です

読み上げ順序はテキストAPIでは解決できない部分である

FPDFText_GetTextは、ある程度の空間的なクリーンアップを伴い、コンテンツストリームから導き出された順序で文字を返します。単一列のレポートであれば、その順序で問題ありません。しかし、それ以外の場合に正しい順序である保証はありません。2段組みのニュースレターでは両方の列を横切ってまっすぐ読み上げる可能性があり、サイドバーが文の途中で割り込んだり、フッターがページの中央に現れたりするかもしれません。これを修正するための情報(タグ付きPDFに含まれ、PDF/UAで必須とされているISO 32000-1 §14.8の論理構造ツリー)は、純粋なテキストページ呼び出しではまったく参照されません。明確なソースを示す構造を意識した順序が必要な場合、それは1つ上のレベルで解決済みの問題です。PDFium Componentの読み取りAPIは、rosStructureまたはrosHeuristicSourceフィールドを持つコンテンツを返し、アクセシブルなPDFリーダーに関する記事でそれを詳しく説明しています。純粋なAPIレベルでは、抽出順序を推測として扱い、UIでその旨を伝え、1つの段組みドキュメントと1つの画像のみのスキャンをリグレッションセットに保持して、両方の失敗モードが目に見えるようにしておくのが妥当な対応です

ビューア自体がキーボード操作可能でなければならない

音声出力があるからといって、ビューアのキーボードアクセスが免除されるわけではありません。読み上げ機能を使用する可能性が最も高い人は、マウスに手を伸ばす可能性が最も低い人たちです。ページパネルにTabStop := Trueと目に見えるフォーカス矩形を与え、3つのキーを処理します。SpaceキーでFVoice.PauseFVoice.Resumeを切り替え、左右の矢印キーでFVoice.Skip('Sentence', 1)を使用してスキップします(戻る場合は負の数を指定します)。SAPIのSkipは文の単位しか理解しないため、単語レベルでスキップするということは、SVSFPurgeBeforeSpeakで再生を破棄し、最後に追跡した単語のオフセットから再び読み上げることを意味します。ハイライトのコードですでに正確なオフセットを保存しているため、これは低コストで済みます。スクリーンリーダーがそれを読み上げられるように、すべてのトランスポートコントロールをキャプション付きの実際のTButtonとして維持してください

これがパイプラインの全体であり、そのすべてが純粋なPDFiumテキストAPIに対して行われます。アプリの存続期間中COMと音声を保持する音声スレッド、文字オフセットとしてUIにマーシャリングされる境界イベント、そして文字ごとのページ空間のボックスが画面上で1つのブレンドされた矩形に変換されます。ジオメトリや追跡を自前で実装したくない場合は、PDFium Componentに単語ごとのボックス、追跡カーソル、自動スクロール追従、文レベルの読み上げ単位がコンポーネントプロパティとして同梱されています。その読み上げデモは、この記事のパイプラインをほんの数回の呼び出しに凝縮したものです