技术文章

Delphi PDFium 查看器中的逐词 TTS 高亮显示

朗读功能除了发出声音外,还有一项可见的任务:当每个词被读出时,必须在页面上点亮该词并使其保持在视图中。为了做到这一点,您需要每个词的边界框,并索引到语音引擎读取的同一个字符流中。获得了框但遗漏了索引,高亮就会落后音频一两个词;获得了索引但在页面状态处理上出错,高亮就会落在完全错误的页面上。这其中的语音部分,即合成器本身,是很少出问题的环节。SAPI 会将词边界精确地报告到字符。容易出问题的是在语音缓冲区中的字符偏移量和渲染页面上的矩形之间进行映射的那个薄层

PDFium Component 为 Delphi、C++Builder 和 Lazarus 提供了该映射,自 v1.53 版本起提供词框,自 v1.56 版本起提供追踪光标。这个接口表面刻意做得很窄:一个返回页面词框的调用,一个将字符偏移量转换为绘制高亮的追踪器,以及几个用于颜色和自动滚动的属性。尽管很窄,但调用的顺序决定了该功能是否正常工作,而且下文提到的大多数失败都源于以错误的顺序调用正确的函数

字符不是词语,而 TTS 引擎以字符为单位发声

语音引擎消耗一个扁平的字符串,并以该字符串中的字符位置报告进度。PDF 页面将字形放置在页面空间中,其中“词语”是一种字形序列的启发式簇。除非您交给合成器的文本与计算词框的文本完全一致(逐字节相同),否则这两个坐标系没有任何共同点。这是第一条规则,没有任何宽容的余地。如果对提取的文本进行规范化空白符、去除软连字符或者进行其他“清理”后再进行发声,那么所有后续的偏移量都会悄无声息地出错。只能完全照搬提取的内容发声,或者维护一张明确的偏移量重映射表。对于真实的文档,没有能够幸存的第三种选择

重映射表并非假想的边缘情况。一旦您的用户界面插入了页面播报(“第五页”)或为合成器展开了缩写,发声的字符串就与提取的字符串产生了分歧。请记录每次插入的位置和长度,然后在每次追踪调用之前减去累积的调整量。这只需要大概二十行的簿记代码,但它却是高亮功能能否经受住下一个功能请求考验,或者在有人首次要求阅读标题时就崩溃的关键所在

词框能为您提供什么

每个 TPdfWordBox 记录携带词语的文本、其在页面文本中的 StartIndex 和字符 Count、页面空间中的 Rect,以及基于 1 的 Page 编号。StartIndex 字段是连接两个坐标系的桥梁:它是 SAPI 在朗读时返回的完全相同的偏移量。PageWordBoxes 返回活动页面的完整数组:

procedure TReaderForm.PreparePage(PageNo: Integer);
begin
  PdfView.PageNumber := PageNo;   // 视图的词框追踪其显示的页面

  FWords := PdfView.PageWordBoxes;
  FPageText := BuildSpeechText(FWords);   // 按顺序连接 Word.Text

  if Length(FWords) = 0 then
    HandleImageOnlyPage(PageNo);          // 没有文本层的扫描件
end;

顺序注释是起着承载作用的。查看器的 PageWordBoxes 对视图当前显示的页面文本层进行词法分析(tokenize),因此首先导航视图,其次进行提取;无需渲染,只需要一个打开的文档即可。(文档组件 TPdf 公开了它自己的 PageWordBoxes,受 Pdf.PageNumber 键控,用于无头(headless)使用。这两个页码是独立的,这本身就是一个陷阱。)对于肉眼可见包含内容的页面返回空结果意味着这是一个纯图像的扫描件。将其路由给 OCR,或者至少播报出来(“第 4 页不包含可读文本”),而不是在没有任何解释的情况下让语音归于沉寂

将 SAPI 词边界连接到追踪器

查看器上的 TrackReadingWordAt 是整个功能的核心。提供页码和字符索引;它能找到包含该字符的词框,在上面绘制阅读光标,并返回词索引,或者当索引落在词语之间时返回 −1。SAPI 的词边界通知准确地提供了它想要的字符位置:

procedure TReaderForm.OnSpeechWordBoundary(StreamPos: Integer);
var
  WordIdx: Integer;
begin
  // 一次调用完成偏移量到词框的映射并移动高亮
  WordIdx := PdfView.TrackReadingWordAt(FPageNo, StreamPos);
  if WordIdx < 0 then
    Exit;                     // 边界落在任何词语之外:保留最后的高亮
end;

这里有两个起到防卫作用的细节发挥了价值。首先,TrackReadingWordAt 为被追踪的页面保留了自己的词框缓存,在页面改变时自动重建,因此无论边界到达得有多快,每个边界的成本都保持平缓。其次,它在边界检查方面并不宽容。等于或超出页面字符计数的索引会返回 −1,而不是限制到最后一个词语。请把 −1 当作“保持前一个高亮”来处理,永远不要当作错误,因为标点符号序列和词间空白合理地产生了不属于任何词语的边界。如果记录每一个 −1 就会将您淹没。取而代之的是,针对每一页统计它们,如果该比例激增,请仔细检查该页,因为这通常意味着在第一条规则中出现了文本规范化不匹配

