技术文章

在 Delphi 中使用文本转语音功能构建无障碍 PDF 查看器

一个朗读按钮的演示可能只需要一个下午,但完善它却要花上一周时间。下午的版本提取页面文本,将其交给 SAPI,并获取音频。而那一周则用于让该功能真正可用:语音绝不能冻结窗口,朗读出的单词必须与音频同步在页面上高亮显示,且空格键必须能暂停整个过程。本文针对原始 PDFium 文本 API 和 Windows 语音 API,在 Delphi 中构建该管道,并提供快速版本中跳过的三个关键部分的有效代码:只执行一次而不是按每次发音执行的 COM 生命周期、真实的单词边界事件,以及将 PDF 空间的单词框转换为可绘制矩形的坐标数学计算

监管背景用一句话即可概括:同步朗读是 WCAG 2.1 对文档软件查看器端的要求,而 ISO 14289-1 (PDF/UA) 定义了最适合它的标记文件端的要求。如果你是在 PDFium Component 之上构建的,你可能根本不需要这个管道:该查看器附带了一个内置的跟踪光标,可通过一次调用将字符偏移量映射到绘制的单词高亮显示,相关内容已在“逐词 TTS 高亮显示”一文中介绍。以下内容适用于你自己拥有整个查看器应用程序并希望自己实现该管道的情况

一个线程渲染,一个线程朗读

该架构包含两个线程和一份契约。UI 线程渲染页面位图,拥有缩放和滚动状态,并绘制高亮叠加层。专用的语音线程拥有 SAPI 语音,且其他任何东西都不会触碰它。这份契约很简单:语音线程将进度作为字符偏移量进行报告,而 UI 线程将这些偏移量转换为矩形

大多数 SAPI 示例将每次发音都包装在 CoInitializeCoUninitialize 中,而查看器会立刻暴露出为什么这样做是错误的。使用 SVSFlagsAsync 调用 Speak 会在文本排队后立即返回,因此同一过程 finally 块中的 CoUninitialize 会在语音仍在朗读时运行,从而销毁拥有它的 COM 单元。根据时机的不同,你可能会遇到静音、截断的发音,或者几分钟后的访问冲突。正确的生命周期很枯燥:在语音线程启动时调用一次 CoInitialize,在该单元内部创建语音,然后在线程退出且语音已释放后,调用一次 CoUninitialize。绝不要针对每次发音进行这些调用

语音还需要一个消息泵,这决定了它能存在于何处。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;

TerminatedSet 会发送 WM_QUIT,以便在查看器关闭时解除消息泵的阻塞。从 UI 线程调用的 SpeakPage 将文本存储在受锁保护的字段中,并发送 WM_SPEAK_PAGE,因为从另一个线程直接调用 FVoice 上的方法将构成在未编组接口上的跨单元 COM 调用。循环之前的单行 PeekMessage 会强制 Windows 创建线程的消息队列,从而消除启动时的竞态条件(在这种条件下,UI 线程过早发送消息会导致失败)

单词边界以字符偏移量的形式到达

通过 IDE 的类型库导入器导入一次 Microsoft 语音对象库,你就会获得包含 TSpVoice 包装器及其强类型事件的 SpeechLib_TLB。其中有两个设置很重要。EventInterests 应缩小到你实际使用的事件,因为保留开启的每个关注点都会产生每一页每个单词的跨线程事件流量;SVEWordBoundary 驱动高亮显示,而 SVEEndInputStream 告诉你发音已结束。此外,OnWord 处理程序会接收 CharacterPosition 和长度,它们是对你传递给 Speak 的确切字符串的索引——这是进入语音缓冲区的偏移量,而不是其他任何东西的偏移量

最后一句话是该功能依赖的不变性:偏移量只有在针对语音正在朗读的字符串时才有意义,因此必须逐字符地朗读你提取的确切文本。如果你为了发音更好听而修剪空白、折叠换行符或展开缩写,那么第一次编辑之后的每个高亮显示都会偏移一个单词。如果 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;

