鸿蒙 PC Markdown 编辑器大纲解析:ATX、Setext 与源码跳转

📅 2026/7/19 23:57:17 👁️ 阅读次数 📝 编程学习
鸿蒙 PC Markdown 编辑器大纲解析:ATX、Setext 与源码跳转

鸿蒙 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 为每一项返回四个字段:

exportinterfaceMarkdownHeading{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 又完全不会切行。大纲服务先用字符扫描建立统一行模型:

interfaceMarkdownLine{text:string;offset:number;line:number;}functionsplitMarkdownLines(content:string):Array<MarkdownLine>{constlines:Array<MarkdownLine>=[];letlineStart:number=0;letlineNumber:number=1;for(letindex:number=0;index<=content.length;index+=1){constatEnd:boolean=index===content.length;constcharacter: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;}returnlines;}

循环条件使用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 标题使用一到六个#

# 一级标题 ### 三级标题 ## 标题文字 ##

OhMarkdown 的匹配规则允许最多三个前导空格,要求井号后至少有空格或制表符,并限制为六级:

constatxMatch:RegExpMatchArray|null=line.text.match(/^ {0,3}(#{1,6})[ \t]+(.+?)\s*$/);if(atxMatch){consttitle: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 允许的可选关闭标记,大纲显示时应去掉:

functioncleanHeadingTitle(value:string):string{returnvalue.replace(/[ \t]+#+[ \t]*$/,'').trim();}

这里要求尾部井号前至少有空白。C#不会变成C,而## 标题 ##会显示为“标题”。清理后为空的标题不会进入大纲,避免出现只有缩进却无法理解的条目。

这不是完整的 Markdown inline 解析。标题中的反引号、强调和链接标记仍按源码文本显示,例如## 使用 \code`` 会保留反引号。Alpha 阶段这样做有两个好处:无需引入 AST 到 ArkTS,点击后的偏移也始终对应源码。未来若希望大纲显示纯文本,可以使用与预览相同的 Markdown 解析器提取 inline 文本,但必须保持跳转偏移来自原始源码。

Setext 标题需要向前看一行

Setext 语法用下一行的等号或连字符表示一级、二级标题:

一级标题 ======== 二级标题 --------

扫描到非空正文行时,解析器检查下一行:

if(line.text.trim().length===0||index+1>=lines.length){continue;}constunderlineMatch: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 ## 这只是示例,不是文档章节 ```

如果用逐行标题正则直接扫描,这个##会污染大纲。解析器记录当前围栏字符和开启长度:

letfenceCharacter:string='';letfenceLength:number=0;constfenceMatch:RegExpMatchArray|null=line.text.match(/^ {0,3}(`{3,}|~{3,})/);if(fenceMatch){constmarker:string=fenceMatch[1];if(fenceCharacter.length===0){fenceCharacter=marker[0];fenceLength=marker.length;}elseif(marker[0]===fenceCharacter&&marker.length>=fenceLength){fenceCharacter='';fenceLength=0;}continue;}if(fenceCharacter.length>0){continue;}

反引号围栏只能由反引号关闭,波浪号围栏只能由波浪号关闭。关闭标记长度必须不小于开启长度,因此四个反引号包裹的内容不会被内部三个反引号提前终止。围栏行本身直接跳过,围栏内部所有行也跳过标题与 Setext 判断。

这段逻辑很短,却比“遇到 ```就翻转布尔值”可靠。简单布尔值无法区分字符类型和长度,也会在代码示例中错误结束。状态机依然有边界,例如完整 CommonMark 对围栏信息字符串和缩进还有更细规则,但当前覆盖了技术文章最常见的误判来源。

缩进代码块暂未专门建模。由于 ATX 只允许最多三个前导空格,四空格代码里的井号不会成为 ATX 标题;Setext 前瞻仍可能遇到复杂边缘组合。后续增加语料时,应优先覆盖四空格代码、列表内围栏、未闭合围栏和超长围栏,而不是只添加正常标题。

原生侧刷新避免使用过期正文

大纲按钮位于 ArkUI 侧边栏。用户可能刚输入一个标题,Bridge 的节流同步尚未触发。如果直接对this.documentContent解析,就会漏掉最新输入。刷新前先从编辑器捕获活动文档:

privateasyncrefreshOutline():Promise<void>{awaitthis.captureActiveDocumentSession();this.outlineEntries=extractMarkdownHeadings(this.documentContent);}

活动面板已是大纲时,正文变化也会重新提取:

this.syncActiveDocumentSession(content);if(this.activePanel==='outline'){this.outlineEntries=extractMarkdownHeadings(content);}

这样大纲打开期间能随编辑更新,关闭期间又不必在每次按键后重复扫描。把计算与可见性绑定,是桌面应用常用的成本控制策略。对于几兆文本,全文扫描仍需要测量;后续可以在 CodeMirror transaction 中获取变更范围,只重算受影响标题,但实现复杂度明显更高。

多标签切换后,大纲必须属于当前会话。由于刷新总是先捕获活动正文,且outlineEntries是页面当前面板状态,不会把甲文档标题继续显示在乙文档中。若未来需要为每个标签保留大纲展开状态,可以把条目缓存到会话对象,但缓存键必须包含 revision,避免正文变化后读取旧结构。

点击标题后由 CodeMirror 完成定位

ArkUI 点击条目时切换到源码模式,并把偏移传入 Web 内核:

privatejumpToHeading(entry:MarkdownHeading):void{this.viewMode='source';this.runEditorScript(`window.OhMarkdownEditor?.jumpToOffset(${entry.offset})`);}

Web 侧先验证边界,再设置选区和滚动:

functionjumpToOffset(offset:number):boolean{if(!Number.isInteger(offset)||offset<0||offset>editor.state.doc.length){returnfalse;}if(currentMode==='preview'){setMode('source');}editor.dispatch({selection:{anchor:offset},effects:EditorView.scrollIntoView(offset,{y:'start',yMargin:18})});editor.focus();returntrue;}

边界检查防止过期大纲把偏移传给已经变化的文档。正常情况下,大纲在内容变化后会刷新;但异步 UI 中仍可能出现用户点击旧渲染项与正文更新交错的窗口,Web 层不能无条件相信原生参数。

预览模式没有源码选区,所以跳转会切回源码。分栏模式则可以保留分栏,只要currentMode不是纯预览。目标放在视口顶部并留出十八像素边距,标题不会被顶栏或边框紧贴。最后恢复编辑器焦点,用户点击大纲后可直接继续写作。

脚本参数是整数,不包含用户文本,因此没有字符串转义问题;仍然只通过受限的OhMarkdownEditorAPI 暴露功能,而不是让原生层拼接任意 DOM 操作。这个边界便于测试,也减少 ArkWeb 能力面。

鸿蒙 PC 模拟器中的大纲

下图来自 MateBook Pro 2in1 模拟器。左侧大纲提取出 H1Title与 H2Target,同时显示源文件行号 9 和 10。编辑区保留原始 Markdown,点击条目后由源码偏移完成定位。

截图中首行包含看似标题标记的混合文本,但没有满足 ATX 标题的行首规则,因此不会进入大纲。第九、十行满足规则,准确生成两个条目。此类带噪声样本比只有# A\n## B的理想文档更能证明解析器不会随便寻找井号。

设备端 ohosTest 使用中文、Setext 和围栏代码构造语料:

constcontent='# 鸿蒙 PC\n\n正文\n---\n\n```md\n'+'## 代码标题\n```\n\n### 目标标题';constheadings: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/ArkTSindexOf对比,直接验证 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 依赖,也为后续符号搜索、面包屑、章节折叠和导出目录提供了可复用的基础。