
鸿蒙 PC Markdown 编辑器大纲解析:ATX、Setext 与源码跳转
长篇 Markdown 文档的导航通常依赖大纲。用户看到的是左侧标题列表,工程实现却必须同时理解 Markdown 语法、换行格式、代码围栏、字符偏移和编辑器滚动。大纲如果把代码块里的 ## 示例 当成真实章节,或者点击标题后跳到中文字符之前的错误位置,它不仅不好用,还会削弱用户对整份文档结构的信任。
本文以鸿蒙 PC 编辑器 OhMarkdown 的大纲能力为例,拆解一个不依赖完整 Markdown AST 的轻量解析器如何覆盖 ATX 标题、Setext 标题和围栏代码块,并通过 UTF-16 偏移让 ArkUI 大纲准确驱动 ArkWeb 中的 CodeMirror。真实代码位于公开仓库 https://gitcode.com/VON-/codex_md_oh,本文基于提交 3a9146e。
大纲的输出不只是标题文字
如果大纲只输出字符串数组,界面可以显示标题,却无法稳定跳转。相同标题可能出现多次,标题文本也可能包含 Markdown 尾部井号。OhMarkdown 为每一项返回四个字段:
ts
export interface MarkdownHeading {
level: number;
title: string;
offset: number;
line: number;
}
level 决定视觉缩进和层级;title 是清理后的显示文字;offset 是源码起始位置,直接用于 CodeMirror 选择和滚动;line 用于界面显示,也便于测试和未来的"转到行"能力。标题身份不是 title,真正可定位的身份是当前文档版本里的偏移。
同时保留行号与偏移有现实价值。行号适合人阅读和日志,偏移适合编辑器 API。只存行号意味着点击时还要重新遍历文档,处理 CRLF 时也容易把换行长度算错;只存偏移则难以在大纲中给用户提示,也不方便诊断。两个字段在一次扫描中就能得到,成本很低。
先把换行拆对,再讨论 Markdown
Markdown 文件可能使用 LF、CRLF,也可能来自历史工具而包含单独 CR。JavaScript 的 split('\n') 会在 CRLF 文档的每行末尾留下 \r,单独 CR 又完全不会切行。大纲服务先用字符扫描建立统一行模型:
ts
interface MarkdownLine {
text: string;
offset: number;
line: number;
}
function splitMarkdownLines(content: string): Array<MarkdownLine> {
const lines: Array<MarkdownLine> = [];
let lineStart: number = 0;
let lineNumber: number = 1;
for (let index: number = 0; index <= content.length; index += 1) {
const atEnd: boolean = index === content.length;
const character: string = atEnd ? '' : content[index];
if (!atEnd && character !== '\n' && character !== '\r') {
continue;
}
lines.push({
text: content.slice(lineStart, index),
offset: lineStart,
line: lineNumber
});
if (!atEnd && character === '\r' && content[index + 1] === '\n') {
index += 1;
}
lineStart = index + 1;
lineNumber += 1;
}
return lines;
}
循环条件使用 index <= content.length,因此即使最后一行没有换行符,也会在 atEnd 分支写入。遇到 CRLF 时额外跳过 \n,下一行偏移自然落在两个码元之后。遇到单独 CR 或 LF 时只前进一个。所有 offset 都来自原始字符串索引,不需要在后续根据"行号乘平均长度"重新估算。
空文档会产生一个空行对象,这并不会生成标题,却让扫描逻辑保持一致。尾部换行也可能产生最后一个空行,同样不会影响结果。这样的行模型比在正则表达式中混合处理 \r?\n 更容易审查,也便于为每种换行格式编写单元测试。
为什么偏移必须使用 UTF-16 语义
ArkTS 字符串、JavaScript 字符串和 CodeMirror 的位置都以 UTF-16 码元为基础。中文常用字通常占一个码元,许多表情或扩展字符占两个。大纲服务通过字符串索引逐步累积偏移,得到的正好是 CodeMirror 接受的坐标。
如果原生层按 UTF-8 字节数计算偏移,# 鸿蒙 PC 中的每个汉字占三个字节,传给 CodeMirror 后位置会严重偏后。如果按 Unicode 码点计算,遇到代理对又会与 JavaScript 索引不同。跨运行时协议必须明确坐标单位;"字符位置"这个含糊说法不足以成为接口契约。
当前实现把大纲解析放在 ArkTS 服务中,但两端都共享 UTF-16 语义,所以偏移无需转换。未来若把解析器下沉到 Rust、C++ 或服务端,就必须在边界处显式转换,否则中文标题和表情标题会成为第一批错误样本。
ATX 标题解析要处理缩进和尾部井号
ATX 标题使用一到六个 #:
md
# 一级标题
### 三级标题
## 标题文字 ##
OhMarkdown 的匹配规则允许最多三个前导空格,要求井号后至少有空格或制表符,并限制为六级:
ts
const atxMatch: RegExpMatchArray | null = line.text.match(
/^ {0,3}(#{1,6})[ \t]+(.+?)\s*$/
);
if (atxMatch) {
const title: string = cleanHeadingTitle(atxMatch[2]);
if (title.length > 0) {
headings.push({
level: atxMatch[1].length,
title: title,
offset: line.offset,
line: line.line
});
}
continue;
}
要求井号后有空白可以避免把 #include、#tag 之类文本误判为标题。超过三个前导空格通常进入缩进代码语义,当前轻量解析器不把它识别为标题。#{1,6} 直接给出层级,不需要再次循环计数。
尾部井号是 Markdown 允许的可选关闭标记,大纲显示时应去掉:
ts
function cleanHeadingTitle(value: string): string {
return value.replace(/[ \t]+#+[ \t]*$/, '').trim();
}
这里要求尾部井号前至少有空白。C# 不会变成 C,而 ## 标题 ## 会显示为"标题"。清理后为空的标题不会进入大纲,避免出现只有缩进却无法理解的条目。
这不是完整的 Markdown inline 解析。标题中的反引号、强调和链接标记仍按源码文本显示,例如 ## 使用 \code`` 会保留反引号。Alpha 阶段这样做有两个好处:无需引入 AST 到 ArkTS,点击后的偏移也始终对应源码。未来若希望大纲显示纯文本,可以使用与预览相同的 Markdown 解析器提取 inline 文本,但必须保持跳转偏移来自原始源码。
Setext 标题需要向前看一行
Setext 语法用下一行的等号或连字符表示一级、二级标题:
md
一级标题
========
二级标题
--------
扫描到非空正文行时,解析器检查下一行:
ts
if (line.text.trim().length === 0 || index + 1 >= lines.length) {
continue;
}
const underlineMatch: RegExpMatchArray | null =
lines[index + 1].text.match(/^ {0,3}(=+|-+)[ \t]*$/);
if (underlineMatch) {
headings.push({
level: underlineMatch[1][0] === '=' ? 1 : 2,
title: line.text.trim(),
offset: line.offset,
line: line.line
});
index += 1;
}
标题偏移指向文字行,而不是下划线。点击大纲后,用户首先看到标题内容。识别成功后 index += 1 跳过下划线,防止它再被当成下一项的普通文字。
Setext 与水平线存在语法接近的问题。单独一行 --- 通常表示水平线,但前一行存在可解释文字时,它也可以作为 Setext 二级标题下划线。Markdown 规范本身需要上下文决定,当前规则遵循"非空前一行加连字符下划线"为标题。因此测试中的 正文\n--- 会产生二级标题"正文"。这不是解析器偶然行为,而是必须写进测试和产品预期的语法选择。
如果希望大纲与某个特定 Markdown 渲染器百分之百一致,最可靠方式是直接复用该渲染器的 token 流。当前服务采用轻量扫描,是因为只需要标题、运行在原生侧、无额外依赖且行为容易测试。选择轻量解析器的代价,就是必须明确它覆盖的语法子集。
代码围栏是一台小状态机
技术文档经常在代码块里展示 Markdown:
md
```md
## 这只是示例,不是文档章节
```
如果用逐行标题正则直接扫描,这个 ## 会污染大纲。解析器记录当前围栏字符和开启长度:
ts
let fenceCharacter: string = '';
let fenceLength: number = 0;
const fenceMatch: RegExpMatchArray | null = line.text.match(
/^ {0,3}(`{3,}|~{3,})/
);
if (fenceMatch) {
const marker: string = fenceMatch[1];
if (fenceCharacter.length === 0) {
fenceCharacter = marker[0];
fenceLength = marker.length;
} else if (marker[0] === fenceCharacter && marker.length >= fenceLength) {
fenceCharacter = '';
fenceLength = 0;
}
continue;
}
if (fenceCharacter.length > 0) {
continue;
}
反引号围栏只能由反引号关闭,波浪号围栏只能由波浪号关闭。关闭标记长度必须不小于开启长度,因此四个反引号包裹的内容不会被内部三个反引号提前终止。围栏行本身直接跳过,围栏内部所有行也跳过标题与 Setext 判断。
这段逻辑很短,却比"遇到 ```就翻转布尔值"可靠。简单布尔值无法区分字符类型和长度,也会在代码示例中错误结束。状态机依然有边界,例如完整 CommonMark 对围栏信息字符串和缩进还有更细规则,但当前覆盖了技术文章最常见的误判来源。
缩进代码块暂未专门建模。由于 ATX 只允许最多三个前导空格,四空格代码里的井号不会成为 ATX 标题;Setext 前瞻仍可能遇到复杂边缘组合。后续增加语料时,应优先覆盖四空格代码、列表内围栏、未闭合围栏和超长围栏,而不是只添加正常标题。
原生侧刷新避免使用过期正文
大纲按钮位于 ArkUI 侧边栏。用户可能刚输入一个标题,Bridge 的节流同步尚未触发。如果直接对 this.documentContent 解析,就会漏掉最新输入。刷新前先从编辑器捕获活动文档:
ts
private async refreshOutline(): Promise<void> {
await this.captureActiveDocumentSession();
this.outlineEntries = extractMarkdownHeadings(this.documentContent);
}
活动面板已是大纲时,正文变化也会重新提取:
ts
this.syncActiveDocumentSession(content);
if (this.activePanel === 'outline') {
this.outlineEntries = extractMarkdownHeadings(content);
}
这样大纲打开期间能随编辑更新,关闭期间又不必在每次按键后重复扫描。把计算与可见性绑定,是桌面应用常用的成本控制策略。对于几兆文本,全文扫描仍需要测量;后续可以在 CodeMirror transaction 中获取变更范围,只重算受影响标题,但实现复杂度明显更高。
多标签切换后,大纲必须属于当前会话。由于刷新总是先捕获活动正文,且 outlineEntries 是页面当前面板状态,不会把甲文档标题继续显示在乙文档中。若未来需要为每个标签保留大纲展开状态,可以把条目缓存到会话对象,但缓存键必须包含 revision,避免正文变化后读取旧结构。
点击标题后由 CodeMirror 完成定位
ArkUI 点击条目时切换到源码模式,并把偏移传入 Web 内核:
ts
private jumpToHeading(entry: MarkdownHeading): void {
this.viewMode = 'source';
this.runEditorScript(
`window.OhMarkdownEditor?.jumpToOffset(${entry.offset})`
);
}
Web 侧先验证边界,再设置选区和滚动:
ts
function jumpToOffset(offset: number): boolean {
if (!Number.isInteger(offset) ||
offset < 0 ||
offset > editor.state.doc.length) {
return false;
}
if (currentMode === 'preview') {
setMode('source');
}
editor.dispatch({
selection: { anchor: offset },
effects: EditorView.scrollIntoView(offset, {
y: 'start',
yMargin: 18
})
});
editor.focus();
return true;
}
边界检查防止过期大纲把偏移传给已经变化的文档。正常情况下,大纲在内容变化后会刷新;但异步 UI 中仍可能出现用户点击旧渲染项与正文更新交错的窗口,Web 层不能无条件相信原生参数。
预览模式没有源码选区,所以跳转会切回源码。分栏模式则可以保留分栏,只要 currentMode 不是纯预览。目标放在视口顶部并留出十八像素边距,标题不会被顶栏或边框紧贴。最后恢复编辑器焦点,用户点击大纲后可直接继续写作。
脚本参数是整数,不包含用户文本,因此没有字符串转义问题;仍然只通过受限的 OhMarkdownEditor API 暴露功能,而不是让原生层拼接任意 DOM 操作。这个边界便于测试,也减少 ArkWeb 能力面。
鸿蒙 PC 模拟器中的大纲
下图来自 MateBook Pro 2in1 模拟器。左侧大纲提取出 H1 Title 与 H2 Target,同时显示源文件行号 9 和 10。编辑区保留原始 Markdown,点击条目后由源码偏移完成定位。

截图中首行包含看似标题标记的混合文本,但没有满足 ATX 标题的行首规则,因此不会进入大纲。第九、十行满足规则,准确生成两个条目。此类带噪声样本比只有 # A\n## B 的理想文档更能证明解析器不会随便寻找井号。
设备端 ohosTest 使用中文、Setext 和围栏代码构造语料:
ts
const content =
'# 鸿蒙 PC\n\n正文\n---\n\n```md\n' +
'## 代码标题\n```\n\n### 目标标题';
const headings: Array<MarkdownHeading> =
extractMarkdownHeadings(content);
expect(headings.length).assertEqual(3);
expect(headings[0].title).assertEqual('鸿蒙 PC');
expect(headings[1].level).assertEqual(2);
expect(headings[2].title).assertEqual('目标标题');
expect(headings[2].offset).assertEqual(
content.indexOf('### 目标标题')
);
预期只有三个标题:ATX 一级标题、由 正文\n--- 形成的 Setext 二级标题、围栏之后的三级标题。代码块中的"代码标题"必须被忽略。最后的偏移与 JavaScript/ArkTS indexOf 对比,直接验证 UTF-16 坐标契约。
Web 自动化则验证跳转行为:先进入纯预览,调用 jumpToOffset 后断言工作区回到源码模式,并确认浏览器选区落在 CodeMirror 内容区域。原生测试负责"算对偏移",Web 测试负责"使用偏移",模拟器负责"用户看到正确界面",三层证据覆盖了完整调用链。
解析器的边界应当公开
当前轻量服务不是完整 CommonMark/GFM 解析器。它明确支持一到六级 ATX、一级和二级 Setext、反引号与波浪号围栏过滤,并保留源码标题文本。它没有处理 HTML 块内伪标题、所有容器块嵌套、引用中的复杂标题语义,也没有把强调或链接转换成纯显示文本。
这种边界并不等于实现质量低。对本地桌面编辑器而言,一个小而确定的解析器可以减少依赖、降低 ArkTS 侧开销,并让标题跳转与源码完全一致。真正的问题不是"没有支持所有语法",而是产品是否错误宣称全覆盖,测试是否遗漏已承诺范围。
如果后续需要与预览严格同构,可以让 Web 侧 markdown-it 输出标题 token 与源码 map,再通过 Bridge 传给原生大纲。那样能复用解析语义,却会增加跨运行时数据传输和更新调度。另一条路线是在 ArkTS 引入 CommonMark 解析库,但要评估包体、性能和 HarmonyOS 兼容性。技术选择应由差异语料和性能数据驱动,而不是为了"用了 AST"而增加复杂度。
结语
一个可靠的大纲功能由几项朴素但关键的约束组成:先按原始换行建立带偏移的行模型,用小状态机排除围栏代码,分别识别 ATX 与 Setext,把坐标单位固定为 UTF-16,在解析前捕获最新正文,并让 CodeMirror 负责选区和滚动。每个环节都不复杂,组合后却跨越了文件格式、Markdown 语法、原生 UI 和 Web 编辑内核。
鸿蒙 PC 编辑器的桌面体验不只取决于窗口是否像 PC。用户点击一个标题,应用能否准确带他回到正在编辑的源码位置,才是工具成熟度的直接体现。大纲服务保持独立、无 UI 依赖,也为后续符号搜索、面包屑、章节折叠和导出目录提供了可复用的基础。