此处应使用 TThread.Queue 进行编组,而不是 Synchronize:处理程序绝不能在 UI 重绘时停顿语音线程,并且如果边界事件的到达速度快于屏幕的绘制速度,陈旧的高亮更新也是无害的,因为下一次更新会覆盖它。以同样的方式连接 OnEndStream 来清除高亮显示,并在连续阅读模式下,加载下一页的文本并提交下一次发音

从字符偏移量到屏幕上的像素

PDFium 按字符报告几何形状。FPDFText_GetCharBox 按照特定的顺序填充四个双精度值(left、right、bottom、top,而不是 Windows 的 left、top、right、bottom),这种顺序引起的难以察觉的错误比文本 API 中的任何其他问题都要多;并且它以页面空间来报告这些值:使用 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 为零,但如果页面定义了裁剪框,则它来自裁剪框,因此应从 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 是一个 1x1 的 TBitmap,在启动时用高亮颜色填充一次(例如 FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF 表示琥珀色),AlphaBlend 将其拉伸覆盖到目标矩形上,因此每一帧都不会分配任何内存,且 SourceConstantAlpha 设为 96 使得单词在色调下依然清晰可读。请在反色和高对比度显示模式下测试此颜色;如果弱视用户无法看到叠加层,那么对于正是为之构建该功能的人来说,它就形同虚设

文本 API 无法解决的部分是阅读顺序

FPDFText_GetText 按派生自内容流并经过一些空间清理的顺序返回字符,这对于单栏报告来说毫无问题。但它没有义务在其他任何地方都保持正确。对于双栏的时事通讯,它可能会直接横跨两栏进行阅读;边栏可能会在一个句子的分句中间将其打断;而页脚可能会出现在页面的中间。能解决这个问题的信​​息——带有标记的 PDF 包含的且 PDF/UA 强制要求的 ISO 32000-1 §14.8 的逻辑结构树——根本不会被原始文本页面调用所参考。如果你需要具有明确来源信号的感知结构的顺序,那是高一个层级才能解决的问题:PDFium Component 的阅读 API 会返回带有一个值为 rosStructurerosHeuristicSource 字段的内容,并且“无障碍 PDF 阅读器”一文对其进行了演练。在原始 API 级别,站得住脚的立场是将提取顺序视为一种估计,并在 UI 中予以说明,同时在回归测试集中保留一个多栏文档和一个纯图像扫描文档,以使这两种故障模式保持可见

查看器本身必须可由键盘操作

拥有语音输出并不意味着查看器可以免除键盘访问支持;最有可能使用朗读功能的人,往往是最不可能使用鼠标的人。将页面面板的 TabStop := True 设为 True,并提供可见的焦点矩形,然后处理三个按键:空格键用于切换 FVoice.PauseFVoice.Resume,左、右方向键用于通过带有正负计数(负数表示后退)的 FVoice.Skip('Sentence', 1) 进行跳转。SAPI 的 Skip 仅理解句子粒度,因此词级跳过意味着要使用 SVSFPurgeBeforeSpeak 清除播放,并从你最后跟踪的单词的偏移量重新开始朗读——这种代价很小,因为高亮代码已经存储了精确的该偏移量。请保持每个传输控件都是一个带有标题的真正的 TButton,以便屏幕阅读器能播报它

这就是整个管道,所有部分都针对原始 PDFium 文本 API:一个在应用程序生命周期内拥有 COM 和语音的语音线程,以字符偏移量形式编组到 UI 的边界事件,以及转换为屏幕上一个混合矩形的按字符页面空间框。如果你不想自己处理几何和跟踪逻辑,PDFium Component 会将每词框、跟踪光标、自动滚动跟随以及句子级阅读单元作为组件属性提供,而它的朗读演示就是本文管道精简至少量调用的结果