光标本身:颜色、跟随与清理

当您自己持有词框时,SetReadingWord 直接绘制高亮;ReadingWordColor 设置其样式;ReadingWordFollow := True 会使视图滚动适当的距离以保持被读的词语可见。最后一个属性确实起到了作用。手工编写的“将当前词语居中”的滚动,会使页面在每次换行时都猛然一动,对晕动敏感的读者会在一分钟内关闭整个功能。高亮仅在活动 TPdfView 当前显示的页面上渲染,因此多页面朗读必须随着语音同步推进 PageNumber,然后在该新页面的首个边界事件到达之前重新运行新页面的准备步骤。如果跳过这一步,每页的前几个高亮就会指向过时的坐标

procedure TReaderForm.StopReading;
begin
  FVoice.Stop;                // 首先停止 SAPI 播放
  PdfView.ClearReadingWord;   // 然后移除高亮;过时的光标会被视为 Bug
end;

关停时的对称性才能保证高亮不出错。每一个暂停、停止和翻页路径都必须以 ClearReadingWord 结束。如果漏掉它,一个琥珀色的矩形就会留在一个停止的页面上,看起来就像是一个缺陷,这是每个测试人员都会提交的那种问题,尽管实际上没有任何东西损坏

语速比文档大小更能给这个管道带来压力。在每分钟 300 词时,边界事件每 200 毫秒到达一次,在最高 SAPI 速率下,它们的到达速度比眼睛舒服跟踪的速度还要快。正确的响应是合并,而不是排队。如果高亮更新仍在挂起时新的边界到达,请丢弃旧边界并绘制最新边界。一个按顺序访问每个词但延迟半秒的光标会感觉像是坏了;一个在与语音保持同步的同时偶尔跳过一个词的光标则不会

区分演示与产品的边缘情况

文档中的几类边缘情况暴露了接缝。组合字符是最微妙的:诸如基础字母加上组合变音符号等 Unicode 序列,占用的字符索引可能多于其视觉呈现的词语数,因此任何假设每个字形一个索引的偏移量算术都会慢慢发生偏移。这是让 TrackReadingWordAt 掌握映射而不是手工计算词语编号的最有力理由。连字符在日常中更为常见,也发生得更为普遍:一个跨行断开的词语会变成两个词框,如果您将其作为单个标记发声,其下半部分的边界事件会解析到第一个词框。这通常没有问题,但这是一种决定,因此请有意识地做出决定而不是偶然发现。标记(Tagging)会改变阅读顺序本身。当文档携带正确的结构标记(属于 ISO 14289,PDF/UA 领域)时,词语排序会遵循逻辑结构;如果没有这些标记,它会回退到布局启发式规则,一个无标记的两列页面可能会横跨这两列直接读取。旋转的页面是最后一个常见的边缘情况:每个词语的 Rect 在页面空间中仍然能正确包围它,但是对于垂直方向文本,为水平排版调整的视口跟随策略会导致滚动非常不协调,因此,在回归集中应至少保留一个旋转文档。有关阅读顺序的处理、通过 ReadingUnits 实现的句子级单元,以及更广泛的辅助堆栈,请参阅在 Delphi 中构建无障碍 PDF 阅读器

一个平台约束决定了部署情况。SAPI 是 Windows 专属的。在 Lazarus 和 FPC 下,词框和追踪 API 是完全相同的,但是 Linux 和 macOS 构建需要将不同的合成器连接到相同的边界事件之后;这种设置包含在在 Lazarus 和 FPC 下运行查看器中。一旦语速提高,高亮的开销还会与您的页面缓存相互作用,而在渲染缓存和缩放性能中的预算算术规则在此同样适用

当单词高亮显示是错误的粒度时

逐词的卡拉 OK 式高亮并不总是读者想要的。在高语速下,光标逐词闪烁会变成它自己的视觉噪音,并且一些听众在跟踪一个句子时,会觉得这比单词的频闪要舒适得多。针对这种情况,该组件公开了一个更粗的单元。ReadingUnits 返回句子和块级别的单元,每个单元带有它自己的高亮矩形,您可以用 SetReadingHighlight 而不是 SetReadingWord 来绘制它们。连接过程形式相同:边界偏移量仍然驱动哪个单元亮起,但是您高亮的单元横跨一个子句或一行,而不是单个标记。较慢的阅读者和高速回放往往都倾向于使用这种方式,且没有任何限制阻止您在一个设置选项后面同时提供这两种模式

在您构建基于此的功能之前,值得明确版本底线:词框需要 PDFium Component v1.53 或更高版本,而追踪光标需要 v1.56 版本。完整的阅读 API、句子级单元以及一个正常工作的朗读演示可在 PDFium Component 的产品页面上找到