解决 NVDA 浏览模式先读角色/状态的插件(内容优先朗读)
大家好,我是沈广荣,今天给大家分享一个我做的小插件,专门解决 NVDA 浏览模式里“先读角色/状态、后读内容”的问题。对中文用户来说,这个顺序会明显拖慢浏览效率,所以我去翻了 NVDA 源码,把顺序做了最小化调整。顺便也把整个过程写下来,分享给大家,下面是分析过程和实现细节。
问题现象
浏览模式移动光标时,NVDA 默认会先报“按钮/链接/可点击”等角色和状态,然后才读正文。中文语序下,这会让信息密度变低,浏览时节奏被打断。
源码分析路径(我怎么定位到问题)
- 朗读入口
- 目录(需先克隆nvda源代码):
nvda源代码/source/speech/ - 核心函数:
speech.py里的getTextInfoSpeech
- 浏览模式的 TextInfo
- 目录:
nvda源代码/source/browseMode.py - 浏览模式的文本对象是
BrowseModeDocumentTextInfo
- 关键序列来源
getTextInfoSpeech会调用info.getTextWithFields(formatConfig)- 返回值是“文本 + 字段命令”的混合序列(
controlStart/controlEnd/formatChange等)
- 顺序产生的原因
- NVDA 在“进入字段”时就把角色/状态加入
speechSequence - 文本被收集到
relativeSpeechSequence,最后拼在后面 - 所以顺序天然是:角色/状态 → 内容
NVDA 原始逻辑(简要)
- 构建
controlFieldStack并与缓存对比,计算公共字段数量 - 对公共字段 + 新字段调用
getControlFieldSpeech,立即追加到speechSequence - 遍历文本与字段命令,文本追加到
relativeSpeechSequence - 最后把文本追加到主序列
结论:角色/状态先读,是它的默认设计。
我们的插件怎么做(保持行为一致,只改顺序)
插件文件(插件目录):globalPlugins/contentFirstBrowse.py
核心策略:
- 几乎完整复制 NVDA 的
getTextInfoSpeech - 只改一个点:控制字段的“开始朗读”延迟到正文出现之后
- 只在“浏览模式 + 光标移动”生效,不影响其他场景
关键代码结构与原因(我尽量会按块说明)
1) 精准生效条件
- 只对
OutputReason.CARET生效 - 只对
BrowseModeDocumentTextInfo生效 - 还会检查
passThrough和_lastCaretMoveWasFocus
原因:避免影响焦点变化、SayAll、快速导航等行为。
2) 保留缓存与格式配置
SpeakTextInfoState和formatConfig全部沿用原逻辑
原因:这是 NVDA 性能与一致性保证,不能破坏。
3) controlFieldStack 构建保持一致
- 解析
initialFields建栈 - 移除
_startOfNode/_endOfNode - 比较旧栈计算
commonFieldCount
原因:这是“进入/退出字段”判断的基础,不能变。
4) 核心改动:延迟字段开始朗读
做法:把“进入字段时要朗读的内容”先放进 pendingStartFields,等正文出现再统一冲刷。
原因:
- 正文先读,角色/状态后读
- 只改时机,不改内容
- 数学内容、可点击状态等仍然保留
5) 文本遍历逻辑保留
controlStart/controlEnd/formatChange处理方式不变- 语言切换、缩进、空行提示都保持原有逻辑
原因:只改“顺序”,不改“语义”。
6) 生命周期管理
- 插件初始化时替换
speechMod.getTextInfoSpeech和speech.getTextInfoSpeech - 卸载时恢复原函数
原因:覆盖所有调用路径,且可安全卸载。
小结
这次改动的核心是:延迟控制字段的开始朗读,让正文先出声。其余 NVDA 行为全部保持一致,因此风险可控、兼容性好。
如果你想自己验证源码,可以直接看 nvda源代码/source/speech/speech.py 的 getTextInfoSpeech,对照插件文件就能看出变化点了。
插件源代码与下载
GitHub 仓库:https://github.com/shenguangrong/contentFirstBrowse