一位盲人用户在你崭新的 Delphi 查看器里打开一份季度报告,打开 NVDA,先听到的是页脚,然后是一列数字,最后才是任何视力正常的读者会最先读到的标题。或者干脆什么也听不到。页面在屏幕上看着完美无缺,而这恰恰是陷阱所在:渲染和朗读是两个不同的问题,由不同的代码解决。一份 PDF 绘制字形的顺序,没有义务与一个人应当听到它们的顺序一致,所以一个只建立在渲染调用之上的查看器,产出的是一幅无瑕的画面和一段没法用的旁白。PDFium Component 是面向 Delphi、C++Builder 和 Lazarus 的 PDFium 引擎 VCL/LCL 封装,它正因如此带着一套独立的朗读 API。绘图 API 无法还原一份它从来就没拿到过的阅读顺序
一个无障碍阅读器的成败系于三件事。它必须提取出屏幕阅读器能念的顺序,让一个可见的词光标始终钉在语音正在说的地方,并且在文档从未加过标签时承认这一点,而不是靠猜再装作没事。每一件都有一个明确的 API 可以伸手去拿,也都有一个漏掉细节就会咬人的失败
阅读顺序住在结构树里,不在绘制顺序里
ISO 32000-1 §14.8 把逻辑结构定义为一棵叠加在页面内容之上的元素树。PDF/UA(ISO 14289-1)更进一步,把这棵树变成强制的:每一块真实内容都必须能按阅读顺序通过它抵达,而页面上的装饰性构件要被标记出来并跳过。一份标签正确的报告知道 "Quarterly Results" 是二级标题,也知道那张合计网格是一个带表头单元格的表格。一份没加标签的报告,则是一堆恰好看起来像文档的定位字形串
ReadablePageContent 在结构存在时会遍历它,交回带着语义 Kind 的片段,取值像 cfHeading 和 cfParagraph,于是界面可以在念出文字之前先说一声"标题",而不是把一行粗体当成普通正文读掉。在没有可用结构树时,同一个调用会退回启发式版面分析:检测分栏、聚类基线、从左到右、自上而下地排序。这种回退对一份单栏备忘录没问题,对一份通讯稿、一张多栏表单,或者任何带侧栏、带引文块的东西就靠不住了。要紧的是知道自己拿到的是哪一种结果,而 API 会直截了当地告诉你。TPdfReadableContent 记录带着一个 Source 字段,顺序来自标签树时为 rosStructure,从几何推断出来时为 rosHeuristic。把猜出来的顺序当作已验证的展示出去,你交付的就是无障碍版的"没人跑过却挂着通过徽章的构建"
打开文件时最便宜的一招,是读一次 IsTagged 并调用一次 ValidatePdfUa,然后把答案缓存起来。PDF/UA 检查没通过并不构成拒收这份文件的理由。它构成的理由是在状态栏里写上"阅读顺序为估算",这样当客户寄来一封关于旁白错乱的投诉时,支持人员立刻就知道自己面对的是文件里的标签问题,还是你代码里的缺陷
用 ReadingUnits 把页面变成语音队列
对文本转语音来说,ReadingUnits 承担了主要的活。它为当前活动页返回一个 TPdfReadingUnit 记录数组,每条记录持有要念的文本、它的语义角色,以及在页面上定位它的矩形。还有一个覆盖全文档的搭档 DocumentReadingUnits,供你想要跨页连续朗读时使用。一个单元恰好塞进语音队列的一个槽位:
procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
Units: TPdfReadingUnits;
i: Integer;
begin
Pdf.PageNumber := PageNumber; // ReadingUnits 作用于当前活动页
Units := Pdf.ReadingUnits;
FSpeechQueue.Clear;
for i := Low(Units) to High(Units) do
FSpeechQueue.Add(Units[i]); // 文本 + 语义 + 高亮矩形
FCurrentPage := PageNumber;
SpeakNextUnit;
end;
那个循环里有两处很容易做错。队列要按页维护,用户一翻页就重建,因为朗读单元携带的是页面空间的矩形;一个从第三页留下来的队列,会把它的高亮画到第四页上。还有,在一个明显有内容的页面上拿到空的 Units 数组,就把它当作你的纯图像检测器。一张扫描页是一堆像素,底下没有文本层,正确的做法是念出一句警告("这一页没有可提取的文本"),而不是陷入一种听众分辨不出是不是卡死的沉默
跟着语音走的词光标
一次高亮一整段,对一位一边听朗读一边用眼睛追词的低视力用户来说显得迟钝。词级高亮,也就是卡拉 OK 效果,需要两样东西:每个词的几何位置,以及一种把 TTS 引擎的进度报告映射到那份几何上的办法。PageWordBoxes 以 TPdfWordBox 记录的形式把几何交给你,每条带着词文本、它的字符偏移、字符数量和一个页面空间矩形。TrackReadingWordAt 则给你那份映射。把 SAPI 的词边界事件本来就会报告的字符位置喂给它,它就把这个偏移解析成词框数组里的一个索引,并在一次调用里把光标画到匹配的那个词上
procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
// 视图的词框来自视图正在显示的那一页
// 单独设置 Pdf.PageNumber 并不会让视图跟着移动
PdfView.PageNumber := PageNumber;
FWordBoxes := PdfView.PageWordBoxes;
end;
procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
WordIdx: Integer;
begin
// TrackReadingWordAt 既映射偏移,也顺手画出词光标
WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
if WordIdx < 0 then
PdfView.ClearReadingWord; // 边界事件跑过了本页文本的末尾
end;
这份契约在一处很慷慨,在另一处毫不通融。慷慨的一面:TrackReadingWordAt 为它正在跟踪的那一页自带词框缓存,所以没有什么需要预加载,而且完全不发生渲染,因为词框来自文本层。一个没有可见窗口的无头语音服务照样能跟踪位置。不通融的一面:字符索引必须指进组件提取出来的那份文本,而不是你自己拼出来的某个清洗过的字符串。当 CharIndex 跑过本页文本末尾时,函数返回 -1 而不是抛异常,这在 TTS 引擎为结尾标点多发一次边界事件时天天发生。把 -1 读作"清除光标",绝不要读作错误
在显示这一侧,ReadingWordColor 设定光标颜色。默认的琥珀色在大多数页面背景上都撑得住,但要在你的查看器提供的每一种显示滤镜下都测一遍。琥珀色光标在颜色反转下可能完全消失,而反转与语音同时开着,恰恰是低视力用户的工作方式,所以你最需要做对的那一种组合,正是一次快速演示永远碰不到的那一种。把 ReadingWordFollow 设为 True,视图就会自己把正在朗读的词滚进视野,在一个放大到跨屏的页面上你离不开这个。注意一条作用域规则:SetReadingWord 只在当前活动的 TPdfView 页面上作画。事先想清楚手动滚动是暂停语音,还是让跟随行为压过手动滚动,因为两者都不选,结果就是声音继续念着,而光标停在屏幕外的某个地方
会把你的阅读器搞垮的那些文档
有少数几种输入形态能稳定地击垮一个天真的实现,稳定到它们应当作为常驻样例留在回归套件里,而不是当成修完就忘的一次性缺陷
- 没加标签但文字丰富的文件。启发式顺序对一份线性报告往往是对的,而侧栏或引文块一出现就错。把顺序标记为估算,界面里和诊断日志里都要标,这样日后这个失败才读得懂
- 纯图像扫描件。完全没有文本层。用空的朗读单元把它们抓住,并把用户指向上游的 OCR 环节,而不是让阅读器去旁白一张空页
- 组合字符与混合书写系统。Unicode 组合记号并不总能一对一地收拢成视觉上的词,所以词框数量可能与你自己的分词器所预期的对不上。不要用你自己切分文本算出来的偏移去索引词框数组;只使用
TrackReadingWordAt返回的索引
像审计员那样测,而不是像做演示那样测
"它把我的样例念出来了"什么也证明不了。一个站得住脚的通过,是挂着 NVDA 把三份文件跑过成品构建:一份已知加了标签的文件,标题被当作标题播报、表格按行顺序朗读;一份已知未加标签的文件,估算顺序的指示清晰可见;再加一份扫描件,无文本警告确实被念了出来。每一份都会走一条顺利路径会跳过的分支
在此之后,确认词光标在两倍语速和半速下都咬得住,并且 ReadingWordFollow 的滚动不会和用户自己的滚动打架。然后一边放语音一边把每一种颜色滤镜轮一遍,盯着光标看它是否始终没有消失。低视力颜色滤镜一文详细讲了那条渲染路径,而词语音光标深入剖析拆解了 TTS 的时序
上面用到的朗读单元与词框 API 随 PDFium Component 一同发布,支持 Delphi 与 C++Builder(VCL)以及 Lazarus/FPC(LCL)。产品页链接了完整的 API 参考,包括这些示例背后朗读单元和词框的记录布局